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,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
|
+
}
|
package/cli/scanners/routes.mjs
CHANGED
|
@@ -8,12 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
10
10
|
import { resolve, join, relative, basename, extname, dirname } from 'node:path';
|
|
11
|
-
import { resolveSourceRoots } from '../shared-source.mjs';
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
'.cache', '__pycache__', '.venv', 'vendor', '.turbo',
|
|
16
|
-
]);
|
|
11
|
+
import { resolveSourceRoots, readScannable } from '../shared-source.mjs';
|
|
12
|
+
import { DEFAULT_IGNORE_DIRS as IGNORE_DIRS, shouldIgnore, relPosix } from '../shared-ignore.mjs';
|
|
13
|
+
import { extractJsRouteCalls, extractJsRouteObjects, extractJsMountsAndImports } from './js-ast.mjs';
|
|
14
|
+
import { extractPythonFiles } from './py-ast.mjs';
|
|
17
15
|
|
|
18
16
|
/**
|
|
19
17
|
* Scan routes from source code with framework-aware parsing.
|
|
@@ -80,12 +78,16 @@ export function scanRoutesDeep(dir, stack, docTools, opts = {}) {
|
|
|
80
78
|
routes.push(...scanFastAPIRoutes(dir));
|
|
81
79
|
}
|
|
82
80
|
|
|
83
|
-
// Deduplicate by method+path
|
|
81
|
+
// Deduplicate by method+path, and honor .docguardignore / config.ignore so a
|
|
82
|
+
// fixtures dir with fake routes doesn't pollute the API surface. Filtering the
|
|
83
|
+
// RESULTS (route.file → project-relative) keeps the per-framework walkers as-is.
|
|
84
|
+
const cfg = opts.config || {};
|
|
84
85
|
const seen = new Set();
|
|
85
86
|
return routes.filter(r => {
|
|
86
87
|
const key = `${r.method}:${r.path}`;
|
|
87
88
|
if (seen.has(key)) return false;
|
|
88
89
|
seen.add(key);
|
|
90
|
+
if (r.file && shouldIgnore(relPosix(dir, resolve(dir, r.file)), cfg)) return false;
|
|
89
91
|
return true;
|
|
90
92
|
});
|
|
91
93
|
}
|
|
@@ -118,8 +120,15 @@ function scanNextJsRoutes(dir) {
|
|
|
118
120
|
const relDir = relative(resolve(dir, apiBase), dirname(filePath));
|
|
119
121
|
const apiPath = '/' + relDir
|
|
120
122
|
.replace(/\\/g, '/')
|
|
121
|
-
|
|
122
|
-
.
|
|
123
|
+
// Strip route-group segments like `(admin)` — they organize files but
|
|
124
|
+
// do NOT appear in the URL. The frontend scanner already does this; the
|
|
125
|
+
// route scanner used to leak them, e.g. `/api/(admin)/users`.
|
|
126
|
+
.split('/')
|
|
127
|
+
.filter(seg => seg && !/^\(.*\)$/.test(seg))
|
|
128
|
+
.join('/')
|
|
129
|
+
.replace(/\[\[\.\.\.(\w+)\]\]/g, ':$1*') // Optional catch-all [[...slug]] — before [...slug]
|
|
130
|
+
.replace(/\[\.\.\.(\w+)\]/g, ':$1*') // Catch-all [...slug]
|
|
131
|
+
.replace(/\[(\w+)\]/g, ':$1'); // Dynamic [id]
|
|
123
132
|
|
|
124
133
|
// Extract exported HTTP methods
|
|
125
134
|
const methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
|
|
@@ -195,9 +204,22 @@ function scanNextJsRoutes(dir) {
|
|
|
195
204
|
// ── Express / Generic Node.js ───────────────────────────────────────────────
|
|
196
205
|
|
|
197
206
|
function scanExpressRoutes(dir, roots = null) {
|
|
198
|
-
|
|
207
|
+
// Regex is the FALLBACK (used only when @babel/parser can't parse a file).
|
|
208
|
+
// It hardcodes app/router/server receivers; the AST path matches any receiver.
|
|
199
209
|
const routePattern = /(?:app|router|server)\s*\.\s*(get|post|put|delete|patch|head|options)\s*\(\s*['"`]([^'"`]+)['"`]/gi;
|
|
200
210
|
|
|
211
|
+
// ── Phase 0: collect every candidate file ONCE ──────────────────────────────
|
|
212
|
+
// The mount map (phase 1) and the route emit (phase 2) must see the same set,
|
|
213
|
+
// and we don't want to walk the tree twice.
|
|
214
|
+
const files = []; // { content, filePath, fileLabel }
|
|
215
|
+
const seenPaths = new Set();
|
|
216
|
+
const addFile = (filePath, fileLabel) => {
|
|
217
|
+
if (seenPaths.has(filePath)) return;
|
|
218
|
+
const content = readFileSafe(filePath);
|
|
219
|
+
if (!content) return;
|
|
220
|
+
seenPaths.add(filePath);
|
|
221
|
+
files.push({ content, filePath, fileLabel });
|
|
222
|
+
};
|
|
201
223
|
// Monorepo-aware: walk resolved absolute source roots when provided,
|
|
202
224
|
// otherwise fall back to conventional root-relative directories.
|
|
203
225
|
const searchTargets = roots && roots.length
|
|
@@ -205,51 +227,114 @@ function scanExpressRoutes(dir, roots = null) {
|
|
|
205
227
|
: ['src', 'routes', 'api', 'server', 'lib'].map(d => resolve(dir, d));
|
|
206
228
|
for (const fullDir of searchTargets) {
|
|
207
229
|
if (!existsSync(fullDir)) continue;
|
|
208
|
-
|
|
209
230
|
walkRouteDirs(fullDir, (filePath) => {
|
|
210
|
-
if (
|
|
211
|
-
|
|
212
|
-
|
|
231
|
+
if (isJSFile(filePath)) addFile(filePath, relative(dir, filePath));
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
for (const rootFile of ['app.js', 'app.mjs', 'app.ts', 'server.js', 'server.ts', 'index.js', 'index.ts']) {
|
|
235
|
+
const filePath = resolve(dir, rootFile);
|
|
236
|
+
if (existsSync(filePath)) addFile(filePath, rootFile);
|
|
237
|
+
}
|
|
213
238
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
239
|
+
// ── Phase 1: build the mount map ────────────────────────────────────────────
|
|
240
|
+
// absFilePath -> [{ receiver|null, prefix }]. `receiver === null` means the
|
|
241
|
+
// prefix applies to EVERY route in that file (an imported sub-router); a
|
|
242
|
+
// non-null receiver means it applies only to routes whose receiver matches
|
|
243
|
+
// (a same-file `const r = Router(); app.use('/api', r)` — so a sibling
|
|
244
|
+
// `app.get('/health')` in the same file is NOT wrongly prefixed).
|
|
245
|
+
const mountMap = buildExpressMountMap(files);
|
|
246
|
+
|
|
247
|
+
// ── Phase 2: emit routes, prefixing by mount where known ────────────────────
|
|
248
|
+
const routes = [];
|
|
249
|
+
for (const { content, filePath, fileLabel } of files) {
|
|
250
|
+
const mounts = mountMap.get(filePath) || [];
|
|
251
|
+
const emit = (method, path, index, receiver) => {
|
|
252
|
+
const prefixes = mounts
|
|
253
|
+
.filter(m => m.receiver === null || m.receiver === receiver)
|
|
254
|
+
.map(m => m.prefix);
|
|
255
|
+
const finalPaths = prefixes.length ? prefixes.map(p => joinRoutePath(p, path)) : [path];
|
|
256
|
+
for (const fullPath of finalPaths) {
|
|
217
257
|
routes.push({
|
|
218
|
-
method:
|
|
219
|
-
path:
|
|
220
|
-
handler: extractHandlerName(content,
|
|
221
|
-
file:
|
|
258
|
+
method: method.toUpperCase(),
|
|
259
|
+
path: fullPath,
|
|
260
|
+
handler: extractHandlerName(content, index),
|
|
261
|
+
file: fileLabel,
|
|
222
262
|
source: 'express',
|
|
223
|
-
auth: hasAuthMiddleware(content,
|
|
224
|
-
description: extractNearbyComment(content,
|
|
263
|
+
auth: hasAuthMiddleware(content, path),
|
|
264
|
+
description: extractNearbyComment(content, index),
|
|
225
265
|
});
|
|
226
266
|
}
|
|
227
|
-
}
|
|
267
|
+
};
|
|
268
|
+
const ast = extractJsRouteCalls(content, filePath);
|
|
269
|
+
if (ast) {
|
|
270
|
+
for (const r of ast) emit(r.method, r.path, r.start, r.receiver ?? null);
|
|
271
|
+
} else {
|
|
272
|
+
const regex = new RegExp(routePattern.source, 'gi');
|
|
273
|
+
let match;
|
|
274
|
+
while ((match = regex.exec(content)) !== null) emit(match[1], match[2], match.index, null);
|
|
275
|
+
}
|
|
228
276
|
}
|
|
229
277
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const filePath = resolve(dir, rootFile);
|
|
233
|
-
if (!existsSync(filePath)) continue;
|
|
234
|
-
const content = readFileSafe(filePath);
|
|
235
|
-
if (!content) return;
|
|
278
|
+
return routes;
|
|
279
|
+
}
|
|
236
280
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
281
|
+
/**
|
|
282
|
+
* Build the Express mount map from the collected files (phase 1 above).
|
|
283
|
+
* For each `<x>.use('/prefix', router)`:
|
|
284
|
+
* - router is an IMPORTED binding → the prefix applies to ALL routes in the
|
|
285
|
+
* resolved target file (receiver: null). One router per file is the norm.
|
|
286
|
+
* - router is a LOCAL identifier → the prefix applies only to that file's
|
|
287
|
+
* routes whose receiver matches (receiver: ident).
|
|
288
|
+
*
|
|
289
|
+
* Known limitations (documented, not silently wrong): transitive composition
|
|
290
|
+
* (`app.use('/api', api)` then `api.use('/x', x)` does NOT yield `/api/x`), and
|
|
291
|
+
* dynamic mount paths (non-string-literal prefixes) are skipped. Unmounted
|
|
292
|
+
* files keep their bare paths — exactly the pre-mount-map behavior.
|
|
293
|
+
*/
|
|
294
|
+
function buildExpressMountMap(files) {
|
|
295
|
+
const map = new Map();
|
|
296
|
+
const add = (absFile, receiver, prefix) => {
|
|
297
|
+
if (!map.has(absFile)) map.set(absFile, []);
|
|
298
|
+
map.get(absFile).push({ receiver, prefix });
|
|
299
|
+
};
|
|
300
|
+
for (const { content, filePath } of files) {
|
|
301
|
+
const mi = extractJsMountsAndImports(content, filePath);
|
|
302
|
+
if (!mi) continue;
|
|
303
|
+
for (const { prefix, ident } of mi.mounts) {
|
|
304
|
+
const spec = mi.imports[ident];
|
|
305
|
+
if (spec) {
|
|
306
|
+
const target = resolveLocalImport(filePath, spec);
|
|
307
|
+
if (target) add(target, null, prefix);
|
|
308
|
+
} else {
|
|
309
|
+
add(filePath, ident, prefix);
|
|
310
|
+
}
|
|
249
311
|
}
|
|
250
312
|
}
|
|
313
|
+
return map;
|
|
314
|
+
}
|
|
251
315
|
|
|
252
|
-
|
|
316
|
+
/** Resolve a RELATIVE import specifier to an absolute file path (best effort). */
|
|
317
|
+
function resolveLocalImport(fromFile, spec) {
|
|
318
|
+
if (!spec.startsWith('.')) return null; // bare/node_modules specifiers aren't our routers
|
|
319
|
+
const base = resolve(dirname(fromFile), spec);
|
|
320
|
+
for (const ext of ['', '.ts', '.js', '.mjs', '.cjs', '.tsx', '.jsx']) {
|
|
321
|
+
const cand = base + ext;
|
|
322
|
+
try { if (existsSync(cand) && statSync(cand).isFile()) return cand; } catch { /* skip */ }
|
|
323
|
+
}
|
|
324
|
+
for (const idx of ['index.ts', 'index.js', 'index.mjs']) {
|
|
325
|
+
const cand = join(base, idx);
|
|
326
|
+
try { if (existsSync(cand) && statSync(cand).isFile()) return cand; } catch { /* skip */ }
|
|
327
|
+
}
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Join a mount prefix and a route path into one normalized `/a/b` path. */
|
|
332
|
+
function joinRoutePath(prefix, p) {
|
|
333
|
+
if (!prefix) return p;
|
|
334
|
+
const left = prefix.replace(/\/+$/, ''); // drop trailing slash(es)
|
|
335
|
+
const right = (p === '/' || p === '') ? '' : ('/' + p.replace(/^\/+/, '')); // single leading slash
|
|
336
|
+
const joined = left + right;
|
|
337
|
+
return joined || '/';
|
|
253
338
|
}
|
|
254
339
|
|
|
255
340
|
// ── Fastify ─────────────────────────────────────────────────────────────────
|
|
@@ -266,18 +351,28 @@ function scanFastifyRoutes(dir, roots = null) {
|
|
|
266
351
|
const content = readFileSafe(filePath);
|
|
267
352
|
if (!content) return;
|
|
268
353
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
354
|
+
const emit = (method, path, index) => routes.push({
|
|
355
|
+
method: method.toUpperCase(),
|
|
356
|
+
path,
|
|
357
|
+
handler: extractHandlerName(content, index),
|
|
358
|
+
file: relative(dir, filePath),
|
|
359
|
+
source: 'fastify',
|
|
360
|
+
auth: hasAuthCheck(content),
|
|
361
|
+
description: extractNearbyComment(content, index),
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// AST-first: method shorthand (fastify.get('/x')) AND the declarative
|
|
365
|
+
// object form (fastify.route({ method, url })) the regex never matched.
|
|
366
|
+
// Both return null only on parse failure → regex fallback.
|
|
367
|
+
const calls = extractJsRouteCalls(content, filePath);
|
|
368
|
+
const objs = extractJsRouteObjects(content, filePath);
|
|
369
|
+
if (calls || objs) {
|
|
370
|
+
for (const r of calls || []) emit(r.method, r.path, r.start);
|
|
371
|
+
for (const r of objs || []) emit(r.method, r.path, r.start);
|
|
372
|
+
} else {
|
|
373
|
+
let match;
|
|
374
|
+
const regex = new RegExp(pattern.source, 'gi');
|
|
375
|
+
while ((match = regex.exec(content)) !== null) emit(match[1], match[2], match.index);
|
|
281
376
|
}
|
|
282
377
|
});
|
|
283
378
|
}
|
|
@@ -302,18 +397,25 @@ function scanHonoRoutes(dir, roots = null) {
|
|
|
302
397
|
const content = readFileSafe(filePath);
|
|
303
398
|
if (!content) return;
|
|
304
399
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
400
|
+
const emit = (method, path, index) => routes.push({
|
|
401
|
+
method: method.toUpperCase(),
|
|
402
|
+
path,
|
|
403
|
+
handler: '',
|
|
404
|
+
file: relative(dir, filePath),
|
|
405
|
+
source: 'hono',
|
|
406
|
+
auth: hasAuthCheck(content),
|
|
407
|
+
description: extractNearbyComment(content, index),
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// AST-first (any receiver, multi-line, template paths — Hono/Koa method
|
|
411
|
+
// shorthand `app.get('/x')` / `router.get('/x')`); regex fallback.
|
|
412
|
+
const calls = extractJsRouteCalls(content, filePath);
|
|
413
|
+
if (calls) {
|
|
414
|
+
for (const r of calls) emit(r.method, r.path, r.start);
|
|
415
|
+
} else {
|
|
416
|
+
let match;
|
|
417
|
+
const regex = new RegExp(pattern.source, 'gi');
|
|
418
|
+
while ((match = regex.exec(content)) !== null) emit(match[1], match[2], match.index);
|
|
317
419
|
}
|
|
318
420
|
});
|
|
319
421
|
}
|
|
@@ -357,10 +459,33 @@ function scanFastAPIRoutes(dir) {
|
|
|
357
459
|
const pattern = /@(?:app|router)\s*\.\s*(get|post|put|delete|patch)\s*\(\s*['"]([^'"]+)['"]/gi;
|
|
358
460
|
|
|
359
461
|
const pyFiles = findFiles(dir, /\.py$/);
|
|
462
|
+
// AST-first: ONE python3 subprocess parses every file. `null` means Python is
|
|
463
|
+
// unavailable or the subprocess failed → regex fallback for all files. A
|
|
464
|
+
// per-file `ok:false` falls back for just that file. The AST form also reads
|
|
465
|
+
// multi-line decorators and Flask `methods=[...]` arrays the regex misses.
|
|
466
|
+
const astByFile = extractPythonFiles(pyFiles);
|
|
467
|
+
|
|
360
468
|
for (const filePath of pyFiles) {
|
|
361
469
|
const content = readFileSafe(filePath);
|
|
362
470
|
if (!content) continue;
|
|
363
471
|
|
|
472
|
+
const parsed = astByFile && astByFile[filePath];
|
|
473
|
+
const fileAuth = content.includes('Depends(') && content.includes('auth');
|
|
474
|
+
if (parsed && parsed.ok) {
|
|
475
|
+
for (const r of parsed.routes || []) {
|
|
476
|
+
routes.push({
|
|
477
|
+
method: r.method,
|
|
478
|
+
path: r.path,
|
|
479
|
+
handler: r.func || '',
|
|
480
|
+
file: relative(dir, filePath),
|
|
481
|
+
source: 'fastapi',
|
|
482
|
+
auth: fileAuth,
|
|
483
|
+
description: r.desc || '',
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
|
|
364
489
|
let match;
|
|
365
490
|
const regex = new RegExp(pattern.source, 'gi');
|
|
366
491
|
while ((match = regex.exec(content)) !== null) {
|
|
@@ -370,7 +495,7 @@ function scanFastAPIRoutes(dir) {
|
|
|
370
495
|
handler: extractPythonFunctionName(content, match.index),
|
|
371
496
|
file: relative(dir, filePath),
|
|
372
497
|
source: 'fastapi',
|
|
373
|
-
auth:
|
|
498
|
+
auth: fileAuth,
|
|
374
499
|
description: extractPythonDocstring(content, match.index),
|
|
375
500
|
});
|
|
376
501
|
}
|
|
@@ -515,7 +640,7 @@ function scanRustWebRoutes(dir) {
|
|
|
515
640
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
516
641
|
|
|
517
642
|
function readFileSafe(path) {
|
|
518
|
-
|
|
643
|
+
return readScannable(path); // size-capped; skips minified/generated bundles
|
|
519
644
|
}
|
|
520
645
|
|
|
521
646
|
function isJSFile(path) {
|