/** * 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 } 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); } // Collect all real files in the project for glob matching const projectFiles = await collectProjectFiles(targetPath); 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 deprecated "globs" field', description: `${file.relPath} uses "globs:" which is legacy. Use "paths:" instead.`, file: file.absPath, evidence: `globs: ${JSON.stringify(frontmatter.globs)}`, recommendation: 'Rename "globs:" to "paths:" in frontmatter.', autoFixable: true, })); } if (paths) { const patterns = Array.isArray(paths) ? paths : [paths]; for (const pattern of patterns) { if (typeof pattern !== 'string') continue; // Check if pattern matches any real files const matchCount = countGlobMatches(pattern, projectFiles, targetPath); 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, })); } // 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} */ 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); }