@ghostlygawd/codeweb 0.9.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/.claude-plugin/plugin.json +30 -0
- package/CHANGELOG.md +480 -0
- package/LICENSE +21 -0
- package/README.md +573 -0
- package/agents/codeweb-dissector.md +56 -0
- package/agents/codeweb-domain-mapper.md +63 -0
- package/commands/codeweb.md +57 -0
- package/hooks/hooks.json +47 -0
- package/hooks/post-edit-diff.mjs +83 -0
- package/hooks/pre-edit-impact.mjs +94 -0
- package/hooks/session-brief.mjs +53 -0
- package/package.json +64 -0
- package/scripts/annotate.mjs +46 -0
- package/scripts/bench-ts-engine.mjs +59 -0
- package/scripts/bench.mjs +133 -0
- package/scripts/break-cycles.mjs +0 -0
- package/scripts/brief.mjs +28 -0
- package/scripts/build-report.mjs +182 -0
- package/scripts/campaign.mjs +61 -0
- package/scripts/check-consistency.mjs +45 -0
- package/scripts/ci-gate.mjs +86 -0
- package/scripts/cluster3.mjs +112 -0
- package/scripts/codemod.mjs +130 -0
- package/scripts/context-pack.mjs +118 -0
- package/scripts/deadcode.mjs +96 -0
- package/scripts/diff.mjs +0 -0
- package/scripts/explain.mjs +87 -0
- package/scripts/extract-symbols.mjs +1307 -0
- package/scripts/find-similar.mjs +86 -0
- package/scripts/find.mjs +50 -0
- package/scripts/fitness.mjs +90 -0
- package/scripts/grammars/PROVENANCE.md +36 -0
- package/scripts/grammars/tree-sitter-c-sharp.wasm +0 -0
- package/scripts/grammars/tree-sitter-go.wasm +0 -0
- package/scripts/grammars/tree-sitter-java.wasm +0 -0
- package/scripts/grammars/tree-sitter-python.wasm +0 -0
- package/scripts/grammars/tree-sitter-rust.wasm +0 -0
- package/scripts/grammars/tree-sitter-typescript.wasm +0 -0
- package/scripts/hotspots.mjs +53 -0
- package/scripts/lib/annotations.mjs +51 -0
- package/scripts/lib/bench-core.mjs +178 -0
- package/scripts/lib/brief-core.mjs +92 -0
- package/scripts/lib/campaign.mjs +73 -0
- package/scripts/lib/claims-check.mjs +44 -0
- package/scripts/lib/cli.mjs +140 -0
- package/scripts/lib/complexity.mjs +68 -0
- package/scripts/lib/dup-check.mjs +51 -0
- package/scripts/lib/find-core.mjs +85 -0
- package/scripts/lib/gate-md.mjs +50 -0
- package/scripts/lib/graph-ops.mjs +319 -0
- package/scripts/lib/hotspots.mjs +34 -0
- package/scripts/lib/query-core.mjs +85 -0
- package/scripts/lib/reading-order.mjs +53 -0
- package/scripts/lib/reliance.mjs +143 -0
- package/scripts/lib/risk.mjs +18 -0
- package/scripts/lib/shingles.mjs +39 -0
- package/scripts/lib/skeleton.mjs +41 -0
- package/scripts/lib/stats.mjs +92 -0
- package/scripts/lib/ts-engine.mjs +605 -0
- package/scripts/mcp-server.mjs +370 -0
- package/scripts/optimize.mjs +152 -0
- package/scripts/overlap.mjs +380 -0
- package/scripts/placement.mjs +93 -0
- package/scripts/query.mjs +94 -0
- package/scripts/reading-order.mjs +36 -0
- package/scripts/refresh.mjs +73 -0
- package/scripts/release-utils.mjs +158 -0
- package/scripts/release.mjs +62 -0
- package/scripts/report-template.html +730 -0
- package/scripts/review.mjs +112 -0
- package/scripts/risk.mjs +77 -0
- package/scripts/run.mjs +96 -0
- package/scripts/screenshot.mjs +104 -0
- package/scripts/simulate-edit.mjs +70 -0
- package/scripts/stats.mjs +31 -0
- package/scripts/trend.mjs +147 -0
- package/scripts/version-sync.mjs +19 -0
- package/skills/codebase-anatomy/SKILL.md +174 -0
- package/skills/codebase-anatomy/references/engine-detection.md +49 -0
- package/skills/codebase-anatomy/references/graph-schema.md +120 -0
- package/skills/codebase-anatomy/references/overlap-heuristics.md +52 -0
|
@@ -0,0 +1,1307 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// codeweb symbol extractor: emits {nodes, edges} JSON conforming to the graph schema
|
|
3
|
+
// (skills/codebase-anatomy/references/graph-schema.md).
|
|
4
|
+
//
|
|
5
|
+
// Strategy (hybrid engine, in code):
|
|
6
|
+
// - Symbol discovery prefers universal-ctags (`ctags --output-format=json`) when installed;
|
|
7
|
+
// otherwise a built-in regex scanner handles JS/TS and Python.
|
|
8
|
+
// - Call edges are derived by scanning call sites and mapping each to the enclosing symbol —
|
|
9
|
+
// this works with or without ctags.
|
|
10
|
+
// - Read-only: it never executes the target. The only child processes are `ctags`/`rg`,
|
|
11
|
+
// which statically inspect files already on disk.
|
|
12
|
+
//
|
|
13
|
+
// Usage:
|
|
14
|
+
// node extract-symbols.mjs <path> [--out fragment.json] [--no-ctags]
|
|
15
|
+
|
|
16
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
17
|
+
import { execFileSync } from 'node:child_process';
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { relative, resolve, join, dirname, extname } from 'node:path';
|
|
20
|
+
import { isTestFile, roleOf, compileRoleOverrides } from './lib/graph-ops.mjs'; // F4/v7: test predicate + code-role (shared, one truth)
|
|
21
|
+
import { cyclomatic, nestingDepth } from './lib/complexity.mjs'; // F4: per-symbol complexity/nesting
|
|
22
|
+
import { loadTsEngine, loadLangEngine, probeAst } from './lib/ts-engine.mjs'; // optional tree-sitter tiers (JS/TS + Java/C# dispatch)
|
|
23
|
+
|
|
24
|
+
// F0: bump when scanSymbols/ctagsSymbols OUTPUT or the cache format changes — invalidates stale caches.
|
|
25
|
+
// v2: nodes carry complexity/maxDepth (F4) + the cache holds per-file edge lists + a symbol signature (F9).
|
|
26
|
+
// v3: namespace/default-import member-access resolution (util.merge() / new Default()) -> new call edges.
|
|
27
|
+
// v4: Python docstrings/comments masked before symbol+edge scan -> drops phantom symbols/edges.
|
|
28
|
+
// v5: opt-in tree-sitter engine owns JS/TS method nodes (class-qualified ids) + dispatch edges.
|
|
29
|
+
// v6: methods carry an owner (class / Rust impl type / Go receiver) -> owner-qualified ids
|
|
30
|
+
// (`file:Type.method`) in EVERY tier, ending same-file same-name id collisions.
|
|
31
|
+
// v7: nodes carry a `role` (product|test|fixture|example|bench|generated); bodyEnd scans MASKED
|
|
32
|
+
// lines (multi-line templates/comments can no longer desync brace matching); class-field arrow
|
|
33
|
+
// methods discovered; bare-identifier arguments become `ref` edges (not fabricated `call`s);
|
|
34
|
+
// bare-name resolution is package-scoped (no more cross-package name-collision edges).
|
|
35
|
+
// v8: Java + C# discovery (class/interface/enum/record/struct + methods with owner-qualified ids,
|
|
36
|
+
// visibility-as-export, C# base-list inherit edges); pom.xml/build.gradle/.csproj join the
|
|
37
|
+
// package-boundary manifests.
|
|
38
|
+
// v9: tree-sitter tier default-on when installed (dispatch recall); export-star re-export chains
|
|
39
|
+
// resolve (barrel files no longer swallow edges).
|
|
40
|
+
const SCANNER_VERSION = 11; // v11: cache entries carry AST products (ast/cx) so warm runs skip the engine (Spec A)
|
|
41
|
+
const sha1 = (s) => createHash('sha1').update(s).digest('hex');
|
|
42
|
+
|
|
43
|
+
// Derive the file path from a node id (`<file>:<label>`); ids use '/' in paths and ':' only as the
|
|
44
|
+
// label separator, so the last ':' splits them.
|
|
45
|
+
const idFile = (id) => id.slice(0, id.lastIndexOf(':'));
|
|
46
|
+
|
|
47
|
+
const argv = process.argv.slice(2);
|
|
48
|
+
const opts = { path: null, out: null, ctags: true, target: null, cache: null, full: false, engine: process.env.CODEWEB_ENGINE || null };
|
|
49
|
+
for (let i = 0; i < argv.length; i++) {
|
|
50
|
+
const t = argv[i];
|
|
51
|
+
if (t === '--out') opts.out = argv[++i];
|
|
52
|
+
else if (t === '--target') opts.target = argv[++i];
|
|
53
|
+
else if (t === '--cache') opts.cache = argv[++i]; // F0: per-file scan cache (incremental freshness)
|
|
54
|
+
else if (t === '--full') opts.full = true; // F9: ignore the edge cache, derive all edges from scratch
|
|
55
|
+
else if (t === '--no-ctags') opts.ctags = false;
|
|
56
|
+
else if (t === '--engine') opts.engine = argv[++i]; // optional tree-sitter tier (exact cyclomatic); default regex
|
|
57
|
+
else if (!opts.path) opts.path = t;
|
|
58
|
+
}
|
|
59
|
+
if (!opts.path) { console.error('usage: extract-symbols.mjs <path> [--out f.json] [--target label] [--no-ctags]'); process.exit(1); }
|
|
60
|
+
const root = resolve(opts.path);
|
|
61
|
+
|
|
62
|
+
// Spec E: role overrides from the TARGET's own codeweb.rules.json (`roles: [{glob, role}]`) —
|
|
63
|
+
// applied after path heuristics, first match wins, invalid config is a hard exit 2 (never a
|
|
64
|
+
// silent skip). Absent file / absent section -> byte-identical behavior to before.
|
|
65
|
+
let roleOverride = () => null;
|
|
66
|
+
try {
|
|
67
|
+
const rulesPath = join(root, 'codeweb.rules.json');
|
|
68
|
+
if (existsSync(rulesPath)) roleOverride = compileRoleOverrides(JSON.parse(readFileSync(rulesPath, 'utf8')).roles);
|
|
69
|
+
} catch (e) { console.error(`[extract] ${e.message}`); process.exit(2); }
|
|
70
|
+
const roleFor = (rel) => roleOverride(rel) || roleOf(rel);
|
|
71
|
+
if (!existsSync(root)) { console.error(`[extract] not found: ${root}`); process.exit(1); }
|
|
72
|
+
|
|
73
|
+
const SRC = /\.(js|mjs|cjs|jsx|ts|tsx|py|rs|go|java|cs|rb|php|kt|kts|swift)$/;
|
|
74
|
+
const SKIP = /(^|[\\/])(node_modules|\.git|dist|build|out|vendor|third_party|\.codeweb|coverage)([\\/]|$)/;
|
|
75
|
+
|
|
76
|
+
const KEYWORDS = new Set(['if','for','while','switch','catch','return','function','typeof','await','new','super','constructor','else','do','try','finally','class','import','export','const','let','var','async','yield','case','in','of','instanceof','delete','void','throw','with','print']);
|
|
77
|
+
|
|
78
|
+
function tryExec(cmd, args) { try { return execFileSync(cmd, args, { encoding: 'utf8', maxBuffer: 1 << 28 }); } catch { return null; } }
|
|
79
|
+
function toolExists(cmd) { return tryExec(cmd, ['--version']) != null; }
|
|
80
|
+
|
|
81
|
+
// Blank Python triple-quoted strings (docstrings) and `#` comments so the symbol/edge scanners never
|
|
82
|
+
// see `def`/`class`/calls that live INSIDE documentation — the root cause of phantom symbols and
|
|
83
|
+
// fabricated edges (e.g. flask helpers.py's make_response docstring fabricates a render_template
|
|
84
|
+
// caller). Column- AND line-count-preserving (masked regions -> spaces) so the unmasked text's
|
|
85
|
+
// bodyEnd/signature line offsets are unaffected. Single-line '...'/"..." strings are blanked first so
|
|
86
|
+
// a `#` or `"""` inside them can't be mistaken for a comment/docstring delimiter. Best-effort, same
|
|
87
|
+
// ethos as stripSC: escapes inside triple-strings aren't tracked, worst case is a slightly-off mask.
|
|
88
|
+
// Ruby masking (Spec I): line-local — string contents first (so interpolation can't fake a
|
|
89
|
+
// comment), then `#`-to-EOL. Regexes built via RegExp so no quote character sits inside a regex
|
|
90
|
+
// LITERAL (maskJs doesn't mask those; a bare quote there desyncs its string state when codeweb
|
|
91
|
+
// maps itself — its own gate caught this class on ts-engine.mjs).
|
|
92
|
+
const RB_DQ = new RegExp('"(?:[^"\\\\]|\\\\[^])*"', 'g');
|
|
93
|
+
const RB_SQ = new RegExp(String.fromCharCode(39) + '(?:[^' + String.fromCharCode(39) + '\\\\]|\\\\[^])*' + String.fromCharCode(39), 'g');
|
|
94
|
+
function maskRuby(text) {
|
|
95
|
+
return text.split(/\r?\n/).map((ln) => ln
|
|
96
|
+
.replace(RB_DQ, '""')
|
|
97
|
+
.replace(RB_SQ, "''")
|
|
98
|
+
.replace(/#.*$/, '')).join('\n');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function maskPy(text) {
|
|
102
|
+
const lines = text.split(/\r?\n/);
|
|
103
|
+
const out = [];
|
|
104
|
+
let triple = null; // active multi-line triple-quote delimiter ('"""' or "'''")
|
|
105
|
+
for (const line of lines) {
|
|
106
|
+
const n = line.length; let res = '', i = 0;
|
|
107
|
+
while (i < n) {
|
|
108
|
+
if (triple) {
|
|
109
|
+
const end = line.indexOf(triple, i);
|
|
110
|
+
if (end === -1) { res += ' '.repeat(n - i); i = n; }
|
|
111
|
+
else { res += ' '.repeat(end + 3 - i); i = end + 3; triple = null; }
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const ch = line[i];
|
|
115
|
+
if (ch === '#') { res += ' '.repeat(n - i); i = n; continue; } // comment to EOL
|
|
116
|
+
if (ch === '"' || ch === "'") {
|
|
117
|
+
const tri = line.substr(i, 3);
|
|
118
|
+
if (tri === '"""' || tri === "'''") {
|
|
119
|
+
const end = line.indexOf(tri, i + 3);
|
|
120
|
+
if (end === -1) { triple = tri; res += ' '.repeat(n - i); i = n; } // opens, spans lines
|
|
121
|
+
else { res += ' '.repeat(end + 3 - i); i = end + 3; } // single-line triple
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
let j = i + 1; // single-line string
|
|
125
|
+
while (j < n && line[j] !== ch) { if (line[j] === '\\') j++; j++; }
|
|
126
|
+
const stop = Math.min(j + 1, n);
|
|
127
|
+
res += ' '.repeat(stop - i); i = stop;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
res += ch; i++;
|
|
131
|
+
}
|
|
132
|
+
out.push(res);
|
|
133
|
+
}
|
|
134
|
+
return out.join('\n');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// JS/TS counterpart of maskPy for the edge-derivation scan: blanks `//` line comments and `/* */`
|
|
138
|
+
// block comments (and ' " string interiors) to spaces, preserving line + column counts, so a call
|
|
139
|
+
// written INSIDE a comment can't fabricate a call edge (e.g. a checkout.mjs doc comment
|
|
140
|
+
// `cartSubtotal() -> computeLineTotal()` fabricating two edges — the same phantom-edge class the
|
|
141
|
+
// Python docstring mask already fixes). STRING-AWARE: a `//` inside a string (`"http://…"`) is not a
|
|
142
|
+
// comment. Template literals are special — their TEXT is blanked but a `${ … }` interpolation is REAL
|
|
143
|
+
// code and is kept verbatim (so `${ fmt() }` still edges). Block comments and template literals may
|
|
144
|
+
// span lines, so that open state is carried across the loop. Best-effort (same ethos as maskPy):
|
|
145
|
+
// escapes inside template exprs and nested templates aren't fully state-tracked; worst case is a
|
|
146
|
+
// slightly-off mask, never a crash.
|
|
147
|
+
function maskJs(text) {
|
|
148
|
+
const lines = text.split(/\r?\n/);
|
|
149
|
+
const out = [];
|
|
150
|
+
let inBlock = false; // inside /* */ spanning lines
|
|
151
|
+
let inTemplate = false; // inside `...` template TEXT (not inside ${})
|
|
152
|
+
let exprDepth = 0; // brace depth inside a template ${ ... } interpolation (0 = not in expr)
|
|
153
|
+
for (const line of lines) {
|
|
154
|
+
const n = line.length; let res = '', i = 0;
|
|
155
|
+
while (i < n) {
|
|
156
|
+
if (inBlock) {
|
|
157
|
+
const end = line.indexOf('*/', i);
|
|
158
|
+
if (end === -1) { res += ' '.repeat(n - i); i = n; }
|
|
159
|
+
else { res += ' '.repeat(end + 2 - i); i = end + 2; inBlock = false; }
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (inTemplate && exprDepth === 0) { // template TEXT — blank it, watch for `${` / closing backtick
|
|
163
|
+
const ch = line[i];
|
|
164
|
+
if (ch === '`') { res += ' '; i++; inTemplate = false; continue; }
|
|
165
|
+
if (ch === '$' && line[i + 1] === '{') { res += '${'; i += 2; exprDepth = 1; continue; } // keep expr
|
|
166
|
+
res += ' '; i++; continue; // blank template literal text
|
|
167
|
+
}
|
|
168
|
+
if (exprDepth > 0) { // inside ${ ... } — keep verbatim (real code), match braces
|
|
169
|
+
const ch = line[i];
|
|
170
|
+
if (ch === '"' || ch === "'") { // skip string interior so a `}` inside it can't miscount
|
|
171
|
+
let j = i + 1; while (j < n && line[j] !== ch) { if (line[j] === '\\') j++; j++; }
|
|
172
|
+
const stop = Math.min(j + 1, n); res += line.slice(i, stop); i = stop; continue;
|
|
173
|
+
}
|
|
174
|
+
if (ch === '{') exprDepth++;
|
|
175
|
+
else if (ch === '}') { exprDepth--; if (exprDepth === 0) { res += ch; i++; inTemplate = true; continue; } }
|
|
176
|
+
res += ch; i++; continue;
|
|
177
|
+
}
|
|
178
|
+
const ch = line[i]; // normal code
|
|
179
|
+
if (ch === '/' && line[i + 1] === '/') { res += ' '.repeat(n - i); i = n; continue; } // line comment
|
|
180
|
+
if (ch === '/' && line[i + 1] === '*') { // block comment
|
|
181
|
+
const end = line.indexOf('*/', i + 2);
|
|
182
|
+
if (end === -1) { inBlock = true; res += ' '.repeat(n - i); i = n; }
|
|
183
|
+
else { res += ' '.repeat(end + 2 - i); i = end + 2; }
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (ch === '"' || ch === "'") { // string literal
|
|
187
|
+
let j = i + 1; while (j < n && line[j] !== ch) { if (line[j] === '\\') j++; j++; }
|
|
188
|
+
const stop = Math.min(j + 1, n); res += ' '.repeat(stop - i); i = stop; continue;
|
|
189
|
+
}
|
|
190
|
+
if (ch === '`') { res += ' '; i++; inTemplate = true; continue; } // open template literal
|
|
191
|
+
res += ch; i++;
|
|
192
|
+
}
|
|
193
|
+
out.push(res);
|
|
194
|
+
}
|
|
195
|
+
return out.join('\n');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---- enumerate source files ----
|
|
199
|
+
function listFiles() {
|
|
200
|
+
const viaRg = tryExec('rg', ['--files', root]);
|
|
201
|
+
let files;
|
|
202
|
+
if (viaRg != null) {
|
|
203
|
+
files = viaRg.split(/\r?\n/).filter(Boolean);
|
|
204
|
+
} else {
|
|
205
|
+
files = [];
|
|
206
|
+
const walk = (d) => { for (const e of readdirSync(d, { withFileTypes: true })) { const p = join(d, e.name); if (SKIP.test(p)) continue; if (e.isDirectory()) walk(p); else files.push(p); } };
|
|
207
|
+
walk(root);
|
|
208
|
+
}
|
|
209
|
+
// Canonicalize enumeration order: `rg --files` (parallel walk) and readdir can return files in a
|
|
210
|
+
// nondeterministic order, which leaks into node-array order AND cluster3's domain-assignment
|
|
211
|
+
// tie-breaks — making the pipeline non-reproducible. Sorting pins a stable order without changing
|
|
212
|
+
// the file set. (Surfaced + verified by the determinism study, H1.)
|
|
213
|
+
return files.filter((f) => SRC.test(f) && !SKIP.test(f)).sort();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const rel = (f) => relative(root, f).replace(/\\/g, '/');
|
|
217
|
+
|
|
218
|
+
// ---- package boundaries (workspace scoping) -------------------------------------------------
|
|
219
|
+
// The nearest manifest dir above a file ('' = target root). Bare-name call resolution never crosses
|
|
220
|
+
// a package boundary: in a monorepo, cross-package calls go through imports (which resolve
|
|
221
|
+
// precisely); a cross-package NAME COLLISION is exactly how `create-vite` template files ended up
|
|
222
|
+
// "calling" vite's normalizePath. Single-package repos (or trees with no manifests) all map to ''
|
|
223
|
+
// — behavior there is unchanged.
|
|
224
|
+
const MANIFESTS = ['package.json', 'Cargo.toml', 'go.mod', 'pyproject.toml', 'pom.xml', 'build.gradle', 'build.gradle.kts', 'Gemfile', 'composer.json', 'Package.swift'];
|
|
225
|
+
const manifestMemo = new Map(); // dir -> boolean
|
|
226
|
+
const hasManifest = (dir) => {
|
|
227
|
+
if (!manifestMemo.has(dir)) {
|
|
228
|
+
let found = MANIFESTS.some((m) => existsSync(join(root, dir, m)));
|
|
229
|
+
// C# projects name their manifest <Project>.csproj — a fixed-name check can't see it
|
|
230
|
+
if (!found) { try { found = readdirSync(join(root, dir)).some((f) => f.endsWith('.csproj') || f.endsWith('.sln')); } catch { /* unreadable dir */ } }
|
|
231
|
+
manifestMemo.set(dir, found);
|
|
232
|
+
}
|
|
233
|
+
return manifestMemo.get(dir);
|
|
234
|
+
};
|
|
235
|
+
const pkgMemo = new Map(); // file's dir -> package dir
|
|
236
|
+
function pkgOf(relFile) {
|
|
237
|
+
const dir0 = relFile.includes('/') ? relFile.slice(0, relFile.lastIndexOf('/')) : '';
|
|
238
|
+
if (pkgMemo.has(dir0)) return pkgMemo.get(dir0);
|
|
239
|
+
let dir = dir0, found = '';
|
|
240
|
+
while (dir) {
|
|
241
|
+
if (hasManifest(dir)) { found = dir; break; }
|
|
242
|
+
const i = dir.lastIndexOf('/');
|
|
243
|
+
dir = i === -1 ? '' : dir.slice(0, i);
|
|
244
|
+
}
|
|
245
|
+
pkgMemo.set(dir0, found);
|
|
246
|
+
return found;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---- per-language regex symbol scan ----
|
|
250
|
+
function scanSymbols(file, text) {
|
|
251
|
+
const ext = extname(file).toLowerCase();
|
|
252
|
+
const lines = (ext === '.py' ? maskPy(text) : text).split(/\r?\n/); // hide def/class inside docstrings
|
|
253
|
+
const syms = [];
|
|
254
|
+
const push = (name, line, kind, exported, owner) => { if (name && !KEYWORDS.has(name)) syms.push({ name, line: line + 1, kind, exports: !!exported, ...(owner ? { owner } : {}) }); };
|
|
255
|
+
if (ext === '.py') {
|
|
256
|
+
lines.forEach((ln, i) => {
|
|
257
|
+
let m;
|
|
258
|
+
if ((m = /^\s*def\s+([A-Za-z_]\w*)/.exec(ln))) push(m[1], i, /^\S/.test(ln) ? 'function' : 'method', true);
|
|
259
|
+
else if ((m = /^\s*class\s+([A-Za-z_]\w*)/.exec(ln))) push(m[1], i, 'class', true);
|
|
260
|
+
});
|
|
261
|
+
} else if (ext === '.rs') {
|
|
262
|
+
// Rust: fn/struct/enum/trait. A `fn` indented inside an `impl`/`trait` block is a method; at
|
|
263
|
+
// column 0 it's a free function. `pub` (incl. `pub(crate)`) -> exported. The name after the
|
|
264
|
+
// keyword is always a real identifier (you can't write `fn fn`), so push directly rather than
|
|
265
|
+
// through the JS-keyword filter — that keeps idiomatic Rust names like `new`/`default`/`drop`.
|
|
266
|
+
// Owner: a prescan records `impl [Trait for] Type { … }` extents (column-0 `impl` to the first
|
|
267
|
+
// column-0 `}`, idiomatic rustfmt shape) so a method knows its impl type — two `fn new` across
|
|
268
|
+
// two impls in one file must not share an id.
|
|
269
|
+
const implRe = /^impl(?:\s*<[^>]*>)?\s+(?:.*?\bfor\s+)?([A-Za-z_]\w*)/;
|
|
270
|
+
const implRanges = [];
|
|
271
|
+
for (let i = 0; i < lines.length; i++) {
|
|
272
|
+
const im = implRe.exec(lines[i]);
|
|
273
|
+
if (!im) continue;
|
|
274
|
+
let end = lines.length - 1;
|
|
275
|
+
for (let j = i + 1; j < lines.length; j++) { if (/^\}/.test(lines[j])) { end = j; break; } }
|
|
276
|
+
implRanges.push({ type: im[1], start: i + 1, end: end + 1 });
|
|
277
|
+
}
|
|
278
|
+
const implOwner = (lineNo) => { let best = null; for (const r of implRanges) if (lineNo > r.start && lineNo <= r.end && (!best || r.start > best.start)) best = r; return best ? best.type : undefined; };
|
|
279
|
+
const DEF = /^(\s*)(pub(?:\([a-z]+\))?\s+)?(?:async\s+)?(?:unsafe\s+)?(?:const\s+)?(fn|struct|enum|trait)\s+([A-Za-z_]\w*)/;
|
|
280
|
+
lines.forEach((ln, i) => {
|
|
281
|
+
const m = DEF.exec(ln);
|
|
282
|
+
if (!m) return;
|
|
283
|
+
const indent = m[1].length, exported = !!m[2], key = m[3], name = m[4];
|
|
284
|
+
const kind = key === 'fn' ? (indent > 0 ? 'method' : 'function') : 'class';
|
|
285
|
+
const owner = kind === 'method' ? implOwner(i + 1) : undefined;
|
|
286
|
+
syms.push({ name, line: i + 1, kind, exports: exported, ...(owner ? { owner } : {}) });
|
|
287
|
+
});
|
|
288
|
+
} else if (ext === '.go') {
|
|
289
|
+
// Go: `func F(...)` is a function; `func (r R) M(...)` (a receiver in parens before the name)
|
|
290
|
+
// is a method; `type X struct|interface { … }` is a class. Visibility is by initial case — an
|
|
291
|
+
// uppercase first letter is exported. Names after func/type are real identifiers -> push direct.
|
|
292
|
+
// Owner: the receiver TYPE (last identifier in the receiver, `*`/generics stripped) qualifies the
|
|
293
|
+
// id — `func (a A) Do` and `func (b B) Do` in one file are different methods, not one.
|
|
294
|
+
const methodRe = /^\s*func\s+\(([^)]*)\)\s+([A-Za-z_]\w*)/;
|
|
295
|
+
const funcRe = /^\s*func\s+([A-Za-z_]\w*)/;
|
|
296
|
+
const typeRe = /^\s*type\s+([A-Za-z_]\w*)\s+(?:struct|interface)\b/;
|
|
297
|
+
const exp = (n) => /^[A-Z]/.test(n);
|
|
298
|
+
const recvType = (recv) => { const m2 = /([A-Za-z_]\w*)\s*$/.exec(recv.replace(/\[[^\]]*\]/g, '').replace(/\*/g, '').trim()); return m2 ? m2[1] : undefined; };
|
|
299
|
+
lines.forEach((ln, i) => {
|
|
300
|
+
let m;
|
|
301
|
+
if ((m = methodRe.exec(ln))) syms.push({ name: m[2], line: i + 1, kind: 'method', exports: exp(m[2]), ...(recvType(m[1]) ? { owner: recvType(m[1]) } : {}) });
|
|
302
|
+
else if ((m = funcRe.exec(ln))) syms.push({ name: m[1], line: i + 1, kind: 'function', exports: exp(m[1]) });
|
|
303
|
+
else if ((m = typeRe.exec(ln))) syms.push({ name: m[1], line: i + 1, kind: 'class', exports: exp(m[1]) });
|
|
304
|
+
});
|
|
305
|
+
} else if (ext === '.java') {
|
|
306
|
+
// Java: class/interface/enum/record -> 'class' (public -> exported); a name(...)-{ line inside a
|
|
307
|
+
// type is a method/constructor. Owner qualification reuses the enclosing-class mechanism (every
|
|
308
|
+
// Java method is inside a type). Annotation lines (@Override) match nothing. Control-flow words
|
|
309
|
+
// are filtered by KEYWORDS; `throws` clauses are tolerated before the brace.
|
|
310
|
+
const TYPE = /^\s*(?:@[\w.$]+(?:\([^)]*\))?\s+)?(?:(?:public|protected|private|static|final|abstract|sealed|non-sealed|strictfp)\s+)*(class|interface|enum|record)\s+([A-Za-z_$][\w$]*)/;
|
|
311
|
+
const METHOD = /^\s+(?:(?:public|protected|private|static|final|abstract|synchronized|native|default|strictfp)\s+)*(?:<[^>]+>\s+)?(?:[\w$<>\[\],.?\s]+?\s+)?([A-Za-z_$][\w$]*)\s*\([^;{]*\)\s*(?:throws\s+[\w$,.\s]+)?\{/;
|
|
312
|
+
const CTRL = new Set(['if', 'for', 'while', 'switch', 'catch', 'try', 'do', 'synchronized', 'assert', 'return', 'throw', 'new', 'else']);
|
|
313
|
+
lines.forEach((ln, i) => {
|
|
314
|
+
let m;
|
|
315
|
+
if ((m = TYPE.exec(ln))) push(m[2], i, 'class', /\bpublic\b/.test(ln));
|
|
316
|
+
else if ((m = METHOD.exec(ln)) && !CTRL.has(m[1])) push(m[1], i, 'method', /\bpublic\b/.test(ln));
|
|
317
|
+
});
|
|
318
|
+
} else if (ext === '.cs') {
|
|
319
|
+
// C#: class/interface/struct/record/enum -> 'class' (public -> exported); methods like Java
|
|
320
|
+
// (expression-bodied `=> …;` members end via the brace-less semicolon rule). Properties
|
|
321
|
+
// (`int X { get; set; }`) are skipped — no param list, and they'd be reference noise.
|
|
322
|
+
const TYPE = /^\s*(?:\[[^\]]*\]\s*)?(?:(?:public|private|protected|internal|static|sealed|abstract|partial|readonly|ref)\s+)*(class|interface|struct|record|enum)\s+([A-Za-z_][\w]*)/;
|
|
323
|
+
// brace on the same line, `=> expr;`, or NOTHING after `)` — C#'s dominant Allman style puts
|
|
324
|
+
// the `{` on the next line (bodyEnd handles that; a call statement line ends `);` so it can't
|
|
325
|
+
// false-match the bare-`)` form).
|
|
326
|
+
const METHOD = /^\s+(?:(?:public|private|protected|internal|static|virtual|override|abstract|sealed|async|extern|unsafe|new|partial)\s+)+(?:[\w<>\[\],.?\s]+?\s+)?([A-Za-z_][\w]*)\s*\([^;{]*\)\s*(?:where\s+[^{]+)?(?:\{|=>|$)/;
|
|
327
|
+
const CTRL = new Set(['if', 'for', 'foreach', 'while', 'switch', 'catch', 'try', 'do', 'using', 'lock', 'fixed', 'return', 'throw', 'new', 'else']);
|
|
328
|
+
lines.forEach((ln, i) => {
|
|
329
|
+
let m;
|
|
330
|
+
if ((m = TYPE.exec(ln))) push(m[2], i, 'class', /\bpublic\b/.test(ln));
|
|
331
|
+
else if ((m = METHOD.exec(ln)) && !CTRL.has(m[1])) push(m[1], i, 'method', /\bpublic\b/.test(ln));
|
|
332
|
+
});
|
|
333
|
+
} else if (ext === '.rb') {
|
|
334
|
+
// Ruby (Spec I): class/module + def (self. = class-level, same owner), everything public by
|
|
335
|
+
// default. Extents are indentation-based (idiomatic 2-space Ruby) — see isIndentLang below.
|
|
336
|
+
// Line-anchored patterns are comment-safe (`# def x` can't match ^\s*def).
|
|
337
|
+
const ownerRanges = [];
|
|
338
|
+
const ownerAt = (lineNo) => { let best = null; for (const o of ownerRanges) if (lineNo > o.start && lineNo < o.end && (!best || o.start > best.start)) best = o; return best?.name; };
|
|
339
|
+
lines.forEach((ln, i) => {
|
|
340
|
+
let m;
|
|
341
|
+
if ((m = /^(\s*)(?:class|module)\s+([A-Z]\w*)/.exec(ln))) {
|
|
342
|
+
const indent = m[1].length;
|
|
343
|
+
let end = lines.length;
|
|
344
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
345
|
+
if (/^\s*end\b/.test(lines[j]) && (lines[j].match(/^\s*/)[0].length <= indent)) { end = j + 1; break; }
|
|
346
|
+
}
|
|
347
|
+
ownerRanges.push({ name: m[2], start: i + 1, end, indent });
|
|
348
|
+
push(m[2], i, 'class', true);
|
|
349
|
+
} else if ((m = /^(\s*)def\s+(?:self\.)?([A-Za-z_]\w*[?!]?)/.exec(ln))) {
|
|
350
|
+
const owner = m[1].length ? ownerAt(i + 1) : undefined;
|
|
351
|
+
push(m[2], i, owner ? 'method' : 'function', true, owner);
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
} else if (ext === '.php') {
|
|
355
|
+
// PHP (Spec I): class/interface/trait + function members (visibility-as-export, public
|
|
356
|
+
// default); top-level functions. Owner qualification rides the enclosing-class ranges.
|
|
357
|
+
const TYPE = /^\s*(?:abstract\s+|final\s+)?(?:class|interface|trait)\s+([A-Za-z_]\w*)/;
|
|
358
|
+
const FN = /^(\s*)(?:(?:public|protected|private|static|final|abstract)\s+)*function\s+&?\s*([A-Za-z_]\w*)\s*\(/;
|
|
359
|
+
lines.forEach((ln, i) => {
|
|
360
|
+
let m;
|
|
361
|
+
if ((m = TYPE.exec(ln))) push(m[1], i, 'class', true);
|
|
362
|
+
else if ((m = FN.exec(ln))) push(m[2], i, m[1].length ? 'method' : 'function', !/\b(?:private|protected)\b/.test(ln));
|
|
363
|
+
});
|
|
364
|
+
} else if (ext === '.kt' || ext === '.kts') {
|
|
365
|
+
// Kotlin (Spec I): class/object/interface + fun members (public-by-default; private/internal
|
|
366
|
+
// -> not exported); expression-bodied `fun f() = …` extents collapse to one line via bodyEnd.
|
|
367
|
+
// `fun Type.name(` (extension function) owner-qualifies to the receiver type directly.
|
|
368
|
+
const TYPE = /^\s*(?:(?:public|private|internal|protected|open|final|abstract|sealed|data|inner|enum|annotation|value)\s+)*(?:class|object|interface)\s+([A-Za-z_]\w*)/;
|
|
369
|
+
const FUN = /^(\s*)(?:(?:public|private|internal|protected|open|override|final|abstract|suspend|inline|operator|infix|tailrec|external|actual|expect)\s+)*fun\s+(?:<[^>]+>\s+)?(?:([A-Za-z_][\w.]*)\.)?([A-Za-z_]\w*)\s*\(/;
|
|
370
|
+
lines.forEach((ln, i) => {
|
|
371
|
+
let m;
|
|
372
|
+
if ((m = TYPE.exec(ln))) push(m[1], i, 'class', !/\b(?:private|internal)\b/.test(ln));
|
|
373
|
+
else if ((m = FUN.exec(ln))) {
|
|
374
|
+
const recv = m[2] ? m[2].split('.').pop() : undefined;
|
|
375
|
+
push(m[3], i, m[1].length || recv ? 'method' : 'function', !/\b(?:private|internal)\b/.test(ln), recv);
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
} else if (ext === '.swift') {
|
|
379
|
+
// Swift (Spec I): class/struct/enum/protocol/actor/extension + func members. Default access
|
|
380
|
+
// is internal (module-scoped) -> only public/open count as exported. Extension members
|
|
381
|
+
// owner-qualify to the extended type via the extension's range.
|
|
382
|
+
const TYPE = /^\s*(?:(?:public|open|internal|fileprivate|private|final|indirect)\s+)*(?:class|struct|enum|protocol|extension|actor)\s+([A-Za-z_]\w*)/;
|
|
383
|
+
const FUNC = /^(\s*)(?:(?:public|open|internal|fileprivate|private|final|static|class|override|mutating|nonmutating|convenience|required|dynamic|@\w+(?:\([^)]*\))?)\s+)*func\s+([A-Za-z_]\w*)\s*[(<]/;
|
|
384
|
+
lines.forEach((ln, i) => {
|
|
385
|
+
let m;
|
|
386
|
+
if ((m = TYPE.exec(ln))) push(m[1], i, 'class', /\b(?:public|open)\b/.test(ln));
|
|
387
|
+
else if ((m = FUNC.exec(ln))) push(m[2], i, m[1].length ? 'method' : 'function', /\b(?:public|open)\b/.test(ln));
|
|
388
|
+
});
|
|
389
|
+
} else {
|
|
390
|
+
const exported = (ln) => /\bexport\b/.test(ln);
|
|
391
|
+
lines.forEach((ln, i) => {
|
|
392
|
+
let m;
|
|
393
|
+
if ((m = /^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)/.exec(ln))) push(m[1], i, 'function', exported(ln));
|
|
394
|
+
else if ((m = /^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function\b|\*?\s*\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)/.exec(ln))) push(m[1], i, 'function', exported(ln));
|
|
395
|
+
else if ((m = /^\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/.exec(ln))) push(m[1], i, 'class', exported(ln));
|
|
396
|
+
else if ((m = /^\s{2,}(?:public|private|protected|static|readonly|async|get|set|\*)?\s*([A-Za-z_$][\w$]*)\s*\([^;=]*\)\s*\{/.exec(ln))) push(m[1], i, 'method', false);
|
|
397
|
+
// class-field arrow methods (`handleClick = () => {` / `run = async (x) => …`) — the standard
|
|
398
|
+
// React/TS pattern the method regex (name + paren) can't see. Marked `field`: the node is only
|
|
399
|
+
// kept when an ENCLOSING CLASS confirms it (a bare local `cb = () => {}` reassignment inside a
|
|
400
|
+
// function must not become a phantom method).
|
|
401
|
+
else if ((m = /^\s{2,}(?:public\s+|private\s+|protected\s+|readonly\s+|static\s+)*([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/.exec(ln))) { if (m[1] && !KEYWORDS.has(m[1])) syms.push({ name: m[1], line: i + 1, kind: 'method', exports: false, field: true }); }
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
return syms;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ctags accelerator: returns array of {name,line,kind} or null
|
|
408
|
+
function ctagsSymbols(file) {
|
|
409
|
+
const out = tryExec('ctags', ['--output-format=json', '--fields=+n-P', '-f', '-', file]);
|
|
410
|
+
if (out == null) return null;
|
|
411
|
+
const syms = [];
|
|
412
|
+
for (const ln of out.split(/\r?\n/)) {
|
|
413
|
+
if (!ln.trim()) continue;
|
|
414
|
+
try { const j = JSON.parse(ln); if (j._type === 'tag' && j.name) syms.push({ name: j.name, line: j.line || 1, kind: j.kind || 'symbol', exports: false }); } catch { /* skip */ }
|
|
415
|
+
}
|
|
416
|
+
return syms.length ? syms : null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ---- function body extent -----------------------------------------------------------------
|
|
420
|
+
// Real end-of-body so a symbol's range never runs to EOF and absorbs the trailing top-level code
|
|
421
|
+
// (the root cause of fabricated call edges — e.g. query.mjs:parseArgs credited with 9 calls it
|
|
422
|
+
// never makes). Brace-matched for JS/TS; dedent for Python. Strings + line/inline-block comments
|
|
423
|
+
// are stripped before brace counting — best-effort: multi-line strings and template `${}` are not
|
|
424
|
+
// state-tracked, so the worst case is a slightly-off end, never a run-to-EOF. startIdx is 0-based;
|
|
425
|
+
// returns the 0-based inclusive last line of the body.
|
|
426
|
+
const stripSC = (line) => line
|
|
427
|
+
.replace(/\/\/.*$/, '') // line comment
|
|
428
|
+
.replace(/\/\*.*?\*\//g, ' ') // single-line block comment
|
|
429
|
+
.replace(/(['"`])(?:\\.|(?!\1).)*?\1/g, ' ') // same-line string / template literal
|
|
430
|
+
.replace(/\[(?:\\.|[^\]\n])*\]/g, ' ') // regex char classes — strip stray [{] / [^}]
|
|
431
|
+
.replace(/\\./g, ' '); // escaped chars — \{ \} in regex literals (e.g. /\s*\{/)
|
|
432
|
+
function bodyEnd(lines, startIdx, isPy) {
|
|
433
|
+
if (isPy) {
|
|
434
|
+
const indent = (s) => s.length - s.replace(/^\s+/, '').length;
|
|
435
|
+
const base = indent(lines[startIdx] || '');
|
|
436
|
+
let end = startIdx;
|
|
437
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
438
|
+
if (lines[i].trim() === '') continue; // blank lines don't end a body
|
|
439
|
+
if (indent(lines[i]) <= base) break; // dedent to <= the def -> body ended above
|
|
440
|
+
end = i;
|
|
441
|
+
}
|
|
442
|
+
return end;
|
|
443
|
+
}
|
|
444
|
+
// Brace count gated on paren depth 0: a `{`/`}` inside a parameter list — destructuring
|
|
445
|
+
// (`function f({ a, b }) {`) or an object-literal default (`f(o = { x: 1 }) {`) — must NOT be
|
|
446
|
+
// read as the body brace, or the body would end at the signature line (the destructuring `{ }`
|
|
447
|
+
// balances to zero before the real body opens), mis-attributing every body call to <module>. The
|
|
448
|
+
// structural body braces are always at paren depth 0; object-literal call args inside the body sit
|
|
449
|
+
// at paren depth >= 1 and are balanced, so skipping them is strictly safe.
|
|
450
|
+
let depth = 0, started = false, paren = 0;
|
|
451
|
+
for (let i = startIdx; i < lines.length; i++) {
|
|
452
|
+
const s = stripSC(lines[i]);
|
|
453
|
+
for (let c = 0; c < s.length; c++) {
|
|
454
|
+
const ch = s[c];
|
|
455
|
+
if (ch === '(') paren++;
|
|
456
|
+
else if (ch === ')') { if (paren > 0) paren--; }
|
|
457
|
+
else if (paren === 0) {
|
|
458
|
+
if (ch === '{') { depth++; started = true; }
|
|
459
|
+
else if (ch === '}') { depth--; if (started && depth <= 0) return i; }
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if (!started && /;\s*$/.test(s)) return i; // brace-less body (arrow/expr) ending in ';'
|
|
463
|
+
}
|
|
464
|
+
return lines.length - 1;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ---- F3: single-line signature extraction ------------------------------------------------
|
|
468
|
+
// Returns {params, returns, raw} from a function/method DECLARATION line, or null when the param
|
|
469
|
+
// list isn't fully on that line (multi-line / paren-less arrow) — never a guess (best-effort, the
|
|
470
|
+
// same ethos as bodyEnd). The extractor is line-oriented, so multi-line params are intentionally null.
|
|
471
|
+
const splitTopLevelParams = (s) => {
|
|
472
|
+
// Track only unambiguous bracket pairs. `<`/`>` are NOT tracked — they double as comparison
|
|
473
|
+
// operators in default values (`a = x > 0, b`), and treating them as brackets would mis-balance
|
|
474
|
+
// depth and drop trailing params. TS generics (`a: Map<string, number>`) still split correctly:
|
|
475
|
+
// the inner comma yields a non-identifier fragment that paramName() discards.
|
|
476
|
+
const out = []; let depth = 0, cur = '';
|
|
477
|
+
for (const ch of s) {
|
|
478
|
+
if ('([{'.includes(ch)) depth++;
|
|
479
|
+
else if (')]}'.includes(ch)) depth--;
|
|
480
|
+
if (ch === ',' && depth === 0) { out.push(cur); cur = ''; } else cur += ch;
|
|
481
|
+
}
|
|
482
|
+
if (cur.trim() || out.length) out.push(cur);
|
|
483
|
+
return out;
|
|
484
|
+
};
|
|
485
|
+
const paramName = (entry) => {
|
|
486
|
+
let e = entry.trim();
|
|
487
|
+
if (!e) return null;
|
|
488
|
+
e = e.replace(/^(\*\*?|\.\.\.)/, '').split('=')[0].split(':')[0].trim().split(/\s+/)[0]; // *args/**kw/...rest, default, annotation; trailing-token = Go `a int` -> `a`
|
|
489
|
+
return /^[A-Za-z_$][\w$]*$/.test(e) ? e : null; // destructuring/other -> dropped
|
|
490
|
+
};
|
|
491
|
+
function parseSignature(line, name, isPy) {
|
|
492
|
+
const nameEsc = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
493
|
+
// the param-list open-paren for BOTH `name(...)` (declaration) and `name = [async] [function [g]] (...)`
|
|
494
|
+
// (arrow / function-expression assignment) — so `const f = (a, b) => …` parses, not just `function f(a, b)`.
|
|
495
|
+
const nameRe = new RegExp(`(?:^|[^\\w$])${nameEsc}\\s*(?:=\\s*(?:async\\s+)?(?:function\\s*\\*?\\s*[\\w$]*\\s*)?)?\\(`);
|
|
496
|
+
const m = nameRe.exec(line);
|
|
497
|
+
if (!m) return null; // no param paren attributable to `name` on this line
|
|
498
|
+
const open = m.index + m[0].length - 1;
|
|
499
|
+
let depth = 0, close = -1;
|
|
500
|
+
for (let i = open; i < line.length; i++) { const ch = line[i]; if (ch === '(') depth++; else if (ch === ')') { depth--; if (depth === 0) { close = i; break; } } }
|
|
501
|
+
if (close === -1) return null; // params spill onto the next line -> null
|
|
502
|
+
const raw = line.slice(open + 1, close);
|
|
503
|
+
const params = splitTopLevelParams(raw).map(paramName).filter((x) => x != null);
|
|
504
|
+
let returns = null;
|
|
505
|
+
if (isPy) { const r = /->\s*([^:]+):/.exec(line.slice(close)); if (r) returns = r[1].trim(); }
|
|
506
|
+
else { const r = /^\s*:\s*([^={]+?)\s*(?:=>|\{|$)/.exec(line.slice(close + 1)); if (r) returns = r[1].trim(); }
|
|
507
|
+
return { params, returns, raw };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const useCtags = opts.ctags && toolExists('ctags');
|
|
511
|
+
const files = listFiles();
|
|
512
|
+
|
|
513
|
+
// Tree-sitter tier — DEFAULT-ON since v9 (it was opt-in): dynamic-dispatch call edges (this.m(),
|
|
514
|
+
// typed-receiver x.m()) are the regex tier's one recall gap, measured directly in the oracle A/B
|
|
515
|
+
// (6/30 under-recalled symbols, all dispatch/re-export). web-tree-sitter is an optionalDependency —
|
|
516
|
+
// when it or the vendored grammar is unavailable, loadTsEngine() returns null and every file falls
|
|
517
|
+
// back to the regex scanner (CI without npm install keeps working; meta.engine records which tier
|
|
518
|
+
// ran, and the scan cache is namespaced per engine, so reproducibility holds per install state).
|
|
519
|
+
// `--engine regex` / CODEWEB_ENGINE=regex force the old behavior.
|
|
520
|
+
// Spec A (docs/specs/perf-lazy-ast.md): PROBE availability up front (file existence + module
|
|
521
|
+
// resolution — cheap), LOAD the WASM engines lazily at the first file that actually needs a
|
|
522
|
+
// parse. A warm cached run — or any run whose AST products all come from the cache — never pays
|
|
523
|
+
// the ~1.4s runtime+grammar init. Everything decided at startup (cache namespace, meta stamp,
|
|
524
|
+
// banner engine name) derives from the probe, so fragments stay byte-identical either way.
|
|
525
|
+
let astProbe = { ts: false, java: false, csharp: false, tsVersion: null };
|
|
526
|
+
if (opts.engine !== 'regex') {
|
|
527
|
+
astProbe = probeAst();
|
|
528
|
+
if (!astProbe.ts && (opts.engine === 'tree-sitter' || opts.engine === 'ts')) {
|
|
529
|
+
console.error('[extract] --engine tree-sitter requested but web-tree-sitter/grammar unavailable; falling back to regex F4');
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
// Poison guard: if a probe said "available" but the real load later fails, complexity/dispatch
|
|
533
|
+
// fell back to regex mid-run — a `+ts`-namespaced cache must never memoize that state.
|
|
534
|
+
let astLoadFailed = false;
|
|
535
|
+
let _tsEngineState; // undefined = not attempted, null = load failed, object = loaded
|
|
536
|
+
async function tsEngineGet() {
|
|
537
|
+
if (!astProbe.ts) return null;
|
|
538
|
+
if (_tsEngineState === undefined) {
|
|
539
|
+
_tsEngineState = await loadTsEngine();
|
|
540
|
+
if (!_tsEngineState) { astLoadFailed = true; console.error('[extract] AST probe passed but the ts engine failed to load — scan cache disabled for this run'); }
|
|
541
|
+
}
|
|
542
|
+
return _tsEngineState;
|
|
543
|
+
}
|
|
544
|
+
// Java/C# dispatch tier (docs/specs/java-cs-tree-sitter.md): regex keeps owning their NODES;
|
|
545
|
+
// the AST contributes the dispatch edges regex precision-gates away. Loaded lazily on the first
|
|
546
|
+
// file of each language; unavailable -> byte-identical regex output (the standing contract).
|
|
547
|
+
const langEngines = {}; // 'java'|'csharp' -> engine|null
|
|
548
|
+
async function langEngineFor(langKey) {
|
|
549
|
+
if (opts.engine === 'regex' || !astProbe[langKey]) return null;
|
|
550
|
+
if (langEngines[langKey] === undefined) {
|
|
551
|
+
langEngines[langKey] = await loadLangEngine(langKey);
|
|
552
|
+
if (!langEngines[langKey]) { astLoadFailed = true; console.error(`[extract] AST probe passed but the ${langKey} engine failed to load — scan cache disabled for this run`); }
|
|
553
|
+
}
|
|
554
|
+
return langEngines[langKey];
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// F0: load the scan cache (keyed by content hash + engine mode + scanner version). A re-run reuses
|
|
558
|
+
// cached symbol-discovery for byte-identical files and re-scans only changed ones — edge derivation
|
|
559
|
+
// is still GLOBAL (see below), so the fragment is identical with or without the cache. tree-sitter is
|
|
560
|
+
// its own engine namespace (`+ts`) so class-qualified syms can't be served to a regex run, or vice versa.
|
|
561
|
+
const engineMode = (useCtags ? 'ctags' : 'regex') + (opts.engine !== 'regex' && astProbe.ts ? '+ts' : '');
|
|
562
|
+
let oldCache = null;
|
|
563
|
+
if (opts.cache) { try { const c = JSON.parse(readFileSync(opts.cache, 'utf8')); if (c && c.version === SCANNER_VERSION && c.engine === engineMode) oldCache = c; } catch { /* corrupt/absent -> cold */ } }
|
|
564
|
+
const newCache = opts.cache ? { version: SCANNER_VERSION, engine: engineMode, files: {} } : null;
|
|
565
|
+
let scanCount = 0;
|
|
566
|
+
const scanFile = (f, text) => { scanCount++; return (useCtags && ctagsSymbols(f)) || scanSymbols(f, text); };
|
|
567
|
+
|
|
568
|
+
// ---- build nodes per file, with line ranges ----
|
|
569
|
+
const nodes = [];
|
|
570
|
+
const fileSyms = new Map(); // file -> {text, ranges:[{id,name,start,end,kind}]}
|
|
571
|
+
const dispatchByFile = new Map(); // file -> [{from,to}] dispatch edges (tree-sitter engine only)
|
|
572
|
+
const typedLangsSeen = new Set(); // 'java'|'csharp' actually processed by the AST tier this run (banner)
|
|
573
|
+
const typedIntentsByFile = new Map(); // rel -> [{from, recvType, method}] Java/C# typed-receiver calls, resolved globally after all nodes exist
|
|
574
|
+
// v7 STALENESS STAMPS: per-file size+mtime recorded in meta.sources so query tools can cheaply
|
|
575
|
+
// detect that the graph no longer matches disk ("aware" — an agent must know its map is stale).
|
|
576
|
+
const sources = {};
|
|
577
|
+
// v10 CONFIDENCE CALIBRATION: files using dynamic dispatch (computed member calls, getattr,
|
|
578
|
+
// non-literal require, event emitters) hide call edges no static map can see. Record WHERE, so
|
|
579
|
+
// answer-time tools can say "0 callers, but this repo routes calls dynamically in N file(s) —
|
|
580
|
+
// absence of callers is weaker evidence" instead of sounding equally sure everywhere.
|
|
581
|
+
const dynamicFiles = [];
|
|
582
|
+
const DYNAMIC_RE = /\[[A-Za-z_$][\w$]*\]\s*\(|\bgetattr\s*\(|require\s*\(\s*[^'"`)\s]|\.emit\s*\(|globalThis\s*\[|window\s*\[/;
|
|
583
|
+
for (const f of files) {
|
|
584
|
+
let text; try { text = readFileSync(f, 'utf8'); } catch { continue; }
|
|
585
|
+
const r = rel(f);
|
|
586
|
+
try { const st = statSync(f); sources[r] = { s: st.size, m: Math.round(st.mtimeMs) }; } catch { /* stamp is best-effort */ }
|
|
587
|
+
if (DYNAMIC_RE.test(text)) dynamicFiles.push(r);
|
|
588
|
+
const isPy = r.endsWith('.py');
|
|
589
|
+
const isJsTs = /\.(jsx?|mjs|cjs|tsx?)$/.test(r);
|
|
590
|
+
const isBraceLang = isJsTs || /\.(java|cs|php|kt|kts|swift)$/.test(r); // maskJs handles //, /* */ and "…" for all of them
|
|
591
|
+
const isIndentLang = isPy || r.endsWith('.rb'); // extents by dedent (Python) / end-at-indent (Ruby)
|
|
592
|
+
const langKey = r.endsWith('.java') ? 'java' : r.endsWith('.cs') ? 'csharp'
|
|
593
|
+
: r.endsWith('.py') ? 'python' : r.endsWith('.go') ? 'go' : r.endsWith('.rs') ? 'rust' : null;
|
|
594
|
+
// Does the AST tier owe this file products (methods/dispatch/complexity)? Drives cache-hit
|
|
595
|
+
// validity — a hit without them must re-scan — and the lazy engine load below (Spec A).
|
|
596
|
+
const needsAst = opts.engine !== 'regex' && ((isJsTs && astProbe.ts) || (langKey && astProbe[langKey]));
|
|
597
|
+
let syms;
|
|
598
|
+
let astHit = null; // the cache entry serving this file's AST products (fully-valid hit only)
|
|
599
|
+
if (opts.cache) {
|
|
600
|
+
const h = sha1(text);
|
|
601
|
+
const hit = oldCache && oldCache.files[r];
|
|
602
|
+
const hitOk = hit && hit.hash === h && (!needsAst || (hit.ast && (!isJsTs || hit.cx)));
|
|
603
|
+
if (hitOk) { syms = hit.syms; if (needsAst) astHit = hit; }
|
|
604
|
+
else syms = scanFile(f, text); // cache miss -> re-scan
|
|
605
|
+
newCache.files[r] = { hash: h, syms }; // prune deleted files (only current)
|
|
606
|
+
if (astHit) { newCache.files[r].ast = astHit.ast; newCache.files[r].cx = astHit.cx; }
|
|
607
|
+
} else {
|
|
608
|
+
syms = scanFile(f, text);
|
|
609
|
+
}
|
|
610
|
+
const seen = new Set();
|
|
611
|
+
syms = syms.filter((s) => { const k = s.name + ':' + s.line; if (seen.has(k)) return false; seen.add(k); return true; }).sort((a, b) => a.line - b.line);
|
|
612
|
+
const lines = text.split(/\r?\n/);
|
|
613
|
+
const total = lines.length;
|
|
614
|
+
// Body extents are measured on MASKED lines: stripSC alone is line-local, so a multi-line template
|
|
615
|
+
// literal (or block comment) containing braces desynced the brace counter and bodies swallowed
|
|
616
|
+
// whole neighboring functions (a 5-line helper recorded as 550 loc on vite — poisoning
|
|
617
|
+
// context-pack size, complexity, and body-confirmed duplication). maskJs/maskPy carry string/
|
|
618
|
+
// comment state ACROSS lines; ${} interpolations stay live so real code still counts.
|
|
619
|
+
const scanLines = (isPy ? maskPy(text) : r.endsWith('.rb') ? maskRuby(text) : isBraceLang ? maskJs(text) : text).split(/\r?\n/);
|
|
620
|
+
const fileRole = roleFor(r);
|
|
621
|
+
// When the tree-sitter engine is active it OWNS JS/TS method discovery (class-qualified ids) and the
|
|
622
|
+
// dispatch edges; the regex scanner still owns classes, functions and const-arrow functions (which
|
|
623
|
+
// the parse-tree walk doesn't cover). extractJsTs returns null on any parse failure -> keep the regex
|
|
624
|
+
// methods (graceful per-file fallback). Build the qualified ids in ONE parser so dispatch from/to
|
|
625
|
+
// always match an emitted node — never a second line-containment guess that could silently disagree.
|
|
626
|
+
let tsResult = null;
|
|
627
|
+
let fileCx = null; // hit: {nodeId -> exact cx} lookup; miss: collector recorded into the cache entry
|
|
628
|
+
let engForFile = null;
|
|
629
|
+
if (isJsTs && needsAst) {
|
|
630
|
+
if (astHit) {
|
|
631
|
+
tsResult = astHit.ast.tsr; // includes the null-on-parse-failure state, memoized deterministically
|
|
632
|
+
fileCx = astHit.cx;
|
|
633
|
+
} else {
|
|
634
|
+
engForFile = await tsEngineGet(); // FIRST real need -> the one-time WASM init happens here
|
|
635
|
+
if (engForFile) {
|
|
636
|
+
tsResult = engForFile.extractJsTs(text, r);
|
|
637
|
+
fileCx = {};
|
|
638
|
+
if (newCache) { newCache.files[r].ast = { tsr: tsResult }; newCache.files[r].cx = fileCx; }
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
// the parse tree owns method_definition nodes; class-FIELD arrows (`handleClick = () => {}`)
|
|
642
|
+
// are only discovered by the regex scan (field: true), so they must survive the handoff.
|
|
643
|
+
if (tsResult) syms = syms.filter((s) => s.kind !== 'method' || s.field);
|
|
644
|
+
}
|
|
645
|
+
const ranges = [];
|
|
646
|
+
const fileIds = new Set(); // per-file id uniqueness — duplicate ids corrupt byName/edges/diff keys
|
|
647
|
+
syms.forEach((s) => {
|
|
648
|
+
const start = s.line;
|
|
649
|
+
// real body extent (brace match / dedent), NOT next-symbol-line — so the last symbol can't
|
|
650
|
+
// run to EOF and absorb the trailing top-level code (the fabricated-edge bug).
|
|
651
|
+
const end = Math.min(bodyEnd(scanLines, start - 1, isIndentLang) + 1, total);
|
|
652
|
+
const loc = Math.min(end - start + 1, 2000);
|
|
653
|
+
// Owner-qualified id (`file:Type.method`, matching the tree-sitter tier's scheme). Rust/Go owners
|
|
654
|
+
// come from the scan (impl/receiver); Python + JS/TS methods resolve to the ENCLOSING class range
|
|
655
|
+
// (classes precede their methods in the line-sorted ranges). Same-file same-name methods across
|
|
656
|
+
// classes/impls/receivers were previously ONE colliding id.
|
|
657
|
+
let owner = s.owner;
|
|
658
|
+
if (!owner && s.kind === 'method') {
|
|
659
|
+
let best = null;
|
|
660
|
+
for (const rg of ranges) if (rg.kind === 'class' && start > rg.start && start <= rg.end && (!best || rg.start > best.start)) best = rg;
|
|
661
|
+
if (best) owner = best.name;
|
|
662
|
+
}
|
|
663
|
+
if (s.field && !owner) return; // arrow-field candidate with no enclosing class -> not a method
|
|
664
|
+
let id = r + ':' + (owner ? owner + '.' + s.name : s.name);
|
|
665
|
+
if (fileIds.has(id)) id += '@' + start; // last-resort disambiguator (e.g. TS overload stubs)
|
|
666
|
+
fileIds.add(id);
|
|
667
|
+
ranges.push({ id, name: s.name, start, end, kind: s.kind });
|
|
668
|
+
const node = { id, label: s.name, kind: s.kind, file: r, line: start, loc, exports: s.exports, domain: '', summary: '', role: fileRole };
|
|
669
|
+
if (s.kind === 'function' || s.kind === 'method') {
|
|
670
|
+
node.signature = parseSignature(lines[start - 1] || '', s.name, isPy); // F3: contract for callers
|
|
671
|
+
// F4: approximate cyclomatic complexity + max nesting from the SAME body extent (lines [start, end]).
|
|
672
|
+
// Only function/method nodes carry these (a class/module has no single control-flow body).
|
|
673
|
+
const body = lines.slice(start - 1, start - 1 + loc).join('\n');
|
|
674
|
+
const lang = isPy ? 'py' : 'js';
|
|
675
|
+
// Exact McCabe via tree-sitter for JS/TS when the tier is active (TS grammar is a JS superset
|
|
676
|
+
// for control-flow counting); otherwise the regex F4 approximation. maxDepth stays regex F4 —
|
|
677
|
+
// exact nesting is a later increment. Rust/Go/Python always use regex (no TS grammar match).
|
|
678
|
+
// Exact values ride the scan cache (Spec A): a hit looks them up; a miss computes + records.
|
|
679
|
+
let cx = null;
|
|
680
|
+
if (isJsTs && fileCx) {
|
|
681
|
+
if (astHit) cx = Object.prototype.hasOwnProperty.call(fileCx, id) ? fileCx[id] : null;
|
|
682
|
+
else if (engForFile) cx = fileCx[id] = engForFile.cyclomaticExact(body);
|
|
683
|
+
}
|
|
684
|
+
node.complexity = cx != null ? cx : cyclomatic(body, lang);
|
|
685
|
+
// Spec H: Type-3 statement fingerprints ride the same parse (>=6 statements only).
|
|
686
|
+
const t3 = tsResult && tsResult.t3ByLine && tsResult.t3ByLine[start];
|
|
687
|
+
if (t3) node.t3 = t3;
|
|
688
|
+
node.maxDepth = nestingDepth(body, lang);
|
|
689
|
+
}
|
|
690
|
+
nodes.push(node);
|
|
691
|
+
});
|
|
692
|
+
// Tree-sitter method nodes: class-qualified id, BARE label (so byName / codemod's ambiguity guard /
|
|
693
|
+
// overlap clustering keep keying on the same bare name), exact complexity from the parse tree;
|
|
694
|
+
// signature + maxDepth reuse the regex F3/F4 helpers on the method's source slice (exact nesting is a
|
|
695
|
+
// later increment). Added to `ranges` so deriveFileEdges attributes a call FROM a method to its
|
|
696
|
+
// qualified id — the same id the dispatch edge uses for `from` (enclosing() prefers the method over
|
|
697
|
+
// its containing class by largest start, so the qualified method id wins).
|
|
698
|
+
if (tsResult) {
|
|
699
|
+
for (const m of tsResult.methods) {
|
|
700
|
+
const start = m.line;
|
|
701
|
+
const end = Math.min(m.endLine, total);
|
|
702
|
+
const loc = Math.min(end - start + 1, 2000);
|
|
703
|
+
const body = lines.slice(start - 1, end).join('\n');
|
|
704
|
+
if (fileIds.has(m.id)) continue; // regex tier already owns this id (defensive)
|
|
705
|
+
fileIds.add(m.id);
|
|
706
|
+
ranges.push({ id: m.id, name: m.label, start, end, kind: 'method' });
|
|
707
|
+
nodes.push({
|
|
708
|
+
id: m.id, label: m.label, kind: 'method', file: r, line: start, loc,
|
|
709
|
+
exports: false, domain: '', summary: '', role: fileRole,
|
|
710
|
+
signature: parseSignature(lines[start - 1] || '', m.label, false),
|
|
711
|
+
complexity: m.complexity,
|
|
712
|
+
maxDepth: nestingDepth(body, 'js'),
|
|
713
|
+
...(tsResult.t3ByLine && tsResult.t3ByLine[start] ? { t3: tsResult.t3ByLine[start] } : {}),
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
if (tsResult.dispatch.length) dispatchByFile.set(f, tsResult.dispatch);
|
|
717
|
+
}
|
|
718
|
+
// Java/C#/Python/Go/Rust: AST dispatch tier (edges only — regex owns the nodes). this/self/
|
|
719
|
+
// receiver calls resolve in-file now; typed-receiver intents wait for the global pass (the
|
|
720
|
+
// receiver's type may live anywhere). Products ride the scan cache exactly like the JS/TS
|
|
721
|
+
// tier's (Spec A); Spec F adds the three dedicated walkers.
|
|
722
|
+
if (langKey && needsAst) {
|
|
723
|
+
typedLangsSeen.add(langKey);
|
|
724
|
+
let d = null;
|
|
725
|
+
if (astHit) d = astHit.ast.d;
|
|
726
|
+
else {
|
|
727
|
+
const eng = await langEngineFor(langKey);
|
|
728
|
+
d = (eng && eng.extractDispatch(text, r)) || null;
|
|
729
|
+
if (eng && newCache) newCache.files[r].ast = { d };
|
|
730
|
+
}
|
|
731
|
+
if (d) {
|
|
732
|
+
if (d.thisCalls.length) dispatchByFile.set(f, (dispatchByFile.get(f) || []).concat(d.thisCalls));
|
|
733
|
+
if (d.typedIntents.length) typedIntentsByFile.set(r, d.typedIntents);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
fileSyms.set(f, { text, ranges });
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// ---- index symbol names -> node ids ----
|
|
740
|
+
const byName = new Map();
|
|
741
|
+
for (const n of nodes) { if (!byName.has(n.label)) byName.set(n.label, []); byName.get(n.label).push(n.id); }
|
|
742
|
+
|
|
743
|
+
// ---- resolve imports: aliases (for accurate cross-file calls) + import edges ----
|
|
744
|
+
const relSet = new Set(files.map(rel));
|
|
745
|
+
const nodeIdSet = new Set(nodes.map((n) => n.id));
|
|
746
|
+
const kindById = new Map(nodes.map((n) => [n.id, n.kind])); // for class-usage ref edges
|
|
747
|
+
const anchorByFile = new Map(); // rel -> {id, loc} most-substantial symbol of the file
|
|
748
|
+
for (const n of nodes) { const cur = anchorByFile.get(n.file); if (!cur || (n.loc || 0) > cur.loc) anchorByFile.set(n.file, { id: n.id, loc: n.loc || 0 }); }
|
|
749
|
+
const anchorId = (r) => { const a = anchorByFile.get(r); return a ? a.id : null; };
|
|
750
|
+
function resolveImport(fromAbs, spec) {
|
|
751
|
+
if (!/^[.]/.test(spec)) return null; // local relative imports only
|
|
752
|
+
let r = rel(resolve(dirname(fromAbs), spec)).replace(/\\/g, '/');
|
|
753
|
+
const cands = [r, r + '.js', r + '.mjs', r + '.cjs', r + '.ts', r + '.tsx', r + '.jsx', r + '/index.js', r + '/index.ts', r + '/index.mjs'];
|
|
754
|
+
for (const c of cands) if (relSet.has(c)) return c;
|
|
755
|
+
return null;
|
|
756
|
+
}
|
|
757
|
+
// Resolve a Python module spec to a repo-relative file (`x.py` or a package's `x/__init__.py`).
|
|
758
|
+
// level > 0 -> RELATIVE: climb `level` package dirs from the importing file, then append the dotted
|
|
759
|
+
// path. Anchored to the file's real location, so it can't collide with stdlib.
|
|
760
|
+
// level = 0 -> ABSOLUTE: suffix-match the dotted path against known files (sys.path roots unknown).
|
|
761
|
+
// Require >=2 segments so `import json`/`import os` can't grab a local single-name package
|
|
762
|
+
// (e.g. flask's own src/flask/json); the shortest match wins (deterministic).
|
|
763
|
+
const pyFile = (stem) => { if (!stem) return null; for (const c of [stem + '.py', stem + '/__init__.py']) if (relSet.has(c)) return c; return null; };
|
|
764
|
+
function resolvePyModule(fromAbs, level, dotted) {
|
|
765
|
+
const parts = dotted ? dotted.split('.').filter(Boolean) : [];
|
|
766
|
+
if (level > 0) {
|
|
767
|
+
let baseAbs = dirname(fromAbs);
|
|
768
|
+
for (let i = 1; i < level; i++) baseAbs = dirname(baseAbs);
|
|
769
|
+
const baseRel = rel(baseAbs).replace(/\\/g, '/');
|
|
770
|
+
return pyFile([baseRel, ...parts].filter(Boolean).join('/'));
|
|
771
|
+
}
|
|
772
|
+
if (parts.length < 2) return null;
|
|
773
|
+
const tail = parts.join('/');
|
|
774
|
+
let best = null;
|
|
775
|
+
for (const rp of relSet) {
|
|
776
|
+
const hit = rp === tail + '.py' || rp.endsWith('/' + tail + '.py') || rp === tail + '/__init__.py' || rp.endsWith('/' + tail + '/__init__.py');
|
|
777
|
+
if (hit && (!best || rp.length < best.length || (rp.length === best.length && rp < best))) best = rp;
|
|
778
|
+
}
|
|
779
|
+
return best;
|
|
780
|
+
}
|
|
781
|
+
// A file's DEFAULT EXPORT, when it is a single named symbol defined in that file (`export default
|
|
782
|
+
// class X` / `export default function X` / `export default X;` / `export { X as default }` /
|
|
783
|
+
// `module.exports = X`). Returns its node id, else null (an object/array/expression default — e.g.
|
|
784
|
+
// `export default { merge, ... }` — has no single owning symbol, so a default import of it is a
|
|
785
|
+
// MODULE dependency). Lets a default import attribute its coarse edge to the real exported symbol
|
|
786
|
+
// (AxiosError) instead of an arbitrary anchor, while object-default barrels (utils) fall back to <module>.
|
|
787
|
+
function defaultExportOf(r, text, names) {
|
|
788
|
+
let m;
|
|
789
|
+
if ((m = /export\s+default\s+(?:abstract\s+)?(?:async\s+)?(?:class|function\s*\*?)\s+([A-Za-z_$][\w$]*)/.exec(text)) && names.has(m[1])) return r + ':' + m[1];
|
|
790
|
+
if ((m = /export\s*\{[^}]*?\b([A-Za-z_$][\w$]*)\s+as\s+default\b/.exec(text)) && names.has(m[1])) return r + ':' + m[1];
|
|
791
|
+
if ((m = /export\s+default\s+([A-Za-z_$][\w$]*)\s*;/.exec(text)) && names.has(m[1])) return r + ':' + m[1];
|
|
792
|
+
if ((m = /module\.exports\s*=\s*([A-Za-z_$][\w$]*)\s*;/.exec(text)) && names.has(m[1])) return r + ':' + m[1];
|
|
793
|
+
return null;
|
|
794
|
+
}
|
|
795
|
+
const defaultExportByFile = new Map(); // rel -> node id of the single-symbol default export (if any)
|
|
796
|
+
for (const [fabs, recd] of fileSyms) {
|
|
797
|
+
const rr = rel(fabs);
|
|
798
|
+
const ds = defaultExportOf(rr, recd.text, new Set(recd.ranges.map((rg) => rg.name)));
|
|
799
|
+
if (ds && nodeIdSet.has(ds)) defaultExportByFile.set(rr, ds);
|
|
800
|
+
}
|
|
801
|
+
// ---- JS/TS re-export resolution (precision-safe, file-anchored) ---------------------------------
|
|
802
|
+
// `export { x as y } from './impl'` re-exports impl's `x` under the public name `y` WITHOUT defining a
|
|
803
|
+
// symbol in the barrel — so a downstream `import { y } from './barrel'` has no `barrel:y` node to bind
|
|
804
|
+
// to, and the call to `y()` was silently dropped (the name-changing-indirection gap: the one case grep
|
|
805
|
+
// also can't follow by the original name). Build, per file, a table of exported-name -> {target, orig}
|
|
806
|
+
// and resolve it TRANSITIVELY (a re-export of a re-export) so an import of a renamed re-export binds to
|
|
807
|
+
// the real underlying symbol. v9: `export * from './m'` chains resolve too — a barrel that star-forwards
|
|
808
|
+
// a module no longer swallows the edge (one of the two measured recall gaps in the oracle A/B).
|
|
809
|
+
const esReExportRe = /export\s*\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g;
|
|
810
|
+
const esStarReExportRe = /export\s*\*\s*from\s*['"]([^'"]+)['"]/g; // plain form only (`export * as ns` binds a namespace, not names)
|
|
811
|
+
const reExportByFile = new Map(); // rel file -> Map(exportedName -> { target: rel file, orig })
|
|
812
|
+
const starReExportByFile = new Map(); // rel file -> [target rel files, in source order]
|
|
813
|
+
for (const f of files) {
|
|
814
|
+
const fsRec = fileSyms.get(f); if (!fsRec) continue;
|
|
815
|
+
const r = rel(f);
|
|
816
|
+
if (!/\.(jsx?|mjs|cjs|tsx?)$/.test(r)) continue; // JS/TS only
|
|
817
|
+
const map = new Map();
|
|
818
|
+
let m;
|
|
819
|
+
esReExportRe.lastIndex = 0;
|
|
820
|
+
while ((m = esReExportRe.exec(fsRec.text))) {
|
|
821
|
+
const target = resolveImport(f, m[2]); if (!target) continue;
|
|
822
|
+
for (const part of m[1].split(',')) {
|
|
823
|
+
const seg = part.trim().split(/\s+as\s+/);
|
|
824
|
+
const orig = (seg[0] || '').trim(), exported = (seg[seg.length - 1] || '').trim();
|
|
825
|
+
if (orig && orig !== 'default') map.set(exported, { target, orig });
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
if (map.size) reExportByFile.set(r, map);
|
|
829
|
+
const stars = [];
|
|
830
|
+
esStarReExportRe.lastIndex = 0;
|
|
831
|
+
while ((m = esStarReExportRe.exec(fsRec.text))) {
|
|
832
|
+
const target = resolveImport(f, m[1]);
|
|
833
|
+
if (target) stars.push(target);
|
|
834
|
+
}
|
|
835
|
+
if (stars.length) starReExportByFile.set(r, stars);
|
|
836
|
+
}
|
|
837
|
+
// v9: a BARREL IS A DEPENDENT. `export { X } from './impl'` means the barrel file must change when
|
|
838
|
+
// X is renamed — the compiler counts that export specifier as a reference, and so must we (it was a
|
|
839
|
+
// measured recall gap: index.ts barrels missing from every dependents answer). Named re-exports edge
|
|
840
|
+
// the barrel's <module> to the resolved SYMBOL; `export *` edges it to the target's <module>
|
|
841
|
+
// (file-level dependency — the names aren't enumerable without reading the target's exports).
|
|
842
|
+
const reExportEdges = []; // [fromModuleId, toId] — appended with the import edges below
|
|
843
|
+
for (const [r, map] of reExportByFile) {
|
|
844
|
+
for (const { target, orig } of map.values()) {
|
|
845
|
+
const resolved = resolveReExport(target, orig);
|
|
846
|
+
if (resolved) reExportEdges.push([r + ':<module>', resolved]);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
for (const [r, stars] of starReExportByFile) {
|
|
850
|
+
for (const target of stars) reExportEdges.push([r + ':<module>', target + ':<module>']);
|
|
851
|
+
}
|
|
852
|
+
// v10 PUBLIC API: symbols reachable from a package entrypoint (package.json main/module/browser/
|
|
853
|
+
// bin/exports, followed through re-export chains) have callers the graph CANNOT see — everyone who
|
|
854
|
+
// installs the package. Stamp them `pub: true` so answers stop implying "0 in-repo callers" means
|
|
855
|
+
// "safe to rename/delete". JS/TS manifests only (other ecosystems: conventional entries, later).
|
|
856
|
+
{
|
|
857
|
+
const entryFiles = new Set();
|
|
858
|
+
const pkgDirs = new Set(['']);
|
|
859
|
+
for (const f of files) pkgDirs.add(pkgOf(rel(f)));
|
|
860
|
+
const addEntry = (dir, spec) => {
|
|
861
|
+
if (typeof spec !== 'string' || !spec || spec.endsWith('.d.ts') || spec.endsWith('.json')) return;
|
|
862
|
+
const base = (dir ? dir + '/' : '') + spec.replace(/^\.\//, '');
|
|
863
|
+
for (const cand of [base, base + '.js', base + '.mjs', base + '.cjs', base + '.ts', base.replace(/\/+$/, '') + '/index.js', base.replace(/\/+$/, '') + '/index.ts']) {
|
|
864
|
+
const norm = cand.replace(/\/{2,}/g, '/');
|
|
865
|
+
if (sources[norm]) { entryFiles.add(norm); return; }
|
|
866
|
+
}
|
|
867
|
+
};
|
|
868
|
+
const collectExports = (dir, v) => {
|
|
869
|
+
if (typeof v === 'string') addEntry(dir, v);
|
|
870
|
+
else if (v && typeof v === 'object') for (const k of Object.keys(v)) { if (k !== 'types') collectExports(dir, v[k]); }
|
|
871
|
+
};
|
|
872
|
+
for (const dir of pkgDirs) {
|
|
873
|
+
let pkg; try { pkg = JSON.parse(readFileSync(join(root, dir, 'package.json'), 'utf8')); } catch { continue; }
|
|
874
|
+
addEntry(dir, pkg.main); addEntry(dir, pkg.module); if (typeof pkg.browser === 'string') addEntry(dir, pkg.browser);
|
|
875
|
+
if (typeof pkg.bin === 'string') addEntry(dir, pkg.bin);
|
|
876
|
+
else if (pkg.bin && typeof pkg.bin === 'object') for (const v of Object.values(pkg.bin)) addEntry(dir, v);
|
|
877
|
+
collectExports(dir, pkg.exports);
|
|
878
|
+
}
|
|
879
|
+
if (entryFiles.size) {
|
|
880
|
+
const byIdMut = new Map(nodes.map((n) => [n.id, n]));
|
|
881
|
+
const reOut = new Map(); // fromModuleId -> [toIds]
|
|
882
|
+
for (const [a, b] of reExportEdges) { if (!reOut.has(a)) reOut.set(a, []); reOut.get(a).push(b); }
|
|
883
|
+
const seenFiles = new Set(), queue = [...entryFiles];
|
|
884
|
+
while (queue.length) {
|
|
885
|
+
const file = queue.shift();
|
|
886
|
+
if (seenFiles.has(file)) continue;
|
|
887
|
+
seenFiles.add(file);
|
|
888
|
+
for (const n of nodes) if (n.file === file && n.exports && n.kind !== 'module') n.pub = true;
|
|
889
|
+
for (const to of reOut.get(file + ':<module>') || []) {
|
|
890
|
+
if (to.endsWith(':<module>')) queue.push(to.slice(0, -':<module>'.length));
|
|
891
|
+
else { const n = byIdMut.get(to); if (n && n.exports) n.pub = true; }
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
// Resolve `targetRel`'s exported `name` to a real symbol node id, following renamed re-export chains
|
|
897
|
+
// and `export *` forwards (first star target that resolves wins — source order, deterministic).
|
|
898
|
+
// Returns the node id or null (unknown name / dead-ends at a non-symbol). Cycle-guarded.
|
|
899
|
+
function resolveReExport(targetRel, name, seen) {
|
|
900
|
+
const key = targetRel + ':' + name;
|
|
901
|
+
if (nodeIdSet.has(key)) return key; // a real symbol in the target file
|
|
902
|
+
seen = seen || new Set();
|
|
903
|
+
if (seen.has(key)) return null; // re-export cycle -> give up (no phantom edge)
|
|
904
|
+
seen.add(key);
|
|
905
|
+
const reMap = reExportByFile.get(targetRel);
|
|
906
|
+
const hop = reMap && reMap.get(name);
|
|
907
|
+
if (hop) { const hit = resolveReExport(hop.target, hop.orig, seen); if (hit) return hit; }
|
|
908
|
+
for (const star of starReExportByFile.get(targetRel) || []) {
|
|
909
|
+
const hit = resolveReExport(star, name, seen);
|
|
910
|
+
if (hit) return hit;
|
|
911
|
+
}
|
|
912
|
+
return null;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// v6: methods carry owner-qualified ids (`file:Type.method`), so a member access resolved by FILE +
|
|
916
|
+
// NAME (`X.from()` where X is an import alias of `file`) can no longer assume `file:name` exists.
|
|
917
|
+
// This map answers "the one member called `name` in `file`" — exactly one match resolves; several
|
|
918
|
+
// same-named methods across owners is ambiguous and stays dropped (precision over recall).
|
|
919
|
+
const AMBIGUOUS_MEMBER = Symbol('ambiguous');
|
|
920
|
+
const memberByFile = new Map(); // `${file}:${label}` -> qualified id | AMBIGUOUS_MEMBER
|
|
921
|
+
for (const n of nodes) {
|
|
922
|
+
const key = n.file + ':' + n.label;
|
|
923
|
+
if (key === n.id) continue; // top-level symbol — nodeIdSet already resolves it
|
|
924
|
+
memberByFile.set(key, memberByFile.has(key) ? AMBIGUOUS_MEMBER : n.id);
|
|
925
|
+
}
|
|
926
|
+
// `file:name` if it exists as a node id, else the unique qualified member, else null.
|
|
927
|
+
const resolveFileMember = (fileRel, name) => {
|
|
928
|
+
const exact = fileRel + ':' + name;
|
|
929
|
+
if (nodeIdSet.has(exact)) return exact;
|
|
930
|
+
const m = memberByFile.get(exact);
|
|
931
|
+
return m && m !== AMBIGUOUS_MEMBER ? m : null;
|
|
932
|
+
};
|
|
933
|
+
|
|
934
|
+
const aliasByFile = new Map(); // fileAbs -> Map(localName -> symbolId in the target file) [named/default value]
|
|
935
|
+
const nsAliasByFile = new Map(); // fileAbs -> Map(localName -> target REL file) [namespace/default OBJECT, for member access]
|
|
936
|
+
const classAliasByFile = new Map(); // fileAbs -> Map(localName -> CLASS node id) [default import whose default export is a class — for instanceof/static-method ref edges]
|
|
937
|
+
const importEdges = [];
|
|
938
|
+
const reqNamed = /(?:const|let|var)\s*\{([^}]*)\}\s*=\s*require\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
939
|
+
const reqDefault = /(?:const|let|var)\s+([\w$]+)\s*=\s*require\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
940
|
+
const esNamed = /import\s+(?:[\w$]+\s*,\s*)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g;
|
|
941
|
+
const esStar = /import\s+\*\s+as\s+([\w$]+)\s+from\s*['"]([^'"]+)['"]/g;
|
|
942
|
+
const esDefault = /import\s+([\w$]+)\s*(?:,\s*\{[^}]*\})?\s+from\s*['"]([^'"]+)['"]/g;
|
|
943
|
+
const esSide = /import\s+['"]([^'"]+)['"]/g;
|
|
944
|
+
// Python (line-oriented, `m` flag): `from [.]*MODULE import NAMES` and `import MODULE [as A][, ...]`.
|
|
945
|
+
const pyFrom = /^[ \t]*from\s+(\.*)([\w.]*)\s+import\s+(.+)$/gm;
|
|
946
|
+
const pyImport = /^[ \t]*import\s+([\w][\w.]*(?:\s+as\s+\w+)?(?:\s*,\s*[\w][\w.]*(?:\s+as\s+\w+)?)*)/gm;
|
|
947
|
+
for (const f of files) {
|
|
948
|
+
const fsRec = fileSyms.get(f); if (!fsRec) continue;
|
|
949
|
+
const r = rel(f), isPy = r.endsWith('.py');
|
|
950
|
+
const text = fsRec.text, aId = anchorId(r), amap = new Map(), nsmap = new Map(), classmap = new Map();
|
|
951
|
+
let m;
|
|
952
|
+
const addNamed = (namesStr, spec) => {
|
|
953
|
+
const target = resolveImport(f, spec); if (!target) return;
|
|
954
|
+
for (const part of namesStr.split(',')) {
|
|
955
|
+
const seg = part.trim().split(/\s+as\s+/);
|
|
956
|
+
const orig = seg[0].trim(), local = seg[seg.length - 1].trim();
|
|
957
|
+
if (!orig) continue;
|
|
958
|
+
const symId = target + ':' + orig;
|
|
959
|
+
// Direct symbol in the target, else follow a renamed re-export chain (`export {orig as …} from`).
|
|
960
|
+
const resolved = nodeIdSet.has(symId) ? symId : resolveReExport(target, orig);
|
|
961
|
+
if (resolved) { amap.set(local, resolved); if (aId && aId !== resolved) importEdges.push([aId, resolved]); }
|
|
962
|
+
}
|
|
963
|
+
};
|
|
964
|
+
// Python `from [.]*MOD import a, b as c`: each name is EITHER a submodule of MOD (-> module object,
|
|
965
|
+
// member-access binding) OR a symbol defined in MOD's file (-> precise alias, like addNamed). The
|
|
966
|
+
// coarse "imports this module" edge lands on the target's <module> node.
|
|
967
|
+
const addPyFrom = (level, dotted, namesStr) => {
|
|
968
|
+
const pkgFile = resolvePyModule(f, level, dotted);
|
|
969
|
+
for (const part of namesStr.replace(/[()]/g, '').split(',')) {
|
|
970
|
+
const seg = part.trim().split(/\s+as\s+/);
|
|
971
|
+
const orig = (seg[0] || '').trim(), local = (seg[seg.length - 1] || '').trim();
|
|
972
|
+
if (!orig || orig === '*') continue;
|
|
973
|
+
const sub = resolvePyModule(f, level, dotted ? dotted + '.' + orig : orig);
|
|
974
|
+
if (sub && sub !== pkgFile) { nsmap.set(local, sub); if (aId) importEdges.push([aId, sub + ':<module>']); continue; }
|
|
975
|
+
if (pkgFile) { const symId = pkgFile + ':' + orig; if (nodeIdSet.has(symId)) { amap.set(local, symId); if (aId && aId !== symId) importEdges.push([aId, symId]); } }
|
|
976
|
+
}
|
|
977
|
+
};
|
|
978
|
+
// Python `import a.b [as c], d`: bind a usable local name -> module object for member access. A
|
|
979
|
+
// dotted path with no alias binds Python's FIRST segment (`a` of `a.b.c`), which member-resolution
|
|
980
|
+
// can't key on, so it gets only the coarse module edge — no false member binding.
|
|
981
|
+
const addPyImports = (namesStr) => {
|
|
982
|
+
for (const part of namesStr.split(',')) {
|
|
983
|
+
const seg = part.trim().split(/\s+as\s+/);
|
|
984
|
+
const dotted = (seg[0] || '').trim(); if (!dotted) continue;
|
|
985
|
+
const alias = seg.length > 1 ? seg[1].trim() : null;
|
|
986
|
+
const t = resolvePyModule(f, 0, dotted); if (!t) continue;
|
|
987
|
+
const local = alias || (dotted.includes('.') ? null : dotted);
|
|
988
|
+
if (local) nsmap.set(local, t);
|
|
989
|
+
if (aId) importEdges.push([aId, t + ':<module>']);
|
|
990
|
+
}
|
|
991
|
+
};
|
|
992
|
+
// Namespace (`import * as X`) / default (`import X from` / `const X = require`): X is the imported
|
|
993
|
+
// MODULE OBJECT. Record X -> target file so `X.member(...)` resolves to target:member in
|
|
994
|
+
// deriveFileEdges; for a default import also alias X -> the target's default export (≈ anchor) so
|
|
995
|
+
// `new X()` / `X()` resolve. The COARSE "imports this module" edge lands on the target's <module>
|
|
996
|
+
// node (created on demand below), NOT on its anchor symbol — member-access now produces the precise
|
|
997
|
+
// per-symbol edges, so attributing the coarse edge to one symbol only pollutes its dependents.
|
|
998
|
+
const addModuleBinding = (local, spec, isDefault) => {
|
|
999
|
+
const t = resolveImport(f, spec); if (!t) return;
|
|
1000
|
+
if (local) nsmap.set(local, t);
|
|
1001
|
+
// A default import binds the target's default export: attribute to its single owning symbol when
|
|
1002
|
+
// there is one (class/fn AxiosError), else the module object (object-default barrel -> <module>).
|
|
1003
|
+
// A namespace import (`import * as X`) is always the module object.
|
|
1004
|
+
// Alias the default import ONLY to a detected single-symbol default (class/fn/identifier). NO anchor
|
|
1005
|
+
// fallback: for an object-literal or anonymous default the anchor is a DIFFERENT symbol, so aliasing
|
|
1006
|
+
// to it made a bare `utils` reference fabricate a call to utils.js's largest symbol (e.g. merge).
|
|
1007
|
+
const defSym = isDefault ? defaultExportByFile.get(t) : null;
|
|
1008
|
+
if (isDefault && local && defSym && !amap.has(local) && aId !== defSym) amap.set(local, defSym);
|
|
1009
|
+
if (isDefault && local && defSym && kindById.get(defSym) === 'class') classmap.set(local, defSym); // X.static()/instanceof X -> ref to class
|
|
1010
|
+
const edgeTarget = defSym || (t + ':<module>');
|
|
1011
|
+
if (aId && aId !== edgeTarget) importEdges.push([aId, edgeTarget]);
|
|
1012
|
+
};
|
|
1013
|
+
const addSide = (spec) => { const t = resolveImport(f, spec); if (!t) return; if (aId) importEdges.push([aId, t + ':<module>']); };
|
|
1014
|
+
if (isPy) {
|
|
1015
|
+
const pyText = maskPy(text); // don't bind imports that live in a docstring/comment
|
|
1016
|
+
while ((m = pyFrom.exec(pyText))) addPyFrom(m[1].length, m[2], m[3]);
|
|
1017
|
+
while ((m = pyImport.exec(pyText))) addPyImports(m[1]);
|
|
1018
|
+
} else {
|
|
1019
|
+
while ((m = reqNamed.exec(text))) addNamed(m[1], m[2]);
|
|
1020
|
+
while ((m = esNamed.exec(text))) addNamed(m[1], m[2]);
|
|
1021
|
+
while ((m = reqDefault.exec(text))) addModuleBinding(m[1], m[2], true);
|
|
1022
|
+
while ((m = esStar.exec(text))) addModuleBinding(m[1], m[2], false);
|
|
1023
|
+
while ((m = esDefault.exec(text))) addModuleBinding(m[1], m[2], true);
|
|
1024
|
+
while ((m = esSide.exec(text))) addSide(m[1]);
|
|
1025
|
+
}
|
|
1026
|
+
if (amap.size) aliasByFile.set(f, amap);
|
|
1027
|
+
if (nsmap.size) nsAliasByFile.set(f, nsmap);
|
|
1028
|
+
if (classmap.size) classAliasByFile.set(f, classmap);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// ---- derive call edges (F9: incremental, per-file, cacheable) ------------------------------
|
|
1032
|
+
const LEGACY_FALLBACK = !!process.env.CODEWEB_LEGACY_FALLBACK; // A/B: restore pre-fix byName[0] wiring for regression testing
|
|
1033
|
+
|
|
1034
|
+
// F9: global symbol signature — a hash of the discovered symbol-node id set (module nodes are derived,
|
|
1035
|
+
// so excluded). A file's edges depend ONLY on its own text + global symbol resolution (byName/alias),
|
|
1036
|
+
// so when the symbol set is unchanged AND a file's content is unchanged, that file's edges are
|
|
1037
|
+
// identical and may be reused. Any added/removed/renamed symbol flips the signature -> full re-derive
|
|
1038
|
+
// (correctness over speed). This is what makes warm-incremental byte-identical to a cold full extract.
|
|
1039
|
+
// Package boundaries participate in the signature: adding/removing a manifest changes bare-name
|
|
1040
|
+
// resolution, so cached per-file edges must invalidate then too.
|
|
1041
|
+
const pkgBoundaries = [...new Set(files.map((f) => pkgOf(rel(f))))].sort();
|
|
1042
|
+
const symbolSig = sha1(nodes.map((n) => n.id).slice().sort().join('\n') + '\0' + pkgBoundaries.join('\n'));
|
|
1043
|
+
|
|
1044
|
+
// Derive ONE file's edges (call/ref/inherit), with from-side = its own symbols or its <module> node.
|
|
1045
|
+
// Pure w.r.t. the file: returns {edges, hasModule, ambiguous}. The precision gate (alias > same-file >
|
|
1046
|
+
// unique-global, drop-ambiguous) is unchanged — only the plumbing moved into a function so it can be
|
|
1047
|
+
// cached per file and skipped when the file + symbol set are unchanged.
|
|
1048
|
+
function deriveFileEdges(r, lines, ranges, aliasMap, nsAliasMap, classAliasMap) {
|
|
1049
|
+
const local = []; const localSet = new Set();
|
|
1050
|
+
let hasModule = false, ambiguous = 0;
|
|
1051
|
+
const isPy = r.endsWith('.py');
|
|
1052
|
+
const enclosing = (lineNo) => { let best = null; for (const rg of ranges) if (lineNo >= rg.start && lineNo <= rg.end && (!best || rg.start > best.start)) best = rg; return best; };
|
|
1053
|
+
const sameFileByName = new Map(ranges.map((rg) => [rg.name, rg.id]));
|
|
1054
|
+
const sameFileClasses = new Map(ranges.filter((rg) => rg.kind === 'class').map((rg) => [rg.name, rg.id]));
|
|
1055
|
+
// The CLASS node a name refers to (imported class alias OR a same-file class) — for ref edges from
|
|
1056
|
+
// `instanceof X` and `X.staticMethod()`. Null for non-classes (an object alias like `utils`).
|
|
1057
|
+
const classOf = (name) => (classAliasMap && classAliasMap.get(name)) || sameFileClasses.get(name) || null;
|
|
1058
|
+
const addEdge = (lineIdx, name, kind = 'call') => {
|
|
1059
|
+
if (KEYWORDS.has(name)) return;
|
|
1060
|
+
const aliased = aliasMap && aliasMap.get(name);
|
|
1061
|
+
if (!aliased && !byName.has(name)) return;
|
|
1062
|
+
const caller = enclosing(lineIdx + 1);
|
|
1063
|
+
if (caller && caller.name === name && lineIdx + 1 === caller.start) return; // its own definition
|
|
1064
|
+
let callerId;
|
|
1065
|
+
if (caller) callerId = caller.id;
|
|
1066
|
+
else { callerId = r + ':<module>'; hasModule = true; } // module/top-level scope
|
|
1067
|
+
let calleeId = aliased || sameFileByName.get(name);
|
|
1068
|
+
if (!calleeId) {
|
|
1069
|
+
// package-scoped unique-name fallback: resolve only within the caller's package (imports
|
|
1070
|
+
// handle legitimate cross-package calls; cross-package bare-name matches are collisions).
|
|
1071
|
+
const defs = byName.get(name) || [];
|
|
1072
|
+
const pkg = pkgOf(r);
|
|
1073
|
+
const inPkg = defs.filter((d) => pkgOf(idFile(d)) === pkg);
|
|
1074
|
+
if (inPkg.length === 1) calleeId = inPkg[0];
|
|
1075
|
+
else if (LEGACY_FALLBACK) calleeId = defs[0];
|
|
1076
|
+
else { ambiguous++; return; }
|
|
1077
|
+
}
|
|
1078
|
+
if (!calleeId || calleeId === callerId) return;
|
|
1079
|
+
const edgeKind = ((kind === 'call' || kind === 'ref') && isTestFile(r) && !isTestFile(idFile(calleeId))) ? 'test' : kind;
|
|
1080
|
+
const key = callerId + ' ' + calleeId + ' ' + edgeKind;
|
|
1081
|
+
if (localSet.has(key)) return;
|
|
1082
|
+
localSet.add(key);
|
|
1083
|
+
local.push({ from: callerId, to: calleeId, kind: edgeKind, weight: 1 });
|
|
1084
|
+
};
|
|
1085
|
+
// Push an edge to an ALREADY-RESOLVED callee id — used for namespace/default import member-access,
|
|
1086
|
+
// where the callee is resolved via the import binding rather than bare-name lookup.
|
|
1087
|
+
const addResolved = (lineIdx, calleeId, kind = 'call') => {
|
|
1088
|
+
const caller = enclosing(lineIdx + 1);
|
|
1089
|
+
let callerId;
|
|
1090
|
+
if (caller) callerId = caller.id; else { callerId = r + ':<module>'; hasModule = true; }
|
|
1091
|
+
if (!calleeId || calleeId === callerId) return;
|
|
1092
|
+
const edgeKind = ((kind === 'call' || kind === 'ref') && isTestFile(r) && !isTestFile(idFile(calleeId))) ? 'test' : kind;
|
|
1093
|
+
const key = callerId + ' ' + calleeId + ' ' + edgeKind; // call & ref to the same target coexist
|
|
1094
|
+
if (localSet.has(key)) return;
|
|
1095
|
+
localSet.add(key);
|
|
1096
|
+
local.push({ from: callerId, to: calleeId, kind: edgeKind, weight: 1 });
|
|
1097
|
+
};
|
|
1098
|
+
const callRe = /([A-Za-z_$][\w$]*)\s*\(/g;
|
|
1099
|
+
const refRe = /[(,]\s*([A-Za-z_$][\w$]*)\s*(?=[,)])/g;
|
|
1100
|
+
const extendsRe = /\bclass\s+[A-Za-z_$][\w$]*\s+extends\s+([A-Za-z_$][\w$]*)/g;
|
|
1101
|
+
const csBaseRe = /\b(?:class|struct|record)\s+[A-Za-z_]\w*(?:<[^>]*>)?\s*:\s*([A-Za-z_][\w.]*)/g; // C# `class A : Base, IFace` -> Base
|
|
1102
|
+
const isCs = r.endsWith('.cs');
|
|
1103
|
+
const instanceofRe = /\binstanceof\s+([A-Za-z_$][\w$]*)/g; // `x instanceof X` -> ref to class X
|
|
1104
|
+
const pyBasesRe = /^\s*class\s+[A-Za-z_]\w*\s*\(([^)]*)\)/;
|
|
1105
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1106
|
+
const ln = lines[i];
|
|
1107
|
+
if (isPy) {
|
|
1108
|
+
const pm = pyBasesRe.exec(ln);
|
|
1109
|
+
if (pm) for (const part of pm[1].split(',')) {
|
|
1110
|
+
const base = part.trim();
|
|
1111
|
+
if (!base || base.includes('=')) continue;
|
|
1112
|
+
const name = base.replace(/^.*\./, '');
|
|
1113
|
+
if (/^[A-Za-z_]\w*$/.test(name)) addEdge(i, name, 'inherit');
|
|
1114
|
+
}
|
|
1115
|
+
} else {
|
|
1116
|
+
extendsRe.lastIndex = 0; let xm;
|
|
1117
|
+
while ((xm = extendsRe.exec(ln))) addEdge(i, xm[1], 'inherit');
|
|
1118
|
+
if (isCs) {
|
|
1119
|
+
csBaseRe.lastIndex = 0;
|
|
1120
|
+
while ((xm = csBaseRe.exec(ln))) { const base = xm[1].split('.').pop(); if (base) addEdge(i, base, 'inherit'); }
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
callRe.lastIndex = 0; let m;
|
|
1124
|
+
while ((m = callRe.exec(ln))) {
|
|
1125
|
+
if (ln[m.index - 1] === '.') {
|
|
1126
|
+
// member call obj.fn(): resolve ONLY when obj is a namespace/default import alias (a param or
|
|
1127
|
+
// local obj.method() must stay unresolved — see reference-edges PRECISION). This recovers the
|
|
1128
|
+
// cross-file usage the bare-name pass can't see (util.merge(), AxiosHeaders.from()).
|
|
1129
|
+
const before = ln.slice(0, m.index - 1);
|
|
1130
|
+
const om = /([A-Za-z_$][\w$]*)$/.exec(before);
|
|
1131
|
+
if (om) {
|
|
1132
|
+
if (nsAliasMap && nsAliasMap.has(om[1])) {
|
|
1133
|
+
const calleeId = resolveFileMember(nsAliasMap.get(om[1]), m[1]);
|
|
1134
|
+
if (calleeId) addResolved(i, calleeId, 'call');
|
|
1135
|
+
}
|
|
1136
|
+
const cls = classOf(om[1]);
|
|
1137
|
+
if (cls) addResolved(i, cls, 'ref'); // X.staticMethod() -> the caller depends on the class X
|
|
1138
|
+
// X.member.call(...) / X.member.apply(...): the real invocation is of X-file:member.
|
|
1139
|
+
if ((m[1] === 'call' || m[1] === 'apply') && nsAliasMap) {
|
|
1140
|
+
const chain = /([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)$/.exec(before);
|
|
1141
|
+
if (chain && nsAliasMap.has(chain[1])) {
|
|
1142
|
+
const calleeId = resolveFileMember(nsAliasMap.get(chain[1]), chain[2]);
|
|
1143
|
+
if (calleeId) addResolved(i, calleeId, 'call');
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
continue; // not an import-alias member -> stay precision-safe (no edge)
|
|
1148
|
+
}
|
|
1149
|
+
addEdge(i, m[1]);
|
|
1150
|
+
}
|
|
1151
|
+
refRe.lastIndex = 0;
|
|
1152
|
+
while ((m = refRe.exec(ln))) addEdge(i, m[1], 'ref'); // a bare identifier ARGUMENT is a reference (callback/value), not an invocation
|
|
1153
|
+
instanceofRe.lastIndex = 0;
|
|
1154
|
+
while ((m = instanceofRe.exec(ln))) { const cls = classOf(m[1]); if (cls) addResolved(i, cls, 'ref'); }
|
|
1155
|
+
}
|
|
1156
|
+
return { edges: local, hasModule, ambiguous };
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
const edges = [];
|
|
1160
|
+
let ambiguousDropped = 0, edgedCount = 0;
|
|
1161
|
+
// Every successfully-read file derives edges — NOT only files with discovered symbols. A test file
|
|
1162
|
+
// whose only "functions" are anonymous callbacks (`test('…', () => { foo() })`) discovers zero
|
|
1163
|
+
// symbols; excluding it dropped its module-scope call to `foo` entirely, so the imported prod symbol
|
|
1164
|
+
// got no `test` edge (the blast-radius coveringTests signal). With empty ranges, enclosing() returns
|
|
1165
|
+
// null and the call is correctly attributed to the file's <module> (created on demand).
|
|
1166
|
+
const edgeFiles = files.filter((f) => fileSyms.has(f));
|
|
1167
|
+
const reuseEdges = !opts.full && oldCache && oldCache.symbolSig === symbolSig; // edge cache valid iff symbol set unchanged
|
|
1168
|
+
for (const f of edgeFiles) {
|
|
1169
|
+
const { text, ranges } = fileSyms.get(f);
|
|
1170
|
+
const r = rel(f);
|
|
1171
|
+
const lines = (r.endsWith('.py') ? maskPy(text) : r.endsWith('.rb') ? maskRuby(text) : /\.(jsx?|mjs|cjs|tsx?|java|cs|php|kt|kts|swift)$/.test(r) ? maskJs(text) : text).split(/\r?\n/); // no calls from docstrings/comments/strings
|
|
1172
|
+
const cacheEntry = newCache && newCache.files[r]; // carries the content hash from discovery
|
|
1173
|
+
const prev = reuseEdges && oldCache.files[r];
|
|
1174
|
+
let result;
|
|
1175
|
+
if (prev && cacheEntry && prev.hash === cacheEntry.hash && prev.edges) {
|
|
1176
|
+
result = { edges: prev.edges, hasModule: !!prev.hasModule, ambiguous: prev.ambiguous || 0 }; // reuse
|
|
1177
|
+
} else {
|
|
1178
|
+
result = deriveFileEdges(r, lines, ranges, aliasByFile.get(f), nsAliasByFile.get(f), classAliasByFile.get(f));
|
|
1179
|
+
edgedCount++;
|
|
1180
|
+
}
|
|
1181
|
+
if (cacheEntry) { cacheEntry.edges = result.edges; cacheEntry.hasModule = result.hasModule; cacheEntry.ambiguous = result.ambiguous; }
|
|
1182
|
+
if (result.hasModule && !nodeIdSet.has(r + ':<module>')) {
|
|
1183
|
+
nodes.push({ id: r + ':<module>', label: '<module>', kind: 'module', file: r, line: 1, loc: 1, exports: false, domain: '', summary: '', role: roleFor(r) });
|
|
1184
|
+
nodeIdSet.add(r + ':<module>');
|
|
1185
|
+
}
|
|
1186
|
+
for (const e of result.edges) edges.push(e);
|
|
1187
|
+
ambiguousDropped += result.ambiguous;
|
|
1188
|
+
}
|
|
1189
|
+
if (newCache) newCache.symbolSig = symbolSig;
|
|
1190
|
+
|
|
1191
|
+
// ---- append import edges (file anchor -> imported symbol) ----
|
|
1192
|
+
// Deduped against the call edges by (from,to): caller ids are file-local, so this set has no
|
|
1193
|
+
// cross-file collisions and matches the original single-edgeSet behaviour exactly.
|
|
1194
|
+
let importEdgeCount = 0;
|
|
1195
|
+
const edgeKeys = new Set(edges.map((e) => e.from + ' ' + e.to));
|
|
1196
|
+
// A coarse module-import edge (namespace/default/side) targets the imported file's <module> node,
|
|
1197
|
+
// created on demand here so a symbol-less barrel still gets a node. This keeps the file-level "imports
|
|
1198
|
+
// this module" signal (fileCycles/coupling unchanged — same file-pair) without polluting a symbol.
|
|
1199
|
+
const ensureModuleNode = (id) => {
|
|
1200
|
+
if (nodeIdSet.has(id)) return;
|
|
1201
|
+
nodes.push({ id, label: '<module>', kind: 'module', file: idFile(id), line: 1, loc: 1, exports: false, domain: '', summary: '', role: roleFor(idFile(id)) });
|
|
1202
|
+
nodeIdSet.add(id);
|
|
1203
|
+
};
|
|
1204
|
+
for (const [a, b] of [...importEdges, ...reExportEdges]) {
|
|
1205
|
+
if (a.endsWith(':<module>')) ensureModuleNode(a); // a symbol-less barrel still gets its node
|
|
1206
|
+
if (b.endsWith(':<module>')) ensureModuleNode(b);
|
|
1207
|
+
if (!nodeIdSet.has(a) || !nodeIdSet.has(b) || a === b) continue;
|
|
1208
|
+
const key = a + ' ' + b;
|
|
1209
|
+
if (edgeKeys.has(key)) continue;
|
|
1210
|
+
edgeKeys.add(key);
|
|
1211
|
+
const ik = (isTestFile(idFile(a)) && !isTestFile(idFile(b))) ? 'test' : 'import';
|
|
1212
|
+
edges.push({ from: a, to: b, kind: ik, weight: 1 });
|
|
1213
|
+
if (ik === 'import') importEdgeCount++;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// ---- append dynamic-dispatch call edges (tree-sitter engine: this.m() + typed-receiver x.m()) ----
|
|
1217
|
+
// The member-call edges the regex engine deliberately drops. Endpoints are guarded against the final
|
|
1218
|
+
// node set; deduped by (from,to,kind) so a dispatch `call` can coexist with a `ref`/`import` of the
|
|
1219
|
+
// same pair but never duplicates an identical edge. Iterate the already-sorted edgeFiles so the
|
|
1220
|
+
// appended order is deterministic. A test-file caller reclassifies to `test`, matching call edges.
|
|
1221
|
+
let dispatchEdgeCount = 0, dispatchDropped = 0;
|
|
1222
|
+
const edgeTriKeys = new Set(edges.map((e) => e.from + '\t' + e.to + '\t' + e.kind));
|
|
1223
|
+
for (const f of edgeFiles) {
|
|
1224
|
+
const disp = dispatchByFile.get(f);
|
|
1225
|
+
if (!disp) continue;
|
|
1226
|
+
for (const d of disp) {
|
|
1227
|
+
if (d.from === d.to || !nodeIdSet.has(d.from) || !nodeIdSet.has(d.to)) { dispatchDropped++; continue; }
|
|
1228
|
+
const kind = (isTestFile(idFile(d.from)) && !isTestFile(idFile(d.to))) ? 'test' : 'call';
|
|
1229
|
+
const key = d.from + '\t' + d.to + '\t' + kind;
|
|
1230
|
+
if (edgeTriKeys.has(key)) continue;
|
|
1231
|
+
edgeTriKeys.add(key);
|
|
1232
|
+
edges.push({ from: d.from, to: d.to, kind, weight: 1 });
|
|
1233
|
+
dispatchEdgeCount++;
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// Typed-receiver dispatch (Java/C#): resolve each {from, recvType, method} intent against the
|
|
1238
|
+
// WHOLE graph — the receiver's class must resolve to exactly ONE file, and the qualified method
|
|
1239
|
+
// must be a real node. Anything ambiguous or absent is dropped and counted, never guessed (the
|
|
1240
|
+
// same precision contract every dispatch tier honors).
|
|
1241
|
+
let typedWired = 0, typedDropped = 0;
|
|
1242
|
+
if (typedIntentsByFile.size) {
|
|
1243
|
+
const classFiles = new Map(); // class name -> Set(rel files that define it, per owner-qualified ids)
|
|
1244
|
+
for (const id of nodeIdSet) {
|
|
1245
|
+
const m = /^(.+):([A-Za-z_$][\w$]*)\.[^.]+$/.exec(id);
|
|
1246
|
+
if (!m) continue;
|
|
1247
|
+
if (!classFiles.has(m[2])) classFiles.set(m[2], new Set());
|
|
1248
|
+
classFiles.get(m[2]).add(m[1]);
|
|
1249
|
+
}
|
|
1250
|
+
for (const rel of [...typedIntentsByFile.keys()].sort()) {
|
|
1251
|
+
for (const it of typedIntentsByFile.get(rel)) {
|
|
1252
|
+
const files = classFiles.get(it.recvType);
|
|
1253
|
+
if (!files || files.size !== 1) { typedDropped++; continue; } // unknown or ambiguous class -> never guess
|
|
1254
|
+
const toId = [...files][0] + ':' + it.recvType + '.' + it.method;
|
|
1255
|
+
if (!nodeIdSet.has(toId) || !nodeIdSet.has(it.from) || it.from === toId) { typedDropped++; continue; }
|
|
1256
|
+
const kind = (isTestFile(idFile(it.from)) && !isTestFile(idFile(toId))) ? 'test' : 'call';
|
|
1257
|
+
const key = it.from + '\t' + toId + '\t' + kind;
|
|
1258
|
+
if (edgeTriKeys.has(key)) continue;
|
|
1259
|
+
edgeTriKeys.add(key);
|
|
1260
|
+
edges.push({ from: it.from, to: toId, kind, weight: 1 });
|
|
1261
|
+
typedWired++;
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// meta — the single source of truth for the target. `root` (absolute, forward-slashed) + each
|
|
1267
|
+
// node's relative `file` path reconstruct any source file, so downstream stages (overlap/confirm
|
|
1268
|
+
// body-reading, report header) read the target from here instead of re-hardcoding it.
|
|
1269
|
+
const rootFwd = root.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
1270
|
+
const targetLabel = opts.target || rootFwd.split('/').slice(-2).join('/') || rootFwd;
|
|
1271
|
+
const langOf = (f) => (f.endsWith('.py') ? 'python' : f.endsWith('.rs') ? 'rust' : f.endsWith('.go') ? 'go' : f.endsWith('.java') ? 'java' : f.endsWith('.cs') ? 'csharp' : f.endsWith('.rb') ? 'ruby' : f.endsWith('.php') ? 'php' : /\.kts?$/.test(f) ? 'kotlin' : f.endsWith('.swift') ? 'swift' : /\.tsx?$/.test(f) ? 'typescript' : 'javascript');
|
|
1272
|
+
const languages = [...new Set(files.map(langOf))].sort();
|
|
1273
|
+
const fragment = {
|
|
1274
|
+
meta: {
|
|
1275
|
+
root: rootFwd, target: targetLabel, engine: useCtags ? 'ctags' : 'regex',
|
|
1276
|
+
// additive: only present when the tree-sitter tier owns complexity for this run, so the
|
|
1277
|
+
// default (regex) output is byte-identical to before. Pins the grammar version for determinism.
|
|
1278
|
+
// Stamped from the PROBE (identical string to the loaded engine's — pinned by test L4) so a
|
|
1279
|
+
// warm run that never initializes the engine emits the same meta as a cold one (Spec A).
|
|
1280
|
+
...(opts.engine !== 'regex' && astProbe.ts && !astLoadFailed ? { complexityEngine: astProbe.tsVersion } : {}),
|
|
1281
|
+
languages, symbols: nodes.length,
|
|
1282
|
+
sources, // per-file {s: size, m: mtimeMs} staleness stamps
|
|
1283
|
+
// files with dynamic-dispatch patterns — the honest asterisk on every "0 callers" answer
|
|
1284
|
+
...(dynamicFiles.length ? { dynamic: { files: dynamicFiles.length, sample: dynamicFiles.slice(0, 3) } } : {}),
|
|
1285
|
+
// per-DIRECTORY mtime stamps: adding/deleting a file touches its directory, so NEW files (which
|
|
1286
|
+
// per-file stamps cannot see) still flip the staleness check.
|
|
1287
|
+
dirs: Object.fromEntries([...new Set(Object.keys(sources).map((r) => (r.includes('/') ? r.slice(0, r.lastIndexOf('/')) : '.')))].sort().map((d) => {
|
|
1288
|
+
try { return [d, Math.round(statSync(join(root, d)).mtimeMs)]; } catch { return [d, 0]; }
|
|
1289
|
+
})),
|
|
1290
|
+
},
|
|
1291
|
+
nodes, edges,
|
|
1292
|
+
};
|
|
1293
|
+
if (newCache && !astLoadFailed) { try { writeFileSync(resolve(opts.cache), JSON.stringify(newCache)); } catch { /* cache is best-effort */ } }
|
|
1294
|
+
// Dispatch note + banner report from the PROBE (what tier owns the run) plus the live load state:
|
|
1295
|
+
// `ast: loaded` (initialized this run) / `ast: idle` (available, nothing needed a parse — the warm
|
|
1296
|
+
// path Spec A exists for) / `ast: off` (regex opt-out, unavailable, or load failure).
|
|
1297
|
+
const astAvailable = opts.engine !== 'regex' && (astProbe.ts || astProbe.java || astProbe.csharp);
|
|
1298
|
+
const typedLangs = ['java', 'csharp', 'python', 'go', 'rust'].filter((k) => typedLangsSeen.has(k));
|
|
1299
|
+
const dispatchNote = astAvailable
|
|
1300
|
+
? `; wired ${dispatchEdgeCount} dispatch edge(s)${dispatchDropped ? `, dropped ${dispatchDropped} (missing endpoint)` : ''}` +
|
|
1301
|
+
(typedLangs.length ? `; typed-dispatch (${typedLangs.join('+')}) ${typedWired} wired${typedDropped ? `, ${typedDropped} dropped (ambiguous/absent)` : ''}` : '')
|
|
1302
|
+
: '';
|
|
1303
|
+
const anyAstLoaded = !!_tsEngineState || Object.values(langEngines).some(Boolean);
|
|
1304
|
+
const astState = anyAstLoaded ? 'loaded' : (!astAvailable || astLoadFailed) ? 'off' : 'idle';
|
|
1305
|
+
const banner = `[extract] ${nodes.length} symbols, ${edges.length} edges (${edges.length - importEdgeCount} call + ${importEdgeCount} import) from ${files.length} files (${useCtags ? 'ctags' : 'regex'}${opts.engine !== 'regex' && astProbe.ts ? '+tree-sitter' : ''} engine); dropped ${ambiguousDropped} ambiguous bare-call edges${dispatchNote}; scanned ${scanCount}/${files.length} file(s); edged ${edgedCount}/${edgeFiles.length}${opts.cache ? ' (cache on)' : ''}; ast: ${astState}`;
|
|
1306
|
+
if (opts.out) { writeFileSync(resolve(opts.out), JSON.stringify(fragment, null, 2)); console.error(banner + ` -> ${opts.out}`); }
|
|
1307
|
+
else { process.stdout.write(JSON.stringify(fragment)); console.error(banner); }
|