UNPKG

aiwg

Version:

Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo

322 lines 11.8 kB
/** * Lint Runner * * Core lint execution engine. Matches rules to files, * runs checks, and collects diagnostics. * * @issue #810 */ import fs from 'fs'; import fsp from 'fs/promises'; import path from 'path'; import { glob } from 'glob'; import { minimatch } from 'minimatch'; /** * Parse YAML-style frontmatter from a markdown file * Returns key-value pairs from the --- delimited block */ function parseFrontmatter(content) { const match = content.match(/^---\n([\s\S]*?)\n---/); if (!match) return {}; const result = {}; for (const line of match[1].split('\n')) { const kv = line.match(/^(\w[\w-]*)\s*:\s*(.+)$/); if (kv) { result[kv[1]] = kv[2].trim().replace(/^["']|["']$/g, ''); } } return result; } /** * Run a single check against a file's content and frontmatter */ function runCheck(check, content, frontmatter, filePath, targetDir, allFiles) { const diagnostics = []; switch (check.type) { case 'frontmatter-required': { if (!check.fields) break; for (const field of check.fields) { if (!frontmatter[field] || frontmatter[field].trim() === '') { diagnostics.push({ ruleId: '', ruleName: '', severity: 'error', file: filePath, message: `Missing required frontmatter field: ${field}`, fix: `Add '${field}:' to the YAML frontmatter block`, }); } } break; } case 'frontmatter-format': { if (!check.field || !check.pattern) break; const value = frontmatter[check.field]; if (value && !new RegExp(check.pattern).test(value)) { diagnostics.push({ ruleId: '', ruleName: '', severity: 'warn', file: filePath, message: `Frontmatter field '${check.field}' value '${value}' does not match pattern: ${check.pattern}`, fix: `Update '${check.field}' to match the expected format`, }); } break; } case 'reference-resolves': { const refPattern = check.referencePattern || 'REF-\\d{3}'; const regex = new RegExp(refPattern, 'g'); const lines = content.split('\n'); for (let i = 0; i < lines.length; i++) { let match; while ((match = regex.exec(lines[i])) !== null) { const refId = match[0]; const basePath = check.basePath || '.aiwg/research/findings'; const refFile = path.join(targetDir, basePath, `${refId}.md`); if (!fs.existsSync(refFile)) { // Also check relative to cwd const altPath = path.join(process.cwd(), basePath, `${refId}.md`); if (!fs.existsSync(altPath)) { diagnostics.push({ ruleId: '', ruleName: '', severity: 'error', file: filePath, message: `Reference '${refId}' does not resolve to an existing file`, line: i + 1, }); } } } } break; } case 'pattern-match': { if (!check.pattern) break; const regex = new RegExp(check.pattern); if (!regex.test(content)) { diagnostics.push({ ruleId: '', ruleName: '', severity: 'warn', file: filePath, message: `File content does not match expected pattern: ${check.pattern}`, }); } break; } case 'file-exists': { if (!check.field) break; const refPath = frontmatter[check.field]; if (refPath) { const resolved = path.resolve(targetDir, refPath); if (!fs.existsSync(resolved)) { diagnostics.push({ ruleId: '', ruleName: '', severity: 'error', file: filePath, message: `Referenced file '${refPath}' (from field '${check.field}') does not exist`, }); } } break; } case 'id-unique': { // Handled at ruleset level in runRule break; } case 'id-format': { if (!check.pattern) break; const basename = path.basename(filePath, '.md'); if (!new RegExp(check.pattern).test(basename)) { diagnostics.push({ ruleId: '', ruleName: '', severity: 'warn', file: filePath, message: `File name '${basename}' does not match expected format: ${check.pattern}`, fix: `Rename to match the pattern ${check.pattern}`, }); } break; } case 'cross-ref-bidirectional': { const refPattern = check.referencePattern || 'REF-\\d{3}'; const regex = new RegExp(refPattern, 'g'); const currentBasename = path.basename(filePath, '.md'); const matches = content.match(regex) || []; for (const ref of matches) { if (ref === currentBasename) continue; // Find the referenced file const refFile = allFiles.find(f => path.basename(f, '.md') === ref); if (refFile) { try { const refContent = fs.readFileSync(path.resolve(targetDir, refFile), 'utf8'); if (!refContent.includes(currentBasename)) { diagnostics.push({ ruleId: '', ruleName: '', severity: 'info', file: filePath, message: `References '${ref}' but '${ref}' does not reference back to '${currentBasename}'`, fix: `Add a cross-reference to '${currentBasename}' in ${ref}.md`, }); } } catch { // Skip if can't read } } } break; } } return diagnostics; } /** * Run a single rule against all matching files in the target */ async function runRule(rule, targetDir, allFiles) { const diagnostics = []; // Filter files matching the rule's glob const ruleGlob = rule.appliesTo.glob; const matchingFiles = allFiles.filter(f => minimatch(f, ruleGlob)); if (matchingFiles.length === 0) return diagnostics; // Handle id-unique check at the ruleset level const uniqueCheck = rule.checks.find(c => c.type === 'id-unique'); if (uniqueCheck) { const ids = new Map(); for (const file of matchingFiles) { const basename = path.basename(file, '.md'); if (ids.has(basename)) { diagnostics.push({ ruleId: rule.id, ruleName: rule.name, severity: rule.severity, file, message: `Duplicate identifier '${basename}' — also found in ${ids.get(basename)}`, }); } else { ids.set(basename, file); } } } // Run per-file checks for (const file of matchingFiles) { const absPath = path.resolve(targetDir, file); let content; try { content = await fsp.readFile(absPath, 'utf8'); } catch { continue; } const frontmatter = parseFrontmatter(content); for (const check of rule.checks) { if (check.type === 'id-unique') continue; // Already handled const checkDiagnostics = runCheck(check, content, frontmatter, file, targetDir, allFiles); for (const d of checkDiagnostics) { d.ruleId = rule.id; d.ruleName = rule.name; d.severity = d.severity || rule.severity; diagnostics.push(d); } } } return diagnostics; } /** * Collect all files under a target directory */ async function collectFiles(targetDir, recursive) { const pattern = recursive ? '**/*' : '*'; try { const files = await glob(pattern, { cwd: targetDir, nodir: true, dot: false, }); return files.filter(f => f.endsWith('.md') || f.endsWith('.yaml') || f.endsWith('.yml') || f.endsWith('.json')); } catch { return []; } } /** * Auto-detect which rulesets apply to a target path * * Maps known directory patterns to rulesets: * - .aiwg/research/ → research-complete * - .aiwg/requirements/, .aiwg/architecture/ → sdlc-complete */ function autoDetectRulesets(target, available) { const normalized = target.replace(/\\/g, '/'); const matches = []; for (const rs of available) { // Match by framework ID patterns if (normalized.includes('research') && rs.framework.includes('research')) { matches.push(rs); } else if ((normalized.includes('requirements') || normalized.includes('architecture') || normalized.includes('testing') || normalized.includes('deployment')) && rs.framework.includes('sdlc')) { matches.push(rs); } } // If no auto-detection, return all available return matches.length > 0 ? matches : available; } /** * Run lint on a target path with specified or auto-detected rulesets */ export async function runLint(targetDir, rulesets, options = {}) { const recursive = options.recursive ?? true; const allFiles = await collectFiles(targetDir, recursive); const allDiagnostics = []; for (const ruleset of rulesets) { for (const rule of ruleset.rules) { const diagnostics = await runRule(rule, targetDir, allFiles); allDiagnostics.push(...diagnostics); } } const errors = allDiagnostics.filter(d => d.severity === 'error').length; const warnings = allDiagnostics.filter(d => d.severity === 'warn').length; const infos = allDiagnostics.filter(d => d.severity === 'info').length; const failOn = options.failOn || 'error'; let passed = true; if (failOn === 'error' && errors > 0) passed = false; if (failOn === 'warn' && (errors > 0 || warnings > 0)) passed = false; if (failOn === 'info' && allDiagnostics.length > 0) passed = false; return { target: targetDir, rulesets: rulesets.map(r => r.id), diagnostics: allDiagnostics, summary: { filesChecked: allFiles.length, errors, warnings, infos, passed, }, timestamp: new Date().toISOString(), }; } export { autoDetectRulesets }; //# sourceMappingURL=runner.js.map