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.
- package/README.md +4 -4
- package/cli/commands/demo.mjs +1 -1
- package/cli/commands/diff.mjs +19 -8
- package/cli/commands/explain.mjs +178 -17
- package/cli/commands/fix.mjs +17 -2
- package/cli/commands/generate.mjs +2 -2
- package/cli/commands/guard.mjs +86 -11
- package/cli/commands/hooks.mjs +12 -7
- package/cli/commands/init.mjs +18 -6
- package/cli/commands/score.mjs +147 -61
- package/cli/commands/setup.mjs +2 -2
- package/cli/commands/trace.mjs +3 -101
- package/cli/commands/upgrade.mjs +61 -13
- package/cli/config.mjs +245 -0
- package/cli/docguard.mjs +21 -217
- package/cli/ensure-skills.mjs +24 -26
- package/cli/scanners/api-doc.mjs +17 -3
- package/cli/scanners/doc-tools.mjs +32 -15
- package/cli/scanners/frontend.mjs +24 -8
- package/cli/scanners/js-ast.mjs +432 -0
- package/cli/scanners/memory-plan.mjs +1 -1
- package/cli/scanners/py-ast.mjs +213 -0
- package/cli/scanners/routes.mjs +194 -69
- package/cli/scanners/schemas.mjs +97 -51
- package/cli/scanners/speckit.mjs +14 -0
- package/cli/shared-git.mjs +0 -0
- package/cli/shared-ignore.mjs +16 -1
- package/cli/shared-source.mjs +59 -2
- package/cli/shared-trace-patterns.mjs +118 -0
- package/cli/shared.mjs +60 -1
- package/cli/validator-markers.mjs +91 -0
- package/cli/validators/api-surface.mjs +37 -3
- package/cli/validators/canonical-sync.mjs +22 -19
- package/cli/validators/doc-quality.mjs +27 -44
- package/cli/validators/docs-coverage.mjs +13 -0
- package/cli/validators/docs-diff.mjs +16 -6
- package/cli/validators/docs-sync.mjs +4 -3
- package/cli/validators/drift.mjs +3 -2
- package/cli/validators/freshness.mjs +47 -15
- package/cli/validators/metadata-sync.mjs +21 -11
- package/cli/validators/metrics-consistency.mjs +45 -17
- package/cli/validators/security.mjs +13 -5
- package/cli/validators/structure.mjs +6 -5
- package/cli/validators/surface-sync.mjs +7 -5
- package/cli/validators/test-spec.mjs +76 -51
- package/cli/validators/todo-tracking.mjs +4 -2
- package/cli/validators/traceability.mjs +12 -54
- package/cli/writers/sections.mjs +32 -19
- package/docs/commands.md +1 -1
- package/docs/configuration.md +11 -0
- package/docs/faq.md +1 -1
- package/extensions/spec-kit-docguard/README.md +1 -1
- package/extensions/spec-kit-docguard/extension.yml +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -1
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-autofix.yml +3 -2
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +2 -2
- package/package.json +5 -3
|
@@ -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
|
+
}
|