@geml/geml 1.8.2 → 1.8.4

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.
@@ -1,658 +1,658 @@
1
- // geml-code-graph adapter: SCIP index (index.scip, protobuf) → exchange format.
2
- //
3
- // Reads the protobuf DIRECTLY with a minimal embedded wire-format reader — the
4
- // scip CLI ships no Windows binary, and the fields we need are few. Everything
5
- // here is compiler-grade resolution, so edges are resolution:"cpg"; a direct
6
- // hit is confidence:"high"; a call to an interface/abstract member with known
7
- // implementations becomes medium + candidates; references to symbols not
8
- // defined in the project become to_text (unresolved, low).
9
- //
10
- // Produce the index with scip-typescript (TS/JS) or rust-analyzer (Rust):
11
- // npx --yes @sourcegraph/scip-typescript index --output index.scip
12
- // rust-analyzer scip . --output rust.scip
13
- //
14
- // Caller attribution: a reference occurrence belongs to the innermost function
15
- // DEFINITION whose enclosing_range contains it. scip-typescript and
16
- // rust-analyzer both emit enclosing_range on definition occurrences; if absent
17
- // we fall back to "the nearest preceding definition in the file" and mark the
18
- // adapter degraded.
19
- import { readFileSync } from "node:fs";
20
- import { resolve as resolvePath, join, relative, isAbsolute } from "node:path";
21
-
22
- // ---- minimal protobuf wire reader ------------------------------------------
23
- // DEFENSIVE by contract: a malformed .scip (truncated, garbage, or a hostile
24
- // hand-crafted index) must degrade to a clean skip — never an uncaught throw
25
- // that aborts the whole build. Every read is bounds-checked against the region
26
- // end; overruns set p.bad and the field iterator STOPS there (a partial message
27
- // yields whatever prefix parsed cleanly). Wire types this reader does not model
28
- // (groups 3/4, reserved 6/7) also stop iteration rather than throwing.
29
- function varint(buf, p, end) {
30
- let x = 0n, s = 0n, b;
31
- do {
32
- if (p.i >= end) { p.bad = true; return x; } // ran off the region
33
- b = buf[p.i++];
34
- x |= BigInt(b & 0x7f) << s;
35
- s += 7n;
36
- if (s > 70n) { p.bad = true; return x; } // >10 bytes: not a valid varint
37
- } while (b & 0x80);
38
- return x;
39
- }
40
- // Iterate fields of one message region [start, end): yields {no, wt, val|sub}.
41
- function* fields(buf, start, end) {
42
- const p = { i: start, bad: false };
43
- while (p.i < end) {
44
- const key = Number(varint(buf, p, end));
45
- if (p.bad) return;
46
- const no = key >> 3, wt = key & 7;
47
- if (wt === 0) { const val = varint(buf, p, end); if (p.bad) return; yield { no, wt, val }; }
48
- else if (wt === 1) { if (p.i + 8 > end) return; yield { no, wt, val: buf.readBigUInt64LE(p.i) }; p.i += 8; }
49
- else if (wt === 2) { const len = Number(varint(buf, p, end)); if (p.bad || len < 0 || p.i + len > end) return; yield { no, wt, a: p.i, b: p.i + len }; p.i += len; }
50
- else if (wt === 5) { if (p.i + 4 > end) return; yield { no, wt, val: BigInt(buf.readUInt32LE(p.i)) }; p.i += 4; }
51
- else return; // groups (3/4) / reserved (6/7): unmodelled — stop, never throw
52
- }
53
- }
54
- const str = (buf, f) => buf.toString("utf8", f.a, f.b);
55
- // repeated int32, packed (len-delimited varints) or single varint value
56
- function packedInts(buf, f, out) {
57
- if (f.wt === 0) { out.push(Number(f.val)); return; }
58
- const p = { i: f.a, bad: false };
59
- while (p.i < f.b) { const v = varint(buf, p, f.b); if (p.bad) break; out.push(Number(v)); }
60
- }
61
-
62
- // L5: a document's relative_path comes from the (untrusted) .scip and must
63
- // never resolve to a file outside the project root — otherwise the source
64
- // recovery below would readFileSync it, and it would land in symbol/edge paths.
65
- // Reject anything that escapes root after normalization: "..", an absolute
66
- // path, a Windows drive path, or a UNC share.
67
- function escapesRoot(rootAbs, p) {
68
- if (!p) return false;
69
- const relToRoot = relative(rootAbs, resolvePath(rootAbs, p)).replace(/\\/g, "/");
70
- return relToRoot === ".." || relToRoot.startsWith("../") || isAbsolute(relToRoot);
71
- }
72
-
73
- // ---- SCIP field numbers (scip.proto) ---------------------------------------
74
- // Index: metadata=1, documents=2, external_symbols=3
75
- // Document: relative_path=1, occurrences=2, symbols=3, language=4
76
- // Occurrence: range=1, symbol=2, symbol_roles=3, enclosing_range=7
77
- // SymbolInformation: symbol=1, relationships=4, display_name=6
78
- // Relationship: symbol=1, is_implementation=3
79
- const ROLE_DEFINITION = 0x1;
80
- const ROLE_IMPORT = 0x2;
81
-
82
- function parseScip(path) {
83
- const buf = readFileSync(path);
84
- const docs = [];
85
- let projectRoot = "";
86
- for (const f of fields(buf, 0, buf.length)) {
87
- if (f.no === 1 && f.wt === 2) {
88
- // Metadata → project_root (field 3): the directory the indexer ran in.
89
- // Needed to re-anchor document paths when a SUBPROJECT of the repo was
90
- // indexed (scip paths are relative to the indexed project, not the repo).
91
- for (const m of fields(buf, f.a, f.b)) {
92
- if (m.no === 3 && m.wt === 2) projectRoot = str(buf, m);
93
- }
94
- continue;
95
- }
96
- if (f.no !== 2 || f.wt !== 2) continue;
97
- const doc = { path: "", occ: [], rel: [] };
98
- for (const d of fields(buf, f.a, f.b)) {
99
- if (d.no === 1 && d.wt === 2) doc.path = str(buf, d);
100
- else if (d.no === 2 && d.wt === 2) {
101
- const o = { range: [], symbol: "", roles: 0, enclosing: [] };
102
- for (const x of fields(buf, d.a, d.b)) {
103
- if (x.no === 1) packedInts(buf, x, o.range);
104
- else if (x.no === 2 && x.wt === 2) o.symbol = str(buf, x);
105
- else if (x.no === 3 && x.wt === 0) o.roles = Number(x.val);
106
- else if (x.no === 7) packedInts(buf, x, o.enclosing);
107
- }
108
- doc.occ.push(o);
109
- } else if (d.no === 3 && d.wt === 2) {
110
- // SymbolInformation → implementation relationships only
111
- let sym = "";
112
- const impl = [];
113
- for (const x of fields(buf, d.a, d.b)) {
114
- if (x.no === 1 && x.wt === 2) sym = str(buf, x);
115
- else if (x.no === 4 && x.wt === 2) {
116
- let rsym = "", isImpl = false;
117
- for (const r of fields(buf, x.a, x.b)) {
118
- if (r.no === 1 && r.wt === 2) rsym = str(buf, r);
119
- else if (r.no === 3 && r.wt === 0) isImpl = r.val !== 0n;
120
- }
121
- if (isImpl && rsym) impl.push(rsym);
122
- }
123
- }
124
- if (impl.length) doc.rel.push({ sym, impl });
125
- }
126
- }
127
- docs.push(doc);
128
- }
129
- return { docs, projectRoot };
130
- }
131
-
132
- // ---- SCIP symbol grammar helpers -------------------------------------------
133
- // Two producers, two symbol grammars behind the shared SCIP header
134
- // "<scheme> <manager> <package> <version> <descriptors>":
135
- // scip-typescript "scip-typescript npm @geml/geml 1.0.0 src/`geml.ts`/parse()."
136
- // rust-analyzer "rust-analyzer cargo spike 0.1.0 util/multiply()."
137
- // "rust-analyzer cargo spike 0.1.0 impl#[Widget]new()."
138
- // "rust-analyzer cargo core https://… ops/arith/impl#[u32][`Mul<Self>`]mul()."
139
- const isFuncSym = (s) => s.endsWith("().");
140
- const isRustSym = (s) => s.startsWith("rust-analyzer ");
141
- const langOf = (s) => (isRustSym(s) ? "rust" : "typescript");
142
- // Term descriptor (`name.`): a const/property binding. scip-typescript gives
143
- // `const Foo = () => …` — the dominant React component form — a TERM symbol,
144
- // not a method one, so `().` alone would leave arrow components (and every
145
- // `<Foo />` that renders them) out of the graph entirely. The discriminator
146
- // is the definition's enclosing_range: scip-typescript emits it ONLY on
147
- // function-like definitions (verified on the react fixture: `const Logo =
148
- // () =>` carries one; object-literal consts, `createContext(...)` results and
149
- // interface members carry none). Rust symbols are excluded — rust closures
150
- // are locals and rust-analyzer's const semantics are unverified here.
151
- const isTermSym = (s) => s.endsWith(".") && !s.endsWith("().");
152
- const isArrowFnDef = (o) => isTermSym(o.symbol) && !isRustSym(o.symbol) && o.enclosing.length > 0;
153
-
154
- // Descriptor tail: everything after the 4-token header. The version slot may
155
- // be a URL (rust-analyzer sysroot crates) but never contains spaces; spaces
156
- // inside descriptors only occur backtick-escaped, after the header.
157
- const descriptorTail = (s) => {
158
- let i = -1;
159
- for (let n = 0; n < 4; n++) { i = s.indexOf(" ", i + 1); if (i < 0) return s; }
160
- return s.slice(i + 1);
161
- };
162
-
163
- // Tokenize a SCIP descriptor suffix (backtick-escape aware; `` = literal `).
164
- // kinds: ns "a/", type "T#", term "x.", meta "m:", macro "m!", method "f().",
165
- // typeParam "[T]", param "(p)".
166
- function parseDescriptors(d) {
167
- const out = [];
168
- let i = 0;
169
- const readName = () => {
170
- if (d[i] === "`") {
171
- let s = ""; i++;
172
- while (i < d.length) {
173
- if (d[i] === "`") { if (d[i + 1] === "`") { s += "`"; i += 2; continue; } i++; break; }
174
- s += d[i++];
175
- }
176
- return s;
177
- }
178
- const start = i;
179
- while (i < d.length && /[A-Za-z0-9\-+$_]/.test(d[i])) i++;
180
- return d.slice(start, i);
181
- };
182
- while (i < d.length) {
183
- if (d[i] === "[") { i++; const name = readName(); if (d[i] === "]") i++; out.push({ kind: "typeParam", name }); continue; }
184
- if (d[i] === "(") { i++; const name = readName(); if (d[i] === ")") i++; out.push({ kind: "param", name }); continue; }
185
- const name = readName();
186
- const c = d[i];
187
- if (c === "(") { // method: name '(' disambiguator? ')' '.'
188
- while (i < d.length && d[i] !== ")") i++;
189
- i++; // ')'
190
- if (d[i] === ".") i++;
191
- out.push({ kind: "method", name });
192
- continue;
193
- }
194
- i++; // the descriptor suffix char (or one malformed char — progress either way)
195
- out.push({ kind: c === "/" ? "ns" : c === "#" ? "type" : c === "." ? "term" : c === ":" ? "meta" : c === "!" ? "macro" : "?", name });
196
- }
197
- return out;
198
- }
199
-
200
- // rust-analyzer display names: free functions keep their plain name (the
201
- // module path lives in the file/container), members read Type::name. An
202
- // `impl#` scope stands for an impl block — its SELF TYPE is the first
203
- // [type-param] after it (`impl#[Widget]new().` → Widget::new; a trait impl
204
- // carries the trait as a second bracket: `impl#[u32][`Mul<Self>`]mul().` →
205
- // u32::mul).
206
- const rustNameOf = (s) => {
207
- const ds = parseDescriptors(descriptorTail(s));
208
- let mi = -1;
209
- for (let j = ds.length - 1; j >= 0; j--) if (ds[j].kind === "method") { mi = j; break; }
210
- if (mi < 0 || !ds[mi].name) return ds.at(-1)?.name || s.split("/").pop() || s;
211
- let owner;
212
- for (let j = mi - 1; j >= 0; j--) {
213
- const x = ds[j];
214
- if (x.kind === "ns") break; // crossed into the module path — a free function
215
- if (x.kind === "type") {
216
- owner = x.name;
217
- if (owner === "impl") owner = ds.slice(j + 1, mi).find((t) => t.kind === "typeParam")?.name;
218
- break;
219
- }
220
- }
221
- return owner ? `${owner}::${ds[mi].name}` : ds[mi].name;
222
- };
223
-
224
- // Exported for tests: pure string → display name across both grammars.
225
- export const nameOf = (s) => {
226
- if (isRustSym(s)) return rustNameOf(s);
227
- // Class members read class-qualified (`RenderCtx.block`), constructors as
228
- // `Cls.new` — free functions (no `Owner#` scope) keep their plain name.
229
- if (/`?<constructor>`?\(\)\.$/.test(s)) {
230
- const cm = /([A-Za-z0-9_$]+)#`?<constructor>`?\(\)\.$/.exec(s);
231
- return cm ? `${cm[1]}.new` : "new";
232
- }
233
- const m = /(?:([A-Za-z0-9_$]+)#)?([^\/#.`]+)\(\)\.$/.exec(s);
234
- if (m) return m[1] ? `${m[1]}.${m[2]}` : m[2];
235
- // Term symbol (arrow-function component/const, class property arrow):
236
- // `…/Logo.` → Logo, `…/A#onClick.` → A.onClick.
237
- const t = /(?:([A-Za-z0-9_$]+)#)?([A-Za-z0-9_$]+)\.$/.exec(s);
238
- if (t) return t[1] ? `${t[1]}.${t[2]}` : t[2];
239
- return s.split("/").pop() ?? s;
240
- };
241
-
242
- // ---- SFC shadow remap (Vue/Svelte virtualization) ---------------------------
243
- // When the index was produced over a virtual dir (codemap/sfc-virtualize.mjs),
244
- // `remapDir` points at it. Occurrences in shadow files (src/App.vue.ts) are
245
- // attributed back to the original .vue/.svelte path + line through the
246
- // per-shadow map.json; occurrences that map nowhere are pure generated code
247
- // and are DROPPED, never misattributed. Additive: without remapDir nothing
248
- // in extract() changes.
249
- //
250
- // Two SFC-specific recoveries:
251
- // 1. Generated wrappers (svelte2tsx $$render, Volar __VLS_*) are never
252
- // symbols; a reference whose only enclosing definition is generated —
253
- // or that sits at the shadow's top level, as Volar's template projection
254
- // does — is attributed to a synthetic `<Component>.template` node when
255
- // its mapped line lands in the template/markup region.
256
- // 2. svelte2tsx puts the whole <script> inside $$render, so user functions
257
- // are scip `local N` symbols (no display_name, no enclosing_range).
258
- // Function-shaped locals are admitted as definitions: the name comes
259
- // from the shadow text at the definition range, the span from a small
260
- // brace scan. Non-function locals (params, lets) stay invisible.
261
- const GENERATED_NAME = /^(\$\$|__VLS_|__sveltets)/;
262
-
263
- export function loadSfcRemap(remapDir, root) {
264
- let manifest;
265
- try {
266
- manifest = JSON.parse(readFileSync(join(remapDir, "sfc-manifest.json"), "utf8"));
267
- } catch {
268
- return null; // no manifest — treat as a plain index
269
- }
270
- const rootAbs = resolvePath(root);
271
- const bySh = new Map();
272
- for (const f of manifest.files ?? []) {
273
- let side;
274
- try { side = JSON.parse(readFileSync(join(remapDir, f.map), "utf8")); } catch { continue; }
275
- const origAbs = resolvePath(manifest.src, f.original);
276
- const rel = relative(rootAbs, origAbs).replace(/\\/g, "/");
277
- bySh.set(f.shadow, {
278
- original: rel.startsWith("..") ? f.original : rel,
279
- component: side.component ?? f.original.split("/").pop(),
280
- framework: side.framework,
281
- regions: side.regions ?? [],
282
- map: new Map(side.lines ?? []), // 1-based generated line -> original line
283
- shadowAbs: join(remapDir, f.shadow),
284
- _text: undefined,
285
- });
286
- }
287
- return { bySh, dirAbs: resolvePath(remapDir), rootAbs };
288
- }
289
-
290
- const inRegion = (info, origLine) =>
291
- info.regions.some((r) => origLine >= r.start && origLine <= r.end);
292
-
293
- // Shadow text as lines + line-start offsets (lazy, cached per shadow).
294
- function shadowText(info) {
295
- if (!info._text) {
296
- const raw = readFileSync(info.shadowAbs, "utf8");
297
- info._text = { raw, lines: raw.split("\n") };
298
- }
299
- return info._text;
300
- }
301
-
302
- // End line of a local definition's body: from the definition name forward,
303
- // the first `{` before any top-level `;` opens the body — match braces
304
- // (skipping strings, template literals and comments) back to depth 0. An
305
- // arrow with an expression body (no brace) stays single-line. Wrong guesses
306
- // degrade attribution exactly like the adapter's documented degraded mode.
307
- function braceSpanEnd(text, startOffset, startLine) {
308
- const s = text.raw;
309
- let i = startOffset, open = -1;
310
- for (const cap = Math.min(s.length, startOffset + 400); i < cap; i++) {
311
- const c = s[i];
312
- if (c === "{") { open = i; break; }
313
- if (c === ";") return startLine;
314
- }
315
- if (open < 0) return startLine;
316
- let depth = 0, line = startLine;
317
- for (i = open; i < s.length; i++) {
318
- const c = s[i];
319
- if (c === "\n") { line++; continue; }
320
- if (c === '"' || c === "'" || c === "`") {
321
- const q = c;
322
- for (i++; i < s.length; i++) {
323
- if (s[i] === "\\") { i++; continue; }
324
- if (s[i] === "\n" && q !== "`") break;
325
- if (s[i] === "\n") line++;
326
- if (s[i] === q) break;
327
- }
328
- continue;
329
- }
330
- if (c === "/" && s[i + 1] === "/") { while (i < s.length && s[i] !== "\n") i++; i--; continue; }
331
- if (c === "/" && s[i + 1] === "*") {
332
- for (i += 2; i < s.length; i++) { if (s[i] === "\n") line++; if (s[i] === "*" && s[i + 1] === "/") { i++; break; } }
333
- continue;
334
- }
335
- if (c === "{") depth++;
336
- else if (c === "}") { depth--; if (depth === 0) return line; }
337
- }
338
- return line;
339
- }
340
-
341
- // Admit a `local N` definition occurrence when the shadow text says it is a
342
- // function. Returns { name, encl: [startLine0, endLine0] } or null.
343
- function localFnAt(info, range) {
344
- const text = shadowText(info);
345
- const l0 = range[0], cs = range[1], ce = range.length === 3 ? range[2] : range[3];
346
- const lineText = text.lines[l0] ?? "";
347
- const name = lineText.slice(cs, ce);
348
- if (!/^[A-Za-z_$][\w$]*$/.test(name) || GENERATED_NAME.test(name)) return null;
349
- const before = lineText.slice(0, cs), after = lineText.slice(ce);
350
- const isFn = /\bfunction\s*\*?\s*$/.test(before)
351
- || /^\s*=\s*(async\s*)?\(/.test(after)
352
- || /^\s*=\s*(async\s*)?function\b/.test(after)
353
- || /^\s*=\s*(async\s+)?[A-Za-z_$][\w$]*\s*=>/.test(after);
354
- if (!isFn) return null;
355
- let off = 0;
356
- for (let i = 0; i < l0; i++) off += text.lines[i].length + 1;
357
- return { name, encl: [l0, braceSpanEnd(text, off + ce, l0)] };
358
- }
359
-
360
- export function extract({ raw: scipPath, root, remapDir }) {
361
- const { docs, projectRoot } = parseScip(scipPath);
362
- // scip-typescript emits OS-native separators in relative_path on Windows;
363
- // the codemap profile is posix throughout.
364
- for (const d of docs) d.path = d.path.replace(/\\/g, "/");
365
- const sfc = remapDir ? loadSfcRemap(remapDir, root) : null;
366
- if (sfc) {
367
- // Virtual-dir index: shadow docs keep their raw path for now (it keys the
368
- // map lookup; the final passes swap in the original .vue/.svelte path).
369
- // Real project files arrive ../-relative to the virtual dir — anchor them
370
- // repo-relative. Anything else living INSIDE the virtual dir (svelte
371
- // shims, global type stubs) is indexing scaffolding, not source: dropped.
372
- for (const d of docs) {
373
- const info = sfc.bySh.get(d.path);
374
- if (info) { d.sfc = info; continue; }
375
- const abs = resolvePath(sfc.dirAbs, d.path);
376
- if (!relative(sfc.dirAbs, abs).replace(/\\/g, "/").startsWith("..")) { d.drop = true; continue; }
377
- const repoRel = relative(sfc.rootAbs, abs).replace(/\\/g, "/");
378
- if (!repoRel.startsWith("..")) d.path = repoRel;
379
- }
380
- } else if (projectRoot && root) {
381
- // Document paths are relative to the INDEXED project (metadata.project_root),
382
- // which may be a subdirectory of the codemap's --root. Re-anchor them so a
383
- // multi-language merge keeps one coherent repo-relative path space.
384
- // file:// URL -> plain path: after dropping the scheme, a unix path KEEPS
385
- // its leading slash (file:///tmp/x -> /tmp/x); only a Windows drive path
386
- // drops it (file:///C:/x -> C:/x).
387
- const stripUrl = (p) => p.replace(/^file:\/\//, "").replace(/^\/([A-Za-z]:)/, "$1").replace(/\\/g, "/").replace(/\/+$/, "");
388
- const rootP = stripUrl(resolvePath(root));
389
- // project_root is untrusted bytes from the .scip — a bad %xx escape would
390
- // make decodeURIComponent throw and abort the build; fall back to the raw.
391
- let projP;
392
- try { projP = stripUrl(decodeURIComponent(projectRoot)); } catch { projP = stripUrl(projectRoot); }
393
- if (projP.toLowerCase() !== rootP.toLowerCase() && projP.toLowerCase().startsWith(rootP.toLowerCase() + "/")) {
394
- const prefix = projP.slice(rootP.length + 1);
395
- for (const d of docs) d.path = `${prefix}/${d.path}`;
396
- }
397
- }
398
-
399
- // L5: once every path is in its final (posix, re-anchored) form, confine each
400
- // document to the project root — drop any whose relative_path escapes it, so
401
- // a crafted "../../../etc/passwd" or an absolute/drive/UNC path is neither
402
- // read from disk (source recovery) nor attributed into the graph. Gated on a
403
- // provided root: with no root there is no boundary to confine against (the
404
- // standalone/degraded call path). SFC shadow docs are keyed in-root by
405
- // construction and their scaffolding is already dropped above.
406
- if (root != null) {
407
- const rootAbs = resolvePath(root);
408
- let escaped = 0;
409
- for (const d of docs) {
410
- if (d.drop || d.sfc) continue;
411
- if (escapesRoot(rootAbs, d.path)) { d.drop = true; escaped++; }
412
- }
413
- if (escaped) console.error(`scip adapter: skipped ${escaped} document(s) whose relative_path resolves outside the project root`);
414
- }
415
-
416
- // range = [startLine, startChar, endLine(, endChar)] (0-based); normalize.
417
- const spanOf = (r) => (r.length === 3 ? [r[0], r[0]] : [r[0], r[2]]);
418
-
419
- // scip `local N` symbols are per-document — namespace their def/ref key by
420
- // the document so two shadows' locals never collide. Non-local symbols keep
421
- // the raw symbol string as their key (and anchor).
422
- const localKey = (d, sym) => `local:${d.path}#${sym.slice("local ".length)}`;
423
-
424
- // 1. definitions of function symbols
425
- const defs = new Map(); // key -> {file, name, line_start, line_end, encl:[sl,el]}
426
- for (const d of docs) {
427
- if (d.drop) continue;
428
- for (const o of d.occ) {
429
- if (!(o.roles & ROLE_DEFINITION)) continue;
430
- // Merged admission: method symbols AND arrow-function terms (react branch:
431
- // a term definition carrying an enclosing_range is function-like) take the
432
- // standard path; SFC shadow-doc locals (svelte2tsx wraps the <script> in
433
- // $$render, so every user function is a local) take the sfc branch.
434
- if (isFuncSym(o.symbol) || isArrowFnDef(o)) {
435
- const [nl] = spanOf(o.range);
436
- const encl = o.enclosing.length ? spanOf(o.enclosing) : [nl, nl];
437
- const prev = defs.get(o.symbol);
438
- // keep the widest definition (impl over overload signatures)
439
- if (!prev || encl[1] - encl[0] > prev.encl[1] - prev.encl[0]) {
440
- defs.set(o.symbol, { file: d.path, name: nameOf(o.symbol), line_start: encl[0] + 1, line_end: encl[1] + 1, encl });
441
- }
442
- } else if (d.sfc && o.symbol.startsWith("local ")) {
443
- const lf = localFnAt(d.sfc, o.range);
444
- if (lf) {
445
- defs.set(localKey(d, o.symbol), {
446
- file: d.path, name: lf.name,
447
- line_start: lf.encl[0] + 1, line_end: lf.encl[1] + 1, encl: lf.encl,
448
- });
449
- }
450
- }
451
- }
452
- }
453
- // ---- macro-erased definitions: recover them from the SOURCE ----
454
- // An item-rewriting proc macro (workers-rs #[event], #[tokio::main], …) can
455
- // swallow a Rust function wholesale: the rewritten symbol never gets an
456
- // occurrence (not even the fn's name token), so the function AND every call
457
- // it makes vanish from the graph — while the body's call references survive,
458
- // orphaned outside every admitted enclosing range. rust-analyzer leaves no
459
- // structured record of the rewritten item, so the one honest witness left is
460
- // the source file itself: inside each orphan region, the `fn <name>(`
461
- // signatures are exactly the functions the macro consumed. Synthesize a
462
- // definition per signature (named from the source, so `async fn main` comes
463
- // back as `main`), attribute the region's calls to it, and label the rows
464
- // resolution:"heuristic" — reconstructed, not indexer-read. No signature
465
- // found -> refuse; never guess.
466
- const RUST_FN_SIG = /^\s*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?(?:unsafe\s+)?(?:extern\s+"[^"]*"\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)/;
467
- for (const d of docs) {
468
- if (d.drop || d.sfc) continue; // shadow docs have their own admission rules
469
- const admitted = [...defs.values()].filter((v) => v.file === d.path)
470
- .map((v) => v.encl).sort((a, b) => a[0] - b[0]);
471
- const covered = (line) => admitted.some((e) => line >= e[0] && line <= e[1]);
472
- // Orphan evidence: RUST call references (not defs, not imports) on lines
473
- // no admitted definition encloses. scip-typescript rewrites nothing, so
474
- // the recovery is scoped to rust-analyzer symbols.
475
- const orphan = [];
476
- for (const o of d.occ) {
477
- if (o.roles & (ROLE_DEFINITION | ROLE_IMPORT)) continue;
478
- if (!isFuncSym(o.symbol) || !isRustSym(o.symbol)) continue;
479
- const [line] = spanOf(o.range);
480
- if (!covered(line)) orphan.push(line);
481
- }
482
- if (!orphan.length) continue;
483
- orphan.sort((a, b) => a - b);
484
- let srcLines;
485
- try { srcLines = readFileSync(resolvePath(root ?? ".", d.path), "utf8").split(/\r?\n/); } catch { continue; }
486
- // Maximal regions: orphan lines belong together until an admitted range
487
- // intervenes; each region reaches back to the previous admitted range's
488
- // end, covering the attribute + signature lines the macro consumed.
489
- const regions = [];
490
- for (const line of orphan) {
491
- const cur = regions[regions.length - 1];
492
- if (cur && !admitted.some((e) => e[0] > cur.end && e[1] < line)) cur.end = line;
493
- else {
494
- const prevEnd = admitted.filter((e) => e[1] < line).map((e) => e[1]).pop();
495
- regions.push({ start: prevEnd !== undefined ? prevEnd + 1 : 0, end: line });
496
- }
497
- }
498
- for (const r of regions) {
499
- // Signatures inside the region, in order; calls between two signatures
500
- // belong to the earlier one.
501
- const sigs = [];
502
- for (let ln = r.start; ln <= r.end && ln < srcLines.length; ln++) {
503
- const m = RUST_FN_SIG.exec(srcLines[ln]);
504
- if (m) sigs.push({ line: ln, name: m[1] });
505
- }
506
- if (!sigs.length) continue; // no witness in the source — refuse
507
- sigs.forEach((sig, i) => {
508
- const end = i + 1 < sigs.length ? sigs[i + 1].line - 1 : r.end;
509
- const sym = `heuristic:${d.path}#${sig.name}@L${sig.line + 1}`;
510
- defs.set(sym, {
511
- file: d.path, name: sig.name,
512
- line_start: sig.line + 1, line_end: end + 1, encl: [sig.line, end],
513
- synth: true,
514
- });
515
- });
516
- }
517
- }
518
-
519
- const enclosingDegraded = [...defs.values()].every((v) => v.encl[0] === v.encl[1]);
520
-
521
- // implementations map: interface/abstract member symbol -> implementing symbols
522
- const implOf = new Map();
523
- for (const d of docs) {
524
- for (const { sym, impl } of d.rel) {
525
- for (const target of impl) {
526
- if (!implOf.has(target)) implOf.set(target, []);
527
- implOf.get(target).push(sym);
528
- }
529
- }
530
- }
531
-
532
- const symbols = [];
533
- const seenFiles = new Set();
534
- const addFileSym = (file, lang) => {
535
- if (seenFiles.has(file)) return;
536
- seenFiles.add(file);
537
- symbols.push({ anchor: `file:${file}`, lang, kind: "File", name: file.split("/").pop(), file, resolution: "cpg" });
538
- };
539
- // Definitions that survive into `symbols` — in remap mode the edge pass
540
- // must not emit edges to/from a definition that was dropped as generated.
541
- const survivors = new Set();
542
- for (const [sym, v] of defs) {
543
- // lang follows the producing indexer's symbol scheme, so a merged
544
- // TS + Rust codemap keeps each definition honestly labelled.
545
- const lang = langOf(sym);
546
- let file = v.file, line_start = v.line_start, line_end = v.line_end;
547
- const info = sfc?.bySh.get(v.file);
548
- if (info) {
549
- // generated wrappers/helpers never become symbols; a definition whose
550
- // line maps nowhere in the original is pure generated code — dropped.
551
- if (GENERATED_NAME.test(v.name)) continue;
552
- const ls = info.map.get(v.line_start);
553
- if (ls === undefined) continue;
554
- file = info.original;
555
- line_start = ls;
556
- line_end = Math.max(ls, info.map.get(v.line_end) ?? ls);
557
- }
558
- survivors.add(sym);
559
- symbols.push({
560
- anchor: sym, lang, kind: "Function", name: v.name,
561
- file, line_start, line_end,
562
- entry: v.name === "main" ? true : undefined,
563
- // A macro-erased definition is reconstructed from orphan evidence, not
564
- // read from an occurrence — say so.
565
- resolution: v.synth ? "heuristic" : "cpg",
566
- });
567
- addFileSym(file, lang);
568
- }
569
-
570
- // 2. calls: reference occurrences of function symbols, attributed to the
571
- // innermost containing definition of the same file.
572
- const perFileDefs = new Map(); // file -> defs sorted by span size asc
573
- for (const [sym, v] of defs) {
574
- if (!perFileDefs.has(v.file)) perFileDefs.set(v.file, []);
575
- perFileDefs.get(v.file).push({ sym, ...v });
576
- }
577
- for (const list of perFileDefs.values()) list.sort((a, b) => (a.encl[1] - a.encl[0]) - (b.encl[1] - b.encl[0]));
578
- const callerAt = (file, line) => {
579
- const list = perFileDefs.get(file);
580
- if (!list) return undefined;
581
- if (!enclosingDegraded) {
582
- for (const d of list) if (line >= d.encl[0] && line <= d.encl[1]) return d.sym; // innermost first (sorted asc)
583
- return undefined;
584
- }
585
- // degraded: nearest preceding definition start
586
- let best;
587
- for (const d of list) if (d.encl[0] <= line && (!best || d.encl[0] > best.encl[0])) best = d;
588
- return best?.sym;
589
- };
590
-
591
- const edges = [];
592
- const usedRegions = new Map(); // shadow doc path -> info (template node demanded)
593
- for (const d of docs) {
594
- if (d.drop) continue;
595
- for (const o of d.occ) {
596
- if (o.roles & ROLE_DEFINITION) continue;
597
- // Callable reference admission, merged: method symbols always count
598
- // (resolved or to_text); TERM symbols only when they resolve to an
599
- // admitted arrow-function definition — otherwise every property READ
600
- // would become a phantom call; shadow-doc locals via their per-document
601
- // key. Everything else is not a call.
602
- let symKey;
603
- if (isFuncSym(o.symbol)) symKey = o.symbol;
604
- else if (defs.has(o.symbol)) symKey = o.symbol; // term ref -> admitted arrow-fn def
605
- else if (d.sfc && o.symbol.startsWith("local ") && defs.has(localKey(d, o.symbol))) symKey = localKey(d, o.symbol);
606
- else continue;
607
- const [line] = spanOf(o.range);
608
- let from = callerAt(d.path, line);
609
- let site = { file: d.path, line: line + 1 };
610
- if (d.sfc) {
611
- const origLine = d.sfc.map.get(line + 1);
612
- if (!from || !survivors.has(from)) {
613
- // top-level (Volar's template projection) or generated-only caller
614
- // ($$render): the reference belongs to the component's template
615
- // when its mapped line lands there — otherwise drop, never guess.
616
- if (origLine === undefined || !inRegion(d.sfc, origLine)) continue;
617
- from = `sfc:${d.sfc.original}#template`;
618
- usedRegions.set(d.path, d.sfc);
619
- }
620
- if (origLine === undefined) continue; // generated echo of a real reference
621
- site = { file: d.sfc.original, line: origLine };
622
- }
623
- if (!from || from === symKey) continue;
624
- if (defs.has(symKey)) {
625
- if (sfc && !survivors.has(symKey)) continue; // callee was dropped as generated
626
- const impls = (implOf.get(symKey) ?? []).filter((s) => defs.has(s) && (!sfc || survivors.has(s)));
627
- if (impls.length) {
628
- edges.push({ kind: "calls", from, to: symKey, resolution: "cpg", confidence: "medium", note: `dispatch, ${impls.length + 1} candidates`, candidates: [...new Set(impls)].sort(), site });
629
- } else {
630
- edges.push({ kind: "calls", from, to: symKey, resolution: "cpg", confidence: "high", site });
631
- }
632
- } else {
633
- const toText = nameOf(o.symbol);
634
- // shadow docs call generated helpers (__VLS_asFunctionalElement, …)
635
- // once per template element — machinery, not user blind spots.
636
- if (d.sfc && GENERATED_NAME.test(toText)) continue;
637
- edges.push({ kind: "calls", from, to_text: toText, resolution: "cpg", confidence: "low", site });
638
- }
639
- }
640
- }
641
- // Synthetic template nodes — one per SFC whose template/markup gained an
642
- // edge: the honest caller for `@click="save"` / `on:click={bump}`.
643
- for (const info of usedRegions.values()) {
644
- const starts = info.regions.map((r) => r.start), ends = info.regions.map((r) => r.end);
645
- symbols.push({
646
- anchor: `sfc:${info.original}#template`, lang: "typescript", kind: "Function",
647
- name: `${info.component}.template`, file: info.original,
648
- line_start: Math.min(...starts), line_end: Math.max(...ends),
649
- resolution: "cpg",
650
- });
651
- addFileSym(info.original, "typescript");
652
- }
653
-
654
- if (enclosingDegraded) {
655
- console.error("scip adapter: no enclosing_range in this index — caller attribution degraded to nearest-preceding definition");
656
- }
657
- return { symbols, edges };
658
- }
1
+ // geml-code-graph adapter: SCIP index (index.scip, protobuf) → exchange format.
2
+ //
3
+ // Reads the protobuf DIRECTLY with a minimal embedded wire-format reader — the
4
+ // scip CLI ships no Windows binary, and the fields we need are few. Everything
5
+ // here is compiler-grade resolution, so edges are resolution:"cpg"; a direct
6
+ // hit is confidence:"high"; a call to an interface/abstract member with known
7
+ // implementations becomes medium + candidates; references to symbols not
8
+ // defined in the project become to_text (unresolved, low).
9
+ //
10
+ // Produce the index with scip-typescript (TS/JS) or rust-analyzer (Rust):
11
+ // npx --yes @sourcegraph/scip-typescript index --output index.scip
12
+ // rust-analyzer scip . --output rust.scip
13
+ //
14
+ // Caller attribution: a reference occurrence belongs to the innermost function
15
+ // DEFINITION whose enclosing_range contains it. scip-typescript and
16
+ // rust-analyzer both emit enclosing_range on definition occurrences; if absent
17
+ // we fall back to "the nearest preceding definition in the file" and mark the
18
+ // adapter degraded.
19
+ import { readFileSync } from "node:fs";
20
+ import { resolve as resolvePath, join, relative, isAbsolute } from "node:path";
21
+
22
+ // ---- minimal protobuf wire reader ------------------------------------------
23
+ // DEFENSIVE by contract: a malformed .scip (truncated, garbage, or a hostile
24
+ // hand-crafted index) must degrade to a clean skip — never an uncaught throw
25
+ // that aborts the whole build. Every read is bounds-checked against the region
26
+ // end; overruns set p.bad and the field iterator STOPS there (a partial message
27
+ // yields whatever prefix parsed cleanly). Wire types this reader does not model
28
+ // (groups 3/4, reserved 6/7) also stop iteration rather than throwing.
29
+ function varint(buf, p, end) {
30
+ let x = 0n, s = 0n, b;
31
+ do {
32
+ if (p.i >= end) { p.bad = true; return x; } // ran off the region
33
+ b = buf[p.i++];
34
+ x |= BigInt(b & 0x7f) << s;
35
+ s += 7n;
36
+ if (s > 70n) { p.bad = true; return x; } // >10 bytes: not a valid varint
37
+ } while (b & 0x80);
38
+ return x;
39
+ }
40
+ // Iterate fields of one message region [start, end): yields {no, wt, val|sub}.
41
+ function* fields(buf, start, end) {
42
+ const p = { i: start, bad: false };
43
+ while (p.i < end) {
44
+ const key = Number(varint(buf, p, end));
45
+ if (p.bad) return;
46
+ const no = key >> 3, wt = key & 7;
47
+ if (wt === 0) { const val = varint(buf, p, end); if (p.bad) return; yield { no, wt, val }; }
48
+ else if (wt === 1) { if (p.i + 8 > end) return; yield { no, wt, val: buf.readBigUInt64LE(p.i) }; p.i += 8; }
49
+ else if (wt === 2) { const len = Number(varint(buf, p, end)); if (p.bad || len < 0 || p.i + len > end) return; yield { no, wt, a: p.i, b: p.i + len }; p.i += len; }
50
+ else if (wt === 5) { if (p.i + 4 > end) return; yield { no, wt, val: BigInt(buf.readUInt32LE(p.i)) }; p.i += 4; }
51
+ else return; // groups (3/4) / reserved (6/7): unmodelled — stop, never throw
52
+ }
53
+ }
54
+ const str = (buf, f) => buf.toString("utf8", f.a, f.b);
55
+ // repeated int32, packed (len-delimited varints) or single varint value
56
+ function packedInts(buf, f, out) {
57
+ if (f.wt === 0) { out.push(Number(f.val)); return; }
58
+ const p = { i: f.a, bad: false };
59
+ while (p.i < f.b) { const v = varint(buf, p, f.b); if (p.bad) break; out.push(Number(v)); }
60
+ }
61
+
62
+ // L5: a document's relative_path comes from the (untrusted) .scip and must
63
+ // never resolve to a file outside the project root — otherwise the source
64
+ // recovery below would readFileSync it, and it would land in symbol/edge paths.
65
+ // Reject anything that escapes root after normalization: "..", an absolute
66
+ // path, a Windows drive path, or a UNC share.
67
+ function escapesRoot(rootAbs, p) {
68
+ if (!p) return false;
69
+ const relToRoot = relative(rootAbs, resolvePath(rootAbs, p)).replace(/\\/g, "/");
70
+ return relToRoot === ".." || relToRoot.startsWith("../") || isAbsolute(relToRoot);
71
+ }
72
+
73
+ // ---- SCIP field numbers (scip.proto) ---------------------------------------
74
+ // Index: metadata=1, documents=2, external_symbols=3
75
+ // Document: relative_path=1, occurrences=2, symbols=3, language=4
76
+ // Occurrence: range=1, symbol=2, symbol_roles=3, enclosing_range=7
77
+ // SymbolInformation: symbol=1, relationships=4, display_name=6
78
+ // Relationship: symbol=1, is_implementation=3
79
+ const ROLE_DEFINITION = 0x1;
80
+ const ROLE_IMPORT = 0x2;
81
+
82
+ function parseScip(path) {
83
+ const buf = readFileSync(path);
84
+ const docs = [];
85
+ let projectRoot = "";
86
+ for (const f of fields(buf, 0, buf.length)) {
87
+ if (f.no === 1 && f.wt === 2) {
88
+ // Metadata → project_root (field 3): the directory the indexer ran in.
89
+ // Needed to re-anchor document paths when a SUBPROJECT of the repo was
90
+ // indexed (scip paths are relative to the indexed project, not the repo).
91
+ for (const m of fields(buf, f.a, f.b)) {
92
+ if (m.no === 3 && m.wt === 2) projectRoot = str(buf, m);
93
+ }
94
+ continue;
95
+ }
96
+ if (f.no !== 2 || f.wt !== 2) continue;
97
+ const doc = { path: "", occ: [], rel: [] };
98
+ for (const d of fields(buf, f.a, f.b)) {
99
+ if (d.no === 1 && d.wt === 2) doc.path = str(buf, d);
100
+ else if (d.no === 2 && d.wt === 2) {
101
+ const o = { range: [], symbol: "", roles: 0, enclosing: [] };
102
+ for (const x of fields(buf, d.a, d.b)) {
103
+ if (x.no === 1) packedInts(buf, x, o.range);
104
+ else if (x.no === 2 && x.wt === 2) o.symbol = str(buf, x);
105
+ else if (x.no === 3 && x.wt === 0) o.roles = Number(x.val);
106
+ else if (x.no === 7) packedInts(buf, x, o.enclosing);
107
+ }
108
+ doc.occ.push(o);
109
+ } else if (d.no === 3 && d.wt === 2) {
110
+ // SymbolInformation → implementation relationships only
111
+ let sym = "";
112
+ const impl = [];
113
+ for (const x of fields(buf, d.a, d.b)) {
114
+ if (x.no === 1 && x.wt === 2) sym = str(buf, x);
115
+ else if (x.no === 4 && x.wt === 2) {
116
+ let rsym = "", isImpl = false;
117
+ for (const r of fields(buf, x.a, x.b)) {
118
+ if (r.no === 1 && r.wt === 2) rsym = str(buf, r);
119
+ else if (r.no === 3 && r.wt === 0) isImpl = r.val !== 0n;
120
+ }
121
+ if (isImpl && rsym) impl.push(rsym);
122
+ }
123
+ }
124
+ if (impl.length) doc.rel.push({ sym, impl });
125
+ }
126
+ }
127
+ docs.push(doc);
128
+ }
129
+ return { docs, projectRoot };
130
+ }
131
+
132
+ // ---- SCIP symbol grammar helpers -------------------------------------------
133
+ // Two producers, two symbol grammars behind the shared SCIP header
134
+ // "<scheme> <manager> <package> <version> <descriptors>":
135
+ // scip-typescript "scip-typescript npm @geml/geml 1.0.0 src/`geml.ts`/parse()."
136
+ // rust-analyzer "rust-analyzer cargo spike 0.1.0 util/multiply()."
137
+ // "rust-analyzer cargo spike 0.1.0 impl#[Widget]new()."
138
+ // "rust-analyzer cargo core https://… ops/arith/impl#[u32][`Mul<Self>`]mul()."
139
+ const isFuncSym = (s) => s.endsWith("().");
140
+ const isRustSym = (s) => s.startsWith("rust-analyzer ");
141
+ const langOf = (s) => (isRustSym(s) ? "rust" : "typescript");
142
+ // Term descriptor (`name.`): a const/property binding. scip-typescript gives
143
+ // `const Foo = () => …` — the dominant React component form — a TERM symbol,
144
+ // not a method one, so `().` alone would leave arrow components (and every
145
+ // `<Foo />` that renders them) out of the graph entirely. The discriminator
146
+ // is the definition's enclosing_range: scip-typescript emits it ONLY on
147
+ // function-like definitions (verified on the react fixture: `const Logo =
148
+ // () =>` carries one; object-literal consts, `createContext(...)` results and
149
+ // interface members carry none). Rust symbols are excluded — rust closures
150
+ // are locals and rust-analyzer's const semantics are unverified here.
151
+ const isTermSym = (s) => s.endsWith(".") && !s.endsWith("().");
152
+ const isArrowFnDef = (o) => isTermSym(o.symbol) && !isRustSym(o.symbol) && o.enclosing.length > 0;
153
+
154
+ // Descriptor tail: everything after the 4-token header. The version slot may
155
+ // be a URL (rust-analyzer sysroot crates) but never contains spaces; spaces
156
+ // inside descriptors only occur backtick-escaped, after the header.
157
+ const descriptorTail = (s) => {
158
+ let i = -1;
159
+ for (let n = 0; n < 4; n++) { i = s.indexOf(" ", i + 1); if (i < 0) return s; }
160
+ return s.slice(i + 1);
161
+ };
162
+
163
+ // Tokenize a SCIP descriptor suffix (backtick-escape aware; `` = literal `).
164
+ // kinds: ns "a/", type "T#", term "x.", meta "m:", macro "m!", method "f().",
165
+ // typeParam "[T]", param "(p)".
166
+ function parseDescriptors(d) {
167
+ const out = [];
168
+ let i = 0;
169
+ const readName = () => {
170
+ if (d[i] === "`") {
171
+ let s = ""; i++;
172
+ while (i < d.length) {
173
+ if (d[i] === "`") { if (d[i + 1] === "`") { s += "`"; i += 2; continue; } i++; break; }
174
+ s += d[i++];
175
+ }
176
+ return s;
177
+ }
178
+ const start = i;
179
+ while (i < d.length && /[A-Za-z0-9\-+$_]/.test(d[i])) i++;
180
+ return d.slice(start, i);
181
+ };
182
+ while (i < d.length) {
183
+ if (d[i] === "[") { i++; const name = readName(); if (d[i] === "]") i++; out.push({ kind: "typeParam", name }); continue; }
184
+ if (d[i] === "(") { i++; const name = readName(); if (d[i] === ")") i++; out.push({ kind: "param", name }); continue; }
185
+ const name = readName();
186
+ const c = d[i];
187
+ if (c === "(") { // method: name '(' disambiguator? ')' '.'
188
+ while (i < d.length && d[i] !== ")") i++;
189
+ i++; // ')'
190
+ if (d[i] === ".") i++;
191
+ out.push({ kind: "method", name });
192
+ continue;
193
+ }
194
+ i++; // the descriptor suffix char (or one malformed char — progress either way)
195
+ out.push({ kind: c === "/" ? "ns" : c === "#" ? "type" : c === "." ? "term" : c === ":" ? "meta" : c === "!" ? "macro" : "?", name });
196
+ }
197
+ return out;
198
+ }
199
+
200
+ // rust-analyzer display names: free functions keep their plain name (the
201
+ // module path lives in the file/container), members read Type::name. An
202
+ // `impl#` scope stands for an impl block — its SELF TYPE is the first
203
+ // [type-param] after it (`impl#[Widget]new().` → Widget::new; a trait impl
204
+ // carries the trait as a second bracket: `impl#[u32][`Mul<Self>`]mul().` →
205
+ // u32::mul).
206
+ const rustNameOf = (s) => {
207
+ const ds = parseDescriptors(descriptorTail(s));
208
+ let mi = -1;
209
+ for (let j = ds.length - 1; j >= 0; j--) if (ds[j].kind === "method") { mi = j; break; }
210
+ if (mi < 0 || !ds[mi].name) return ds.at(-1)?.name || s.split("/").pop() || s;
211
+ let owner;
212
+ for (let j = mi - 1; j >= 0; j--) {
213
+ const x = ds[j];
214
+ if (x.kind === "ns") break; // crossed into the module path — a free function
215
+ if (x.kind === "type") {
216
+ owner = x.name;
217
+ if (owner === "impl") owner = ds.slice(j + 1, mi).find((t) => t.kind === "typeParam")?.name;
218
+ break;
219
+ }
220
+ }
221
+ return owner ? `${owner}::${ds[mi].name}` : ds[mi].name;
222
+ };
223
+
224
+ // Exported for tests: pure string → display name across both grammars.
225
+ export const nameOf = (s) => {
226
+ if (isRustSym(s)) return rustNameOf(s);
227
+ // Class members read class-qualified (`RenderCtx.block`), constructors as
228
+ // `Cls.new` — free functions (no `Owner#` scope) keep their plain name.
229
+ if (/`?<constructor>`?\(\)\.$/.test(s)) {
230
+ const cm = /([A-Za-z0-9_$]+)#`?<constructor>`?\(\)\.$/.exec(s);
231
+ return cm ? `${cm[1]}.new` : "new";
232
+ }
233
+ const m = /(?:([A-Za-z0-9_$]+)#)?([^\/#.`]+)\(\)\.$/.exec(s);
234
+ if (m) return m[1] ? `${m[1]}.${m[2]}` : m[2];
235
+ // Term symbol (arrow-function component/const, class property arrow):
236
+ // `…/Logo.` → Logo, `…/A#onClick.` → A.onClick.
237
+ const t = /(?:([A-Za-z0-9_$]+)#)?([A-Za-z0-9_$]+)\.$/.exec(s);
238
+ if (t) return t[1] ? `${t[1]}.${t[2]}` : t[2];
239
+ return s.split("/").pop() ?? s;
240
+ };
241
+
242
+ // ---- SFC shadow remap (Vue/Svelte virtualization) ---------------------------
243
+ // When the index was produced over a virtual dir (codemap/sfc-virtualize.mjs),
244
+ // `remapDir` points at it. Occurrences in shadow files (src/App.vue.ts) are
245
+ // attributed back to the original .vue/.svelte path + line through the
246
+ // per-shadow map.json; occurrences that map nowhere are pure generated code
247
+ // and are DROPPED, never misattributed. Additive: without remapDir nothing
248
+ // in extract() changes.
249
+ //
250
+ // Two SFC-specific recoveries:
251
+ // 1. Generated wrappers (svelte2tsx $$render, Volar __VLS_*) are never
252
+ // symbols; a reference whose only enclosing definition is generated —
253
+ // or that sits at the shadow's top level, as Volar's template projection
254
+ // does — is attributed to a synthetic `<Component>.template` node when
255
+ // its mapped line lands in the template/markup region.
256
+ // 2. svelte2tsx puts the whole <script> inside $$render, so user functions
257
+ // are scip `local N` symbols (no display_name, no enclosing_range).
258
+ // Function-shaped locals are admitted as definitions: the name comes
259
+ // from the shadow text at the definition range, the span from a small
260
+ // brace scan. Non-function locals (params, lets) stay invisible.
261
+ const GENERATED_NAME = /^(\$\$|__VLS_|__sveltets)/;
262
+
263
+ export function loadSfcRemap(remapDir, root) {
264
+ let manifest;
265
+ try {
266
+ manifest = JSON.parse(readFileSync(join(remapDir, "sfc-manifest.json"), "utf8"));
267
+ } catch {
268
+ return null; // no manifest — treat as a plain index
269
+ }
270
+ const rootAbs = resolvePath(root);
271
+ const bySh = new Map();
272
+ for (const f of manifest.files ?? []) {
273
+ let side;
274
+ try { side = JSON.parse(readFileSync(join(remapDir, f.map), "utf8")); } catch { continue; }
275
+ const origAbs = resolvePath(manifest.src, f.original);
276
+ const rel = relative(rootAbs, origAbs).replace(/\\/g, "/");
277
+ bySh.set(f.shadow, {
278
+ original: rel.startsWith("..") ? f.original : rel,
279
+ component: side.component ?? f.original.split("/").pop(),
280
+ framework: side.framework,
281
+ regions: side.regions ?? [],
282
+ map: new Map(side.lines ?? []), // 1-based generated line -> original line
283
+ shadowAbs: join(remapDir, f.shadow),
284
+ _text: undefined,
285
+ });
286
+ }
287
+ return { bySh, dirAbs: resolvePath(remapDir), rootAbs };
288
+ }
289
+
290
+ const inRegion = (info, origLine) =>
291
+ info.regions.some((r) => origLine >= r.start && origLine <= r.end);
292
+
293
+ // Shadow text as lines + line-start offsets (lazy, cached per shadow).
294
+ function shadowText(info) {
295
+ if (!info._text) {
296
+ const raw = readFileSync(info.shadowAbs, "utf8");
297
+ info._text = { raw, lines: raw.split("\n") };
298
+ }
299
+ return info._text;
300
+ }
301
+
302
+ // End line of a local definition's body: from the definition name forward,
303
+ // the first `{` before any top-level `;` opens the body — match braces
304
+ // (skipping strings, template literals and comments) back to depth 0. An
305
+ // arrow with an expression body (no brace) stays single-line. Wrong guesses
306
+ // degrade attribution exactly like the adapter's documented degraded mode.
307
+ function braceSpanEnd(text, startOffset, startLine) {
308
+ const s = text.raw;
309
+ let i = startOffset, open = -1;
310
+ for (const cap = Math.min(s.length, startOffset + 400); i < cap; i++) {
311
+ const c = s[i];
312
+ if (c === "{") { open = i; break; }
313
+ if (c === ";") return startLine;
314
+ }
315
+ if (open < 0) return startLine;
316
+ let depth = 0, line = startLine;
317
+ for (i = open; i < s.length; i++) {
318
+ const c = s[i];
319
+ if (c === "\n") { line++; continue; }
320
+ if (c === '"' || c === "'" || c === "`") {
321
+ const q = c;
322
+ for (i++; i < s.length; i++) {
323
+ if (s[i] === "\\") { i++; continue; }
324
+ if (s[i] === "\n" && q !== "`") break;
325
+ if (s[i] === "\n") line++;
326
+ if (s[i] === q) break;
327
+ }
328
+ continue;
329
+ }
330
+ if (c === "/" && s[i + 1] === "/") { while (i < s.length && s[i] !== "\n") i++; i--; continue; }
331
+ if (c === "/" && s[i + 1] === "*") {
332
+ for (i += 2; i < s.length; i++) { if (s[i] === "\n") line++; if (s[i] === "*" && s[i + 1] === "/") { i++; break; } }
333
+ continue;
334
+ }
335
+ if (c === "{") depth++;
336
+ else if (c === "}") { depth--; if (depth === 0) return line; }
337
+ }
338
+ return line;
339
+ }
340
+
341
+ // Admit a `local N` definition occurrence when the shadow text says it is a
342
+ // function. Returns { name, encl: [startLine0, endLine0] } or null.
343
+ function localFnAt(info, range) {
344
+ const text = shadowText(info);
345
+ const l0 = range[0], cs = range[1], ce = range.length === 3 ? range[2] : range[3];
346
+ const lineText = text.lines[l0] ?? "";
347
+ const name = lineText.slice(cs, ce);
348
+ if (!/^[A-Za-z_$][\w$]*$/.test(name) || GENERATED_NAME.test(name)) return null;
349
+ const before = lineText.slice(0, cs), after = lineText.slice(ce);
350
+ const isFn = /\bfunction\s*\*?\s*$/.test(before)
351
+ || /^\s*=\s*(async\s*)?\(/.test(after)
352
+ || /^\s*=\s*(async\s*)?function\b/.test(after)
353
+ || /^\s*=\s*(async\s+)?[A-Za-z_$][\w$]*\s*=>/.test(after);
354
+ if (!isFn) return null;
355
+ let off = 0;
356
+ for (let i = 0; i < l0; i++) off += text.lines[i].length + 1;
357
+ return { name, encl: [l0, braceSpanEnd(text, off + ce, l0)] };
358
+ }
359
+
360
+ export function extract({ raw: scipPath, root, remapDir }) {
361
+ const { docs, projectRoot } = parseScip(scipPath);
362
+ // scip-typescript emits OS-native separators in relative_path on Windows;
363
+ // the codemap profile is posix throughout.
364
+ for (const d of docs) d.path = d.path.replace(/\\/g, "/");
365
+ const sfc = remapDir ? loadSfcRemap(remapDir, root) : null;
366
+ if (sfc) {
367
+ // Virtual-dir index: shadow docs keep their raw path for now (it keys the
368
+ // map lookup; the final passes swap in the original .vue/.svelte path).
369
+ // Real project files arrive ../-relative to the virtual dir — anchor them
370
+ // repo-relative. Anything else living INSIDE the virtual dir (svelte
371
+ // shims, global type stubs) is indexing scaffolding, not source: dropped.
372
+ for (const d of docs) {
373
+ const info = sfc.bySh.get(d.path);
374
+ if (info) { d.sfc = info; continue; }
375
+ const abs = resolvePath(sfc.dirAbs, d.path);
376
+ if (!relative(sfc.dirAbs, abs).replace(/\\/g, "/").startsWith("..")) { d.drop = true; continue; }
377
+ const repoRel = relative(sfc.rootAbs, abs).replace(/\\/g, "/");
378
+ if (!repoRel.startsWith("..")) d.path = repoRel;
379
+ }
380
+ } else if (projectRoot && root) {
381
+ // Document paths are relative to the INDEXED project (metadata.project_root),
382
+ // which may be a subdirectory of the codemap's --root. Re-anchor them so a
383
+ // multi-language merge keeps one coherent repo-relative path space.
384
+ // file:// URL -> plain path: after dropping the scheme, a unix path KEEPS
385
+ // its leading slash (file:///tmp/x -> /tmp/x); only a Windows drive path
386
+ // drops it (file:///C:/x -> C:/x).
387
+ const stripUrl = (p) => p.replace(/^file:\/\//, "").replace(/^\/([A-Za-z]:)/, "$1").replace(/\\/g, "/").replace(/\/+$/, "");
388
+ const rootP = stripUrl(resolvePath(root));
389
+ // project_root is untrusted bytes from the .scip — a bad %xx escape would
390
+ // make decodeURIComponent throw and abort the build; fall back to the raw.
391
+ let projP;
392
+ try { projP = stripUrl(decodeURIComponent(projectRoot)); } catch { projP = stripUrl(projectRoot); }
393
+ if (projP.toLowerCase() !== rootP.toLowerCase() && projP.toLowerCase().startsWith(rootP.toLowerCase() + "/")) {
394
+ const prefix = projP.slice(rootP.length + 1);
395
+ for (const d of docs) d.path = `${prefix}/${d.path}`;
396
+ }
397
+ }
398
+
399
+ // L5: once every path is in its final (posix, re-anchored) form, confine each
400
+ // document to the project root — drop any whose relative_path escapes it, so
401
+ // a crafted "../../../etc/passwd" or an absolute/drive/UNC path is neither
402
+ // read from disk (source recovery) nor attributed into the graph. Gated on a
403
+ // provided root: with no root there is no boundary to confine against (the
404
+ // standalone/degraded call path). SFC shadow docs are keyed in-root by
405
+ // construction and their scaffolding is already dropped above.
406
+ if (root != null) {
407
+ const rootAbs = resolvePath(root);
408
+ let escaped = 0;
409
+ for (const d of docs) {
410
+ if (d.drop || d.sfc) continue;
411
+ if (escapesRoot(rootAbs, d.path)) { d.drop = true; escaped++; }
412
+ }
413
+ if (escaped) console.error(`scip adapter: skipped ${escaped} document(s) whose relative_path resolves outside the project root`);
414
+ }
415
+
416
+ // range = [startLine, startChar, endLine(, endChar)] (0-based); normalize.
417
+ const spanOf = (r) => (r.length === 3 ? [r[0], r[0]] : [r[0], r[2]]);
418
+
419
+ // scip `local N` symbols are per-document — namespace their def/ref key by
420
+ // the document so two shadows' locals never collide. Non-local symbols keep
421
+ // the raw symbol string as their key (and anchor).
422
+ const localKey = (d, sym) => `local:${d.path}#${sym.slice("local ".length)}`;
423
+
424
+ // 1. definitions of function symbols
425
+ const defs = new Map(); // key -> {file, name, line_start, line_end, encl:[sl,el]}
426
+ for (const d of docs) {
427
+ if (d.drop) continue;
428
+ for (const o of d.occ) {
429
+ if (!(o.roles & ROLE_DEFINITION)) continue;
430
+ // Merged admission: method symbols AND arrow-function terms (react branch:
431
+ // a term definition carrying an enclosing_range is function-like) take the
432
+ // standard path; SFC shadow-doc locals (svelte2tsx wraps the <script> in
433
+ // $$render, so every user function is a local) take the sfc branch.
434
+ if (isFuncSym(o.symbol) || isArrowFnDef(o)) {
435
+ const [nl] = spanOf(o.range);
436
+ const encl = o.enclosing.length ? spanOf(o.enclosing) : [nl, nl];
437
+ const prev = defs.get(o.symbol);
438
+ // keep the widest definition (impl over overload signatures)
439
+ if (!prev || encl[1] - encl[0] > prev.encl[1] - prev.encl[0]) {
440
+ defs.set(o.symbol, { file: d.path, name: nameOf(o.symbol), line_start: encl[0] + 1, line_end: encl[1] + 1, encl });
441
+ }
442
+ } else if (d.sfc && o.symbol.startsWith("local ")) {
443
+ const lf = localFnAt(d.sfc, o.range);
444
+ if (lf) {
445
+ defs.set(localKey(d, o.symbol), {
446
+ file: d.path, name: lf.name,
447
+ line_start: lf.encl[0] + 1, line_end: lf.encl[1] + 1, encl: lf.encl,
448
+ });
449
+ }
450
+ }
451
+ }
452
+ }
453
+ // ---- macro-erased definitions: recover them from the SOURCE ----
454
+ // An item-rewriting proc macro (workers-rs #[event], #[tokio::main], …) can
455
+ // swallow a Rust function wholesale: the rewritten symbol never gets an
456
+ // occurrence (not even the fn's name token), so the function AND every call
457
+ // it makes vanish from the graph — while the body's call references survive,
458
+ // orphaned outside every admitted enclosing range. rust-analyzer leaves no
459
+ // structured record of the rewritten item, so the one honest witness left is
460
+ // the source file itself: inside each orphan region, the `fn <name>(`
461
+ // signatures are exactly the functions the macro consumed. Synthesize a
462
+ // definition per signature (named from the source, so `async fn main` comes
463
+ // back as `main`), attribute the region's calls to it, and label the rows
464
+ // resolution:"heuristic" — reconstructed, not indexer-read. No signature
465
+ // found -> refuse; never guess.
466
+ const RUST_FN_SIG = /^\s*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?(?:unsafe\s+)?(?:extern\s+"[^"]*"\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)/;
467
+ for (const d of docs) {
468
+ if (d.drop || d.sfc) continue; // shadow docs have their own admission rules
469
+ const admitted = [...defs.values()].filter((v) => v.file === d.path)
470
+ .map((v) => v.encl).sort((a, b) => a[0] - b[0]);
471
+ const covered = (line) => admitted.some((e) => line >= e[0] && line <= e[1]);
472
+ // Orphan evidence: RUST call references (not defs, not imports) on lines
473
+ // no admitted definition encloses. scip-typescript rewrites nothing, so
474
+ // the recovery is scoped to rust-analyzer symbols.
475
+ const orphan = [];
476
+ for (const o of d.occ) {
477
+ if (o.roles & (ROLE_DEFINITION | ROLE_IMPORT)) continue;
478
+ if (!isFuncSym(o.symbol) || !isRustSym(o.symbol)) continue;
479
+ const [line] = spanOf(o.range);
480
+ if (!covered(line)) orphan.push(line);
481
+ }
482
+ if (!orphan.length) continue;
483
+ orphan.sort((a, b) => a - b);
484
+ let srcLines;
485
+ try { srcLines = readFileSync(resolvePath(root ?? ".", d.path), "utf8").split(/\r?\n/); } catch { continue; }
486
+ // Maximal regions: orphan lines belong together until an admitted range
487
+ // intervenes; each region reaches back to the previous admitted range's
488
+ // end, covering the attribute + signature lines the macro consumed.
489
+ const regions = [];
490
+ for (const line of orphan) {
491
+ const cur = regions[regions.length - 1];
492
+ if (cur && !admitted.some((e) => e[0] > cur.end && e[1] < line)) cur.end = line;
493
+ else {
494
+ const prevEnd = admitted.filter((e) => e[1] < line).map((e) => e[1]).pop();
495
+ regions.push({ start: prevEnd !== undefined ? prevEnd + 1 : 0, end: line });
496
+ }
497
+ }
498
+ for (const r of regions) {
499
+ // Signatures inside the region, in order; calls between two signatures
500
+ // belong to the earlier one.
501
+ const sigs = [];
502
+ for (let ln = r.start; ln <= r.end && ln < srcLines.length; ln++) {
503
+ const m = RUST_FN_SIG.exec(srcLines[ln]);
504
+ if (m) sigs.push({ line: ln, name: m[1] });
505
+ }
506
+ if (!sigs.length) continue; // no witness in the source — refuse
507
+ sigs.forEach((sig, i) => {
508
+ const end = i + 1 < sigs.length ? sigs[i + 1].line - 1 : r.end;
509
+ const sym = `heuristic:${d.path}#${sig.name}@L${sig.line + 1}`;
510
+ defs.set(sym, {
511
+ file: d.path, name: sig.name,
512
+ line_start: sig.line + 1, line_end: end + 1, encl: [sig.line, end],
513
+ synth: true,
514
+ });
515
+ });
516
+ }
517
+ }
518
+
519
+ const enclosingDegraded = [...defs.values()].every((v) => v.encl[0] === v.encl[1]);
520
+
521
+ // implementations map: interface/abstract member symbol -> implementing symbols
522
+ const implOf = new Map();
523
+ for (const d of docs) {
524
+ for (const { sym, impl } of d.rel) {
525
+ for (const target of impl) {
526
+ if (!implOf.has(target)) implOf.set(target, []);
527
+ implOf.get(target).push(sym);
528
+ }
529
+ }
530
+ }
531
+
532
+ const symbols = [];
533
+ const seenFiles = new Set();
534
+ const addFileSym = (file, lang) => {
535
+ if (seenFiles.has(file)) return;
536
+ seenFiles.add(file);
537
+ symbols.push({ anchor: `file:${file}`, lang, kind: "File", name: file.split("/").pop(), file, resolution: "cpg" });
538
+ };
539
+ // Definitions that survive into `symbols` — in remap mode the edge pass
540
+ // must not emit edges to/from a definition that was dropped as generated.
541
+ const survivors = new Set();
542
+ for (const [sym, v] of defs) {
543
+ // lang follows the producing indexer's symbol scheme, so a merged
544
+ // TS + Rust codemap keeps each definition honestly labelled.
545
+ const lang = langOf(sym);
546
+ let file = v.file, line_start = v.line_start, line_end = v.line_end;
547
+ const info = sfc?.bySh.get(v.file);
548
+ if (info) {
549
+ // generated wrappers/helpers never become symbols; a definition whose
550
+ // line maps nowhere in the original is pure generated code — dropped.
551
+ if (GENERATED_NAME.test(v.name)) continue;
552
+ const ls = info.map.get(v.line_start);
553
+ if (ls === undefined) continue;
554
+ file = info.original;
555
+ line_start = ls;
556
+ line_end = Math.max(ls, info.map.get(v.line_end) ?? ls);
557
+ }
558
+ survivors.add(sym);
559
+ symbols.push({
560
+ anchor: sym, lang, kind: "Function", name: v.name,
561
+ file, line_start, line_end,
562
+ entry: v.name === "main" ? true : undefined,
563
+ // A macro-erased definition is reconstructed from orphan evidence, not
564
+ // read from an occurrence — say so.
565
+ resolution: v.synth ? "heuristic" : "cpg",
566
+ });
567
+ addFileSym(file, lang);
568
+ }
569
+
570
+ // 2. calls: reference occurrences of function symbols, attributed to the
571
+ // innermost containing definition of the same file.
572
+ const perFileDefs = new Map(); // file -> defs sorted by span size asc
573
+ for (const [sym, v] of defs) {
574
+ if (!perFileDefs.has(v.file)) perFileDefs.set(v.file, []);
575
+ perFileDefs.get(v.file).push({ sym, ...v });
576
+ }
577
+ for (const list of perFileDefs.values()) list.sort((a, b) => (a.encl[1] - a.encl[0]) - (b.encl[1] - b.encl[0]));
578
+ const callerAt = (file, line) => {
579
+ const list = perFileDefs.get(file);
580
+ if (!list) return undefined;
581
+ if (!enclosingDegraded) {
582
+ for (const d of list) if (line >= d.encl[0] && line <= d.encl[1]) return d.sym; // innermost first (sorted asc)
583
+ return undefined;
584
+ }
585
+ // degraded: nearest preceding definition start
586
+ let best;
587
+ for (const d of list) if (d.encl[0] <= line && (!best || d.encl[0] > best.encl[0])) best = d;
588
+ return best?.sym;
589
+ };
590
+
591
+ const edges = [];
592
+ const usedRegions = new Map(); // shadow doc path -> info (template node demanded)
593
+ for (const d of docs) {
594
+ if (d.drop) continue;
595
+ for (const o of d.occ) {
596
+ if (o.roles & ROLE_DEFINITION) continue;
597
+ // Callable reference admission, merged: method symbols always count
598
+ // (resolved or to_text); TERM symbols only when they resolve to an
599
+ // admitted arrow-function definition — otherwise every property READ
600
+ // would become a phantom call; shadow-doc locals via their per-document
601
+ // key. Everything else is not a call.
602
+ let symKey;
603
+ if (isFuncSym(o.symbol)) symKey = o.symbol;
604
+ else if (defs.has(o.symbol)) symKey = o.symbol; // term ref -> admitted arrow-fn def
605
+ else if (d.sfc && o.symbol.startsWith("local ") && defs.has(localKey(d, o.symbol))) symKey = localKey(d, o.symbol);
606
+ else continue;
607
+ const [line] = spanOf(o.range);
608
+ let from = callerAt(d.path, line);
609
+ let site = { file: d.path, line: line + 1 };
610
+ if (d.sfc) {
611
+ const origLine = d.sfc.map.get(line + 1);
612
+ if (!from || !survivors.has(from)) {
613
+ // top-level (Volar's template projection) or generated-only caller
614
+ // ($$render): the reference belongs to the component's template
615
+ // when its mapped line lands there — otherwise drop, never guess.
616
+ if (origLine === undefined || !inRegion(d.sfc, origLine)) continue;
617
+ from = `sfc:${d.sfc.original}#template`;
618
+ usedRegions.set(d.path, d.sfc);
619
+ }
620
+ if (origLine === undefined) continue; // generated echo of a real reference
621
+ site = { file: d.sfc.original, line: origLine };
622
+ }
623
+ if (!from || from === symKey) continue;
624
+ if (defs.has(symKey)) {
625
+ if (sfc && !survivors.has(symKey)) continue; // callee was dropped as generated
626
+ const impls = (implOf.get(symKey) ?? []).filter((s) => defs.has(s) && (!sfc || survivors.has(s)));
627
+ if (impls.length) {
628
+ edges.push({ kind: "calls", from, to: symKey, resolution: "cpg", confidence: "medium", note: `dispatch, ${impls.length + 1} candidates`, candidates: [...new Set(impls)].sort(), site });
629
+ } else {
630
+ edges.push({ kind: "calls", from, to: symKey, resolution: "cpg", confidence: "high", site });
631
+ }
632
+ } else {
633
+ const toText = nameOf(o.symbol);
634
+ // shadow docs call generated helpers (__VLS_asFunctionalElement, …)
635
+ // once per template element — machinery, not user blind spots.
636
+ if (d.sfc && GENERATED_NAME.test(toText)) continue;
637
+ edges.push({ kind: "calls", from, to_text: toText, resolution: "cpg", confidence: "low", site });
638
+ }
639
+ }
640
+ }
641
+ // Synthetic template nodes — one per SFC whose template/markup gained an
642
+ // edge: the honest caller for `@click="save"` / `on:click={bump}`.
643
+ for (const info of usedRegions.values()) {
644
+ const starts = info.regions.map((r) => r.start), ends = info.regions.map((r) => r.end);
645
+ symbols.push({
646
+ anchor: `sfc:${info.original}#template`, lang: "typescript", kind: "Function",
647
+ name: `${info.component}.template`, file: info.original,
648
+ line_start: Math.min(...starts), line_end: Math.max(...ends),
649
+ resolution: "cpg",
650
+ });
651
+ addFileSym(info.original, "typescript");
652
+ }
653
+
654
+ if (enclosingDegraded) {
655
+ console.error("scip adapter: no enclosing_range in this index — caller attribution degraded to nearest-preceding definition");
656
+ }
657
+ return { symbols, edges };
658
+ }