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,432 @@
1
+ /**
2
+ * JS/TS AST helpers — the "full support" parsing tier for JavaScript and
3
+ * TypeScript, backed by @babel/parser (the project's single runtime dependency).
4
+ *
5
+ * Why a real parser here: regex schema/route extraction across the codebase
6
+ * used `{([^}]+)}` to capture an object body, which stops at the FIRST `}` and
7
+ * therefore silently truncates any definition containing a nested object
8
+ * (`z.object({ a: z.object({...}) })`, a Mongoose `{ type: String }` field,
9
+ * a Drizzle composite key). A truncated body yields missing fields, and a
10
+ * scanner that returns *too few* fields makes the doc validators falsely pass.
11
+ * An AST tracks brace depth for free, so the extracted body is always balanced.
12
+ *
13
+ * This module is intentionally small: it parses once and exposes the few
14
+ * structural extractions the scanners need. Non-JS/TS languages stay on the
15
+ * regex (beta) tier; Python uses the interpreter's own `ast` module.
16
+ *
17
+ * No @babel/traverse — we ship a tiny depth-first walker so the dependency
18
+ * footprint stays at exactly one package (+ its @babel/types tree).
19
+ */
20
+
21
+ import { createRequire } from 'node:module';
22
+ import { extname } from 'node:path';
23
+
24
+ // @babel/parser is a declared runtime dependency, so a normal `npm i` / `npx`
25
+ // install always has it. But we load it OPTIONALLY (sync require in a try) so
26
+ // the CLI never hard-crashes if it's somehow absent — a broken install, a
27
+ // files-only vendoring, or the npm-pack smoke test that unpacks without deps.
28
+ // When it's missing, parseJsTs reports ok:false and the scanners transparently
29
+ // fall back to the regex (beta) tier. The parser enhances; it is never load-
30
+ // bearing for the tool to boot.
31
+ let _babelParse = null;
32
+ try {
33
+ const require = createRequire(import.meta.url);
34
+ _babelParse = require('@babel/parser').parse;
35
+ } catch {
36
+ _babelParse = null;
37
+ }
38
+
39
+ /** True when the AST (full-support) tier is available in this install. */
40
+ export function astTierAvailable() {
41
+ return typeof _babelParse === 'function';
42
+ }
43
+
44
+ /**
45
+ * Babel plugins to enable per file extension. Errors are recovered (not
46
+ * thrown) so a single unsupported syntax form degrades to a partial parse
47
+ * instead of losing the whole file.
48
+ */
49
+ function pluginsFor(filename) {
50
+ const ext = extname(filename || '').toLowerCase();
51
+ const base = ['decorators-legacy', 'classProperties', 'classPrivateProperties', 'topLevelAwait'];
52
+ if (ext === '.ts') return ['typescript', ...base];
53
+ if (ext === '.tsx') return ['typescript', 'jsx', ...base];
54
+ if (ext === '.mts' || ext === '.cts') return ['typescript', ...base];
55
+ // .js/.jsx/.mjs/.cjs and anything else: allow JSX + Flow-free modern JS.
56
+ return ['jsx', ...base];
57
+ }
58
+
59
+ /**
60
+ * Parse JS/TS source into a Babel AST.
61
+ * @returns {{ ast: object|null, ok: boolean, error: string|null }}
62
+ * ok=false means the file could not be parsed — callers should treat that as
63
+ * "couldn't scan" (a surfaced warning), NOT as "scanned and found nothing".
64
+ */
65
+ export function parseJsTs(content, filename = 'file.ts') {
66
+ if (!_babelParse) return { ast: null, ok: false, error: '@babel/parser unavailable (regex fallback in effect)' };
67
+ try {
68
+ const ast = _babelParse(String(content), {
69
+ sourceType: 'unambiguous',
70
+ allowReturnOutsideFunction: true,
71
+ errorRecovery: true,
72
+ plugins: pluginsFor(filename),
73
+ });
74
+ return { ast, ok: true, error: null };
75
+ } catch (err) {
76
+ return { ast: null, ok: false, error: err && err.message ? err.message : String(err) };
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Minimal depth-first AST walker. Visits every node object (anything with a
82
+ * string `.type`), calling `visit(node)`. No parent tracking — callers that
83
+ * need names inspect the node's own children (e.g. a VariableDeclarator's id).
84
+ */
85
+ export function walk(node, visit) {
86
+ if (!node || typeof node !== 'object') return;
87
+ if (typeof node.type === 'string') visit(node);
88
+ for (const key of Object.keys(node)) {
89
+ if (key === 'loc' || key === 'start' || key === 'end' || key === 'range' || key === 'leadingComments' || key === 'trailingComments') continue;
90
+ const child = node[key];
91
+ if (Array.isArray(child)) {
92
+ for (const c of child) walk(c, visit);
93
+ } else if (child && typeof child === 'object' && typeof child.type === 'string') {
94
+ walk(child, visit);
95
+ }
96
+ }
97
+ }
98
+
99
+ /** Inner source text of an ObjectExpression node, i.e. between its `{` and `}`. */
100
+ function objectInner(content, objNode) {
101
+ if (!objNode || objNode.type !== 'ObjectExpression') return '';
102
+ // node.start points at `{`, node.end just past `}`. Strip the braces so the
103
+ // result matches what the old `{([^}]+)}` capture group used to yield — but
104
+ // balanced, so nested objects survive.
105
+ return String(content).slice(objNode.start + 1, objNode.end - 1);
106
+ }
107
+
108
+ /** Callee name as a dotted string, e.g. `z.object`, `mongoose.Schema`, `pgTable`. */
109
+ function calleeName(callee) {
110
+ if (!callee) return '';
111
+ if (callee.type === 'Identifier') return callee.name;
112
+ if (callee.type === 'MemberExpression' && !callee.computed) {
113
+ const obj = callee.object && callee.object.type === 'Identifier' ? callee.object.name : '';
114
+ const prop = callee.property && callee.property.type === 'Identifier' ? callee.property.name : '';
115
+ return obj ? `${obj}.${prop}` : prop;
116
+ }
117
+ return '';
118
+ }
119
+
120
+ const DRIZZLE_TABLE_FNS = new Set(['pgTable', 'mysqlTable', 'sqliteTable']);
121
+
122
+ /**
123
+ * Extract JS/TS schema declarations with BALANCED object bodies.
124
+ *
125
+ * Returns an array of `{ kind, name, table, body }` where `body` is the inner
126
+ * text of the schema's object literal (nested objects intact). The scanners
127
+ * feed `body` to their existing per-ORM field parsers, so only the extraction
128
+ * mechanism changes — not the field interpretation.
129
+ *
130
+ * Returns `null` when the file cannot be parsed, so the caller can distinguish
131
+ * "no schemas here" (—> []) from "couldn't read this file" (—> null).
132
+ *
133
+ * Kinds: 'zod' (z.object), 'drizzle' (pg/mysql/sqliteTable), 'mongoose'
134
+ * (new Schema / new mongoose.Schema).
135
+ */
136
+ export function extractJsSchemaBodies(content, filename = 'file.ts') {
137
+ const { ast, ok } = parseJsTs(content, filename);
138
+ if (!ok || !ast) return null;
139
+
140
+ const out = [];
141
+
142
+ walk(ast, (node) => {
143
+ if (node.type !== 'VariableDeclarator' || !node.id || node.id.type !== 'Identifier') return;
144
+ const name = node.id.name;
145
+ const init = node.init;
146
+ if (!init) return;
147
+
148
+ // Zod: const X = z.object({ ... }) (also z.object(...).strict() etc. —
149
+ // we match the inner-most z.object call's object arg).
150
+ if (init.type === 'CallExpression' && calleeName(init.callee) === 'z.object') {
151
+ const arg = init.arguments[0];
152
+ if (arg && arg.type === 'ObjectExpression') {
153
+ out.push({ kind: 'zod', name, table: null, body: objectInner(content, arg) });
154
+ }
155
+ return;
156
+ }
157
+
158
+ // Drizzle: const X = pgTable('table', { ... })
159
+ if (init.type === 'CallExpression' && DRIZZLE_TABLE_FNS.has(calleeName(init.callee))) {
160
+ const tableArg = init.arguments[0];
161
+ const colsArg = init.arguments[1];
162
+ const table = tableArg && tableArg.type === 'StringLiteral' ? tableArg.value : name;
163
+ if (colsArg && colsArg.type === 'ObjectExpression') {
164
+ out.push({ kind: 'drizzle', name, table, body: objectInner(content, colsArg) });
165
+ }
166
+ return;
167
+ }
168
+
169
+ // Mongoose: const X = new Schema({ ... }) | new mongoose.Schema({ ... })
170
+ if (init.type === 'NewExpression') {
171
+ const cn = calleeName(init.callee);
172
+ if (cn === 'Schema' || cn === 'mongoose.Schema') {
173
+ const arg = init.arguments[0];
174
+ if (arg && arg.type === 'ObjectExpression') {
175
+ out.push({ kind: 'mongoose', name, table: null, body: objectInner(content, arg) });
176
+ }
177
+ }
178
+ }
179
+ });
180
+
181
+ return out;
182
+ }
183
+
184
+ // HTTP route-registration methods (`app.get`, `router.post`, …). `use`/`all`
185
+ // are middleware-ish; `all` is included (it IS a route), `use` is not.
186
+ const HTTP_METHOD_NAMES = new Set(['get', 'post', 'put', 'delete', 'patch', 'head', 'options', 'all']);
187
+
188
+ /** Extract a string path from a call's first arg: StringLiteral or TemplateLiteral. */
189
+ function pathArgValue(node) {
190
+ if (!node) return null;
191
+ if (node.type === 'StringLiteral') return node.value;
192
+ if (node.type === 'TemplateLiteral') {
193
+ if (node.expressions.length === 0) return node.quasis[0]?.value?.cooked ?? null;
194
+ // `/users/${id}` → `/users/:param` so a dynamic segment still looks like a path.
195
+ return node.quasis
196
+ .map((q, i) => (q.value.cooked ?? '') + (i < node.expressions.length ? ':param' : ''))
197
+ .join('');
198
+ }
199
+ return null;
200
+ }
201
+
202
+ /**
203
+ * Extract HTTP route registrations (`<router>.<method>('/path', …)`) from JS/TS
204
+ * via AST. More accurate than regex: it matches ANY receiver identifier (so
205
+ * `userRouter.get`, `v1.post`, `r.delete` are all caught — not just app/router/
206
+ * server), survives multi-line calls and arbitrary whitespace, and reads
207
+ * template-literal paths. The `/`-or-`*` path requirement keeps non-route
208
+ * `.get()` calls (e.g. `map.get('key')`, `headers.get('x')`) out.
209
+ *
210
+ * Returns `null` when the file can't be parsed (caller falls back to regex);
211
+ * otherwise an array of `{ method, path, start }` (start = call node offset, for
212
+ * the caller's comment/handler/auth context lookups).
213
+ */
214
+ export function extractJsRouteCalls(content, filename = 'file.ts') {
215
+ const { ast, ok } = parseJsTs(content, filename);
216
+ if (!ok || !ast) return null;
217
+
218
+ const out = [];
219
+ walk(ast, (node) => {
220
+ if (node.type !== 'CallExpression') return;
221
+ const callee = node.callee;
222
+ if (!callee || callee.type !== 'MemberExpression' || callee.computed) return;
223
+ const prop = callee.property;
224
+ if (!prop || prop.type !== 'Identifier') return;
225
+ const method = prop.name.toLowerCase();
226
+ if (!HTTP_METHOD_NAMES.has(method)) return;
227
+ const path = pathArgValue(node.arguments && node.arguments[0]);
228
+ if (!path || !(path.startsWith('/') || path === '*')) return;
229
+ // `receiver` is the object the method was called on (`router` in
230
+ // `router.get(...)`, `app` in `app.get(...)`). Mount-prefix resolution uses
231
+ // it to apply a same-file `app.use('/api', router)` prefix ONLY to that
232
+ // router's routes — never to sibling `app.get(...)` calls in the same file.
233
+ const receiver = callee.object && callee.object.type === 'Identifier' ? callee.object.name : null;
234
+ out.push({ method: method.toUpperCase(), path, start: node.start ?? 0, receiver });
235
+ });
236
+ return out;
237
+ }
238
+
239
+ /** JSX element name as a string: `Route`, `router.Foo` → `Foo` (member tail). */
240
+ function jsxName(nameNode) {
241
+ if (!nameNode) return null;
242
+ if (nameNode.type === 'JSXIdentifier') return nameNode.name;
243
+ if (nameNode.type === 'JSXMemberExpression') return jsxName(nameNode.property);
244
+ return null;
245
+ }
246
+
247
+ /** Read a JSX attribute's string value: `path="/x"` or `path={"/x"}`. */
248
+ function jsxAttrString(valueNode) {
249
+ if (!valueNode) return null; // valueless attr (e.g. `index`)
250
+ if (valueNode.type === 'StringLiteral') return valueNode.value;
251
+ if (valueNode.type === 'JSXExpressionContainer') {
252
+ const e = valueNode.expression;
253
+ if (e && e.type === 'StringLiteral') return e.value;
254
+ }
255
+ return null;
256
+ }
257
+
258
+ /**
259
+ * Extract React Router screens — `<Route path="/x" element={<Wrapper><Screen/></Wrapper>} />`
260
+ * and the route-object form `{ path: '/x', element: <Screen/> }` / `{ path, Component }`.
261
+ * Returns `{ path, components }[]` where `components` is every capitalized JSX
262
+ * element rendered for that route (the caller picks the real screen and skips
263
+ * wrappers). `null` on parse failure → caller's regex fallback.
264
+ *
265
+ * Why AST: route JSX nests auth wrappers/layouts/suspense fallbacks across many
266
+ * lines; the window-based regex truncates or grabs the wrong component. The AST
267
+ * scopes "the element for THIS route" exactly.
268
+ */
269
+ export function extractJsxRouteScreens(content, filename = 'file.tsx') {
270
+ const { ast, ok } = parseJsTs(content, filename);
271
+ if (!ok || !ast) return null;
272
+
273
+ const componentsIn = (node) => {
274
+ const names = [];
275
+ walk(node, (n) => {
276
+ if (n.type === 'JSXOpeningElement') {
277
+ const nm = jsxName(n.name);
278
+ if (nm && /^[A-Z]/.test(nm)) names.push(nm);
279
+ }
280
+ });
281
+ return names;
282
+ };
283
+
284
+ const out = [];
285
+ walk(ast, (node) => {
286
+ // JSX form: <Route path="..." element={...} />
287
+ if (node.type === 'JSXElement') {
288
+ const open = node.openingElement;
289
+ if (!open || jsxName(open.name) !== 'Route') return;
290
+ let path = null;
291
+ let elementNode = null;
292
+ for (const attr of open.attributes || []) {
293
+ if (attr.type !== 'JSXAttribute' || !attr.name) continue;
294
+ if (attr.name.name === 'path') path = jsxAttrString(attr.value);
295
+ else if (['element', 'component', 'Component'].includes(attr.name.name)) elementNode = attr.value;
296
+ }
297
+ if (path != null) out.push({ path, components: elementNode ? componentsIn(elementNode) : [] });
298
+ return;
299
+ }
300
+ // Route-object form: { path: '...', element: <X/> } | { path, Component: X }
301
+ if (node.type === 'ObjectExpression') {
302
+ let path = null;
303
+ let comps = [];
304
+ for (const prop of node.properties || []) {
305
+ if (prop.type !== 'ObjectProperty' && prop.type !== 'Property') continue;
306
+ const key = propKeyName(prop);
307
+ if (key === 'path' && prop.value && prop.value.type === 'StringLiteral') {
308
+ path = prop.value.value;
309
+ } else if (key === 'element') {
310
+ comps = componentsIn(prop.value);
311
+ } else if (key === 'Component' || key === 'component') {
312
+ if (prop.value && prop.value.type === 'Identifier') comps = [prop.value.name];
313
+ }
314
+ }
315
+ if (path != null) out.push({ path, components: comps });
316
+ }
317
+ });
318
+ return out;
319
+ }
320
+
321
+ /** Read an object-literal property's key name, whether `key:` or `'key':`. */
322
+ function propKeyName(prop) {
323
+ if (!prop || !prop.key) return null;
324
+ if (prop.key.type === 'Identifier') return prop.key.name;
325
+ if (prop.key.type === 'StringLiteral') return prop.key.value;
326
+ return null;
327
+ }
328
+
329
+ /**
330
+ * Extract OBJECT-FORM route registrations — Fastify's `fastify.route({ method,
331
+ * url, handler })` (and `method: ['GET','POST']` arrays). The method-shorthand
332
+ * form (`fastify.get('/x')`) is already covered by extractJsRouteCalls; this
333
+ * adds the declarative form, which the old regex never matched at all.
334
+ *
335
+ * Returns `null` on parse failure; otherwise `{ method, path, start, receiver }[]`
336
+ * (one entry per method when `method` is an array).
337
+ */
338
+ export function extractJsRouteObjects(content, filename = 'file.ts') {
339
+ const { ast, ok } = parseJsTs(content, filename);
340
+ if (!ok || !ast) return null;
341
+
342
+ const out = [];
343
+ walk(ast, (node) => {
344
+ if (node.type !== 'CallExpression') return;
345
+ const callee = node.callee;
346
+ if (!callee || callee.type !== 'MemberExpression' || callee.computed) return;
347
+ if (!callee.property || callee.property.type !== 'Identifier' || callee.property.name !== 'route') return;
348
+ const arg = node.arguments && node.arguments[0];
349
+ if (!arg || arg.type !== 'ObjectExpression') return;
350
+
351
+ let methods = [];
352
+ let path = null;
353
+ for (const prop of arg.properties || []) {
354
+ if (prop.type !== 'ObjectProperty' && prop.type !== 'Property') continue;
355
+ const key = propKeyName(prop);
356
+ if (key === 'method') {
357
+ const v = prop.value;
358
+ if (v.type === 'StringLiteral') methods = [v.value];
359
+ else if (v.type === 'ArrayExpression') {
360
+ methods = (v.elements || []).filter(e => e && e.type === 'StringLiteral').map(e => e.value);
361
+ }
362
+ } else if (key === 'url' || key === 'path') {
363
+ path = pathArgValue(prop.value);
364
+ }
365
+ }
366
+ if (!path || !(path.startsWith('/') || path === '*') || !methods.length) return;
367
+ const receiver = callee.object && callee.object.type === 'Identifier' ? callee.object.name : null;
368
+ for (const m of methods) {
369
+ out.push({ method: String(m).toUpperCase(), path, start: node.start ?? 0, receiver });
370
+ }
371
+ });
372
+ return out;
373
+ }
374
+
375
+ /**
376
+ * Extract Express-style router MOUNTS and module IMPORTS from a JS/TS file, so a
377
+ * caller can resolve the full path of a route declared in a sub-router file.
378
+ *
379
+ * The problem: `userRoutes.ts` declares `router.get('/:id')`, but the real URL
380
+ * is `/api/users/:id` because `app.js` did `app.use('/api/users', userRoutes)`.
381
+ * A per-file scan only sees `/:id` and the documented `/api/users/:id` never
382
+ * matches — every mounted route then double-fires (documented-but-absent AND
383
+ * undocumented). Resolving the mount prefix fixes that.
384
+ *
385
+ * Returns `null` when the file can't be parsed (caller keeps the bare path);
386
+ * otherwise `{ imports, mounts }`:
387
+ * - imports: { localName -> module specifier string } from `import` / `require`
388
+ * - mounts: [{ prefix, ident }] from `<x>.use('/prefix', …, <ident>)`
389
+ * Resolving `ident` (local router vs imported specifier) is the caller's job —
390
+ * it needs filesystem context this pure-AST module deliberately avoids.
391
+ */
392
+ export function extractJsMountsAndImports(content, filename = 'file.ts') {
393
+ const { ast, ok } = parseJsTs(content, filename);
394
+ if (!ok || !ast) return null;
395
+
396
+ const imports = {};
397
+ const mounts = [];
398
+
399
+ walk(ast, (node) => {
400
+ // import X from 'spec' | import { X } from 'spec' | import * as X from 'spec'
401
+ if (node.type === 'ImportDeclaration' && node.source && node.source.type === 'StringLiteral') {
402
+ for (const spec of node.specifiers || []) {
403
+ if (spec.local && spec.local.name) imports[spec.local.name] = node.source.value;
404
+ }
405
+ return;
406
+ }
407
+ // const X = require('spec')
408
+ if (node.type === 'VariableDeclarator' && node.id && node.id.type === 'Identifier'
409
+ && node.init && node.init.type === 'CallExpression'
410
+ && calleeName(node.init.callee) === 'require'
411
+ && node.init.arguments[0] && node.init.arguments[0].type === 'StringLiteral') {
412
+ imports[node.id.name] = node.init.arguments[0].value;
413
+ return;
414
+ }
415
+ // <x>.use('/prefix', …, <routerIdent>) — the prefix is the first string-literal
416
+ // arg; the mounted router is the LAST identifier arg (skips middleware).
417
+ if (node.type === 'CallExpression' && node.callee && node.callee.type === 'MemberExpression'
418
+ && !node.callee.computed && node.callee.property && node.callee.property.name === 'use') {
419
+ const args = node.arguments || [];
420
+ const first = args[0];
421
+ const prefix = first && first.type === 'StringLiteral' ? first.value : null;
422
+ if (!prefix || !prefix.startsWith('/')) return;
423
+ let ident = null;
424
+ for (let i = args.length - 1; i >= 1; i--) {
425
+ if (args[i] && args[i].type === 'Identifier') { ident = args[i].name; break; }
426
+ }
427
+ if (ident) mounts.push({ prefix, ident });
428
+ }
429
+ });
430
+
431
+ return { imports, mounts };
432
+ }
@@ -199,7 +199,7 @@ function _buildMemoryPlanUncached(projectDir, config = {}) {
199
199
  // ── Gather the code-truth surface ──
200
200
  const docTools = detectDocTools(projectDir);
201
201
  const routes = scanRoutesDeep(projectDir, { framework: profile.frameworks.join(' ') }, docTools, { config });
202
- const schemas = scanSchemasDeep(projectDir, { framework: primaryFramework }, docTools);
202
+ const schemas = scanSchemasDeep(projectDir, { framework: primaryFramework }, docTools, config);
203
203
  const entities = schemas.entities || [];
204
204
  const isWebFrontend = profile.ecosystems.some(e => e.kind === 'webapp');
205
205
  const fe = isWebFrontend
@@ -15,6 +15,7 @@
15
15
 
16
16
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
17
17
  import { resolve, join, relative, dirname, basename } from 'node:path';
18
+ import { shouldIgnore, relPosix } from '../shared-ignore.mjs';
18
19
 
19
20
  const IGNORE_DIRS = new Set([
20
21
  'node_modules', '.git', '.next', 'dist', 'build', 'coverage', 'target',
@@ -42,7 +43,8 @@ function readSafe(p) { try { return readFileSync(p, 'utf-8'); } catch { return '
42
43
  function readJson(p) { try { return JSON.parse(readFileSync(p, 'utf-8')); } catch { return null; } }
43
44
 
44
45
  /** Recursively find manifest files (bounded depth, ignoring vendor dirs). */
45
- function findManifests(projectDir, maxDepth = 4) {
46
+ function findManifests(projectDir, maxDepth = 4, config = {}) {
47
+ const root = resolve(projectDir);
46
48
  const found = []; // { absDir, file, lang }
47
49
  const walk = (dir, depth) => {
48
50
  if (depth > maxDepth) return;
@@ -51,15 +53,20 @@ function findManifests(projectDir, maxDepth = 4) {
51
53
  for (const e of entries) {
52
54
  if (e.isDirectory()) {
53
55
  if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue;
56
+ // Honor config.ignore / .docguardignore: a user who excludes tests/ or
57
+ // base-research/ must not have those dirs' manifests (e.g. a fixture
58
+ // package.json declaring express) misclassify the project's stack.
59
+ if (shouldIgnore(relPosix(root, join(dir, e.name)), config)) continue;
54
60
  walk(join(dir, e.name), depth + 1);
55
61
  } else if (e.isFile()) {
62
+ if (shouldIgnore(relPosix(root, join(dir, e.name)), config)) continue;
56
63
  const m = MANIFESTS.find(x => x.file === e.name);
57
64
  if (m) found.push({ absDir: dir, file: e.name, lang: m.lang });
58
65
  else if (e.name.endsWith('.csproj')) found.push({ absDir: dir, file: e.name, lang: 'C#' });
59
66
  }
60
67
  }
61
68
  };
62
- walk(resolve(projectDir), 0);
69
+ walk(root, 0);
63
70
  return found;
64
71
  }
65
72
 
@@ -255,8 +262,8 @@ function buildEcosystem(projectDir, m) {
255
262
  * Multiple manifests in the same dir+language merge into one ecosystem.
256
263
  * @returns {Array<{ language, manifest, dir, framework, kind, deps, entryPoints }>}
257
264
  */
258
- export function detectEcosystems(projectDir, _config = {}) {
259
- const manifests = findManifests(projectDir);
265
+ export function detectEcosystems(projectDir, config = {}) {
266
+ const manifests = findManifests(projectDir, 4, config);
260
267
  const byKey = new Map(); // `${dir}::${lang-family}` → ecosystem
261
268
 
262
269
  // Group Python manifests (pyproject/requirements/setup/Pipfile) in same dir.