From ee14c4f691f2f791c863770e5ab2ef4c247128f6 Mon Sep 17 00:00:00 2001 From: Bun Bun Date: Wed, 17 Jun 2026 12:27:02 +0000 Subject: [PATCH] feat: AI Codebase Doc Drift Detector CLI v0.1.0 --- .gitignore | 6 ++ README.md | 69 +++++++++++++++++ package-lock.json | 51 +++++++++++++ package.json | 22 ++++++ src/cli.ts | 82 ++++++++++++++++++++ src/config.ts | 28 +++++++ src/doc-generator.test.ts | 44 +++++++++++ src/doc-generator.ts | 120 +++++++++++++++++++++++++++++ src/drift-detector.test.ts | 51 +++++++++++++ src/drift-detector.ts | 152 +++++++++++++++++++++++++++++++++++++ src/git-tracker.ts | 43 +++++++++++ src/index.ts | 126 ++++++++++++++++++++++++++++++ src/scanner.test.ts | 52 +++++++++++++ src/scanner.ts | 136 +++++++++++++++++++++++++++++++++ src/types.ts | 100 ++++++++++++++++++++++++ tsconfig.json | 19 +++++ 16 files changed, 1101 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/cli.ts create mode 100644 src/config.ts create mode 100644 src/doc-generator.test.ts create mode 100644 src/doc-generator.ts create mode 100644 src/drift-detector.test.ts create mode 100644 src/drift-detector.ts create mode 100644 src/git-tracker.ts create mode 100644 src/index.ts create mode 100644 src/scanner.test.ts create mode 100644 src/scanner.ts create mode 100644 src/types.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..52f26ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +*.log +.verdict +ai-doc-drift.json +docs/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..b3f5668 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# AI Codebase Doc Drift Detector + +> Auto-detect and document code drift before AI context loss. + +## Problem + +As AI generates more code, the gap between what the code does and what the docs say grows. No automated documentation maintenance keeps pace with AI-generated changes. This tool bridges that gap. + +## What It Does + +- **Scans** your TypeScript/JavaScript codebase for exported functions, classes, and interfaces +- **Generates** `API.md` and `ARCHITECTURE.md` automatically +- **Detects drift** — finds symbols in code that aren't documented (or documented symbols that no longer exist) +- **Updates** docs on demand to match current code + +## Install + +```bash +npm install -g ai-codebase-doc-drift-detector +# or use npx +npx ai-codebase-doc-drift-detector init +``` + +## Usage + +```bash +# Initialize config in your project +ai-doc-drift init + +# Generate initial docs +ai-doc-drift scan + +# Check for drift between code and docs +ai-doc-drift check + +# Regenerate docs from current code +ai-doc-drift update +``` + +## Config (`ai-doc-drift.json`) + +```json +{ + "docsDir": "./docs", + "include": ["./src"], + "exclude": ["./node_modules", "./dist", "./docs", "./.git"], + "extensions": [".ts", ".js", ".tsx", ".jsx"], + "projectName": "My Project" +} +``` + +## How It Works + +1. Parses your source files with lightweight regex (no heavy AST dependencies) +2. Extracts exports, classes, methods, interfaces +3. Generates markdown documentation +4. Compares code against existing docs to detect drift + +## Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Success / no drift | +| 1 | Error (no docs found, no config, etc.) | +| 2 | Drift detected | + +## License + +MIT diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ab9260e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,51 @@ +{ + "name": "ai-codebase-doc-drift-detector", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ai-codebase-doc-drift-detector", + "version": "0.1.0", + "license": "MIT", + "bin": { + "ai-doc-drift": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.3.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ad813ff --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "ai-codebase-doc-drift-detector", + "version": "0.1.0", + "description": "Auto-detect and document code drift before AI context loss", + "main": "dist/index.js", + "bin": { + "ai-doc-drift": "dist/cli.js" + }, + "scripts": { + "build": "tsc", + "test": "node --test dist/**/*.test.js", + "lint": "tsc --noEmit" + }, + "keywords": ["documentation", "drift-detection", "ai", "codebase"], + "author": "BunBun Labs", + "license": "MIT", + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.3.0" + }, + "dependencies": {} +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..082ef63 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/** + * CLI entry point for ai-doc-drift. + */ +import { resolve } from 'node:path'; +import { writeFileSync } from 'node:fs'; +import { loadConfig, saveConfig, hasConfig } from './config.js'; +import { DEFAULT_CONFIG } from './types.js'; +import { runCheck, runScan, runUpdate } from './index.js'; + +const args = process.argv.slice(2); +const command = args[0] ?? 'help'; +const cwd = resolve(process.cwd()); + +function help(): never { + console.log(` +ai-doc-drift — AI Codebase Doc Drift Detector + +Commands: + init Create ai-doc-drift.json in the current directory + scan Scan codebase and generate initial docs + check Check for drift between code and existing docs + update Regenerate docs from current code + help Show this message + +Usage: + ai-doc-drift init + ai-doc-drift scan + ai-doc-drift check +`); + process.exit(0); +} + +async function main(): Promise { + switch (command) { + case 'init': { + if (hasConfig(cwd)) { + console.log('ai-doc-drift.json already exists.'); + process.exit(0); + } + saveConfig(cwd, { ...DEFAULT_CONFIG, projectName: requirePackageName(cwd) ?? 'My Project' }); + console.log('Created ai-doc-drift.json'); + process.exit(0); + } + + case 'scan': { + const { report, exitCode } = runScan(cwd); + console.log(report); + process.exit(exitCode); + } + + case 'check': { + const { report, exitCode } = runCheck(cwd); + console.log(report); + process.exit(exitCode); + } + + case 'update': { + const { report, exitCode } = runUpdate(cwd); + console.log(report); + process.exit(exitCode); + } + + case 'help': + default: + help(); + } +} + +function requirePackageName(cwd: string): string | undefined { + try { + const pkg = JSON.parse(require('node:fs').readFileSync(resolve(cwd, 'package.json'), 'utf-8')); + return pkg.name; + } catch { + return undefined; + } +} + +main().catch((err) => { + console.error(err.message); + process.exit(1); +}); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..caf54ef --- /dev/null +++ b/src/config.ts @@ -0,0 +1,28 @@ +/** + * Configuration management — read / write / validate ai-doc-drift.json. + */ +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import type { Config } from './types.js'; +import { DEFAULT_CONFIG } from './types.js'; + +const CONFIG_NAME = 'ai-doc-drift.json'; + +export function loadConfig(cwd: string): Config { + const path = resolve(cwd, CONFIG_NAME); + if (!existsSync(path)) { + return { ...DEFAULT_CONFIG }; + } + const raw = readFileSync(path, 'utf-8'); + const parsed = JSON.parse(raw) as Partial; + return { ...DEFAULT_CONFIG, ...parsed }; +} + +export function saveConfig(cwd: string, config: Config): void { + const path = resolve(cwd, CONFIG_NAME); + writeFileSync(path, JSON.stringify(config, null, 2) + '\n'); +} + +export function hasConfig(cwd: string): boolean { + return existsSync(resolve(cwd, CONFIG_NAME)); +} diff --git a/src/doc-generator.test.ts b/src/doc-generator.test.ts new file mode 100644 index 0000000..338a912 --- /dev/null +++ b/src/doc-generator.test.ts @@ -0,0 +1,44 @@ +/** + * Tests for doc-generator module. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { generateDocs, readExistingDocs } from './doc-generator.js'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { ScannedFile } from './types.js'; + +describe('doc-generator', () => { + it('generates API.md and ARCHITECTURE.md', () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'doc-test-')); + const scanned: ScannedFile[] = [ + { + path: 'src/index.ts', + exports: [{ name: 'run', type: 'function', line: 1 }], + classes: [{ name: 'App', line: 5, methods: [{ name: 'start', line: 6 }], properties: [] }], + functions: [{ name: 'run', line: 1 }], + interfaces: [{ name: 'Config', line: 10, properties: [{ name: 'name', line: 11 }] }], + }, + ]; + const written = generateDocs(scanned, { docsDir: 'docs', include: [], exclude: [], extensions: [], projectName: 'Test' }, tmpDir); + assert.strictEqual(written.length, 2); + const existing = readExistingDocs(join(tmpDir, 'docs')); + assert.strictEqual(existing.size, 2); + const api = existing.get('API.md') ?? ''; + assert.ok(api.includes('run')); + assert.ok(api.includes('App')); + assert.ok(api.includes('Config')); + rmSync(tmpDir, { recursive: true }); + }); + + it('handles empty scanned results', () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'doc-test-')); + const written = generateDocs([], { docsDir: 'docs', include: [], exclude: [], extensions: [], projectName: 'Empty' }, tmpDir); + assert.strictEqual(written.length, 2); + const existing = readExistingDocs(join(tmpDir, 'docs')); + const api = existing.get('API.md') ?? ''; + assert.ok(api.includes('No exported symbols')); + rmSync(tmpDir, { recursive: true }); + }); +}); diff --git a/src/doc-generator.ts b/src/doc-generator.ts new file mode 100644 index 0000000..3dff299 --- /dev/null +++ b/src/doc-generator.ts @@ -0,0 +1,120 @@ +/** + * Doc Generator — turns scanned symbols into markdown docs. + */ +import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs'; +import { resolve, relative } from 'node:path'; +import type { ScannedFile, Config } from './types.js'; + +export function generateDocs(scanned: ScannedFile[], config: Config, cwd: string): string[] { + const docsDir = resolve(cwd, config.docsDir); + mkdirSync(docsDir, { recursive: true }); + + const written: string[] = []; + + // 1. API.md — all exported functions, classes, interfaces + const apiPath = resolve(docsDir, 'API.md'); + const apiMd = renderApiMd(scanned, config.projectName); + writeFileSync(apiPath, apiMd); + written.push(apiPath); + + // 2. ARCHITECTURE.md — file tree + class map + const archPath = resolve(docsDir, 'ARCHITECTURE.md'); + const archMd = renderArchitectureMd(scanned, config.projectName); + writeFileSync(archPath, archMd); + written.push(archPath); + + return written; +} + +function renderApiMd(scanned: ScannedFile[], projectName: string): string { + let md = `# ${projectName} — API Documentation\n\n`; + md += `> Auto-generated by ai-doc-drift. Do not edit manually — run \`ai-doc-drift update\` instead.\n\n`; + + const allFns = scanned.flatMap((f) => f.functions.map((fn) => ({ ...fn, file: f.path }))); + const allClasses = scanned.flatMap((f) => f.classes.map((c) => ({ ...c, file: f.path }))); + const allInterfaces = scanned.flatMap((f) => f.interfaces.map((i) => ({ ...i, file: f.path }))); + + if (allFns.length) { + md += `## Functions\n\n`; + for (const fn of allFns) { + md += `- \`${fn.name}\` — \`${fn.file}:${fn.line}\`\n`; + } + md += '\n'; + } + + if (allClasses.length) { + md += `## Classes\n\n`; + for (const cls of allClasses) { + md += `### \`${cls.name}\` — \`${cls.file}:${cls.line}\`\n\n`; + if (cls.methods.length) { + md += `**Methods:**\n\n`; + for (const m of cls.methods) { + md += `- \`${m.name}\` (line ${m.line})\n`; + } + md += '\n'; + } + if (cls.properties.length) { + md += `**Properties:**\n\n`; + for (const p of cls.properties) { + md += `- \`${p.name}\`\n`; + } + md += '\n'; + } + } + } + + if (allInterfaces.length) { + md += `## Interfaces\n\n`; + for (const iface of allInterfaces) { + md += `### \`${iface.name}\` — \`${iface.file}:${iface.line}\`\n\n`; + if (iface.properties.length) { + for (const p of iface.properties) { + md += `- \`${p.name}\`\n`; + } + md += '\n'; + } + } + } + + if (!allFns.length && !allClasses.length && !allInterfaces.length) { + md += `_No exported symbols detected._\n`; + } + + return md; +} + +function renderArchitectureMd(scanned: ScannedFile[], projectName: string): string { + let md = `# ${projectName} — Architecture Overview\n\n`; + md += `> Auto-generated by ai-doc-drift.\n\n`; + + md += `## File Map\n\n`; + md += `| File | Exports | Classes | Functions | Interfaces |\n`; + md += `|------|---------|---------|-----------|------------|\n`; + for (const f of scanned) { + md += `| \`${f.path}\` | ${f.exports.length} | ${f.classes.length} | ${f.functions.length} | ${f.interfaces.length} |\n`; + } + md += '\n'; + + const allExports = scanned.flatMap((f) => f.exports.map((e) => `${e.name} (${f.path})`)); + if (allExports.length) { + md += `## Export Index\n\n`; + for (const e of allExports) { + md += `- ${e}\n`; + } + md += '\n'; + } + + return md; +} + +export function readExistingDocs(docsDir: string): Map { + const map = new Map(); + const files = ['API.md', 'ARCHITECTURE.md']; + for (const f of files) { + const p = resolve(docsDir, f); + if (existsSync(p)) { + map.set(f, readFileSync(p, 'utf-8')); + } + } + return map; +} diff --git a/src/drift-detector.test.ts b/src/drift-detector.test.ts new file mode 100644 index 0000000..e9c717a --- /dev/null +++ b/src/drift-detector.test.ts @@ -0,0 +1,51 @@ +/** + * Tests for drift-detector module. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { detectDrift } from './drift-detector.js'; +import type { ScannedFile } from './types.js'; + +describe('drift-detector', () => { + it('reports missing symbols from docs', () => { + const scanned: ScannedFile[] = [ + { + path: 'src/utils.ts', + exports: [{ name: 'foo', type: 'function', line: 1 }], + classes: [], + functions: [{ name: 'foo', line: 1 }], + interfaces: [], + }, + ]; + const docs = new Map(); + docs.set('API.md', '# API\n\n## Functions\n\n- `bar` — line 1\n'); + const report = detectDrift(scanned, docs); + assert.ok(report.items.some((i) => i.symbol === 'foo' && i.type === 'missing_from_docs')); + assert.ok(report.items.some((i) => i.symbol === 'bar' && i.type === 'removed_from_code')); + }); + + it('reports clean when in sync', () => { + const scanned: ScannedFile[] = [ + { + path: 'src/utils.ts', + exports: [{ name: 'foo', type: 'function', line: 1 }], + classes: [], + functions: [{ name: 'foo', line: 1 }], + interfaces: [], + }, + ]; + const docs = new Map(); + docs.set('API.md', '# API\n\n## Functions\n\n- `foo` — line 1\n'); + const report = detectDrift(scanned, docs); + assert.strictEqual(report.items.length, 0); + assert.strictEqual(report.summary.total, 0); + }); + + it('detects removed class', () => { + const scanned: ScannedFile[] = []; + const docs = new Map(); + docs.set('API.md', '## Classes\n\n### `User`\n'); + const report = detectDrift(scanned, docs); + assert.ok(report.items.some((i) => i.symbol === 'User' && i.type === 'removed_from_code')); + }); +}); diff --git a/src/drift-detector.ts b/src/drift-detector.ts new file mode 100644 index 0000000..2d51edd --- /dev/null +++ b/src/drift-detector.ts @@ -0,0 +1,152 @@ +/** + * Drift Detector — compare scanned code against existing docs. + */ +import type { ScannedFile, DriftReport, DriftItem } from './types.js'; + +export function detectDrift(scanned: ScannedFile[], existingDocs: Map): DriftReport { + const items: DriftItem[] = []; + + // Build set of all symbols currently in code + const codeSymbols = new Set(); + const symbolToFile = new Map(); + for (const f of scanned) { + for (const e of f.exports) { + const key = `${f.path}::${e.name}`; + codeSymbols.add(key); + symbolToFile.set(e.name, f.path); + } + for (const c of f.classes) { + const key = `${f.path}::${c.name}`; + codeSymbols.add(key); + symbolToFile.set(c.name, f.path); + } + for (const fn of f.functions) { + const key = `${f.path}::${fn.name}`; + codeSymbols.add(key); + symbolToFile.set(fn.name, f.path); + } + for (const i of f.interfaces) { + const key = `${f.path}::${i.name}`; + codeSymbols.add(key); + symbolToFile.set(i.name, f.path); + } + } + + // Parse documented symbols from API.md + const apiText = existingDocs.get('API.md') ?? ''; + const docSymbols = extractSymbolsFromMarkdown(apiText); + + // Find symbols in docs but not in code (removed) + for (const sym of docSymbols) { + let found = false; + for (const key of codeSymbols) { + if (key.endsWith(`::${sym}`)) { + found = true; + break; + } + } + if (!found) { + items.push({ + type: 'removed_from_code', + symbol: sym, + file: symbolToFile.get(sym) ?? 'unknown', + message: `\`${sym}\` is documented but no longer exists in code`, + }); + } + } + + // Find symbols in code but not in docs (missing) + const docSymbolSet = new Set(docSymbols); + for (const f of scanned) { + for (const e of f.exports) { + if (!docSymbolSet.has(e.name) && e.name !== 'default') { + items.push({ + type: 'missing_from_docs', + symbol: e.name, + file: f.path, + line: e.line, + message: `\`${e.name}\` exported in \`${f.path}\` but not documented`, + }); + } + } + for (const c of f.classes) { + if (!docSymbolSet.has(c.name)) { + items.push({ + type: 'missing_from_docs', + symbol: c.name, + file: f.path, + line: c.line, + message: `Class \`${c.name}\` defined in \`${f.path}\` but not documented`, + }); + } + } + for (const fn of f.functions) { + if (!docSymbolSet.has(fn.name)) { + items.push({ + type: 'missing_from_docs', + symbol: fn.name, + file: f.path, + line: fn.line, + message: `Function \`${fn.name}\` in \`${f.path}\` but not documented`, + }); + } + } + } + + const summary = { + total: items.length, + missingFromDocs: items.filter((i) => i.type === 'missing_from_docs').length, + staleInDocs: items.filter((i) => i.type === 'stale_in_docs').length, + newInCode: items.filter((i) => i.type === 'new_in_code').length, + removedFromCode: items.filter((i) => i.type === 'removed_from_code').length, + }; + + return { + timestamp: new Date().toISOString(), + projectName: 'project', + items, + summary, + }; +} + +function extractSymbolsFromMarkdown(md: string): string[] { + const symbols: string[] = []; + // Match backtick-wrapped names after ### or - + const headingRe = /###\s+`?([A-Za-z_$][A-Za-z0-9_$]*)`?/g; + const listRe = /-\s+`?([A-Za-z_$][A-Za-z0-9_$]*)`?/g; + let m: RegExpExecArray | null; + while ((m = headingRe.exec(md)) !== null) symbols.push(m[1]); + while ((m = listRe.exec(md)) !== null) { + if (!symbols.includes(m[1])) symbols.push(m[1]); + } + return symbols; +} + +export function renderDriftReport(report: DriftReport): string { + let out = `# Doc Drift Report\n\n`; + out += `**Generated:** ${report.timestamp}\n`; + out += `**Total issues:** ${report.summary.total}\n\n`; + + if (report.items.length === 0) { + out += `✅ No drift detected — docs are in sync with code.\n`; + return out; + } + + if (report.summary.missingFromDocs > 0) { + out += `## Missing from Docs (${report.summary.missingFromDocs})\n\n`; + for (const item of report.items.filter((i) => i.type === 'missing_from_docs')) { + out += `- ${item.message}\n`; + } + out += '\n'; + } + + if (report.summary.removedFromCode > 0) { + out += `## Removed from Code (${report.summary.removedFromCode})\n\n`; + for (const item of report.items.filter((i) => i.type === 'removed_from_code')) { + out += `- ${item.message}\n`; + } + out += '\n'; + } + + return out; +} diff --git a/src/git-tracker.ts b/src/git-tracker.ts new file mode 100644 index 0000000..66500b3 --- /dev/null +++ b/src/git-tracker.ts @@ -0,0 +1,43 @@ +/** + * Git Tracker — discover changed files via git. + */ +import { execSync } from 'node:child_process'; + +export function getGitRoot(cwd: string): string | null { + try { + return execSync('git rev-parse --show-toplevel', { cwd, encoding: 'utf-8' }).trim(); + } catch { + return null; + } +} + +export function getChangedFiles(cwd: string, sinceRef?: string): string[] { + try { + const cmd = sinceRef + ? `git diff --name-only ${sinceRef} --` + : 'git diff --name-only HEAD~1 --'; + const out = execSync(cmd, { cwd, encoding: 'utf-8' }).trim(); + if (!out) return []; + return out.split('\n').filter(Boolean); + } catch { + return []; + } +} + +export function getAllTrackedFiles(cwd: string): string[] { + try { + const out = execSync('git ls-files', { cwd, encoding: 'utf-8' }).trim(); + if (!out) return []; + return out.split('\n').filter(Boolean); + } catch { + return []; + } +} + +export function getLastCommitDate(cwd: string): string | null { + try { + return execSync('git log -1 --format=%cI', { cwd, encoding: 'utf-8' }).trim(); + } catch { + return null; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..adfbc63 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,126 @@ +/** + * 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, + }; +} diff --git a/src/scanner.test.ts b/src/scanner.test.ts new file mode 100644 index 0000000..0b97631 --- /dev/null +++ b/src/scanner.test.ts @@ -0,0 +1,52 @@ +/** + * Tests for scanner module. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { scanFile } from './scanner.js'; + +describe('scanner', () => { + it('extracts exported functions', () => { + const source = `export function greet(name: string): string {\n return 'Hello, ' + name;\n}\n`; + const result = scanFile('test.ts', source); + assert.strictEqual(result.exports.length, 1); + assert.strictEqual(result.exports[0].name, 'greet'); + assert.strictEqual(result.exports[0].type, 'function'); + }); + + it('extracts exported const arrow functions', () => { + const source = `export const add = (a: number, b: number): number => a + b;\n`; + const result = scanFile('test.ts', source); + assert.ok(result.functions.some((f) => f.name === 'add')); + }); + + it('extracts classes and methods', () => { + const source = `export class User {\n name: string;\n greet(): string {\n return 'hi';\n }\n}\n`; + const result = scanFile('test.ts', source); + assert.strictEqual(result.classes.length, 1); + assert.strictEqual(result.classes[0].name, 'User'); + assert.ok(result.classes[0].methods.some((m) => m.name === 'greet')); + }); + + it('extracts interfaces', () => { + const source = `export interface Config {\n name: string;\n value: number;\n}\n`; + const result = scanFile('test.ts', source); + assert.strictEqual(result.interfaces.length, 1); + assert.strictEqual(result.interfaces[0].name, 'Config'); + assert.strictEqual(result.interfaces[0].properties.length, 2); + }); + + it('handles empty source', () => { + const result = scanFile('empty.ts', ''); + assert.strictEqual(result.exports.length, 0); + assert.strictEqual(result.classes.length, 0); + assert.strictEqual(result.functions.length, 0); + assert.strictEqual(result.interfaces.length, 0); + }); + + it('extracts default export', () => { + const source = `export default function main() {\n return 1;\n}\n`; + const result = scanFile('test.ts', source); + assert.ok(result.exports.some((e) => e.type === 'default')); + }); +}); diff --git a/src/scanner.ts b/src/scanner.ts new file mode 100644 index 0000000..8be8ac8 --- /dev/null +++ b/src/scanner.ts @@ -0,0 +1,136 @@ +/** + * Scanner — walks source files and extracts exported symbols. + * + * Uses lightweight regex parsing (no AST parser deps) so the CLI stays + * dependency-free and fast for MVP purposes. + */ +import type { ScannedFile, Export, ClassDef, FunctionDef, InterfaceDef, MethodDef, PropertyDef } from './types.js'; + +const EXPORT_DEFAULT_RE = /^\s*export\s+default\s+(?:function\s+)?(?:class\s+)?([A-Za-z_$][A-Za-z0-9_$]*)?/gm; +const EXPORT_RE = + /^\s*export\s+(?:(?:async\s+function|function|class|interface|const|let|var|type)\s+)([A-Za-z_$][A-Za-z0-9_$]*)?/gm; + +const CLASS_RE = /class\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:<[^>]+>)?\s*(?:extends\s+\S+)?\s*\{/g; +const METHOD_RE = /^(?:\s+)?(?:async\s+)?(?:get\s+|set\s+)?([A-Za-z_$][A-Za-z0-9_$]*)\s*\([^)]*\)\s*(?::\s*[^{]+)?\s*\{/gm; +const FN_RE = /(?:^|\s)(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/gm; +const ARROW_FN_RE = + /^\s*export\s+const\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*[:=]\s*(?:<[^>]+>)?\s*\([^)]*\)\s*(?::\s*[^{]+)?\s*=>/gm; +const INTERFACE_RE = /interface\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:<[^>]+>)?\s*\{/g; +const PROP_RE = /^\s+([A-Za-z_$][A-Za-z0-9_$]+)\??\s*:/gm; + +export function scanFile(filePath: string, source: string): ScannedFile { + const exports: Export[] = []; + const classes: ClassDef[] = []; + const functions: FunctionDef[] = []; + const interfaces: InterfaceDef[] = []; + + // Detect export default + EXPORT_DEFAULT_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = EXPORT_DEFAULT_RE.exec(source)) !== null) { + const lineNum = lineAtIndex(source, m.index); + const name = m[1] ?? 'default'; + exports.push({ name, type: 'default', line: lineNum }); + } + + // Detect named exports + EXPORT_RE.lastIndex = 0; + while ((m = EXPORT_RE.exec(source)) !== null) { + const lineNum = lineAtIndex(source, m.index); + const name = m[1] ?? 'anonymous'; + let type: Export['type'] = 'unknown'; + const decl = m[0]; + if (decl.includes('class')) type = 'class'; + else if (decl.includes('interface')) type = 'interface'; + else if (decl.includes('function') || decl.includes('async')) type = 'function'; + else if (decl.includes('const')) type = 'const'; + else if (decl.includes('let')) type = 'let'; + else if (decl.includes('var')) type = 'var'; + else if (decl.includes('type')) type = 'type'; + exports.push({ name, type, line: lineNum }); + } + + // Extract classes + methods + CLASS_RE.lastIndex = 0; + while ((m = CLASS_RE.exec(source)) !== null) { + const lineNum = lineAtIndex(source, m.index); + const className = m[1]; + const classBodyStart = m.index + m[0].length; + let braceDepth = 1; + let idx = classBodyStart; + while (braceDepth > 0 && idx < source.length) { + if (source[idx] === '{') braceDepth++; + else if (source[idx] === '}') braceDepth--; + idx++; + } + const classBody = source.slice(classBodyStart, idx); + const methods: MethodDef[] = []; + const properties: PropertyDef[] = []; + + METHOD_RE.lastIndex = 0; + let mm: RegExpExecArray | null; + while ((mm = METHOD_RE.exec(classBody)) !== null) { + methods.push({ + name: mm[1], + line: lineNum + lineAtIndex(classBody, mm.index), + }); + } + + PROP_RE.lastIndex = 0; + while ((mm = PROP_RE.exec(classBody)) !== null) { + const propLine = classBody.slice(0, mm.index).split('\n').length; + const lineText = classBody.split('\n')[propLine - 1] ?? ''; + if (!lineText.trim().startsWith('//') && !lineText.includes('=>') && !lineText.includes('function')) { + properties.push({ name: mm[1], line: lineNum + propLine }); + } + } + + classes.push({ name: className, line: lineNum, methods, properties }); + } + + // Extract standalone functions + FN_RE.lastIndex = 0; + while ((m = FN_RE.exec(source)) !== null) { + const lineNum = lineAtIndex(source, m.index); + functions.push({ name: m[1], line: lineNum }); + } + + ARROW_FN_RE.lastIndex = 0; + while ((m = ARROW_FN_RE.exec(source)) !== null) { + const lineNum = lineAtIndex(source, m.index); + functions.push({ name: m[1], line: lineNum }); + } + + // Extract interfaces + INTERFACE_RE.lastIndex = 0; + while ((m = INTERFACE_RE.exec(source)) !== null) { + const lineNum = lineAtIndex(source, m.index); + const ifaceName = m[1]; + const bodyStart = m.index + m[0].length; + let braceDepth = 1; + let idx = bodyStart; + while (braceDepth > 0 && idx < source.length) { + if (source[idx] === '{') braceDepth++; + else if (source[idx] === '}') braceDepth--; + idx++; + } + const body = source.slice(bodyStart, idx); + const properties: PropertyDef[] = []; + PROP_RE.lastIndex = 0; + let mp: RegExpExecArray | null; + while ((mp = PROP_RE.exec(body)) !== null) { + properties.push({ name: mp[1], line: lineNum + lineAtIndex(body, mp.index) }); + } + interfaces.push({ name: ifaceName, line: lineNum, properties }); + } + + return { path: filePath, exports, classes, functions, interfaces }; +} + +function lineAtIndex(text: string, index: number): number { + let line = 1; + for (let i = 0; i < index && i < text.length; i++) { + if (text[i] === '\n') line++; + } + return line; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..b42a1d7 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,100 @@ +/** + * Core types for the doc drift detector. + */ + +export interface Config { + docsDir: string; + include: string[]; + exclude: string[]; + extensions: string[]; + projectName: string; +} + +export interface ScannedFile { + path: string; + exports: Export[]; + classes: ClassDef[]; + functions: FunctionDef[]; + interfaces: InterfaceDef[]; +} + +export interface Export { + name: string; + type: 'function' | 'class' | 'interface' | 'const' | 'let' | 'var' | 'default' | 'type' | 'unknown'; + line: number; + signature?: string; +} + +export interface ClassDef { + name: string; + line: number; + methods: MethodDef[]; + properties: PropertyDef[]; +} + +export interface MethodDef { + name: string; + line: number; + signature?: string; +} + +export interface PropertyDef { + name: string; + line: number; + type?: string; +} + +export interface FunctionDef { + name: string; + line: number; + signature?: string; +} + +export interface InterfaceDef { + name: string; + line: number; + properties: PropertyDef[]; +} + +export interface DocFile { + path: string; + title: string; + sections: DocSection[]; +} + +export interface DocSection { + heading: string; + level: number; + content: string; + lineStart: number; + lineEnd: number; +} + +export interface DriftItem { + type: 'missing_from_docs' | 'stale_in_docs' | 'new_in_code' | 'removed_from_code'; + symbol: string; + file: string; + line?: number; + message: string; +} + +export interface DriftReport { + timestamp: string; + projectName: string; + items: DriftItem[]; + summary: { + total: number; + missingFromDocs: number; + staleInDocs: number; + newInCode: number; + removedFromCode: number; + }; +} + +export const DEFAULT_CONFIG: Config = { + docsDir: './docs', + include: ['./src'], + exclude: ['./node_modules', './dist', './docs', './.git', './**/*.test.*', './**/*.spec.*'], + extensions: ['.ts', '.js', '.tsx', '.jsx'], + projectName: 'My Project', +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f44baa9 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}