@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.
Files changed (81) hide show
  1. package/.claude-plugin/plugin.json +30 -0
  2. package/CHANGELOG.md +480 -0
  3. package/LICENSE +21 -0
  4. package/README.md +573 -0
  5. package/agents/codeweb-dissector.md +56 -0
  6. package/agents/codeweb-domain-mapper.md +63 -0
  7. package/commands/codeweb.md +57 -0
  8. package/hooks/hooks.json +47 -0
  9. package/hooks/post-edit-diff.mjs +83 -0
  10. package/hooks/pre-edit-impact.mjs +94 -0
  11. package/hooks/session-brief.mjs +53 -0
  12. package/package.json +64 -0
  13. package/scripts/annotate.mjs +46 -0
  14. package/scripts/bench-ts-engine.mjs +59 -0
  15. package/scripts/bench.mjs +133 -0
  16. package/scripts/break-cycles.mjs +0 -0
  17. package/scripts/brief.mjs +28 -0
  18. package/scripts/build-report.mjs +182 -0
  19. package/scripts/campaign.mjs +61 -0
  20. package/scripts/check-consistency.mjs +45 -0
  21. package/scripts/ci-gate.mjs +86 -0
  22. package/scripts/cluster3.mjs +112 -0
  23. package/scripts/codemod.mjs +130 -0
  24. package/scripts/context-pack.mjs +118 -0
  25. package/scripts/deadcode.mjs +96 -0
  26. package/scripts/diff.mjs +0 -0
  27. package/scripts/explain.mjs +87 -0
  28. package/scripts/extract-symbols.mjs +1307 -0
  29. package/scripts/find-similar.mjs +86 -0
  30. package/scripts/find.mjs +50 -0
  31. package/scripts/fitness.mjs +90 -0
  32. package/scripts/grammars/PROVENANCE.md +36 -0
  33. package/scripts/grammars/tree-sitter-c-sharp.wasm +0 -0
  34. package/scripts/grammars/tree-sitter-go.wasm +0 -0
  35. package/scripts/grammars/tree-sitter-java.wasm +0 -0
  36. package/scripts/grammars/tree-sitter-python.wasm +0 -0
  37. package/scripts/grammars/tree-sitter-rust.wasm +0 -0
  38. package/scripts/grammars/tree-sitter-typescript.wasm +0 -0
  39. package/scripts/hotspots.mjs +53 -0
  40. package/scripts/lib/annotations.mjs +51 -0
  41. package/scripts/lib/bench-core.mjs +178 -0
  42. package/scripts/lib/brief-core.mjs +92 -0
  43. package/scripts/lib/campaign.mjs +73 -0
  44. package/scripts/lib/claims-check.mjs +44 -0
  45. package/scripts/lib/cli.mjs +140 -0
  46. package/scripts/lib/complexity.mjs +68 -0
  47. package/scripts/lib/dup-check.mjs +51 -0
  48. package/scripts/lib/find-core.mjs +85 -0
  49. package/scripts/lib/gate-md.mjs +50 -0
  50. package/scripts/lib/graph-ops.mjs +319 -0
  51. package/scripts/lib/hotspots.mjs +34 -0
  52. package/scripts/lib/query-core.mjs +85 -0
  53. package/scripts/lib/reading-order.mjs +53 -0
  54. package/scripts/lib/reliance.mjs +143 -0
  55. package/scripts/lib/risk.mjs +18 -0
  56. package/scripts/lib/shingles.mjs +39 -0
  57. package/scripts/lib/skeleton.mjs +41 -0
  58. package/scripts/lib/stats.mjs +92 -0
  59. package/scripts/lib/ts-engine.mjs +605 -0
  60. package/scripts/mcp-server.mjs +370 -0
  61. package/scripts/optimize.mjs +152 -0
  62. package/scripts/overlap.mjs +380 -0
  63. package/scripts/placement.mjs +93 -0
  64. package/scripts/query.mjs +94 -0
  65. package/scripts/reading-order.mjs +36 -0
  66. package/scripts/refresh.mjs +73 -0
  67. package/scripts/release-utils.mjs +158 -0
  68. package/scripts/release.mjs +62 -0
  69. package/scripts/report-template.html +730 -0
  70. package/scripts/review.mjs +112 -0
  71. package/scripts/risk.mjs +77 -0
  72. package/scripts/run.mjs +96 -0
  73. package/scripts/screenshot.mjs +104 -0
  74. package/scripts/simulate-edit.mjs +70 -0
  75. package/scripts/stats.mjs +31 -0
  76. package/scripts/trend.mjs +147 -0
  77. package/scripts/version-sync.mjs +19 -0
  78. package/skills/codebase-anatomy/SKILL.md +174 -0
  79. package/skills/codebase-anatomy/references/engine-detection.md +49 -0
  80. package/skills/codebase-anatomy/references/graph-schema.md +120 -0
  81. 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); }