docguard-cli 0.23.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/cli/commands/diff.mjs +1 -1
- package/cli/commands/explain.mjs +178 -17
- package/cli/commands/fix.mjs +17 -2
- package/cli/commands/generate.mjs +2 -2
- package/cli/commands/guard.mjs +86 -11
- package/cli/commands/hooks.mjs +12 -7
- package/cli/commands/init.mjs +18 -6
- package/cli/commands/score.mjs +147 -61
- package/cli/commands/setup.mjs +2 -2
- package/cli/commands/trace.mjs +3 -3
- package/cli/commands/upgrade.mjs +61 -13
- package/cli/config.mjs +18 -1
- package/cli/docguard.mjs +19 -0
- package/cli/ensure-skills.mjs +24 -26
- package/cli/scanners/api-doc.mjs +17 -3
- package/cli/scanners/doc-tools.mjs +32 -15
- package/cli/scanners/frontend.mjs +24 -8
- package/cli/scanners/js-ast.mjs +432 -0
- package/cli/scanners/memory-plan.mjs +1 -1
- package/cli/scanners/py-ast.mjs +213 -0
- package/cli/scanners/routes.mjs +194 -69
- package/cli/scanners/schemas.mjs +97 -51
- package/cli/shared-git.mjs +0 -0
- package/cli/shared-ignore.mjs +16 -1
- package/cli/shared-source.mjs +59 -2
- package/cli/shared-trace-patterns.mjs +13 -0
- package/cli/shared.mjs +60 -1
- package/cli/validator-markers.mjs +91 -0
- package/cli/validators/api-surface.mjs +37 -3
- package/cli/validators/canonical-sync.mjs +22 -19
- package/cli/validators/doc-quality.mjs +2 -42
- package/cli/validators/docs-coverage.mjs +13 -0
- package/cli/validators/docs-sync.mjs +4 -3
- package/cli/validators/drift.mjs +3 -2
- package/cli/validators/freshness.mjs +47 -15
- package/cli/validators/metadata-sync.mjs +21 -11
- package/cli/validators/metrics-consistency.mjs +45 -17
- package/cli/validators/security.mjs +13 -5
- package/cli/validators/structure.mjs +6 -5
- package/cli/validators/surface-sync.mjs +7 -5
- package/cli/validators/test-spec.mjs +76 -51
- package/cli/validators/todo-tracking.mjs +4 -2
- package/cli/validators/traceability.mjs +11 -3
- package/cli/writers/sections.mjs +32 -19
- package/docs/commands.md +1 -1
- package/docs/configuration.md +11 -0
- package/docs/faq.md +1 -1
- package/extensions/spec-kit-docguard/README.md +1 -1
- package/extensions/spec-kit-docguard/extension.yml +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -1
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-autofix.yml +3 -2
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +2 -2
- package/package.json +5 -3
package/cli/scanners/routes.mjs
CHANGED
|
@@ -8,12 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
10
10
|
import { resolve, join, relative, basename, extname, dirname } from 'node:path';
|
|
11
|
-
import { resolveSourceRoots } from '../shared-source.mjs';
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
'.cache', '__pycache__', '.venv', 'vendor', '.turbo',
|
|
16
|
-
]);
|
|
11
|
+
import { resolveSourceRoots, readScannable } from '../shared-source.mjs';
|
|
12
|
+
import { DEFAULT_IGNORE_DIRS as IGNORE_DIRS, shouldIgnore, relPosix } from '../shared-ignore.mjs';
|
|
13
|
+
import { extractJsRouteCalls, extractJsRouteObjects, extractJsMountsAndImports } from './js-ast.mjs';
|
|
14
|
+
import { extractPythonFiles } from './py-ast.mjs';
|
|
17
15
|
|
|
18
16
|
/**
|
|
19
17
|
* Scan routes from source code with framework-aware parsing.
|
|
@@ -80,12 +78,16 @@ export function scanRoutesDeep(dir, stack, docTools, opts = {}) {
|
|
|
80
78
|
routes.push(...scanFastAPIRoutes(dir));
|
|
81
79
|
}
|
|
82
80
|
|
|
83
|
-
// Deduplicate by method+path
|
|
81
|
+
// Deduplicate by method+path, and honor .docguardignore / config.ignore so a
|
|
82
|
+
// fixtures dir with fake routes doesn't pollute the API surface. Filtering the
|
|
83
|
+
// RESULTS (route.file → project-relative) keeps the per-framework walkers as-is.
|
|
84
|
+
const cfg = opts.config || {};
|
|
84
85
|
const seen = new Set();
|
|
85
86
|
return routes.filter(r => {
|
|
86
87
|
const key = `${r.method}:${r.path}`;
|
|
87
88
|
if (seen.has(key)) return false;
|
|
88
89
|
seen.add(key);
|
|
90
|
+
if (r.file && shouldIgnore(relPosix(dir, resolve(dir, r.file)), cfg)) return false;
|
|
89
91
|
return true;
|
|
90
92
|
});
|
|
91
93
|
}
|
|
@@ -118,8 +120,15 @@ function scanNextJsRoutes(dir) {
|
|
|
118
120
|
const relDir = relative(resolve(dir, apiBase), dirname(filePath));
|
|
119
121
|
const apiPath = '/' + relDir
|
|
120
122
|
.replace(/\\/g, '/')
|
|
121
|
-
|
|
122
|
-
.
|
|
123
|
+
// Strip route-group segments like `(admin)` — they organize files but
|
|
124
|
+
// do NOT appear in the URL. The frontend scanner already does this; the
|
|
125
|
+
// route scanner used to leak them, e.g. `/api/(admin)/users`.
|
|
126
|
+
.split('/')
|
|
127
|
+
.filter(seg => seg && !/^\(.*\)$/.test(seg))
|
|
128
|
+
.join('/')
|
|
129
|
+
.replace(/\[\[\.\.\.(\w+)\]\]/g, ':$1*') // Optional catch-all [[...slug]] — before [...slug]
|
|
130
|
+
.replace(/\[\.\.\.(\w+)\]/g, ':$1*') // Catch-all [...slug]
|
|
131
|
+
.replace(/\[(\w+)\]/g, ':$1'); // Dynamic [id]
|
|
123
132
|
|
|
124
133
|
// Extract exported HTTP methods
|
|
125
134
|
const methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
|
|
@@ -195,9 +204,22 @@ function scanNextJsRoutes(dir) {
|
|
|
195
204
|
// ── Express / Generic Node.js ───────────────────────────────────────────────
|
|
196
205
|
|
|
197
206
|
function scanExpressRoutes(dir, roots = null) {
|
|
198
|
-
|
|
207
|
+
// Regex is the FALLBACK (used only when @babel/parser can't parse a file).
|
|
208
|
+
// It hardcodes app/router/server receivers; the AST path matches any receiver.
|
|
199
209
|
const routePattern = /(?:app|router|server)\s*\.\s*(get|post|put|delete|patch|head|options)\s*\(\s*['"`]([^'"`]+)['"`]/gi;
|
|
200
210
|
|
|
211
|
+
// ── Phase 0: collect every candidate file ONCE ──────────────────────────────
|
|
212
|
+
// The mount map (phase 1) and the route emit (phase 2) must see the same set,
|
|
213
|
+
// and we don't want to walk the tree twice.
|
|
214
|
+
const files = []; // { content, filePath, fileLabel }
|
|
215
|
+
const seenPaths = new Set();
|
|
216
|
+
const addFile = (filePath, fileLabel) => {
|
|
217
|
+
if (seenPaths.has(filePath)) return;
|
|
218
|
+
const content = readFileSafe(filePath);
|
|
219
|
+
if (!content) return;
|
|
220
|
+
seenPaths.add(filePath);
|
|
221
|
+
files.push({ content, filePath, fileLabel });
|
|
222
|
+
};
|
|
201
223
|
// Monorepo-aware: walk resolved absolute source roots when provided,
|
|
202
224
|
// otherwise fall back to conventional root-relative directories.
|
|
203
225
|
const searchTargets = roots && roots.length
|
|
@@ -205,51 +227,114 @@ function scanExpressRoutes(dir, roots = null) {
|
|
|
205
227
|
: ['src', 'routes', 'api', 'server', 'lib'].map(d => resolve(dir, d));
|
|
206
228
|
for (const fullDir of searchTargets) {
|
|
207
229
|
if (!existsSync(fullDir)) continue;
|
|
208
|
-
|
|
209
230
|
walkRouteDirs(fullDir, (filePath) => {
|
|
210
|
-
if (
|
|
211
|
-
|
|
212
|
-
|
|
231
|
+
if (isJSFile(filePath)) addFile(filePath, relative(dir, filePath));
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
for (const rootFile of ['app.js', 'app.mjs', 'app.ts', 'server.js', 'server.ts', 'index.js', 'index.ts']) {
|
|
235
|
+
const filePath = resolve(dir, rootFile);
|
|
236
|
+
if (existsSync(filePath)) addFile(filePath, rootFile);
|
|
237
|
+
}
|
|
213
238
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
239
|
+
// ── Phase 1: build the mount map ────────────────────────────────────────────
|
|
240
|
+
// absFilePath -> [{ receiver|null, prefix }]. `receiver === null` means the
|
|
241
|
+
// prefix applies to EVERY route in that file (an imported sub-router); a
|
|
242
|
+
// non-null receiver means it applies only to routes whose receiver matches
|
|
243
|
+
// (a same-file `const r = Router(); app.use('/api', r)` — so a sibling
|
|
244
|
+
// `app.get('/health')` in the same file is NOT wrongly prefixed).
|
|
245
|
+
const mountMap = buildExpressMountMap(files);
|
|
246
|
+
|
|
247
|
+
// ── Phase 2: emit routes, prefixing by mount where known ────────────────────
|
|
248
|
+
const routes = [];
|
|
249
|
+
for (const { content, filePath, fileLabel } of files) {
|
|
250
|
+
const mounts = mountMap.get(filePath) || [];
|
|
251
|
+
const emit = (method, path, index, receiver) => {
|
|
252
|
+
const prefixes = mounts
|
|
253
|
+
.filter(m => m.receiver === null || m.receiver === receiver)
|
|
254
|
+
.map(m => m.prefix);
|
|
255
|
+
const finalPaths = prefixes.length ? prefixes.map(p => joinRoutePath(p, path)) : [path];
|
|
256
|
+
for (const fullPath of finalPaths) {
|
|
217
257
|
routes.push({
|
|
218
|
-
method:
|
|
219
|
-
path:
|
|
220
|
-
handler: extractHandlerName(content,
|
|
221
|
-
file:
|
|
258
|
+
method: method.toUpperCase(),
|
|
259
|
+
path: fullPath,
|
|
260
|
+
handler: extractHandlerName(content, index),
|
|
261
|
+
file: fileLabel,
|
|
222
262
|
source: 'express',
|
|
223
|
-
auth: hasAuthMiddleware(content,
|
|
224
|
-
description: extractNearbyComment(content,
|
|
263
|
+
auth: hasAuthMiddleware(content, path),
|
|
264
|
+
description: extractNearbyComment(content, index),
|
|
225
265
|
});
|
|
226
266
|
}
|
|
227
|
-
}
|
|
267
|
+
};
|
|
268
|
+
const ast = extractJsRouteCalls(content, filePath);
|
|
269
|
+
if (ast) {
|
|
270
|
+
for (const r of ast) emit(r.method, r.path, r.start, r.receiver ?? null);
|
|
271
|
+
} else {
|
|
272
|
+
const regex = new RegExp(routePattern.source, 'gi');
|
|
273
|
+
let match;
|
|
274
|
+
while ((match = regex.exec(content)) !== null) emit(match[1], match[2], match.index, null);
|
|
275
|
+
}
|
|
228
276
|
}
|
|
229
277
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const filePath = resolve(dir, rootFile);
|
|
233
|
-
if (!existsSync(filePath)) continue;
|
|
234
|
-
const content = readFileSafe(filePath);
|
|
235
|
-
if (!content) return;
|
|
278
|
+
return routes;
|
|
279
|
+
}
|
|
236
280
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
281
|
+
/**
|
|
282
|
+
* Build the Express mount map from the collected files (phase 1 above).
|
|
283
|
+
* For each `<x>.use('/prefix', router)`:
|
|
284
|
+
* - router is an IMPORTED binding → the prefix applies to ALL routes in the
|
|
285
|
+
* resolved target file (receiver: null). One router per file is the norm.
|
|
286
|
+
* - router is a LOCAL identifier → the prefix applies only to that file's
|
|
287
|
+
* routes whose receiver matches (receiver: ident).
|
|
288
|
+
*
|
|
289
|
+
* Known limitations (documented, not silently wrong): transitive composition
|
|
290
|
+
* (`app.use('/api', api)` then `api.use('/x', x)` does NOT yield `/api/x`), and
|
|
291
|
+
* dynamic mount paths (non-string-literal prefixes) are skipped. Unmounted
|
|
292
|
+
* files keep their bare paths — exactly the pre-mount-map behavior.
|
|
293
|
+
*/
|
|
294
|
+
function buildExpressMountMap(files) {
|
|
295
|
+
const map = new Map();
|
|
296
|
+
const add = (absFile, receiver, prefix) => {
|
|
297
|
+
if (!map.has(absFile)) map.set(absFile, []);
|
|
298
|
+
map.get(absFile).push({ receiver, prefix });
|
|
299
|
+
};
|
|
300
|
+
for (const { content, filePath } of files) {
|
|
301
|
+
const mi = extractJsMountsAndImports(content, filePath);
|
|
302
|
+
if (!mi) continue;
|
|
303
|
+
for (const { prefix, ident } of mi.mounts) {
|
|
304
|
+
const spec = mi.imports[ident];
|
|
305
|
+
if (spec) {
|
|
306
|
+
const target = resolveLocalImport(filePath, spec);
|
|
307
|
+
if (target) add(target, null, prefix);
|
|
308
|
+
} else {
|
|
309
|
+
add(filePath, ident, prefix);
|
|
310
|
+
}
|
|
249
311
|
}
|
|
250
312
|
}
|
|
313
|
+
return map;
|
|
314
|
+
}
|
|
251
315
|
|
|
252
|
-
|
|
316
|
+
/** Resolve a RELATIVE import specifier to an absolute file path (best effort). */
|
|
317
|
+
function resolveLocalImport(fromFile, spec) {
|
|
318
|
+
if (!spec.startsWith('.')) return null; // bare/node_modules specifiers aren't our routers
|
|
319
|
+
const base = resolve(dirname(fromFile), spec);
|
|
320
|
+
for (const ext of ['', '.ts', '.js', '.mjs', '.cjs', '.tsx', '.jsx']) {
|
|
321
|
+
const cand = base + ext;
|
|
322
|
+
try { if (existsSync(cand) && statSync(cand).isFile()) return cand; } catch { /* skip */ }
|
|
323
|
+
}
|
|
324
|
+
for (const idx of ['index.ts', 'index.js', 'index.mjs']) {
|
|
325
|
+
const cand = join(base, idx);
|
|
326
|
+
try { if (existsSync(cand) && statSync(cand).isFile()) return cand; } catch { /* skip */ }
|
|
327
|
+
}
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Join a mount prefix and a route path into one normalized `/a/b` path. */
|
|
332
|
+
function joinRoutePath(prefix, p) {
|
|
333
|
+
if (!prefix) return p;
|
|
334
|
+
const left = prefix.replace(/\/+$/, ''); // drop trailing slash(es)
|
|
335
|
+
const right = (p === '/' || p === '') ? '' : ('/' + p.replace(/^\/+/, '')); // single leading slash
|
|
336
|
+
const joined = left + right;
|
|
337
|
+
return joined || '/';
|
|
253
338
|
}
|
|
254
339
|
|
|
255
340
|
// ── Fastify ─────────────────────────────────────────────────────────────────
|
|
@@ -266,18 +351,28 @@ function scanFastifyRoutes(dir, roots = null) {
|
|
|
266
351
|
const content = readFileSafe(filePath);
|
|
267
352
|
if (!content) return;
|
|
268
353
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
354
|
+
const emit = (method, path, index) => routes.push({
|
|
355
|
+
method: method.toUpperCase(),
|
|
356
|
+
path,
|
|
357
|
+
handler: extractHandlerName(content, index),
|
|
358
|
+
file: relative(dir, filePath),
|
|
359
|
+
source: 'fastify',
|
|
360
|
+
auth: hasAuthCheck(content),
|
|
361
|
+
description: extractNearbyComment(content, index),
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// AST-first: method shorthand (fastify.get('/x')) AND the declarative
|
|
365
|
+
// object form (fastify.route({ method, url })) the regex never matched.
|
|
366
|
+
// Both return null only on parse failure → regex fallback.
|
|
367
|
+
const calls = extractJsRouteCalls(content, filePath);
|
|
368
|
+
const objs = extractJsRouteObjects(content, filePath);
|
|
369
|
+
if (calls || objs) {
|
|
370
|
+
for (const r of calls || []) emit(r.method, r.path, r.start);
|
|
371
|
+
for (const r of objs || []) emit(r.method, r.path, r.start);
|
|
372
|
+
} else {
|
|
373
|
+
let match;
|
|
374
|
+
const regex = new RegExp(pattern.source, 'gi');
|
|
375
|
+
while ((match = regex.exec(content)) !== null) emit(match[1], match[2], match.index);
|
|
281
376
|
}
|
|
282
377
|
});
|
|
283
378
|
}
|
|
@@ -302,18 +397,25 @@ function scanHonoRoutes(dir, roots = null) {
|
|
|
302
397
|
const content = readFileSafe(filePath);
|
|
303
398
|
if (!content) return;
|
|
304
399
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
400
|
+
const emit = (method, path, index) => routes.push({
|
|
401
|
+
method: method.toUpperCase(),
|
|
402
|
+
path,
|
|
403
|
+
handler: '',
|
|
404
|
+
file: relative(dir, filePath),
|
|
405
|
+
source: 'hono',
|
|
406
|
+
auth: hasAuthCheck(content),
|
|
407
|
+
description: extractNearbyComment(content, index),
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// AST-first (any receiver, multi-line, template paths — Hono/Koa method
|
|
411
|
+
// shorthand `app.get('/x')` / `router.get('/x')`); regex fallback.
|
|
412
|
+
const calls = extractJsRouteCalls(content, filePath);
|
|
413
|
+
if (calls) {
|
|
414
|
+
for (const r of calls) emit(r.method, r.path, r.start);
|
|
415
|
+
} else {
|
|
416
|
+
let match;
|
|
417
|
+
const regex = new RegExp(pattern.source, 'gi');
|
|
418
|
+
while ((match = regex.exec(content)) !== null) emit(match[1], match[2], match.index);
|
|
317
419
|
}
|
|
318
420
|
});
|
|
319
421
|
}
|
|
@@ -357,10 +459,33 @@ function scanFastAPIRoutes(dir) {
|
|
|
357
459
|
const pattern = /@(?:app|router)\s*\.\s*(get|post|put|delete|patch)\s*\(\s*['"]([^'"]+)['"]/gi;
|
|
358
460
|
|
|
359
461
|
const pyFiles = findFiles(dir, /\.py$/);
|
|
462
|
+
// AST-first: ONE python3 subprocess parses every file. `null` means Python is
|
|
463
|
+
// unavailable or the subprocess failed → regex fallback for all files. A
|
|
464
|
+
// per-file `ok:false` falls back for just that file. The AST form also reads
|
|
465
|
+
// multi-line decorators and Flask `methods=[...]` arrays the regex misses.
|
|
466
|
+
const astByFile = extractPythonFiles(pyFiles);
|
|
467
|
+
|
|
360
468
|
for (const filePath of pyFiles) {
|
|
361
469
|
const content = readFileSafe(filePath);
|
|
362
470
|
if (!content) continue;
|
|
363
471
|
|
|
472
|
+
const parsed = astByFile && astByFile[filePath];
|
|
473
|
+
const fileAuth = content.includes('Depends(') && content.includes('auth');
|
|
474
|
+
if (parsed && parsed.ok) {
|
|
475
|
+
for (const r of parsed.routes || []) {
|
|
476
|
+
routes.push({
|
|
477
|
+
method: r.method,
|
|
478
|
+
path: r.path,
|
|
479
|
+
handler: r.func || '',
|
|
480
|
+
file: relative(dir, filePath),
|
|
481
|
+
source: 'fastapi',
|
|
482
|
+
auth: fileAuth,
|
|
483
|
+
description: r.desc || '',
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
|
|
364
489
|
let match;
|
|
365
490
|
const regex = new RegExp(pattern.source, 'gi');
|
|
366
491
|
while ((match = regex.exec(content)) !== null) {
|
|
@@ -370,7 +495,7 @@ function scanFastAPIRoutes(dir) {
|
|
|
370
495
|
handler: extractPythonFunctionName(content, match.index),
|
|
371
496
|
file: relative(dir, filePath),
|
|
372
497
|
source: 'fastapi',
|
|
373
|
-
auth:
|
|
498
|
+
auth: fileAuth,
|
|
374
499
|
description: extractPythonDocstring(content, match.index),
|
|
375
500
|
});
|
|
376
501
|
}
|
|
@@ -515,7 +640,7 @@ function scanRustWebRoutes(dir) {
|
|
|
515
640
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
516
641
|
|
|
517
642
|
function readFileSafe(path) {
|
|
518
|
-
|
|
643
|
+
return readScannable(path); // size-capped; skips minified/generated bundles
|
|
519
644
|
}
|
|
520
645
|
|
|
521
646
|
function isJSFile(path) {
|
package/cli/scanners/schemas.mjs
CHANGED
|
@@ -8,11 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
10
10
|
import { resolve, join, relative, basename, extname } from 'node:path';
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
]);
|
|
11
|
+
import { extractJsSchemaBodies } from './js-ast.mjs';
|
|
12
|
+
import { extractPythonFiles } from './py-ast.mjs';
|
|
13
|
+
import { readScannable } from '../shared-source.mjs';
|
|
14
|
+
import { DEFAULT_IGNORE_DIRS as IGNORE_DIRS, shouldIgnore, relPosix } from '../shared-ignore.mjs';
|
|
16
15
|
|
|
17
16
|
/**
|
|
18
17
|
* Deep scan schemas from ORM definitions, validation libraries, and OpenAPI specs.
|
|
@@ -21,7 +20,7 @@ const IGNORE_DIRS = new Set([
|
|
|
21
20
|
* @param {object} docTools - Detected doc tools (may include OpenAPI)
|
|
22
21
|
* @returns {object} { entities: [...], relationships: [...], source: string }
|
|
23
22
|
*/
|
|
24
|
-
export function scanSchemasDeep(dir, stack, docTools) {
|
|
23
|
+
export function scanSchemasDeep(dir, stack, docTools, config = {}) {
|
|
25
24
|
// Priority 1: OpenAPI schemas
|
|
26
25
|
if (docTools?.openapi?.found && docTools.openapi.schemas?.length > 0) {
|
|
27
26
|
return {
|
|
@@ -78,10 +77,22 @@ export function scanSchemasDeep(dir, stack, docTools) {
|
|
|
78
77
|
}
|
|
79
78
|
}
|
|
80
79
|
|
|
80
|
+
// Honor .docguardignore / config.ignore: drop entities whose source file the
|
|
81
|
+
// user excluded (e.g. test/fixtures/**), then drop relationships that point at
|
|
82
|
+
// a dropped entity. Filtering the RESULTS (not the walk) keeps the cache and
|
|
83
|
+
// the per-ORM walkers untouched. entity.file is project-relative already.
|
|
84
|
+
const keptEntities = entities.filter(
|
|
85
|
+
e => !e.file || !shouldIgnore(relPosix(dir, resolve(dir, e.file)), config)
|
|
86
|
+
);
|
|
87
|
+
const keptNames = new Set(keptEntities.map(e => e.name));
|
|
88
|
+
const keptRelationships = keptEntities.length === entities.length
|
|
89
|
+
? relationships
|
|
90
|
+
: relationships.filter(r => keptNames.has(r.from) && keptNames.has(r.to));
|
|
91
|
+
|
|
81
92
|
return {
|
|
82
|
-
entities,
|
|
83
|
-
relationships,
|
|
84
|
-
source:
|
|
93
|
+
entities: keptEntities,
|
|
94
|
+
relationships: keptRelationships,
|
|
95
|
+
source: keptEntities.length > 0 ? keptEntities[0].source : 'none',
|
|
85
96
|
};
|
|
86
97
|
}
|
|
87
98
|
|
|
@@ -220,26 +231,15 @@ function scanDrizzleSchemas(dir) {
|
|
|
220
231
|
const content = readFileSafe(filePath);
|
|
221
232
|
if (!content || !content.includes('Table(')) return;
|
|
222
233
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const varName = match[1];
|
|
227
|
-
const tableName = match[2];
|
|
228
|
-
const body = match[3];
|
|
234
|
+
// Emit one entity from a (tableName, body) pair. `body` is the balanced
|
|
235
|
+
// inner text of the table's column object.
|
|
236
|
+
const emit = (tableName, body) => {
|
|
229
237
|
const fields = parseDrizzleColumns(body);
|
|
230
|
-
|
|
231
|
-
// Look for references (foreign keys)
|
|
232
238
|
for (const field of fields) {
|
|
233
239
|
if (field._ref) {
|
|
234
|
-
relationships.push({
|
|
235
|
-
from: tableName,
|
|
236
|
-
to: field._ref,
|
|
237
|
-
type: 'many-to-one',
|
|
238
|
-
field: field.name,
|
|
239
|
-
});
|
|
240
|
+
relationships.push({ from: tableName, to: field._ref, type: 'many-to-one', field: field.name });
|
|
240
241
|
}
|
|
241
242
|
}
|
|
242
|
-
|
|
243
243
|
entities.push({
|
|
244
244
|
name: tableName,
|
|
245
245
|
fields: fields.map(f => ({ ...f, _ref: undefined })),
|
|
@@ -247,6 +247,17 @@ function scanDrizzleSchemas(dir) {
|
|
|
247
247
|
source: 'drizzle',
|
|
248
248
|
description: '',
|
|
249
249
|
});
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
// Full-support tier: AST extraction (nested braces handled). Falls back
|
|
253
|
+
// to the legacy regex only when @babel/parser can't parse the file.
|
|
254
|
+
const ast = extractJsSchemaBodies(content, filePath);
|
|
255
|
+
if (ast) {
|
|
256
|
+
for (const s of ast) if (s.kind === 'drizzle') emit(s.table, s.body);
|
|
257
|
+
} else {
|
|
258
|
+
let match;
|
|
259
|
+
const regex = new RegExp(tablePattern.source, 'g');
|
|
260
|
+
while ((match = regex.exec(content)) !== null) emit(match[2], match[3]);
|
|
250
261
|
}
|
|
251
262
|
});
|
|
252
263
|
}
|
|
@@ -327,20 +338,28 @@ function scanZodSchemas(dir) {
|
|
|
327
338
|
const content = readFileSafe(filePath);
|
|
328
339
|
if (!content || !content.includes('z.object')) return;
|
|
329
340
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
while ((match = regex.exec(content)) !== null) {
|
|
333
|
-
const schemaName = match[1].replace(/Schema$|Validator$/, '');
|
|
334
|
-
const body = match[2];
|
|
335
|
-
const fields = parseZodFields(body);
|
|
336
|
-
|
|
341
|
+
const emit = (rawName, body) => {
|
|
342
|
+
const schemaName = rawName.replace(/Schema$|Validator$/, '');
|
|
337
343
|
entities.push({
|
|
338
344
|
name: schemaName,
|
|
339
|
-
fields,
|
|
345
|
+
fields: parseZodFields(body),
|
|
340
346
|
file: relative(dir, filePath),
|
|
341
347
|
source: 'zod',
|
|
342
348
|
description: '',
|
|
343
349
|
});
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const ast = extractJsSchemaBodies(content, filePath);
|
|
353
|
+
if (ast) {
|
|
354
|
+
// Keep the legacy naming gate (only *Schema/Validator/Input/Output) so
|
|
355
|
+
// inline z.object() validations aren't treated as data-model entities.
|
|
356
|
+
for (const s of ast) {
|
|
357
|
+
if (s.kind === 'zod' && /(?:Schema|Validator|Input|Output)$/.test(s.name)) emit(s.name, s.body);
|
|
358
|
+
}
|
|
359
|
+
} else {
|
|
360
|
+
let match;
|
|
361
|
+
const regex = new RegExp(zodPattern.source, 'g');
|
|
362
|
+
while ((match = regex.exec(content)) !== null) emit(match[1], match[2]);
|
|
344
363
|
}
|
|
345
364
|
});
|
|
346
365
|
}
|
|
@@ -407,25 +426,14 @@ function scanMongooseSchemas(dir) {
|
|
|
407
426
|
const content = readFileSafe(filePath);
|
|
408
427
|
if (!content || !content.includes('Schema(')) return;
|
|
409
428
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
while ((match = regex.exec(content)) !== null) {
|
|
413
|
-
const schemaName = match[1].replace(/Schema$/i, '');
|
|
414
|
-
const body = match[2];
|
|
429
|
+
const emit = (rawName, body) => {
|
|
430
|
+
const schemaName = rawName.replace(/Schema$/i, '');
|
|
415
431
|
const fields = parseMongooseFields(body);
|
|
416
|
-
|
|
417
|
-
// Check for refs (relationships)
|
|
418
432
|
for (const field of fields) {
|
|
419
433
|
if (field._ref) {
|
|
420
|
-
relationships.push({
|
|
421
|
-
from: schemaName,
|
|
422
|
-
to: field._ref,
|
|
423
|
-
type: 'many-to-one',
|
|
424
|
-
field: field.name,
|
|
425
|
-
});
|
|
434
|
+
relationships.push({ from: schemaName, to: field._ref, type: 'many-to-one', field: field.name });
|
|
426
435
|
}
|
|
427
436
|
}
|
|
428
|
-
|
|
429
437
|
entities.push({
|
|
430
438
|
name: schemaName.charAt(0).toUpperCase() + schemaName.slice(1),
|
|
431
439
|
fields: fields.map(f => ({ ...f, _ref: undefined })),
|
|
@@ -433,6 +441,15 @@ function scanMongooseSchemas(dir) {
|
|
|
433
441
|
source: 'mongoose',
|
|
434
442
|
description: '',
|
|
435
443
|
});
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const ast = extractJsSchemaBodies(content, filePath);
|
|
447
|
+
if (ast) {
|
|
448
|
+
for (const s of ast) if (s.kind === 'mongoose') emit(s.name, s.body);
|
|
449
|
+
} else {
|
|
450
|
+
let match;
|
|
451
|
+
const regex = new RegExp(schemaPattern.source, 'g');
|
|
452
|
+
while ((match = regex.exec(content)) !== null) emit(match[1], match[2]);
|
|
436
453
|
}
|
|
437
454
|
});
|
|
438
455
|
}
|
|
@@ -504,8 +521,38 @@ function mapMongooseType(type) {
|
|
|
504
521
|
function scanPythonModels(dir) {
|
|
505
522
|
const entities = [];
|
|
506
523
|
const relationships = [];
|
|
507
|
-
|
|
508
|
-
|
|
524
|
+
|
|
525
|
+
// Collect .py files first so the AST tier parses them in ONE python3
|
|
526
|
+
// subprocess. `null` → Python unavailable / subprocess failed → regex
|
|
527
|
+
// fallback for all; a per-file `ok:false` falls back for that file only.
|
|
528
|
+
// The AST tier gets every field exactly (no body-capture truncation, no
|
|
529
|
+
// miss on multi-base classes) — undercounting fields is what makes the
|
|
530
|
+
// data-model validators falsely pass on a stale DATA-MODEL.md.
|
|
531
|
+
const pyFiles = [];
|
|
532
|
+
walkDir(dir, (filePath) => { if (filePath.endsWith('.py')) pyFiles.push(filePath); });
|
|
533
|
+
const astByFile = extractPythonFiles(pyFiles);
|
|
534
|
+
|
|
535
|
+
for (const filePath of pyFiles) {
|
|
536
|
+
const parsed = astByFile && astByFile[filePath];
|
|
537
|
+
if (parsed && parsed.ok) {
|
|
538
|
+
for (const s of parsed.schemas || []) {
|
|
539
|
+
const fields = (s.fields || []).map(f => ({
|
|
540
|
+
name: f.name, type: f.type || '', required: f.required !== false, description: '',
|
|
541
|
+
}));
|
|
542
|
+
if (fields.length > 0) entities.push({ name: s.name, fields, file: filePath, source: s.kind });
|
|
543
|
+
for (const to of s.rels || []) relationships.push({ from: s.name, to, type: 'related' });
|
|
544
|
+
}
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
scanPythonModelsRegex(filePath, entities, relationships);
|
|
548
|
+
}
|
|
549
|
+
return { entities, relationships };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Regex (beta) fallback — used per-file when the Python AST tier is unavailable
|
|
553
|
+
// or couldn't parse that file. Identical behavior to the pre-AST scanner.
|
|
554
|
+
function scanPythonModelsRegex(filePath, entities, relationships) {
|
|
555
|
+
{
|
|
509
556
|
const content = readFileSafe(filePath);
|
|
510
557
|
if (!content) return;
|
|
511
558
|
if (!/class\s+\w+\s*\([^)]*(Base|BaseModel|db\.Model|Model|SQLModel)/.test(content)) return;
|
|
@@ -549,8 +596,7 @@ function scanPythonModels(dir) {
|
|
|
549
596
|
}
|
|
550
597
|
if (fields.length > 0) entities.push({ name, fields, file: filePath, source: 'pydantic' });
|
|
551
598
|
}
|
|
552
|
-
}
|
|
553
|
-
return { entities, relationships };
|
|
599
|
+
}
|
|
554
600
|
}
|
|
555
601
|
|
|
556
602
|
// ── Rust: Diesel `table! { ... }` ─────────────────────────────────────────────
|
|
@@ -687,7 +733,7 @@ function extractOpenAPIRelationships(schemas) {
|
|
|
687
733
|
}
|
|
688
734
|
|
|
689
735
|
function readFileSafe(path) {
|
|
690
|
-
|
|
736
|
+
return readScannable(path); // size-capped; skips minified/generated bundles
|
|
691
737
|
}
|
|
692
738
|
|
|
693
739
|
// v0.15-P2: walkDir is called 8 times across schemas.mjs (Pydantic, Mongoose,
|
package/cli/shared-git.mjs
CHANGED
|
Binary file
|
package/cli/shared-ignore.mjs
CHANGED
|
@@ -50,7 +50,22 @@ const ALWAYS_REJECT_PATH_RE =
|
|
|
50
50
|
* Returns [] if the file is missing or unreadable — never throws.
|
|
51
51
|
*/
|
|
52
52
|
import { readFileSync, existsSync } from 'node:fs';
|
|
53
|
-
import { resolve as resolvePath } from 'node:path';
|
|
53
|
+
import { resolve as resolvePath, relative as relativePath, sep } from 'node:path';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Project-relative path with POSIX (`/`) separators — the canonical form that
|
|
57
|
+
* every validator should compare against docs, ignore globs, and changed-file
|
|
58
|
+
* sets.
|
|
59
|
+
*
|
|
60
|
+
* Replaces the old `absPath.replace(projectDir + '/', '')` idiom, which failed
|
|
61
|
+
* two ways: on Windows the `/` literal never matched the OS `\` separators, and
|
|
62
|
+
* for a sibling dir sharing a prefix (`/repo` vs `/repo-staging`) the replace
|
|
63
|
+
* was a no-op — both cases left an ABSOLUTE path, silently breaking
|
|
64
|
+
* `content.includes(relPath)`, glob matching, and `--changed-only` scoping.
|
|
65
|
+
*/
|
|
66
|
+
export function relPosix(projectDir, absPath) {
|
|
67
|
+
return relativePath(projectDir, absPath).split(sep).join('/');
|
|
68
|
+
}
|
|
54
69
|
|
|
55
70
|
export function loadDocguardIgnore(projectDir) {
|
|
56
71
|
const p = resolvePath(projectDir, '.docguardignore');
|