/** * Core library — orchestrates scan / check / update operations. */ import { readFileSync, readdirSync, statSync } from 'node:fs'; import { resolve, relative, extname } from 'node:path'; import type { Config, ScannedFile } from './types.js'; import { loadConfig, hasConfig } from './config.js'; import { scanFile } from './scanner.js'; import { generateDocs, readExistingDocs } from './doc-generator.js'; import { detectDrift, renderDriftReport } from './drift-detector.js'; import { getGitRoot, getAllTrackedFiles } from './git-tracker.js'; function shouldInclude(filePath: string, config: Config, cwd: string): boolean { const rel = relative(cwd, filePath); const ext = extname(filePath); if (!config.extensions.includes(ext)) return false; for (const exc of config.exclude) { const pattern = exc.replace(/\*\*/g, '.*').replace(/\*/g, '[^/]*'); const re = new RegExp(pattern); if (re.test(rel)) return false; } for (const inc of config.include) { const incPath = resolve(cwd, inc); const incStat = statSync(incPath); if (incStat.isDirectory()) { if (rel.startsWith(relative(cwd, incPath))) return true; } else if (rel === relative(cwd, incPath)) { return true; } } return false; } export function scanProject(cwd: string): { scanned: ScannedFile[]; config: Config } { if (!hasConfig(cwd)) { throw new Error(`No ai-doc-drift.json found in ${cwd}. Run \`ai-doc-drift init\` first.`); } const config = loadConfig(cwd); const gitRoot = getGitRoot(cwd); let files: string[] = []; if (gitRoot) { files = getAllTrackedFiles(cwd) .map((f) => resolve(cwd, f)) .filter((f) => shouldInclude(f, config, cwd)); } else { // Fallback: recursive walk of include dirs const walk = (dir: string): string[] => { const entries = readdirSync(dir, { withFileTypes: true }); const result: string[] = []; for (const e of entries) { const p = resolve(dir, e.name); if (e.isDirectory()) { const rel = relative(cwd, p); let excluded = false; for (const exc of config.exclude) { const pattern = exc.replace(/\*\*/g, '.*').replace(/\*/g, '[^/]*'); const re = new RegExp(pattern); if (re.test(rel)) { excluded = true; break; } } if (!excluded) result.push(...walk(p)); } else if (e.isFile() && shouldInclude(p, config, cwd)) { result.push(p); } } return result; }; for (const inc of config.include) { const incPath = resolve(cwd, inc); if (statSync(incPath).isDirectory()) { files.push(...walk(incPath)); } else { files.push(incPath); } } } const scanned: ScannedFile[] = []; for (const f of files) { try { const source = readFileSync(f, 'utf-8'); scanned.push(scanFile(relative(cwd, f), source)); } catch { // skip unreadable } } return { scanned, config }; } export function runCheck(cwd: string): { report: string; exitCode: number } { const { scanned, config } = scanProject(cwd); const existing = readExistingDocs(resolve(cwd, config.docsDir)); if (existing.size === 0) { return { report: `No existing docs found in ${config.docsDir}. Run \`ai-doc-drift scan\` first.`, exitCode: 1, }; } const drift = detectDrift(scanned, existing); drift.projectName = config.projectName; const report = renderDriftReport(drift); return { report, exitCode: drift.items.length > 0 ? 2 : 0 }; } export function runScan(cwd: string): { report: string; exitCode: number } { const { scanned, config } = scanProject(cwd); const written = generateDocs(scanned, config, cwd); return { report: `Generated docs:\n${written.map((w) => ` - ${w}`).join('\n')}`, exitCode: 0, }; } export function runUpdate(cwd: string): { report: string; exitCode: number } { const { scanned, config } = scanProject(cwd); const written = generateDocs(scanned, config, cwd); return { report: `Updated docs:\n${written.map((w) => ` - ${w}`).join('\n')}`, exitCode: 0, }; }