@geml/geml 1.1.1 → 1.3.2

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.
@@ -7,41 +7,67 @@
7
7
  // implementations becomes medium + candidates; references to symbols not
8
8
  // defined in the project become to_text (unresolved, low).
9
9
  //
10
- // Produce the index with scip-typescript (TS/JS):
10
+ // Produce the index with scip-typescript (TS/JS) or rust-analyzer (Rust):
11
11
  // npx --yes @sourcegraph/scip-typescript index --output index.scip
12
+ // rust-analyzer scip . --output rust.scip
12
13
  //
13
14
  // Caller attribution: a reference occurrence belongs to the innermost function
14
- // DEFINITION whose enclosing_range contains it. scip-typescript emits
15
- // enclosing_range on definition occurrences; if absent we fall back to "the
16
- // nearest preceding definition in the file" and mark the adapter degraded.
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.
17
19
  import { readFileSync } from "node:fs";
18
- import { resolve as resolvePath } from "node:path";
20
+ import { resolve as resolvePath, join, relative, isAbsolute } from "node:path";
19
21
 
20
22
  // ---- minimal protobuf wire reader ------------------------------------------
21
- function varint(buf, p) {
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) {
22
30
  let x = 0n, s = 0n, b;
23
- do { b = buf[p.i++]; x |= BigInt(b & 0x7f) << s; s += 7n; } while (b & 0x80);
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);
24
38
  return x;
25
39
  }
26
40
  // Iterate fields of one message region [start, end): yields {no, wt, val|sub}.
27
41
  function* fields(buf, start, end) {
28
- const p = { i: start };
42
+ const p = { i: start, bad: false };
29
43
  while (p.i < end) {
30
- const key = Number(varint(buf, p));
44
+ const key = Number(varint(buf, p, end));
45
+ if (p.bad) return;
31
46
  const no = key >> 3, wt = key & 7;
32
- if (wt === 0) yield { no, wt, val: varint(buf, p) };
33
- else if (wt === 1) { yield { no, wt, val: buf.readBigUInt64LE(p.i) }; p.i += 8; }
34
- else if (wt === 2) { const len = Number(varint(buf, p)); yield { no, wt, a: p.i, b: p.i + len }; p.i += len; }
35
- else if (wt === 5) { yield { no, wt, val: BigInt(buf.readUInt32LE(p.i)) }; p.i += 4; }
36
- else throw new Error(`scip: unsupported wire type ${wt}`);
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
37
52
  }
38
53
  }
39
54
  const str = (buf, f) => buf.toString("utf8", f.a, f.b);
40
55
  // repeated int32, packed (len-delimited varints) or single varint value
41
56
  function packedInts(buf, f, out) {
42
57
  if (f.wt === 0) { out.push(Number(f.val)); return; }
43
- const p = { i: f.a };
44
- while (p.i < f.b) out.push(Number(varint(buf, p)));
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);
45
71
  }
46
72
 
47
73
  // ---- SCIP field numbers (scip.proto) ---------------------------------------
@@ -51,6 +77,7 @@ function packedInts(buf, f, out) {
51
77
  // SymbolInformation: symbol=1, relationships=4, display_name=6
52
78
  // Relationship: symbol=1, is_implementation=3
53
79
  const ROLE_DEFINITION = 0x1;
80
+ const ROLE_IMPORT = 0x2;
54
81
 
55
82
  function parseScip(path) {
56
83
  const buf = readFileSync(path);
@@ -103,9 +130,100 @@ function parseScip(path) {
103
130
  }
104
131
 
105
132
  // ---- SCIP symbol grammar helpers -------------------------------------------
106
- // e.g. "scip-typescript npm @geml/geml 1.0.0 src/`geml.ts`/parse()."
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()."
107
139
  const isFuncSym = (s) => s.endsWith("().");
108
- const nameOf = (s) => {
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);
109
227
  // Class members read class-qualified (`RenderCtx.block`), constructors as
110
228
  // `Cls.new` — free functions (no `Owner#` scope) keep their plain name.
111
229
  if (/`?<constructor>`?\(\)\.$/.test(s)) {
@@ -114,44 +232,290 @@ const nameOf = (s) => {
114
232
  }
115
233
  const m = /(?:([A-Za-z0-9_$]+)#)?([^\/#.`]+)\(\)\.$/.exec(s);
116
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];
117
239
  return s.split("/").pop() ?? s;
118
240
  };
119
241
 
120
- export function extract({ raw: scipPath, root }) {
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 }) {
121
361
  const { docs, projectRoot } = parseScip(scipPath);
122
362
  // scip-typescript emits OS-native separators in relative_path on Windows;
123
363
  // the codemap profile is posix throughout.
124
364
  for (const d of docs) d.path = d.path.replace(/\\/g, "/");
125
- // Document paths are relative to the INDEXED project (metadata.project_root),
126
- // which may be a subdirectory of the codemap's --root. Re-anchor them so a
127
- // multi-language merge keeps one coherent repo-relative path space.
128
- if (projectRoot && root) {
129
- const norm = (p) => p.replace(/^file:\/\/\/?/, "").replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
130
- const rootN = norm(resolvePath(root));
131
- const projN = norm(decodeURIComponent(projectRoot));
132
- if (projN !== rootN && projN.startsWith(rootN + "/")) {
133
- const prefix = decodeURIComponent(projectRoot).replace(/^file:\/\/\/?/, "").replace(/\\/g, "/").replace(/\/+$/, "").slice(rootN.length + 1);
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);
134
395
  for (const d of docs) d.path = `${prefix}/${d.path}`;
135
396
  }
136
397
  }
137
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
+
138
416
  // range = [startLine, startChar, endLine(, endChar)] (0-based); normalize.
139
417
  const spanOf = (r) => (r.length === 3 ? [r[0], r[0]] : [r[0], r[2]]);
140
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
+
141
424
  // 1. definitions of function symbols
142
- const defs = new Map(); // symbol -> {file, name, line_start, line_end, encl:[sl,el]}
425
+ const defs = new Map(); // key -> {file, name, line_start, line_end, encl:[sl,el]}
143
426
  for (const d of docs) {
427
+ if (d.drop) continue;
144
428
  for (const o of d.occ) {
145
- if (!(o.roles & ROLE_DEFINITION) || !isFuncSym(o.symbol)) continue;
146
- const [nl] = spanOf(o.range);
147
- const encl = o.enclosing.length ? spanOf(o.enclosing) : [nl, nl];
148
- const prev = defs.get(o.symbol);
149
- // keep the widest definition (impl over overload signatures)
150
- if (!prev || encl[1] - encl[0] > prev.encl[1] - prev.encl[0]) {
151
- defs.set(o.symbol, { file: d.path, name: nameOf(o.symbol), line_start: encl[0] + 1, line_end: encl[1] + 1, encl });
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
+ }
152
450
  }
153
451
  }
154
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
+
155
519
  const enclosingDegraded = [...defs.values()].every((v) => v.encl[0] === v.encl[1]);
156
520
 
157
521
  // implementations map: interface/abstract member symbol -> implementing symbols
@@ -167,17 +531,40 @@ export function extract({ raw: scipPath, root }) {
167
531
 
168
532
  const symbols = [];
169
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();
170
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);
171
559
  symbols.push({
172
- anchor: sym, lang: "typescript", kind: "Function", name: v.name,
173
- file: v.file, line_start: v.line_start, line_end: v.line_end,
560
+ anchor: sym, lang, kind: "Function", name: v.name,
561
+ file, line_start, line_end,
174
562
  entry: v.name === "main" ? true : undefined,
175
- resolution: "cpg",
563
+ // A macro-erased definition is reconstructed from orphan evidence, not
564
+ // read from an occurrence — say so.
565
+ resolution: v.synth ? "heuristic" : "cpg",
176
566
  });
177
- if (!seenFiles.has(v.file)) {
178
- seenFiles.add(v.file);
179
- symbols.push({ anchor: `file:${v.file}`, lang: "typescript", kind: "File", name: v.file.split("/").pop(), file: v.file, resolution: "cpg" });
180
- }
567
+ addFileSym(file, lang);
181
568
  }
182
569
 
183
570
  // 2. calls: reference occurrences of function symbols, attributed to the
@@ -202,25 +589,67 @@ export function extract({ raw: scipPath, root }) {
202
589
  };
203
590
 
204
591
  const edges = [];
592
+ const usedRegions = new Map(); // shadow doc path -> info (template node demanded)
205
593
  for (const d of docs) {
594
+ if (d.drop) continue;
206
595
  for (const o of d.occ) {
207
- if ((o.roles & ROLE_DEFINITION) || !isFuncSym(o.symbol)) continue;
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;
208
607
  const [line] = spanOf(o.range);
209
- const from = callerAt(d.path, line);
210
- if (!from || from === o.symbol) continue;
211
- const site = { file: d.path, line: line + 1 };
212
- if (defs.has(o.symbol)) {
213
- const impls = (implOf.get(o.symbol) ?? []).filter((s) => defs.has(s));
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)));
214
627
  if (impls.length) {
215
- edges.push({ kind: "calls", from, to: o.symbol, resolution: "cpg", confidence: "medium", note: `dispatch, ${impls.length + 1} candidates`, candidates: [...new Set(impls)].sort(), site });
628
+ edges.push({ kind: "calls", from, to: symKey, resolution: "cpg", confidence: "medium", note: `dispatch, ${impls.length + 1} candidates`, candidates: [...new Set(impls)].sort(), site });
216
629
  } else {
217
- edges.push({ kind: "calls", from, to: o.symbol, resolution: "cpg", confidence: "high", site });
630
+ edges.push({ kind: "calls", from, to: symKey, resolution: "cpg", confidence: "high", site });
218
631
  }
219
632
  } else {
220
- edges.push({ kind: "calls", from, to_text: nameOf(o.symbol), resolution: "cpg", confidence: "low", site });
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 });
221
638
  }
222
639
  }
223
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
+ }
224
653
 
225
654
  if (enclosingDegraded) {
226
655
  console.error("scip adapter: no enclosing_range in this index — caller attribution degraded to nearest-preceding definition");
@@ -10,10 +10,15 @@
10
10
  export const readFileSync = () => "";
11
11
  export const writeFileSync = () => {};
12
12
  export const existsSync = () => false;
13
+ export const realpathSync = (p) => p;
14
+ export const statSync = () => ({ isDirectory: () => false });
13
15
  export const basename = (p) => p;
14
16
  export const dirname = (p) => p;
15
17
  export const resolve = (...p) => p.join("/");
16
18
  export const join = (...p) => p.join("/");
19
+ export const isAbsolute = () => false;
20
+ export const relative = (_from, to) => to;
21
+ export const sep = "/";
17
22
  export const fileURLToPath = (u) => String(u);
18
23
  export const spawnSync = () => ({ status: 1 });
19
24
  export const createHash = () => ({