← back to ClawCoder

src/lib/analyzer/rules.ts

518 lines

import type { CriterionResult, Finding } from './types'

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function makeCriterion(
  id: string,
  name: string,
  weight: number,
  maxScore: number,
  score: number,
  findings: Finding[] = []
): CriterionResult {
  return { id, name, weight, maxScore, score, findings }
}

// ---------------------------------------------------------------------------
// 1. Line Count (15%)
// ---------------------------------------------------------------------------
export function scoreLineCount(content: string): CriterionResult {
  const lines = content.split('\n').length
  let score: number
  const findings: Finding[] = []

  if (lines <= 80) {
    score = 15
  } else if (lines <= 150) {
    score = 12
    findings.push({
      severity: 'info',
      message: `File is ${lines} lines. Under 80 is ideal.`,
    })
  } else if (lines <= 200) {
    score = 8
    findings.push({
      severity: 'warning',
      message: `File is ${lines} lines. Consider splitting into .claude/rules/ files.`,
    })
  } else if (lines <= 300) {
    score = 4
    findings.push({
      severity: 'warning',
      message: `File is ${lines} lines — well above the recommended 200-line limit.`,
    })
  } else {
    score = 0
    findings.push({
      severity: 'critical',
      message: `File is ${lines} lines — far too long. Split into modular .claude/rules/ files.`,
    })
  }

  return makeCriterion('line-count', 'Line Count', 15, 15, score, findings)
}

// ---------------------------------------------------------------------------
// 2. Structure (10%)
// ---------------------------------------------------------------------------
export function scoreStructure(content: string): CriterionResult {
  const headerMatches = content.match(/^## .+/gm)
  const headerCount = headerMatches ? headerMatches.length : 0
  const findings: Finding[] = []

  let score: number
  if (headerCount >= 4) {
    score = 10
  } else if (headerCount === 3) {
    score = 8
    findings.push({
      severity: 'info',
      message: `Found ${headerCount} section headers. 4+ is recommended.`,
    })
  } else if (headerCount === 2) {
    score = 5
    findings.push({
      severity: 'warning',
      message: `Only ${headerCount} section headers. Add more structure with ## headings.`,
    })
  } else if (headerCount === 1) {
    score = 3
    findings.push({
      severity: 'warning',
      message: 'Only 1 section header found. Structure your file with multiple ## sections.',
    })
  } else {
    score = 0
    findings.push({
      severity: 'critical',
      message: 'No ## section headers found. Add sections like ## Commands, ## Safety, ## Workflow.',
    })
  }

  return makeCriterion('structure', 'Structure', 10, 10, score, findings)
}

// ---------------------------------------------------------------------------
// 3. Specificity (12%)
// ---------------------------------------------------------------------------
export function scoreSpecificity(content: string): CriterionResult {
  const commandPatterns = [
    /\b(npm|yarn|pnpm)\s+(run\s+)?\w+/g,
    /\b(pip|pip3)\s+install\b/g,
    /\bcargo\s+(build|run|test|check)\b/g,
    /\bmake\s+\w+/g,
    /\b(pytest|jest|vitest)\b/g,
    /\bnpx\s+\w+/g,
    /\bgo\s+(build|test|run)\b/g,
    /\bdocker\s+(build|run|compose)\b/g,
    /\bbun\s+(run|test|install)\b/g,
  ]

  const allMatches = new Set<string>()
  for (const pattern of commandPatterns) {
    const matches = content.match(pattern)
    if (matches) {
      for (const m of matches) allMatches.add(m)
    }
  }

  const count = allMatches.size
  const findings: Finding[] = []
  let score: number

  if (count >= 4) {
    score = 12
  } else if (count >= 2) {
    score = 8
    findings.push({
      severity: 'info',
      message: `Found ${count} specific commands. 4+ is ideal.`,
    })
  } else if (count === 1) {
    score = 4
    findings.push({
      severity: 'warning',
      message: 'Only 1 specific command found. Add more concrete tool commands.',
    })
  } else {
    score = 0
    findings.push({
      severity: 'warning',
      message: 'No specific tool commands found (npm, yarn, pip, cargo, pytest, jest, etc.).',
    })
  }

  return makeCriterion('specificity', 'Specificity', 12, 12, score, findings)
}

// ---------------------------------------------------------------------------
// 4. Safety Rules (12%)
// ---------------------------------------------------------------------------
export function scoreSafetyRules(content: string): CriterionResult {
  const safetyKeywords = [
    'safety',
    "don't delete",
    "don't modify",
    'do not delete',
    'do not modify',
    'never',
    'protected',
    'credential',
    'secret',
    'destructive',
    'permission',
    'force push',
    'reset hard',
    'dangerous',
    'careful',
    'backup',
  ]

  const lower = content.toLowerCase()
  const found = safetyKeywords.filter(kw => lower.includes(kw))
  const findings: Finding[] = []
  let score: number

  if (found.length >= 4) {
    score = 12
  } else if (found.length >= 2) {
    score = 8
    findings.push({
      severity: 'info',
      message: `Found ${found.length} safety keywords. 4+ is ideal for thorough safety coverage.`,
    })
  } else if (found.length === 1) {
    score = 4
    findings.push({
      severity: 'warning',
      message: `Only 1 safety keyword found ("${found[0]}"). Add explicit safety rules.`,
    })
  } else {
    score = 0
    findings.push({
      severity: 'critical',
      message: 'No safety rules detected. Add a ## Safety section with explicit constraints.',
    })
  }

  return makeCriterion('safety-rules', 'Safety Rules', 12, 12, score, findings)
}

// ---------------------------------------------------------------------------
// 5. Build Commands (8%)
// ---------------------------------------------------------------------------
export function scoreBuildCommands(content: string): CriterionResult {
  const commandLabels = [
    /\binstall\s*:/im,
    /\bdev\s*:/im,
    /\btest\s*:/im,
    /\blint\s*:/im,
    /\bbuild\s*:/im,
    /\bformat\s*:/im,
    /\bstart\s*:/im,
    /\bdeploy\s*:/im,
  ]

  const found = commandLabels.filter(rx => rx.test(content))
  const findings: Finding[] = []
  let score: number

  if (found.length >= 4) {
    score = 8
  } else if (found.length >= 2) {
    score = 5
    findings.push({
      severity: 'info',
      message: `Found ${found.length} labeled commands. 4+ recommended (install, dev, test, lint, build, format).`,
    })
  } else if (found.length === 1) {
    score = 2
    findings.push({
      severity: 'warning',
      message: 'Only 1 labeled command found. Add install:, dev:, test:, lint:, build: labels.',
    })
  } else {
    score = 0
    findings.push({
      severity: 'warning',
      message: 'No labeled build commands found. Add a ## Commands section with install:, dev:, test:, etc.',
    })
  }

  return makeCriterion('build-commands', 'Build Commands', 8, 8, score, findings)
}

// ---------------------------------------------------------------------------
// 6. Code Style (5%)
// ---------------------------------------------------------------------------
export function scoreCodeStyle(content: string): CriterionResult {
  const styleKeywords = [
    'style',
    'indent',
    'naming',
    'linter',
    'eslint',
    'prettier',
    'biome',
    'formatting',
    'code style',
    'code-style',
    '.claude/rules/',
  ]

  const lower = content.toLowerCase()
  const found = styleKeywords.filter(kw => lower.includes(kw))
  const findings: Finding[] = []
  let score: number

  if (found.length > 0) {
    score = 5
  } else {
    score = 0
    findings.push({
      severity: 'info',
      message: 'No code style references found. Consider adding a ## Code Style section or referencing .claude/rules/code-style.md.',
    })
  }

  return makeCriterion('code-style', 'Code Style', 5, 5, score, findings)
}

// ---------------------------------------------------------------------------
// 7. Modular Imports (10%)
// ---------------------------------------------------------------------------
export function scoreModularImports(content: string): CriterionResult {
  const hasDocsImport = /@docs\//.test(content) || /@~\//.test(content)
  const hasRulesRef = /\.claude\/rules\//.test(content)
  const findings: Finding[] = []
  let score: number

  if (hasDocsImport && hasRulesRef) {
    score = 10
  } else if (hasDocsImport || hasRulesRef) {
    score = 5
    const missing = hasDocsImport
      ? '.claude/rules/ references'
      : '@docs/ or @~/ imports'
    findings.push({
      severity: 'info',
      message: `Found one type of modular import but missing ${missing}.`,
    })
  } else {
    score = 0
    findings.push({
      severity: 'warning',
      message: 'No modular imports found. Reference @docs/ architecture files and .claude/rules/ for split configuration.',
    })
  }

  return makeCriterion('modular-imports', 'Modular Imports', 10, 10, score, findings)
}

// ---------------------------------------------------------------------------
// 8. No Secrets (10%)
// ---------------------------------------------------------------------------
export function scoreNoSecrets(content: string): CriterionResult {
  const secretPatterns = [
    { rx: /(?:api_key|api[-_]?secret|token|password|passwd)\s*[=:]\s*\S+/gi, label: 'key/password assignment' },
    { rx: /\bsk-[a-zA-Z0-9]{20,}/g, label: 'OpenAI-style secret key (sk-...)' },
    { rx: /\bghp_[a-zA-Z0-9]{36,}/g, label: 'GitHub personal access token (ghp_...)' },
    { rx: /\bshpat_[a-zA-Z0-9]{20,}/g, label: 'Shopify access token (shpat_...)' },
    { rx: /Bearer\s+[a-zA-Z0-9._\-]{20,}/g, label: 'Bearer token' },
    { rx: /Basic\s+[A-Za-z0-9+/=]{20,}/g, label: 'Basic auth token' },
    { rx: /\bAKIA[A-Z0-9]{16}\b/g, label: 'AWS access key (AKIA...)' },
  ]

  const findings: Finding[] = []
  const lines = content.split('\n')

  for (const { rx, label } of secretPatterns) {
    for (let i = 0; i < lines.length; i++) {
      const matches = lines[i].match(rx)
      if (matches) {
        for (const match of matches) {
          findings.push({
            severity: 'critical',
            message: `Potential ${label} found: "${match.substring(0, 30)}..."`,
            line: i + 1,
          })
        }
      }
    }
  }

  const score = findings.length > 0 ? 0 : 10

  return makeCriterion('no-secrets', 'No Secrets', 10, 10, score, findings)
}

// ---------------------------------------------------------------------------
// 9. No Bloat (8%)
// ---------------------------------------------------------------------------
export function scoreNoBloat(content: string): CriterionResult {
  const findings: Finding[] = []
  const lines = content.split('\n')
  let bloatScore = 0

  // Check for excessive consecutive empty lines (3+ in a row)
  let consecutiveEmpty = 0
  for (let i = 0; i < lines.length; i++) {
    if (lines[i].trim() === '') {
      consecutiveEmpty++
      if (consecutiveEmpty >= 3) {
        bloatScore++
        if (consecutiveEmpty === 3) {
          findings.push({
            severity: 'info',
            message: `Excessive empty lines around line ${i + 1}.`,
            line: i + 1,
          })
        }
      }
    } else {
      consecutiveEmpty = 0
    }
  }

  // Check for duplicate ## headers
  const headers = lines
    .filter(l => /^## /.test(l))
    .map(l => l.trim().toLowerCase())
  const seen = new Set<string>()
  for (const h of headers) {
    if (seen.has(h)) {
      bloatScore += 3
      findings.push({
        severity: 'warning',
        message: `Duplicate section header: "${h}"`,
      })
    }
    seen.add(h)
  }

  // Check for very long lines (>300 characters, ignoring URLs)
  for (let i = 0; i < lines.length; i++) {
    if (lines[i].length > 300 && !lines[i].match(/^https?:\/\//)) {
      bloatScore++
      if (bloatScore <= 5) {
        findings.push({
          severity: 'info',
          message: `Line ${i + 1} is ${lines[i].length} characters long.`,
          line: i + 1,
        })
      }
    }
  }

  let score: number
  if (bloatScore === 0) {
    score = 8
  } else if (bloatScore <= 3) {
    score = 4
  } else {
    score = 0
  }

  return makeCriterion('no-bloat', 'No Bloat', 8, 8, score, findings)
}

// ---------------------------------------------------------------------------
// 10. Skills / Subagents (3%)
// ---------------------------------------------------------------------------
export function scoreSkillsSubagents(content: string): CriterionResult {
  const keywords = ['skill', 'subagent', 'sub-agent', '.claude/agents']
  const lower = content.toLowerCase()
  const found = keywords.some(kw => lower.includes(kw))
  const findings: Finding[] = []

  if (!found) {
    findings.push({
      severity: 'info',
      message: 'No skill or subagent references found. Consider defining .claude/agents/ if your workflow uses delegation.',
    })
  }

  return makeCriterion(
    'skills-subagents',
    'Skills & Subagents',
    3,
    3,
    found ? 3 : 0,
    findings
  )
}

// ---------------------------------------------------------------------------
// 11. Hook Enforcement (3%)
// ---------------------------------------------------------------------------
export function scoreHookEnforcement(content: string): CriterionResult {
  const keywords = ['hook', '.claude/settings', 'PreToolUse', 'PostToolUse', 'SessionStart']
  const lower = content.toLowerCase()
  const found = keywords.some(kw => lower.includes(kw) || content.includes(kw))
  const findings: Finding[] = []

  if (!found) {
    findings.push({
      severity: 'info',
      message: 'No hook or .claude/settings references found. Hooks can automate linting and testing on file changes.',
    })
  }

  return makeCriterion(
    'hook-enforcement',
    'Hook Enforcement',
    3,
    3,
    found ? 3 : 0,
    findings
  )
}

// ---------------------------------------------------------------------------
// 12. MCP Config (4%)
// ---------------------------------------------------------------------------
export function scoreMcpConfig(content: string): CriterionResult {
  const keywords = ['mcp', '.mcp.json', 'mcp server', 'mcp-server']
  const lower = content.toLowerCase()
  const found = keywords.some(kw => lower.includes(kw))
  const findings: Finding[] = []

  if (!found) {
    findings.push({
      severity: 'info',
      message: 'No MCP configuration references found. MCP servers extend Claude with external tools.',
    })
  }

  return makeCriterion(
    'mcp-config',
    'MCP Config',
    4,
    4,
    found ? 4 : 0,
    findings
  )
}

// ---------------------------------------------------------------------------
// Export all rules as an array factory
// ---------------------------------------------------------------------------
export type RuleFunction = (content: string) => CriterionResult

export const ALL_RULES: RuleFunction[] = [
  scoreLineCount,
  scoreStructure,
  scoreSpecificity,
  scoreSafetyRules,
  scoreBuildCommands,
  scoreCodeStyle,
  scoreModularImports,
  scoreNoSecrets,
  scoreNoBloat,
  scoreSkillsSubagents,
  scoreHookEnforcement,
  scoreMcpConfig,
]