@geml/geml 1.4.2 → 1.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +186 -155
- package/codemap/adapters/crg.mjs +120 -120
- package/codemap/adapters/joern.mjs +131 -131
- package/codemap/adapters/scip.mjs +658 -658
- package/codemap/browser-stub.mjs +29 -29
- package/codemap/build.mjs +609 -609
- package/codemap/cross-stack.mjs +303 -303
- package/codemap/detect.mjs +399 -399
- package/codemap/emit.mjs +480 -480
- package/codemap/entries.mjs +129 -129
- package/codemap/exclude.mjs +52 -52
- package/codemap/find.mjs +63 -63
- package/codemap/foldings.mjs +110 -110
- package/codemap/joern-export.sc +83 -83
- package/codemap/mcp-server.mjs +172 -172
- package/codemap/normalize.mjs +275 -275
- package/codemap/recipe-trust.mjs +103 -103
- package/codemap/refresh.mjs +310 -310
- package/codemap/render-all.mjs +64 -64
- package/codemap/serve.mjs +578 -578
- package/codemap/sfc-virtualize.mjs +367 -367
- package/codemap/verify.mjs +148 -148
- package/dist/chart.d.ts +2 -0
- package/dist/chart.js +15 -15
- package/dist/diagnostics.d.ts +9 -0
- package/dist/diagnostics.js +73 -0
- package/dist/geml.d.ts +2 -5
- package/dist/geml.js +191 -98
- package/dist/history.d.ts +10 -0
- package/dist/history.js +25 -24
- package/dist/inline.js +7 -7
- package/dist/mcp.d.ts +18 -0
- package/dist/mcp.js +528 -0
- package/dist/render.js +136 -136
- package/dist/table.d.ts +2 -0
- package/dist/table.js +11 -11
- package/package.json +62 -62
|
@@ -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
|
+
}
|