feat: AI Codebase Doc Drift Detector CLI v0.1.0
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.verdict
|
||||
ai-doc-drift.json
|
||||
docs/
|
||||
@@ -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
|
||||
Generated
+51
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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',
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user