@geml/geml 1.3.2 → 1.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +155 -109
- 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 -579
- package/codemap/cross-stack.mjs +303 -0
- package/codemap/detect.mjs +399 -399
- package/codemap/emit.mjs +480 -432
- 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 -272
- 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 -143
- package/dist/block-edit.d.ts +1 -0
- package/dist/block-edit.js +112 -0
- package/dist/geml.js +759 -161
- package/dist/render.d.ts +2 -2
- package/dist/render.js +261 -178
- package/package.json +1 -2
package/codemap/detect.mjs
CHANGED
|
@@ -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
|
+
}
|