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.
- package/README.md +1 -1
- package/cli/commands/diff.mjs +1 -1
- package/cli/commands/explain.mjs +178 -17
- package/cli/commands/fix.mjs +17 -2
- package/cli/commands/generate.mjs +69 -3
- package/cli/commands/guard.mjs +86 -11
- package/cli/commands/hooks.mjs +12 -7
- package/cli/commands/init.mjs +24 -8
- package/cli/commands/score.mjs +147 -61
- package/cli/commands/setup.mjs +2 -2
- package/cli/commands/sync.mjs +6 -0
- package/cli/commands/trace.mjs +3 -3
- package/cli/commands/upgrade.mjs +61 -13
- package/cli/config.mjs +18 -1
- package/cli/docguard.mjs +156 -2
- 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/project-type.mjs +11 -4
- package/cli/scanners/py-ast.mjs +213 -0
- package/cli/scanners/routes.mjs +194 -69
- package/cli/scanners/schemas.mjs +97 -51
- package/cli/shared-git.mjs +0 -0
- package/cli/shared-ignore.mjs +23 -2
- package/cli/shared-source.mjs +59 -2
- package/cli/shared-trace-patterns.mjs +13 -0
- package/cli/shared.mjs +92 -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 +2 -42
- package/cli/validators/docs-coverage.mjs +13 -0
- 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/generated-staleness.mjs +16 -1
- 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 +11 -3
- 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
|
|
@@ -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(
|
|
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,
|
|
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.
|