docguard-cli 0.23.0 → 0.25.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 (61) 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 +69 -3
  6. package/cli/commands/guard.mjs +86 -11
  7. package/cli/commands/hooks.mjs +12 -7
  8. package/cli/commands/init.mjs +24 -8
  9. package/cli/commands/score.mjs +147 -61
  10. package/cli/commands/setup.mjs +2 -2
  11. package/cli/commands/sync.mjs +6 -0
  12. package/cli/commands/trace.mjs +3 -3
  13. package/cli/commands/upgrade.mjs +61 -13
  14. package/cli/config.mjs +18 -1
  15. package/cli/docguard.mjs +156 -2
  16. package/cli/ensure-skills.mjs +24 -26
  17. package/cli/scanners/api-doc.mjs +17 -3
  18. package/cli/scanners/doc-tools.mjs +32 -15
  19. package/cli/scanners/frontend.mjs +24 -8
  20. package/cli/scanners/js-ast.mjs +432 -0
  21. package/cli/scanners/memory-plan.mjs +1 -1
  22. package/cli/scanners/project-type.mjs +11 -4
  23. package/cli/scanners/py-ast.mjs +213 -0
  24. package/cli/scanners/routes.mjs +194 -69
  25. package/cli/scanners/schemas.mjs +97 -51
  26. package/cli/shared-git.mjs +0 -0
  27. package/cli/shared-ignore.mjs +23 -2
  28. package/cli/shared-source.mjs +59 -2
  29. package/cli/shared-trace-patterns.mjs +13 -0
  30. package/cli/shared.mjs +92 -1
  31. package/cli/validator-markers.mjs +91 -0
  32. package/cli/validators/api-surface.mjs +37 -3
  33. package/cli/validators/canonical-sync.mjs +22 -19
  34. package/cli/validators/doc-quality.mjs +2 -42
  35. package/cli/validators/docs-coverage.mjs +13 -0
  36. package/cli/validators/docs-sync.mjs +4 -3
  37. package/cli/validators/drift.mjs +3 -2
  38. package/cli/validators/freshness.mjs +47 -15
  39. package/cli/validators/generated-staleness.mjs +16 -1
  40. package/cli/validators/metadata-sync.mjs +21 -11
  41. package/cli/validators/metrics-consistency.mjs +45 -17
  42. package/cli/validators/security.mjs +13 -5
  43. package/cli/validators/structure.mjs +6 -5
  44. package/cli/validators/surface-sync.mjs +7 -5
  45. package/cli/validators/test-spec.mjs +76 -51
  46. package/cli/validators/todo-tracking.mjs +4 -2
  47. package/cli/validators/traceability.mjs +11 -3
  48. package/cli/writers/sections.mjs +32 -19
  49. package/docs/commands.md +1 -1
  50. package/docs/configuration.md +11 -0
  51. package/docs/faq.md +1 -1
  52. package/extensions/spec-kit-docguard/README.md +1 -1
  53. package/extensions/spec-kit-docguard/extension.yml +2 -2
  54. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  55. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  56. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  57. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  58. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -1
  59. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-autofix.yml +3 -2
  60. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +2 -2
  61. package/package.json +5 -3
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Python AST helpers — the "full support" parsing tier for Python, backed by
3
+ * the interpreter's OWN `ast` module (no npm/pip dependency: we shell out to
4
+ * the `python3` already on the developer's machine).
5
+ *
6
+ * Why a real parser here: the regex Python scanners match `@app.get("…")`
7
+ * decorators and `class X(BaseModel):` blocks line-by-line. That misses
8
+ * multi-line decorators, method-array Flask routes, and — most dangerously —
9
+ * undercounts a model's fields, which makes the data-model validators falsely
10
+ * PASS on stale docs. Python's `ast` gets every decorator and field exactly.
11
+ *
12
+ * Load model: OPTIONAL, exactly like the JS @babel/parser tier. If `python3`
13
+ * (or `python`) isn't on PATH, or the subprocess errors, every entry point here
14
+ * returns `null` and the callers transparently fall back to their regex (beta)
15
+ * tier. Python parsing never becomes load-bearing for the CLI to run.
16
+ */
17
+ import { spawnSync } from 'node:child_process';
18
+
19
+ // Cached interpreter probe: undefined = unchecked, null = unavailable,
20
+ // string = the working command ('python3' or 'python').
21
+ let _pyCmd;
22
+
23
+ function pyCmd() {
24
+ if (_pyCmd !== undefined) return _pyCmd;
25
+ for (const cmd of ['python3', 'python']) {
26
+ try {
27
+ const r = spawnSync(cmd, ['-c', 'import ast,sys,json'], { encoding: 'utf-8', timeout: 4000 });
28
+ if (r.status === 0) { _pyCmd = cmd; return _pyCmd; }
29
+ } catch { /* try the next candidate */ }
30
+ }
31
+ _pyCmd = null;
32
+ return _pyCmd;
33
+ }
34
+
35
+ /** True when a usable Python 3 interpreter (with ast/json) is on PATH. */
36
+ export function pyAstAvailable() {
37
+ return pyCmd() !== null;
38
+ }
39
+
40
+ // The extractor runs INSIDE python3. It reads newline-separated file paths on
41
+ // stdin and writes a JSON array — one entry per file — to stdout. A file that
42
+ // can't be parsed yields { ok: false } so the caller can fall back for THAT
43
+ // file instead of silently treating it as "scanned, found nothing".
44
+ //
45
+ // Contains no backticks and no ${...}, so it embeds safely in a JS template.
46
+ const PY_EXTRACTOR = `
47
+ import ast, sys, json
48
+
49
+ HTTP = {"get", "post", "put", "delete", "patch", "head", "options"}
50
+ PYD_BASES = {"BaseModel", "SQLModel"}
51
+ ORM_BASES = {"Base", "Model", "DeclarativeBase"}
52
+ ORM_COLS = {"Column", "mapped_column", "relationship"}
53
+
54
+ def str_of(node):
55
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
56
+ return node.value
57
+ return None
58
+
59
+ def routes_from_func(fn):
60
+ out = []
61
+ doc = ast.get_docstring(fn) or ""
62
+ desc = doc.strip().split("\\n")[0] if doc else ""
63
+ for dec in fn.decorator_list:
64
+ if not isinstance(dec, ast.Call) or not isinstance(dec.func, ast.Attribute):
65
+ continue
66
+ method = dec.func.attr.lower()
67
+ if method in HTTP:
68
+ path = str_of(dec.args[0]) if dec.args else None
69
+ if path and path.startswith("/"):
70
+ out.append({"method": method.upper(), "path": path, "func": fn.name, "desc": desc})
71
+ elif method == "route": # Flask: @app.route("/x", methods=["GET","POST"])
72
+ path = str_of(dec.args[0]) if dec.args else None
73
+ methods = ["GET"]
74
+ for kw in dec.keywords:
75
+ if kw.arg == "methods" and isinstance(kw.value, (ast.List, ast.Tuple)):
76
+ ms = [str_of(e) for e in kw.value.elts]
77
+ ms = [m.upper() for m in ms if m]
78
+ if ms:
79
+ methods = ms
80
+ if path and path.startswith("/"):
81
+ for m in methods:
82
+ out.append({"method": m, "path": path, "func": fn.name, "desc": desc})
83
+ return out
84
+
85
+ def base_names(cls):
86
+ names = []
87
+ for b in cls.bases:
88
+ if isinstance(b, ast.Name):
89
+ names.append(b.id)
90
+ elif isinstance(b, ast.Attribute):
91
+ names.append(b.attr)
92
+ return names
93
+
94
+ def type_str(node):
95
+ f = getattr(ast, "unparse", None) # ast.unparse is 3.9+; degrade to "" otherwise
96
+ if f is None or node is None:
97
+ return ""
98
+ try:
99
+ return f(node)
100
+ except Exception:
101
+ return ""
102
+
103
+ def call_name(call):
104
+ fn = call.func
105
+ if isinstance(fn, ast.Attribute):
106
+ return fn.attr
107
+ if isinstance(fn, ast.Name):
108
+ return fn.id
109
+ return ""
110
+
111
+ def fields_from_class(cls):
112
+ pyd, orm, rels = [], [], []
113
+ for stmt in cls.body:
114
+ # Pydantic: name: type [= default]
115
+ if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name):
116
+ t = type_str(stmt.annotation)
117
+ has_none_default = isinstance(stmt.value, ast.Constant) and stmt.value.value is None
118
+ required = not ("Optional" in t or "None" in t or has_none_default)
119
+ pyd.append({"name": stmt.target.id, "type": t, "required": required})
120
+ if isinstance(stmt.value, ast.Call) and call_name(stmt.value) == "relationship" and stmt.value.args:
121
+ tgt = str_of(stmt.value.args[0])
122
+ if tgt:
123
+ rels.append(tgt)
124
+ # SQLAlchemy: name = Column(Type, nullable=...) / mapped_column(...) / relationship("X")
125
+ elif isinstance(stmt, ast.Assign) and isinstance(stmt.value, ast.Call):
126
+ cname = call_name(stmt.value)
127
+ if cname in ORM_COLS:
128
+ t = ""
129
+ if stmt.value.args:
130
+ a0 = stmt.value.args[0]
131
+ if isinstance(a0, ast.Name):
132
+ t = a0.id
133
+ elif isinstance(a0, ast.Attribute):
134
+ t = a0.attr
135
+ elif isinstance(a0, ast.Call):
136
+ t = call_name(a0)
137
+ required = True
138
+ for kw in stmt.value.keywords:
139
+ if kw.arg == "nullable" and isinstance(kw.value, ast.Constant) and kw.value.value is True:
140
+ required = False
141
+ for tgt in stmt.targets:
142
+ if isinstance(tgt, ast.Name):
143
+ orm.append({"name": tgt.id, "type": t, "required": required})
144
+ if cname == "relationship" and stmt.value.args:
145
+ rel = str_of(stmt.value.args[0])
146
+ if rel:
147
+ rels.append(rel)
148
+ return pyd, orm, rels
149
+
150
+ results = []
151
+ for path in sys.stdin.read().splitlines():
152
+ path = path.strip()
153
+ if not path:
154
+ continue
155
+ try:
156
+ with open(path, "r", encoding="utf-8") as f:
157
+ tree = ast.parse(f.read(), filename=path)
158
+ except Exception:
159
+ results.append({"file": path, "ok": False})
160
+ continue
161
+ routes, schemas = [], []
162
+ for node in ast.walk(tree):
163
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
164
+ routes.extend(routes_from_func(node))
165
+ elif isinstance(node, ast.ClassDef):
166
+ bn = base_names(node)
167
+ pyd, orm, rels = fields_from_class(node)
168
+ if any(b in PYD_BASES for b in bn) and pyd:
169
+ schemas.append({"name": node.name, "fields": pyd, "kind": "pydantic", "rels": rels})
170
+ elif any(b in ORM_BASES for b in bn) and orm:
171
+ schemas.append({"name": node.name, "fields": orm, "kind": "sqlalchemy", "rels": rels})
172
+ results.append({"file": path, "ok": True, "routes": routes, "schemas": schemas})
173
+
174
+ sys.stdout.write(json.dumps(results))
175
+ `;
176
+
177
+ /**
178
+ * Parse a batch of Python files in ONE python3 subprocess.
179
+ *
180
+ * @param {string[]} filePaths - absolute paths to .py files
181
+ * @returns {Object<string, {ok:boolean, routes?, schemas?}>|null}
182
+ * A map keyed by the input path, or `null` when Python is unavailable / the
183
+ * subprocess failed / output was unparseable (caller falls back to regex).
184
+ * An empty input returns `{}` (nothing to do, but Python IS available).
185
+ */
186
+ export function extractPythonFiles(filePaths) {
187
+ const cmd = pyCmd();
188
+ if (!cmd) return null;
189
+ if (!filePaths || filePaths.length === 0) return {};
190
+
191
+ let r;
192
+ try {
193
+ r = spawnSync(cmd, ['-c', PY_EXTRACTOR], {
194
+ input: filePaths.join('\n'),
195
+ encoding: 'utf-8',
196
+ maxBuffer: 64 * 1024 * 1024,
197
+ timeout: 30000,
198
+ });
199
+ } catch {
200
+ return null;
201
+ }
202
+ if (!r || r.status !== 0 || !r.stdout) return null;
203
+
204
+ let parsed;
205
+ try { parsed = JSON.parse(r.stdout); } catch { return null; }
206
+ if (!Array.isArray(parsed)) return null;
207
+
208
+ const byFile = {};
209
+ for (const entry of parsed) {
210
+ if (entry && entry.file) byFile[entry.file] = entry;
211
+ }
212
+ return byFile;
213
+ }
@@ -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) {