← 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,
]