@geml/geml 1.8.2 → 1.8.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.
@@ -1,399 +1,399 @@
1
- // geml-code-graph language auto-detection — the `geml codemap build --root <dir>`
2
- // "fastest onboarding" path: no --adapter, no --db, no --lang, one command.
3
- //
4
- // detectLanguages walks the project tree and decides, per language, which
5
- // indexer runs and — for Joern — which frontend (GEML_LANG). It mirrors the
6
- // geml-code-graph skill's detection table: MANIFESTS first (a pom.xml means
7
- // Java even if generated .js outnumber .java), THEN source-file EXTENSIONS.
8
- // A repo can yield several jobs (TypeScript via SCIP + Java via Joern);
9
- // build.mjs runs each and merges every extraction into ONE codemap.
10
- //
11
- // Pure by design: given a precomputed { files, manifests } it touches no
12
- // filesystem, so it unit-tests without any indexer installed. indexerCommand
13
- // turns one job into the argv/env/raw a subprocess (and the refresh recipe)
14
- // needs — also pure. Neither ever spawns a process.
15
-
16
- import { readdirSync, readFileSync } from "node:fs";
17
- import { join, relative } from "node:path";
18
- import { globToRegExp } from "./exclude.mjs";
19
-
20
- // Directories that never hold first-party source: pruned during the walk so a
21
- // vendored dependency tree or build output can't swing the extension counts
22
- // (and drag a whole Joern frontend into the build). Mirrors normalize.mjs
23
- // SKIP_DIRS plus the codemap's own output dir.
24
- export const SKIP_DIRS = new Set([
25
- "node_modules", "target", "dist", "out", "build", ".git", "vendor",
26
- ".geml-code-graph", ".geml-build", ".idea", ".gradle",
27
- ]);
28
-
29
- // Manifest filename -> the language it declares. Presence is a STRONG signal
30
- // (no threshold): it fires even for a single source file, and takes priority
31
- // over the extension count.
32
- const MANIFEST_LANG = {
33
- "tsconfig.json": "TypeScript",
34
- "Cargo.toml": "Rust",
35
- "pom.xml": "Java",
36
- "build.gradle": "Java",
37
- "build.gradle.kts": "Java",
38
- "go.mod": "Go",
39
- };
40
-
41
- // Source extension -> language. scip-typescript indexes JS as well as TS, so
42
- // .js/.jsx map to the same TypeScript/scip job. .vue/.svelte SFCs also belong
43
- // to the TypeScript family: their group's job carries an `sfc` flag and the
44
- // build virtualizes them (codemap/sfc-virtualize.mjs) before scip runs.
45
- const EXT_LANG = {
46
- ts: "TypeScript", tsx: "TypeScript", js: "TypeScript", jsx: "TypeScript",
47
- vue: "TypeScript", svelte: "TypeScript",
48
- rs: "Rust",
49
- java: "Java",
50
- c: "C", h: "C",
51
- py: "Python",
52
- go: "Go",
53
- kt: "Kotlin",
54
- };
55
-
56
- // A path counts as "source" if its extension maps to a language we index. Used
57
- // by `refresh` to skip a rebuild when a commit touched only docs/config/CI —
58
- // files that can't change the call graph.
59
- export const isSourcePath = (p) => {
60
- const dot = p.lastIndexOf(".");
61
- return dot >= 0 && EXT_LANG[p.slice(dot + 1).toLowerCase()] !== undefined;
62
- };
63
-
64
- // Language -> indexer + Joern frontend. scip covers TypeScript/JS (via
65
- // scip-typescript) and Rust (via rust-analyzer — the SCIP adapter reads both
66
- // symbol grammars); everything else is a Joern frontend whose --language name
67
- // (UPPERCASE) we pass as GEML_LANG so a mixed repo never falls back to
68
- // Joern's majority-language autodetect.
69
- export const LANG_JOB = {
70
- TypeScript: { indexer: "scip", gemlLang: undefined },
71
- Rust: { indexer: "scip", gemlLang: undefined },
72
- Java: { indexer: "joern", gemlLang: "JAVASRC" },
73
- C: { indexer: "joern", gemlLang: "NEWC" },
74
- Python: { indexer: "joern", gemlLang: "PYTHONSRC" },
75
- Go: { indexer: "joern", gemlLang: "GO" },
76
- Kotlin: { indexer: "joern", gemlLang: "KOTLIN" },
77
- };
78
-
79
- // A language detected ONLY by file extension (no manifest) must clear a small
80
- // presence bar, so a stray helper script (one .py in a big TS repo) can't drag
81
- // a whole Joern frontend into the build. Manifests bypass the bar entirely.
82
- export const MIN_EXT_SHARE = 0.05;
83
-
84
- // Representative extension for a language (for the human-readable plan signal).
85
- const extOf = (lang) => Object.keys(EXT_LANG).find((e) => EXT_LANG[e] === lang);
86
-
87
- // Walk `root`, returning repo-relative POSIX source files and manifest files.
88
- // SKIP_DIRS and dotdirs are pruned structurally (a gitignored path is dropped
89
- // later by the caller's excluder). `readdir` is injectable for tests.
90
- export function collectSourceFiles(root, { readdir = readdirSync } = {}) {
91
- const files = []; // repo-relative POSIX source files
92
- const manifests = []; // repo-relative POSIX manifest files
93
- const pkgs = []; // repo-relative POSIX package.json files (TS/JS project roots)
94
- const walk = (dir) => {
95
- let ents;
96
- try { ents = readdir(dir, { withFileTypes: true }); } catch { return; }
97
- for (const e of ents) {
98
- if (e.isDirectory()) {
99
- if (!SKIP_DIRS.has(e.name) && !e.name.startsWith(".")) walk(join(dir, e.name));
100
- continue;
101
- }
102
- if (!e.isFile()) continue;
103
- const rel = relative(root, join(dir, e.name)).replace(/\\/g, "/");
104
- if (MANIFEST_LANG[e.name]) manifests.push(rel);
105
- if (e.name === "package.json") pkgs.push(rel);
106
- const dot = e.name.lastIndexOf(".");
107
- const ext = dot > 0 ? e.name.slice(dot + 1).toLowerCase() : "";
108
- if (EXT_LANG[ext]) files.push(rel);
109
- }
110
- };
111
- walk(root);
112
- return { files, manifests, pkgs };
113
- }
114
-
115
- // Group the repo's TS/JS files by their NEAREST project manifest (tsconfig.json
116
- // or package.json) directory — scip-typescript indexes one PROJECT at a time,
117
- // so a monorepo of front-end apps (each its own package.json, maybe no
118
- // tsconfig at all) needs one indexer run per app, not one at the repo root
119
- // (which sees "no files got indexed"). Files with no manifest above them
120
- // group under the root (""). Each group also reports which SFC extensions
121
- // (.vue/.svelte) it contains — the sfc-flag input. Pure; exported for tests.
122
- export function tsProjectGroups(tsFiles, tsconfigDirs, pkgDirs) {
123
- const dirs = [...new Set([...tsconfigDirs, ...pkgDirs])]
124
- .sort((a, b) => b.length - a.length); // deepest first → nearest wins
125
- const withTsconfig = new Set(tsconfigDirs);
126
- const groups = new Map(); // subroot -> { n, sfcExts:Set }
127
- for (const f of tsFiles) {
128
- const home = dirs.find((d) => d === "" || f.startsWith(d + "/")) ?? "";
129
- if (!groups.has(home)) groups.set(home, { n: 0, sfcExts: new Set() });
130
- const g = groups.get(home);
131
- g.n++;
132
- const m = /\.(vue|svelte)$/i.exec(f);
133
- if (m) g.sfcExts.add(m[1].toLowerCase());
134
- }
135
- return [...groups.keys()].sort().map((subroot) => ({
136
- subroot,
137
- hasTsconfig: withTsconfig.has(subroot),
138
- sfcExts: [...groups.get(subroot).sfcExts].sort(),
139
- }));
140
- }
141
-
142
- // Minimal TOML peek at a Cargo.toml's [workspace] table: its `members` /
143
- // `exclude` string arrays (possibly multi-line, possibly globs like
144
- // "crates/*"). That is exactly what decides which crate dirs one
145
- // `rust-analyzer scip .` run at that directory will load — full TOML parsing
146
- // is not needed. Returns null when the file declares no [workspace] at all.
147
- export function cargoWorkspace(toml) {
148
- const m = /^[ \t]*\[workspace\][ \t]*\r?$/m.exec(toml);
149
- if (!m) return null;
150
- let body = toml.slice(m.index + m[0].length);
151
- const next = /^[ \t]*\[[^\]]+\][ \t]*\r?$/m.exec(body); // next table header ends the section
152
- if (next) body = body.slice(0, next.index);
153
- const list = (key) => {
154
- const a = new RegExp(`^[ \\t]*${key}\\s*=\\s*\\[([^\\]]*)\\]`, "m").exec(body);
155
- return a ? [...a[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]) : [];
156
- };
157
- return { members: list("members"), exclude: list("exclude") };
158
- }
159
-
160
- // The sfc flag for one TS project group: an SFC extension must be PRESENT in
161
- // the group AND its framework declared in the group's package.json (deps or
162
- // devDeps) — a stray .vue in a repo that never installed vue is not a Vue
163
- // project. `readJson` is injectable for tests.
164
- export function sfcFlagOf(group, pkgJsonPath, readJson) {
165
- if (!group.sfcExts?.length) return undefined;
166
- let pkg;
167
- try { pkg = readJson(pkgJsonPath); } catch { return undefined; }
168
- const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
169
- const frameworks = group.sfcExts.filter((ext) => deps[ext]); // ext === framework package name
170
- return frameworks.length ? frameworks.join(",") : undefined;
171
- }
172
-
173
- // Decide the indexer jobs for `root`. Returns [] when nothing supported is
174
- // found. Each job: { language, indexer:"scip"|"joern", adapter, gemlLang?,
175
- // signal, subroot?, sfc? } — sfc ("vue"/"svelte"/"vue,svelte") marks a TS
176
- // project whose SFCs the build virtualizes before running scip.
177
- //
178
- // Options:
179
- // excluder (relPosixPath) => bool — drop gitignored/--exclude paths
180
- // readdir injected fs.readdirSync (tests)
181
- // readJson injected package.json reader (tests) — sfc flag input
182
- // readText injected text reader (tests) — root Cargo.toml [workspace] peek
183
- // files, manifests precomputed (from collectSourceFiles) to skip the walk
184
- export function detectLanguages(root, { excluder = () => false, readdir, readJson, readText, files, manifests, pkgs } = {}) {
185
- readJson ??= (p) => JSON.parse(readFileSync(p, "utf8"));
186
- readText ??= (p) => readFileSync(p, "utf8");
187
- if (!files || !manifests) {
188
- const c = collectSourceFiles(root, { readdir });
189
- files = files ?? c.files;
190
- manifests = manifests ?? c.manifests;
191
- pkgs = pkgs ?? c.pkgs;
192
- }
193
- const keptFiles = files.filter((f) => !excluder(f));
194
- const keptManifests = manifests.filter((m) => !excluder(m));
195
- const keptPkgs = (pkgs ?? []).filter((p) => !excluder(p));
196
-
197
- // 1. manifest languages — strong signal, priority over extension counts.
198
- const detected = new Map(); // language -> signal string
199
- for (const m of keptManifests) {
200
- const name = m.slice(m.lastIndexOf("/") + 1);
201
- const lang = MANIFEST_LANG[name];
202
- if (lang && !detected.has(lang)) detected.set(lang, name);
203
- }
204
-
205
- // 2. extension counts across the surviving source files.
206
- const counts = new Map(); // language -> file count
207
- for (const f of keptFiles) {
208
- const dot = f.lastIndexOf(".");
209
- const lang = EXT_LANG[dot >= 0 ? f.slice(dot + 1).toLowerCase() : ""];
210
- if (lang) counts.set(lang, (counts.get(lang) ?? 0) + 1);
211
- }
212
- const total = [...counts.values()].reduce((a, b) => a + b, 0);
213
-
214
- // 3. extension-only languages that clear the presence bar and weren't
215
- // already established by a manifest.
216
- for (const [lang, n] of counts) {
217
- if (detected.has(lang)) continue;
218
- if (total > 0 && n / total >= MIN_EXT_SHARE) detected.set(lang, `.${extOf(lang)}`);
219
- }
220
-
221
- const jobs = [];
222
- for (const [language, signal] of detected) {
223
- const spec = LANG_JOB[language];
224
- if (!spec) continue;
225
- if (language === "TypeScript") {
226
- // One scip run per nearest-manifest project (see tsProjectGroups).
227
- const isTs = (f) => /\.(ts|tsx|js|jsx|vue|svelte)$/i.test(f);
228
- const dirOf = (p) => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "");
229
- const pkgDirs = new Set(keptPkgs.map(dirOf));
230
- const groups = tsProjectGroups(
231
- keptFiles.filter(isTs),
232
- keptManifests.filter((m) => m.endsWith("tsconfig.json")).map(dirOf),
233
- [...pkgDirs],
234
- );
235
- for (const g of groups) {
236
- // SFCs present + framework declared in the group's own package.json
237
- // -> the build virtualizes this project before indexing it.
238
- const sfc = pkgDirs.has(g.subroot)
239
- ? sfcFlagOf(g, join(root, ...(g.subroot ? g.subroot.split("/") : []), "package.json"), readJson)
240
- : undefined;
241
- // A group indexes only if it's a real project: it HAS a tsconfig, or is
242
- // a Vue/Svelte app (SFC) — the virtualizer gives that one a synthetic
243
- // tsconfig. A tsconfig-less, non-SFC group is loose files, not a project:
244
- // skip it. (We don't synthesize a config with scip's --infer-tsconfig —
245
- // that swept the whole tree and littered a stub tsconfig.json.)
246
- if (!g.hasTsconfig && !sfc) continue;
247
- jobs.push({
248
- language, indexer: spec.indexer, adapter: spec.indexer, gemlLang: spec.gemlLang,
249
- subroot: g.subroot || undefined,
250
- sfc,
251
- signal: (g.hasTsconfig
252
- ? (g.subroot ? `${g.subroot}/tsconfig.json` : "tsconfig.json")
253
- // No tsconfig in THIS group (only an SFC app reaches here now — the
254
- // virtualizer supplies its config): name the package.json so the
255
- // signal doesn't echo some OTHER group's tsconfig.
256
- : (g.subroot ? `${g.subroot}/package.json`
257
- : keptPkgs.includes("package.json") ? "package.json" : `.${extOf(language)}`))
258
- + (sfc ? ` +${sfc}-sfc` : ""),
259
- });
260
- }
261
- continue;
262
- }
263
- if (language === "Rust") {
264
- // One `rust-analyzer scip .` run loads ONE Cargo workspace: the run at
265
- // the repo root covers the root package + its [workspace] members and
266
- // nothing else. A crate that opted out (its own [workspace] table, e.g.
267
- // a top-level cli/) or was never a member is invisible to that run — it
268
- // gets its OWN run in its OWN directory. Anchors are crate-qualified
269
- // ("rust-analyzer cargo <crate> …"), so merged runs never collide.
270
- const dirOf = (p) => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "");
271
- const cargoDirs = [...new Set(keptManifests.filter((m) => m.endsWith("Cargo.toml")).map(dirOf))];
272
- if (!cargoDirs.length) { // extension-only signal: keep the single root run
273
- jobs.push({ language, indexer: spec.indexer, adapter: spec.indexer, gemlLang: spec.gemlLang, signal });
274
- continue;
275
- }
276
- const rootHas = cargoDirs.includes("");
277
- let members = [], excluded = [];
278
- if (rootHas) {
279
- try {
280
- const ws = cargoWorkspace(readText(join(root, "Cargo.toml")));
281
- if (ws) { members = ws.members; excluded = ws.exclude; }
282
- } catch { /* unreadable root manifest — treat as memberless */ }
283
- }
284
- const rootCovers = (d) =>
285
- members.some((g) => globToRegExp(g).test(d)) && !excluded.some((g) => globToRegExp(g).test(d));
286
- let standalone = cargoDirs.filter((d) => d && !(rootHas && rootCovers(d))).sort();
287
- // A crate nested under another standalone crate belongs to THAT run.
288
- standalone = standalone.filter((d) => !standalone.some((s) => s !== d && d.startsWith(s + "/")));
289
- if (rootHas) jobs.push({ language, indexer: spec.indexer, adapter: spec.indexer, gemlLang: spec.gemlLang, signal });
290
- for (const d of standalone) {
291
- jobs.push({ language, indexer: spec.indexer, adapter: spec.indexer, gemlLang: spec.gemlLang, subroot: d, signal: `${d}/Cargo.toml` });
292
- }
293
- continue;
294
- }
295
- jobs.push({ language, indexer: spec.indexer, adapter: spec.indexer, gemlLang: spec.gemlLang, signal });
296
- }
297
- // Deterministic order: scip before joern, then by GEML_LANG, then language;
298
- // same-language projects DEEPEST subroot first — a nested project's anchors
299
- // must win collisions with an enclosing one, so the merge keeps the FIRST
300
- // occurrence, which must be the deeper (more precise) index.
301
- jobs.sort((a, b) =>
302
- (a.indexer === b.indexer ? 0 : a.indexer === "scip" ? -1 : 1)
303
- || (a.gemlLang ?? "").localeCompare(b.gemlLang ?? "")
304
- || a.language.localeCompare(b.language)
305
- || (b.subroot ?? "").length - (a.subroot ?? "").length
306
- || (a.subroot ?? "").localeCompare(b.subroot ?? ""));
307
- return jobs;
308
- }
309
-
310
- // The npx -p set the SFC virtualizer needs, per framework. typescript is
311
- // pinned @5: typescript@latest is 7.x, which @vue/language-core rejects.
312
- const SFC_NPX_PKGS = {
313
- vue: ["@vue/language-core"],
314
- svelte: ["svelte2tsx", "svelte"],
315
- };
316
-
317
- // Turn one detection job into the concrete command a subprocess runs. Pure:
318
- // the caller supplies resolved absolute paths. `raw` is what the matching
319
- // adapter consumes downstream — a .scip FILE for scip, the JSONL output DIR
320
- // for joern (joern-export.sc writes methods.jsonl + calls.jsonl there).
321
- // root resolved project root (scip cwd; joern GEML_SRC)
322
- // buildDir where intermediates land (typically <out>/_build)
323
- // scriptPath resolved path to joern-export.sc
324
- // sfcScript resolved path to sfc-virtualize.mjs (sfc jobs only)
325
- //
326
- // An sfc job returns TWO steps: `pre` (the virtualizer, run first — shadows,
327
- // map sidecars and a synthetic tsconfig land in `remapDir`) and the main
328
- // scip run, which executes IN the virtual dir against that tsconfig. The
329
- // build passes `remapDir` through to the scip adapter.
330
- export function indexerCommand(job, { root, buildDir, scriptPath, sfcScript }) {
331
- if (job.indexer === "scip") {
332
- // One .scip per project run — a subrooted job (monorepo app, standalone
333
- // crate) runs IN that directory and writes a slug-named index; the adapter
334
- // re-anchors its paths via the index's metadata.project_root, so the merge
335
- // stays repo-relative.
336
- const slug = job.subroot ? String(job.subroot).replace(/\//g, "-") : "";
337
- const subrootAbs = job.subroot ? join(root, ...String(job.subroot).split("/")) : root;
338
- // rust.scip / rust-<slug>.scip next to index.scip, so a mixed TS + Rust
339
- // repo never overwrites one index with the other. A subrooted Rust job is
340
- // a crate OUTSIDE the root workspace: rust-analyzer runs in the crate dir.
341
- if (job.language === "Rust") {
342
- const raw = join(buildDir, slug ? `rust-${slug}.scip` : "rust.scip");
343
- return {
344
- adapter: "scip",
345
- raw,
346
- argv: ["rust-analyzer", "scip", ".", "--output", raw],
347
- env: undefined,
348
- cwd: job.subroot ? subrootAbs : root,
349
- };
350
- }
351
- const raw = join(buildDir, slug ? `index-${slug}.scip` : "index.scip");
352
- if (job.sfc) {
353
- // Virtualize first, then index the virtual dir: its synthetic tsconfig
354
- // covers the shadows AND the project's real TS/JS, so this ONE scip run
355
- // replaces the plain per-project run.
356
- const remapDir = join(buildDir, slug ? `virtual-${slug}` : "virtual-root");
357
- const pkgs = [...new Set(String(job.sfc).split(",").flatMap((f) => SFC_NPX_PKGS[f] ?? []))];
358
- return {
359
- adapter: "scip",
360
- raw,
361
- remapDir,
362
- pre: {
363
- argv: ["npx", "-y", ...pkgs.flatMap((p) => ["-p", p]), "-p", "typescript@5", "node", sfcScript],
364
- env: { GEML_SRC: subrootAbs, GEML_OUT: remapDir },
365
- cwd: root,
366
- },
367
- argv: ["npx", "--yes", "@sourcegraph/scip-typescript", "index", "--output", raw],
368
- env: undefined,
369
- cwd: remapDir,
370
- };
371
- }
372
- // A plain TS/JS project: scip-typescript reads the tsconfig in its cwd.
373
- // detect only emits a scip job for a group that HAS a tsconfig (SFC apps are
374
- // handled above, indexing their virtual dir's synthetic config), so there is
375
- // never a config to infer — a tsconfig-less, non-SFC group is loose files,
376
- // not a project, and was dropped back in detectLanguages.
377
- return {
378
- adapter: "scip",
379
- raw,
380
- argv: ["npx", "--yes", "@sourcegraph/scip-typescript", "index", "--output", raw],
381
- env: undefined,
382
- cwd: job.subroot ? subrootAbs : root,
383
- };
384
- }
385
- // joern: one output dir per frontend so several Joern jobs never clash.
386
- const raw = join(buildDir, `joern-${String(job.gemlLang).toLowerCase()}`);
387
- // Run IN the build dir, not the repo root. Joern's importCode writes its CPG
388
- // workspace to <cwd>/workspace/; anchoring cwd at buildDir keeps that cache
389
- // inside .geml-code-graph/_build/workspace/ instead of scattering a stray
390
- // `workspace/` at the repo root. GEML_SRC/GEML_OUT are absolute and the
391
- // script path is absolute, so the move never affects what Joern reads or writes.
392
- return {
393
- adapter: "joern",
394
- raw,
395
- argv: ["joern", "--script", scriptPath],
396
- env: { GEML_SRC: root, GEML_OUT: raw, GEML_LANG: job.gemlLang },
397
- cwd: buildDir,
398
- };
399
- }
1
+ // geml-code-graph language auto-detection — the `geml codemap build --root <dir>`
2
+ // "fastest onboarding" path: no --adapter, no --db, no --lang, one command.
3
+ //
4
+ // detectLanguages walks the project tree and decides, per language, which
5
+ // indexer runs and — for Joern — which frontend (GEML_LANG). It mirrors the
6
+ // geml-code-graph skill's detection table: MANIFESTS first (a pom.xml means
7
+ // Java even if generated .js outnumber .java), THEN source-file EXTENSIONS.
8
+ // A repo can yield several jobs (TypeScript via SCIP + Java via Joern);
9
+ // build.mjs runs each and merges every extraction into ONE codemap.
10
+ //
11
+ // Pure by design: given a precomputed { files, manifests } it touches no
12
+ // filesystem, so it unit-tests without any indexer installed. indexerCommand
13
+ // turns one job into the argv/env/raw a subprocess (and the refresh recipe)
14
+ // needs — also pure. Neither ever spawns a process.
15
+
16
+ import { readdirSync, readFileSync } from "node:fs";
17
+ import { join, relative } from "node:path";
18
+ import { globToRegExp } from "./exclude.mjs";
19
+
20
+ // Directories that never hold first-party source: pruned during the walk so a
21
+ // vendored dependency tree or build output can't swing the extension counts
22
+ // (and drag a whole Joern frontend into the build). Mirrors normalize.mjs
23
+ // SKIP_DIRS plus the codemap's own output dir.
24
+ export const SKIP_DIRS = new Set([
25
+ "node_modules", "target", "dist", "out", "build", ".git", "vendor",
26
+ ".geml-code-graph", ".geml-build", ".idea", ".gradle",
27
+ ]);
28
+
29
+ // Manifest filename -> the language it declares. Presence is a STRONG signal
30
+ // (no threshold): it fires even for a single source file, and takes priority
31
+ // over the extension count.
32
+ const MANIFEST_LANG = {
33
+ "tsconfig.json": "TypeScript",
34
+ "Cargo.toml": "Rust",
35
+ "pom.xml": "Java",
36
+ "build.gradle": "Java",
37
+ "build.gradle.kts": "Java",
38
+ "go.mod": "Go",
39
+ };
40
+
41
+ // Source extension -> language. scip-typescript indexes JS as well as TS, so
42
+ // .js/.jsx map to the same TypeScript/scip job. .vue/.svelte SFCs also belong
43
+ // to the TypeScript family: their group's job carries an `sfc` flag and the
44
+ // build virtualizes them (codemap/sfc-virtualize.mjs) before scip runs.
45
+ const EXT_LANG = {
46
+ ts: "TypeScript", tsx: "TypeScript", js: "TypeScript", jsx: "TypeScript",
47
+ vue: "TypeScript", svelte: "TypeScript",
48
+ rs: "Rust",
49
+ java: "Java",
50
+ c: "C", h: "C",
51
+ py: "Python",
52
+ go: "Go",
53
+ kt: "Kotlin",
54
+ };
55
+
56
+ // A path counts as "source" if its extension maps to a language we index. Used
57
+ // by `refresh` to skip a rebuild when a commit touched only docs/config/CI —
58
+ // files that can't change the call graph.
59
+ export const isSourcePath = (p) => {
60
+ const dot = p.lastIndexOf(".");
61
+ return dot >= 0 && EXT_LANG[p.slice(dot + 1).toLowerCase()] !== undefined;
62
+ };
63
+
64
+ // Language -> indexer + Joern frontend. scip covers TypeScript/JS (via
65
+ // scip-typescript) and Rust (via rust-analyzer — the SCIP adapter reads both
66
+ // symbol grammars); everything else is a Joern frontend whose --language name
67
+ // (UPPERCASE) we pass as GEML_LANG so a mixed repo never falls back to
68
+ // Joern's majority-language autodetect.
69
+ export const LANG_JOB = {
70
+ TypeScript: { indexer: "scip", gemlLang: undefined },
71
+ Rust: { indexer: "scip", gemlLang: undefined },
72
+ Java: { indexer: "joern", gemlLang: "JAVASRC" },
73
+ C: { indexer: "joern", gemlLang: "NEWC" },
74
+ Python: { indexer: "joern", gemlLang: "PYTHONSRC" },
75
+ Go: { indexer: "joern", gemlLang: "GO" },
76
+ Kotlin: { indexer: "joern", gemlLang: "KOTLIN" },
77
+ };
78
+
79
+ // A language detected ONLY by file extension (no manifest) must clear a small
80
+ // presence bar, so a stray helper script (one .py in a big TS repo) can't drag
81
+ // a whole Joern frontend into the build. Manifests bypass the bar entirely.
82
+ export const MIN_EXT_SHARE = 0.05;
83
+
84
+ // Representative extension for a language (for the human-readable plan signal).
85
+ const extOf = (lang) => Object.keys(EXT_LANG).find((e) => EXT_LANG[e] === lang);
86
+
87
+ // Walk `root`, returning repo-relative POSIX source files and manifest files.
88
+ // SKIP_DIRS and dotdirs are pruned structurally (a gitignored path is dropped
89
+ // later by the caller's excluder). `readdir` is injectable for tests.
90
+ export function collectSourceFiles(root, { readdir = readdirSync } = {}) {
91
+ const files = []; // repo-relative POSIX source files
92
+ const manifests = []; // repo-relative POSIX manifest files
93
+ const pkgs = []; // repo-relative POSIX package.json files (TS/JS project roots)
94
+ const walk = (dir) => {
95
+ let ents;
96
+ try { ents = readdir(dir, { withFileTypes: true }); } catch { return; }
97
+ for (const e of ents) {
98
+ if (e.isDirectory()) {
99
+ if (!SKIP_DIRS.has(e.name) && !e.name.startsWith(".")) walk(join(dir, e.name));
100
+ continue;
101
+ }
102
+ if (!e.isFile()) continue;
103
+ const rel = relative(root, join(dir, e.name)).replace(/\\/g, "/");
104
+ if (MANIFEST_LANG[e.name]) manifests.push(rel);
105
+ if (e.name === "package.json") pkgs.push(rel);
106
+ const dot = e.name.lastIndexOf(".");
107
+ const ext = dot > 0 ? e.name.slice(dot + 1).toLowerCase() : "";
108
+ if (EXT_LANG[ext]) files.push(rel);
109
+ }
110
+ };
111
+ walk(root);
112
+ return { files, manifests, pkgs };
113
+ }
114
+
115
+ // Group the repo's TS/JS files by their NEAREST project manifest (tsconfig.json
116
+ // or package.json) directory — scip-typescript indexes one PROJECT at a time,
117
+ // so a monorepo of front-end apps (each its own package.json, maybe no
118
+ // tsconfig at all) needs one indexer run per app, not one at the repo root
119
+ // (which sees "no files got indexed"). Files with no manifest above them
120
+ // group under the root (""). Each group also reports which SFC extensions
121
+ // (.vue/.svelte) it contains — the sfc-flag input. Pure; exported for tests.
122
+ export function tsProjectGroups(tsFiles, tsconfigDirs, pkgDirs) {
123
+ const dirs = [...new Set([...tsconfigDirs, ...pkgDirs])]
124
+ .sort((a, b) => b.length - a.length); // deepest first → nearest wins
125
+ const withTsconfig = new Set(tsconfigDirs);
126
+ const groups = new Map(); // subroot -> { n, sfcExts:Set }
127
+ for (const f of tsFiles) {
128
+ const home = dirs.find((d) => d === "" || f.startsWith(d + "/")) ?? "";
129
+ if (!groups.has(home)) groups.set(home, { n: 0, sfcExts: new Set() });
130
+ const g = groups.get(home);
131
+ g.n++;
132
+ const m = /\.(vue|svelte)$/i.exec(f);
133
+ if (m) g.sfcExts.add(m[1].toLowerCase());
134
+ }
135
+ return [...groups.keys()].sort().map((subroot) => ({
136
+ subroot,
137
+ hasTsconfig: withTsconfig.has(subroot),
138
+ sfcExts: [...groups.get(subroot).sfcExts].sort(),
139
+ }));
140
+ }
141
+
142
+ // Minimal TOML peek at a Cargo.toml's [workspace] table: its `members` /
143
+ // `exclude` string arrays (possibly multi-line, possibly globs like
144
+ // "crates/*"). That is exactly what decides which crate dirs one
145
+ // `rust-analyzer scip .` run at that directory will load — full TOML parsing
146
+ // is not needed. Returns null when the file declares no [workspace] at all.
147
+ export function cargoWorkspace(toml) {
148
+ const m = /^[ \t]*\[workspace\][ \t]*\r?$/m.exec(toml);
149
+ if (!m) return null;
150
+ let body = toml.slice(m.index + m[0].length);
151
+ const next = /^[ \t]*\[[^\]]+\][ \t]*\r?$/m.exec(body); // next table header ends the section
152
+ if (next) body = body.slice(0, next.index);
153
+ const list = (key) => {
154
+ const a = new RegExp(`^[ \\t]*${key}\\s*=\\s*\\[([^\\]]*)\\]`, "m").exec(body);
155
+ return a ? [...a[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]) : [];
156
+ };
157
+ return { members: list("members"), exclude: list("exclude") };
158
+ }
159
+
160
+ // The sfc flag for one TS project group: an SFC extension must be PRESENT in
161
+ // the group AND its framework declared in the group's package.json (deps or
162
+ // devDeps) — a stray .vue in a repo that never installed vue is not a Vue
163
+ // project. `readJson` is injectable for tests.
164
+ export function sfcFlagOf(group, pkgJsonPath, readJson) {
165
+ if (!group.sfcExts?.length) return undefined;
166
+ let pkg;
167
+ try { pkg = readJson(pkgJsonPath); } catch { return undefined; }
168
+ const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
169
+ const frameworks = group.sfcExts.filter((ext) => deps[ext]); // ext === framework package name
170
+ return frameworks.length ? frameworks.join(",") : undefined;
171
+ }
172
+
173
+ // Decide the indexer jobs for `root`. Returns [] when nothing supported is
174
+ // found. Each job: { language, indexer:"scip"|"joern", adapter, gemlLang?,
175
+ // signal, subroot?, sfc? } — sfc ("vue"/"svelte"/"vue,svelte") marks a TS
176
+ // project whose SFCs the build virtualizes before running scip.
177
+ //
178
+ // Options:
179
+ // excluder (relPosixPath) => bool — drop gitignored/--exclude paths
180
+ // readdir injected fs.readdirSync (tests)
181
+ // readJson injected package.json reader (tests) — sfc flag input
182
+ // readText injected text reader (tests) — root Cargo.toml [workspace] peek
183
+ // files, manifests precomputed (from collectSourceFiles) to skip the walk
184
+ export function detectLanguages(root, { excluder = () => false, readdir, readJson, readText, files, manifests, pkgs } = {}) {
185
+ readJson ??= (p) => JSON.parse(readFileSync(p, "utf8"));
186
+ readText ??= (p) => readFileSync(p, "utf8");
187
+ if (!files || !manifests) {
188
+ const c = collectSourceFiles(root, { readdir });
189
+ files = files ?? c.files;
190
+ manifests = manifests ?? c.manifests;
191
+ pkgs = pkgs ?? c.pkgs;
192
+ }
193
+ const keptFiles = files.filter((f) => !excluder(f));
194
+ const keptManifests = manifests.filter((m) => !excluder(m));
195
+ const keptPkgs = (pkgs ?? []).filter((p) => !excluder(p));
196
+
197
+ // 1. manifest languages — strong signal, priority over extension counts.
198
+ const detected = new Map(); // language -> signal string
199
+ for (const m of keptManifests) {
200
+ const name = m.slice(m.lastIndexOf("/") + 1);
201
+ const lang = MANIFEST_LANG[name];
202
+ if (lang && !detected.has(lang)) detected.set(lang, name);
203
+ }
204
+
205
+ // 2. extension counts across the surviving source files.
206
+ const counts = new Map(); // language -> file count
207
+ for (const f of keptFiles) {
208
+ const dot = f.lastIndexOf(".");
209
+ const lang = EXT_LANG[dot >= 0 ? f.slice(dot + 1).toLowerCase() : ""];
210
+ if (lang) counts.set(lang, (counts.get(lang) ?? 0) + 1);
211
+ }
212
+ const total = [...counts.values()].reduce((a, b) => a + b, 0);
213
+
214
+ // 3. extension-only languages that clear the presence bar and weren't
215
+ // already established by a manifest.
216
+ for (const [lang, n] of counts) {
217
+ if (detected.has(lang)) continue;
218
+ if (total > 0 && n / total >= MIN_EXT_SHARE) detected.set(lang, `.${extOf(lang)}`);
219
+ }
220
+
221
+ const jobs = [];
222
+ for (const [language, signal] of detected) {
223
+ const spec = LANG_JOB[language];
224
+ if (!spec) continue;
225
+ if (language === "TypeScript") {
226
+ // One scip run per nearest-manifest project (see tsProjectGroups).
227
+ const isTs = (f) => /\.(ts|tsx|js|jsx|vue|svelte)$/i.test(f);
228
+ const dirOf = (p) => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "");
229
+ const pkgDirs = new Set(keptPkgs.map(dirOf));
230
+ const groups = tsProjectGroups(
231
+ keptFiles.filter(isTs),
232
+ keptManifests.filter((m) => m.endsWith("tsconfig.json")).map(dirOf),
233
+ [...pkgDirs],
234
+ );
235
+ for (const g of groups) {
236
+ // SFCs present + framework declared in the group's own package.json
237
+ // -> the build virtualizes this project before indexing it.
238
+ const sfc = pkgDirs.has(g.subroot)
239
+ ? sfcFlagOf(g, join(root, ...(g.subroot ? g.subroot.split("/") : []), "package.json"), readJson)
240
+ : undefined;
241
+ // A group indexes only if it's a real project: it HAS a tsconfig, or is
242
+ // a Vue/Svelte app (SFC) — the virtualizer gives that one a synthetic
243
+ // tsconfig. A tsconfig-less, non-SFC group is loose files, not a project:
244
+ // skip it. (We don't synthesize a config with scip's --infer-tsconfig —
245
+ // that swept the whole tree and littered a stub tsconfig.json.)
246
+ if (!g.hasTsconfig && !sfc) continue;
247
+ jobs.push({
248
+ language, indexer: spec.indexer, adapter: spec.indexer, gemlLang: spec.gemlLang,
249
+ subroot: g.subroot || undefined,
250
+ sfc,
251
+ signal: (g.hasTsconfig
252
+ ? (g.subroot ? `${g.subroot}/tsconfig.json` : "tsconfig.json")
253
+ // No tsconfig in THIS group (only an SFC app reaches here now — the
254
+ // virtualizer supplies its config): name the package.json so the
255
+ // signal doesn't echo some OTHER group's tsconfig.
256
+ : (g.subroot ? `${g.subroot}/package.json`
257
+ : keptPkgs.includes("package.json") ? "package.json" : `.${extOf(language)}`))
258
+ + (sfc ? ` +${sfc}-sfc` : ""),
259
+ });
260
+ }
261
+ continue;
262
+ }
263
+ if (language === "Rust") {
264
+ // One `rust-analyzer scip .` run loads ONE Cargo workspace: the run at
265
+ // the repo root covers the root package + its [workspace] members and
266
+ // nothing else. A crate that opted out (its own [workspace] table, e.g.
267
+ // a top-level cli/) or was never a member is invisible to that run — it
268
+ // gets its OWN run in its OWN directory. Anchors are crate-qualified
269
+ // ("rust-analyzer cargo <crate> …"), so merged runs never collide.
270
+ const dirOf = (p) => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "");
271
+ const cargoDirs = [...new Set(keptManifests.filter((m) => m.endsWith("Cargo.toml")).map(dirOf))];
272
+ if (!cargoDirs.length) { // extension-only signal: keep the single root run
273
+ jobs.push({ language, indexer: spec.indexer, adapter: spec.indexer, gemlLang: spec.gemlLang, signal });
274
+ continue;
275
+ }
276
+ const rootHas = cargoDirs.includes("");
277
+ let members = [], excluded = [];
278
+ if (rootHas) {
279
+ try {
280
+ const ws = cargoWorkspace(readText(join(root, "Cargo.toml")));
281
+ if (ws) { members = ws.members; excluded = ws.exclude; }
282
+ } catch { /* unreadable root manifest — treat as memberless */ }
283
+ }
284
+ const rootCovers = (d) =>
285
+ members.some((g) => globToRegExp(g).test(d)) && !excluded.some((g) => globToRegExp(g).test(d));
286
+ let standalone = cargoDirs.filter((d) => d && !(rootHas && rootCovers(d))).sort();
287
+ // A crate nested under another standalone crate belongs to THAT run.
288
+ standalone = standalone.filter((d) => !standalone.some((s) => s !== d && d.startsWith(s + "/")));
289
+ if (rootHas) jobs.push({ language, indexer: spec.indexer, adapter: spec.indexer, gemlLang: spec.gemlLang, signal });
290
+ for (const d of standalone) {
291
+ jobs.push({ language, indexer: spec.indexer, adapter: spec.indexer, gemlLang: spec.gemlLang, subroot: d, signal: `${d}/Cargo.toml` });
292
+ }
293
+ continue;
294
+ }
295
+ jobs.push({ language, indexer: spec.indexer, adapter: spec.indexer, gemlLang: spec.gemlLang, signal });
296
+ }
297
+ // Deterministic order: scip before joern, then by GEML_LANG, then language;
298
+ // same-language projects DEEPEST subroot first — a nested project's anchors
299
+ // must win collisions with an enclosing one, so the merge keeps the FIRST
300
+ // occurrence, which must be the deeper (more precise) index.
301
+ jobs.sort((a, b) =>
302
+ (a.indexer === b.indexer ? 0 : a.indexer === "scip" ? -1 : 1)
303
+ || (a.gemlLang ?? "").localeCompare(b.gemlLang ?? "")
304
+ || a.language.localeCompare(b.language)
305
+ || (b.subroot ?? "").length - (a.subroot ?? "").length
306
+ || (a.subroot ?? "").localeCompare(b.subroot ?? ""));
307
+ return jobs;
308
+ }
309
+
310
+ // The npx -p set the SFC virtualizer needs, per framework. typescript is
311
+ // pinned @5: typescript@latest is 7.x, which @vue/language-core rejects.
312
+ const SFC_NPX_PKGS = {
313
+ vue: ["@vue/language-core"],
314
+ svelte: ["svelte2tsx", "svelte"],
315
+ };
316
+
317
+ // Turn one detection job into the concrete command a subprocess runs. Pure:
318
+ // the caller supplies resolved absolute paths. `raw` is what the matching
319
+ // adapter consumes downstream — a .scip FILE for scip, the JSONL output DIR
320
+ // for joern (joern-export.sc writes methods.jsonl + calls.jsonl there).
321
+ // root resolved project root (scip cwd; joern GEML_SRC)
322
+ // buildDir where intermediates land (typically <out>/_build)
323
+ // scriptPath resolved path to joern-export.sc
324
+ // sfcScript resolved path to sfc-virtualize.mjs (sfc jobs only)
325
+ //
326
+ // An sfc job returns TWO steps: `pre` (the virtualizer, run first — shadows,
327
+ // map sidecars and a synthetic tsconfig land in `remapDir`) and the main
328
+ // scip run, which executes IN the virtual dir against that tsconfig. The
329
+ // build passes `remapDir` through to the scip adapter.
330
+ export function indexerCommand(job, { root, buildDir, scriptPath, sfcScript }) {
331
+ if (job.indexer === "scip") {
332
+ // One .scip per project run — a subrooted job (monorepo app, standalone
333
+ // crate) runs IN that directory and writes a slug-named index; the adapter
334
+ // re-anchors its paths via the index's metadata.project_root, so the merge
335
+ // stays repo-relative.
336
+ const slug = job.subroot ? String(job.subroot).replace(/\//g, "-") : "";
337
+ const subrootAbs = job.subroot ? join(root, ...String(job.subroot).split("/")) : root;
338
+ // rust.scip / rust-<slug>.scip next to index.scip, so a mixed TS + Rust
339
+ // repo never overwrites one index with the other. A subrooted Rust job is
340
+ // a crate OUTSIDE the root workspace: rust-analyzer runs in the crate dir.
341
+ if (job.language === "Rust") {
342
+ const raw = join(buildDir, slug ? `rust-${slug}.scip` : "rust.scip");
343
+ return {
344
+ adapter: "scip",
345
+ raw,
346
+ argv: ["rust-analyzer", "scip", ".", "--output", raw],
347
+ env: undefined,
348
+ cwd: job.subroot ? subrootAbs : root,
349
+ };
350
+ }
351
+ const raw = join(buildDir, slug ? `index-${slug}.scip` : "index.scip");
352
+ if (job.sfc) {
353
+ // Virtualize first, then index the virtual dir: its synthetic tsconfig
354
+ // covers the shadows AND the project's real TS/JS, so this ONE scip run
355
+ // replaces the plain per-project run.
356
+ const remapDir = join(buildDir, slug ? `virtual-${slug}` : "virtual-root");
357
+ const pkgs = [...new Set(String(job.sfc).split(",").flatMap((f) => SFC_NPX_PKGS[f] ?? []))];
358
+ return {
359
+ adapter: "scip",
360
+ raw,
361
+ remapDir,
362
+ pre: {
363
+ argv: ["npx", "-y", ...pkgs.flatMap((p) => ["-p", p]), "-p", "typescript@5", "node", sfcScript],
364
+ env: { GEML_SRC: subrootAbs, GEML_OUT: remapDir },
365
+ cwd: root,
366
+ },
367
+ argv: ["npx", "--yes", "@sourcegraph/scip-typescript", "index", "--output", raw],
368
+ env: undefined,
369
+ cwd: remapDir,
370
+ };
371
+ }
372
+ // A plain TS/JS project: scip-typescript reads the tsconfig in its cwd.
373
+ // detect only emits a scip job for a group that HAS a tsconfig (SFC apps are
374
+ // handled above, indexing their virtual dir's synthetic config), so there is
375
+ // never a config to infer — a tsconfig-less, non-SFC group is loose files,
376
+ // not a project, and was dropped back in detectLanguages.
377
+ return {
378
+ adapter: "scip",
379
+ raw,
380
+ argv: ["npx", "--yes", "@sourcegraph/scip-typescript", "index", "--output", raw],
381
+ env: undefined,
382
+ cwd: job.subroot ? subrootAbs : root,
383
+ };
384
+ }
385
+ // joern: one output dir per frontend so several Joern jobs never clash.
386
+ const raw = join(buildDir, `joern-${String(job.gemlLang).toLowerCase()}`);
387
+ // Run IN the build dir, not the repo root. Joern's importCode writes its CPG
388
+ // workspace to <cwd>/workspace/; anchoring cwd at buildDir keeps that cache
389
+ // inside .geml-code-graph/_build/workspace/ instead of scattering a stray
390
+ // `workspace/` at the repo root. GEML_SRC/GEML_OUT are absolute and the
391
+ // script path is absolute, so the move never affects what Joern reads or writes.
392
+ return {
393
+ adapter: "joern",
394
+ raw,
395
+ argv: ["joern", "--script", scriptPath],
396
+ env: { GEML_SRC: root, GEML_OUT: raw, GEML_LANG: job.gemlLang },
397
+ cwd: buildDir,
398
+ };
399
+ }