docguard-cli 0.22.1 → 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 (61) hide show
  1. package/README.md +4 -4
  2. package/cli/commands/demo.mjs +1 -1
  3. package/cli/commands/diff.mjs +19 -8
  4. package/cli/commands/explain.mjs +178 -17
  5. package/cli/commands/fix.mjs +17 -2
  6. package/cli/commands/generate.mjs +2 -2
  7. package/cli/commands/guard.mjs +86 -11
  8. package/cli/commands/hooks.mjs +12 -7
  9. package/cli/commands/init.mjs +18 -6
  10. package/cli/commands/score.mjs +147 -61
  11. package/cli/commands/setup.mjs +2 -2
  12. package/cli/commands/trace.mjs +3 -101
  13. package/cli/commands/upgrade.mjs +61 -13
  14. package/cli/config.mjs +245 -0
  15. package/cli/docguard.mjs +21 -217
  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/py-ast.mjs +213 -0
  23. package/cli/scanners/routes.mjs +194 -69
  24. package/cli/scanners/schemas.mjs +97 -51
  25. package/cli/scanners/speckit.mjs +14 -0
  26. package/cli/shared-git.mjs +0 -0
  27. package/cli/shared-ignore.mjs +16 -1
  28. package/cli/shared-source.mjs +59 -2
  29. package/cli/shared-trace-patterns.mjs +118 -0
  30. package/cli/shared.mjs +60 -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 +27 -44
  35. package/cli/validators/docs-coverage.mjs +13 -0
  36. package/cli/validators/docs-diff.mjs +16 -6
  37. package/cli/validators/docs-sync.mjs +4 -3
  38. package/cli/validators/drift.mjs +3 -2
  39. package/cli/validators/freshness.mjs +47 -15
  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 +12 -54
  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
@@ -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
+ }