/** * RUL Scanner — Rules Validator * Validates .claude/rules/ files: glob matching against real files, orphan detection, frontmatter. * Finding IDs: CA-RUL-NNN */ import { readTextFile } from './lib/file-discovery.mjs'; import { finding, scannerResult } from './lib/output.mjs'; import { SEVERITY } from './lib/severity.mjs'; import { parseFrontmatter } from './lib/yaml-parser.mjs'; import { lineCount, truncate } from './lib/string-utils.mjs'; import { readdir, stat } from 'node:fs/promises'; import { join, resolve, relative, sep } from 'node:path'; const SCANNER = 'RUL'; /** * Scan .claude/rules/ directories for issues. * @param {string} targetPath * @param {{ files: import('./lib/file-discovery.mjs').ConfigFile[] }} discovery * @returns {Promise} */ export async function scan(targetPath, discovery) { const start = Date.now(); const ruleFiles = discovery.files.filter(f => f.type === 'rule'); const findings = []; let filesScanned = 0; if (ruleFiles.length === 0) { return scannerResult(SCANNER, 'skipped', [], 0, Date.now() - start); } // Rule path patterns scope relative to the rule's OWN project root (the dir // containing its .claude/), not the outer scan root. Resolve + cache per root. const home = process.env.HOME || process.env.USERPROFILE || ''; const projectFilesByRoot = new Map(); async function projectFilesFor(root) { if (!projectFilesByRoot.has(root)) { projectFilesByRoot.set(root, await collectProjectFiles(root)); } return projectFilesByRoot.get(root); } for (const file of ruleFiles) { const content = await readTextFile(file.absPath); if (!content) continue; filesScanned++; const { frontmatter, body, bodyStartLine } = parseFrontmatter(content); const lines = lineCount(content); // --- Frontmatter checks --- if (!frontmatter) { // Rules without frontmatter are "always on" — not necessarily wrong, just note it if (lines > 5) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.info, title: 'Rule has no frontmatter (always active)', description: `${file.relPath} has no YAML frontmatter. It will be loaded for ALL files. Add paths: frontmatter to scope it.`, file: file.absPath, recommendation: 'Add frontmatter with paths: to limit when this rule applies.', })); } } else { // Check for paths/globs frontmatter const paths = frontmatter.paths || frontmatter.globs; if (frontmatter.globs && !frontmatter.paths) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.low, title: 'Rule uses "globs" instead of documented "paths"', description: `${file.relPath} uses "globs:" for scoping. Claude Code's documentation specifies "paths:" as the rule-scoping field; "globs:" is not documented. Rename to "paths:" so the rule scopes as intended.`, file: file.absPath, evidence: `globs: ${JSON.stringify(frontmatter.globs)}`, recommendation: 'Rename "globs:" to "paths:" — paths: is the documented field for path-scoped rules.', autoFixable: true, })); } if (paths) { const patterns = Array.isArray(paths) ? paths : [paths]; // A rule scopes relative to its own project root (parent of its .claude/), // not the scan root. User-global rules (root === HOME) match against the // active project at runtime, so "matches no files here" is not meaningful. const projectRoot = deriveProjectRoot(file.absPath) || targetPath; const isUserGlobal = home && projectRoot === home; if (!isUserGlobal) { const projectFiles = await projectFilesFor(projectRoot); for (const pattern of patterns) { if (typeof pattern !== 'string') continue; // Check if pattern matches any real files (relative to the rule's root) const matchCount = countGlobMatches(pattern, projectFiles, projectRoot); if (matchCount === 0) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.high, title: 'Rule path pattern matches no files', description: `${file.relPath}: pattern "${pattern}" matches 0 files. This rule will never activate.`, file: file.absPath, evidence: `paths: "${pattern}"`, recommendation: 'Check the glob pattern. Common issues: wrong directory name, missing **, incorrect extension.', autoFixable: false, })); } } } } } // --- Content quality checks --- if (lines < 2) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.low, title: 'Rule file is nearly empty', description: `${file.relPath} has only ${lines} line(s).`, file: file.absPath, recommendation: 'Add meaningful content or remove the file.', autoFixable: false, })); } // Check for overly broad rules (huge files without path scoping) if (!frontmatter?.paths && !frontmatter?.globs && lines > 50) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.medium, title: 'Large unscoped rule file', description: `${file.relPath} has ${lines} lines and no path scoping. It loads into context for every file interaction.`, file: file.absPath, evidence: `${lines} lines, no paths: frontmatter`, recommendation: 'Add paths: frontmatter to scope this rule, or split into smaller path-specific rules.', autoFixable: false, })); } // A large PATH-SCOPED rule follows best practice, but path-scoped rules are // NOT re-injected after a context compaction — they reload only when a // matching file is read again (context-window.md). A big one carrying // must-always-hold instructions can silently drop out mid-session. if (frontmatter?.paths && lines > 50) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.low, title: 'Large path-scoped rule is lost after compaction', description: `${file.relPath} is path-scoped (${lines} lines). Path-scoped rules load only when a matching file is read, and after a context compaction they are not re-injected until a matching file is read again — so a large scoped rule carrying must-always-hold instructions can silently drop out mid-session.`, file: file.absPath, evidence: `${lines} lines, path-scoped`, recommendation: 'If parts of this rule must always apply, move them to the project-root CLAUDE.md (re-injected after compaction). Keep path-scoped rules for context only needed when those files are open.', autoFixable: false, })); } // Check file extension if (!file.absPath.endsWith('.md')) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.medium, title: 'Rule file is not .md', description: `${file.relPath} is not a .md file. Only .md files are loaded from rules/.`, file: file.absPath, recommendation: 'Rename to .md extension.', autoFixable: true, })); } } return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start); } /** * Collect project file paths for glob matching (limited depth). * @param {string} targetPath * @returns {Promise} */ async function collectProjectFiles(targetPath, depth = 0) { if (depth > 4) return []; const SKIP = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.next', '.nuxt', 'vendor']); const files = []; let entries; try { entries = await readdir(targetPath, { withFileTypes: true }); } catch { return files; } for (const entry of entries) { const fullPath = join(targetPath, entry.name); if (entry.isFile()) { files.push(fullPath); } else if (entry.isDirectory() && !SKIP.has(entry.name) && !entry.name.startsWith('.')) { const subFiles = await collectProjectFiles(fullPath, depth + 1); files.push(...subFiles); if (files.length > 5000) break; // Safety limit } } return files; } /** * Count how many files match a simplified glob pattern. * Supports: *, **, specific extensions. * @param {string} pattern * @param {string[]} files * @param {string} basePath * @returns {number} */ /** * Resolve the project root a rule scopes against: the directory containing the * `.claude/` dir the rule lives under. `/a/b/.claude/rules/x.md` → `/a/b`. * Returns null if the path has no `.claude` segment. */ function deriveProjectRoot(ruleAbsPath) { const parts = ruleAbsPath.split(sep); const idx = parts.lastIndexOf('.claude'); if (idx <= 0) return null; return parts.slice(0, idx).join(sep); } function countGlobMatches(pattern, files, basePath) { try { const regex = globToRegex(pattern); let count = 0; for (const file of files) { const rel = relative(basePath, file); if (regex.test(rel)) count++; } return count; } catch { return -1; // Pattern parsing error — don't report as orphan } } /** * Convert a simple glob pattern to a regex. * Handles ** matching zero or more path segments. * @param {string} pattern * @returns {RegExp} */ function globToRegex(pattern) { let regex = pattern .replace(/\./g, '\\.') .replace(/\/\*\*\//g, '{{GLOBSTAR_SLASH}}') .replace(/\*\*/g, '{{GLOBSTAR}}') .replace(/\*/g, '[^/]*') .replace(/\{\{GLOBSTAR_SLASH\}\}/g, '(?:/.+/|/)') // **/ matches 0+ intermediate dirs .replace(/\{\{GLOBSTAR\}\}/g, '.*') .replace(/\?/g, '[^/]'); // Handle leading patterns if (!regex.startsWith('.*') && !regex.startsWith('/')) { regex = '(?:^|/)' + regex; } return new RegExp(regex); }