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