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.
Files changed (58) hide show
  1. package/README.md +1 -1
  2. package/cli/commands/diff.mjs +1 -1
  3. package/cli/commands/explain.mjs +178 -17
  4. package/cli/commands/fix.mjs +17 -2
  5. package/cli/commands/generate.mjs +2 -2
  6. package/cli/commands/guard.mjs +86 -11
  7. package/cli/commands/hooks.mjs +12 -7
  8. package/cli/commands/init.mjs +18 -6
  9. package/cli/commands/score.mjs +147 -61
  10. package/cli/commands/setup.mjs +2 -2
  11. package/cli/commands/trace.mjs +3 -3
  12. package/cli/commands/upgrade.mjs +61 -13
  13. package/cli/config.mjs +18 -1
  14. package/cli/docguard.mjs +19 -0
  15. package/cli/ensure-skills.mjs +24 -26
  16. package/cli/scanners/api-doc.mjs +17 -3
  17. package/cli/scanners/doc-tools.mjs +32 -15
  18. package/cli/scanners/frontend.mjs +24 -8
  19. package/cli/scanners/js-ast.mjs +432 -0
  20. package/cli/scanners/memory-plan.mjs +1 -1
  21. package/cli/scanners/py-ast.mjs +213 -0
  22. package/cli/scanners/routes.mjs +194 -69
  23. package/cli/scanners/schemas.mjs +97 -51
  24. package/cli/shared-git.mjs +0 -0
  25. package/cli/shared-ignore.mjs +16 -1
  26. package/cli/shared-source.mjs +59 -2
  27. package/cli/shared-trace-patterns.mjs +13 -0
  28. package/cli/shared.mjs +60 -1
  29. package/cli/validator-markers.mjs +91 -0
  30. package/cli/validators/api-surface.mjs +37 -3
  31. package/cli/validators/canonical-sync.mjs +22 -19
  32. package/cli/validators/doc-quality.mjs +2 -42
  33. package/cli/validators/docs-coverage.mjs +13 -0
  34. package/cli/validators/docs-sync.mjs +4 -3
  35. package/cli/validators/drift.mjs +3 -2
  36. package/cli/validators/freshness.mjs +47 -15
  37. package/cli/validators/metadata-sync.mjs +21 -11
  38. package/cli/validators/metrics-consistency.mjs +45 -17
  39. package/cli/validators/security.mjs +13 -5
  40. package/cli/validators/structure.mjs +6 -5
  41. package/cli/validators/surface-sync.mjs +7 -5
  42. package/cli/validators/test-spec.mjs +76 -51
  43. package/cli/validators/todo-tracking.mjs +4 -2
  44. package/cli/validators/traceability.mjs +11 -3
  45. package/cli/writers/sections.mjs +32 -19
  46. package/docs/commands.md +1 -1
  47. package/docs/configuration.md +11 -0
  48. package/docs/faq.md +1 -1
  49. package/extensions/spec-kit-docguard/README.md +1 -1
  50. package/extensions/spec-kit-docguard/extension.yml +2 -2
  51. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  52. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  53. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  54. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  55. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -1
  56. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-autofix.yml +3 -2
  57. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +2 -2
  58. package/package.json +5 -3
@@ -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
- const IGNORE_DIRS = new Set([
14
- 'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
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
- .replace(/\[\.\.\.(\w+)\]/g, ':$1*') // Catch-all [...slug]
122
- .replace(/\[(\w+)\]/g, ':$1'); // Dynamic [id]
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
- const routes = [];
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 (!isJSFile(filePath)) return;
211
- const content = readFileSafe(filePath);
212
- if (!content) return;
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
- let match;
215
- const regex = new RegExp(routePattern.source, 'gi');
216
- while ((match = regex.exec(content)) !== null) {
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: match[1].toUpperCase(),
219
- path: match[2],
220
- handler: extractHandlerName(content, match.index),
221
- file: relative(dir, filePath),
258
+ method: method.toUpperCase(),
259
+ path: fullPath,
260
+ handler: extractHandlerName(content, index),
261
+ file: fileLabel,
222
262
  source: 'express',
223
- auth: hasAuthMiddleware(content, match[2]),
224
- description: extractNearbyComment(content, match.index),
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
- // Also check root files
231
- for (const rootFile of ['app.js', 'app.mjs', 'app.ts', 'server.js', 'server.ts', 'index.js', 'index.ts']) {
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
- let match;
238
- const regex = new RegExp(routePattern.source, 'gi');
239
- while ((match = regex.exec(content)) !== null) {
240
- routes.push({
241
- method: match[1].toUpperCase(),
242
- path: match[2],
243
- handler: extractHandlerName(content, match.index),
244
- file: rootFile,
245
- source: 'express',
246
- auth: hasAuthMiddleware(content, match[2]),
247
- description: extractNearbyComment(content, match.index),
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
- return routes;
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
- let match;
270
- const regex = new RegExp(pattern.source, 'gi');
271
- while ((match = regex.exec(content)) !== null) {
272
- routes.push({
273
- method: match[1].toUpperCase(),
274
- path: match[2],
275
- handler: extractHandlerName(content, match.index),
276
- file: relative(dir, filePath),
277
- source: 'fastify',
278
- auth: hasAuthCheck(content),
279
- description: extractNearbyComment(content, match.index),
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
- let match;
306
- const regex = new RegExp(pattern.source, 'gi');
307
- while ((match = regex.exec(content)) !== null) {
308
- routes.push({
309
- method: match[1].toUpperCase(),
310
- path: match[2],
311
- handler: '',
312
- file: relative(dir, filePath),
313
- source: 'hono',
314
- auth: hasAuthCheck(content),
315
- description: extractNearbyComment(content, match.index),
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: content.includes('Depends(') && content.includes('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
- try { return readFileSync(path, 'utf-8'); } catch { return null; }
643
+ return readScannable(path); // size-capped; skips minified/generated bundles
519
644
  }
520
645
 
521
646
  function isJSFile(path) {
@@ -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
- const IGNORE_DIRS = new Set([
13
- 'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
14
- '.cache', '__pycache__', '.venv', 'vendor', '.turbo',
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: entities.length > 0 ? entities[0].source : 'none',
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
- let match;
224
- const regex = new RegExp(tablePattern.source, 'g');
225
- while ((match = regex.exec(content)) !== null) {
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
- let match;
331
- const regex = new RegExp(zodPattern.source, 'g');
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
- let match;
411
- const regex = new RegExp(schemaPattern.source, 'g');
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
- walkDir(dir, (filePath) => {
508
- if (!filePath.endsWith('.py')) return;
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
- try { return readFileSync(path, 'utf-8'); } catch { return null; }
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,
Binary file
@@ -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');