feat: AI Codebase Doc Drift Detector CLI v0.1.0

This commit is contained in:
Bun Bun
2026-06-17 12:27:02 +00:00
commit ee14c4f691
16 changed files with 1101 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
*.log
.verdict
ai-doc-drift.json
docs/
+69
View File
@@ -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
+51
View File
@@ -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"
}
}
}
+22
View File
@@ -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": {}
}
+82
View File
@@ -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<void> {
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);
});
+28
View File
@@ -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<Config>;
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));
}
+44
View File
@@ -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 });
});
});
+120
View File
@@ -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<string, string> {
const map = new Map<string, string>();
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;
}
+51
View File
@@ -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<string, string>();
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<string, string>();
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<string, string>();
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'));
});
});
+152
View File
@@ -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<string, string>): DriftReport {
const items: DriftItem[] = [];
// Build set of all symbols currently in code
const codeSymbols = new Set<string>();
const symbolToFile = new Map<string, string>();
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;
}
+43
View File
@@ -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;
}
}
+126
View File
@@ -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,
};
}
+52
View File
@@ -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'));
});
});
+136
View File
@@ -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;
}
+100
View File
@@ -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',
};
+19
View File
@@ -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"]
}