@geml/geml 1.0.0 → 1.1.1

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.
@@ -0,0 +1,24 @@
1
+ // Stubs for the Node built-ins that geml-parser imports for its CLI/history
2
+ // code paths. Those paths never run in the browser (the CLI block is gated by
3
+ // `process.argv` — an empty shim there — and history functions are never
4
+ // called from parse()). These exist only so the static `import`s resolve when
5
+ // the modules load in a browser:
6
+ // - `geml codemap serve` maps node:* here via an import map (/_dist/_node-stub.js)
7
+ // - the viewer/playground esbuild bundles alias node:* to this same file
8
+ // Calling any of these would be a bug, so they no-op harmlessly.
9
+
10
+ export const readFileSync = () => "";
11
+ export const writeFileSync = () => {};
12
+ export const existsSync = () => false;
13
+ export const basename = (p) => p;
14
+ export const dirname = (p) => p;
15
+ export const resolve = (...p) => p.join("/");
16
+ export const join = (...p) => p.join("/");
17
+ export const fileURLToPath = (u) => String(u);
18
+ export const spawnSync = () => ({ status: 1 });
19
+ export const createHash = () => ({
20
+ update() { return this; },
21
+ digest() { return ""; },
22
+ });
23
+
24
+ export default {};
@@ -0,0 +1,354 @@
1
+ #!/usr/bin/env node
2
+ // geml-code-graph build — one shot: adapter → exchange format (build/) → GEML tree (graph/).
3
+ //
4
+ // geml codemap build --root <repo-root> # AUTO: detect languages,
5
+ // # run the right indexer(s), merge into one map
6
+ // geml codemap build --db <graph.db> --root <repo-root> # crg
7
+ // geml codemap build --adapter joern --raw <dir> \
8
+ // --adapter scip --raw <index.scip> --root <repo> # merged multi-language
9
+ // geml codemap build --adapter joern --raw <dir> --root <repo-root> # joern
10
+ // [--out .geml-code-graph] [--build .geml-code-graph/_build]
11
+ // [--container module|dir|file] container granularity (default dir)
12
+ // [--lang <JAVASRC|NEWC|…>] force the Joern frontend (auto mode,
13
+ // mixed-majority repos)
14
+ // [--joern <path>] Joern install (auto mode): the launcher OR the
15
+ // unzipped joern-cli dir; else GEML_JOERN, else PATH
16
+ // [--history [-m "msg"]] snapshot changed docs into .gemlhistory
17
+ // sidecars (per-node history + revert)
18
+ //
19
+ // Auto mode (no --adapter and no --db, just --root): detect.mjs picks the
20
+ // indexer per language from manifests + source extensions, we run scip
21
+ // (npx @sourcegraph/scip-typescript) and/or Joern (joern-export.sc) into
22
+ // <out>/_build/, then feed the results into the SAME merge as the explicit
23
+ // --adapter path, and record the replay recipe into _index/refresh.json.
24
+ //
25
+ // Output shape: docs/codemap-profile.md — one document per container (single
26
+ // meta with module/src/entry, empty-body code blocks with src=/anchor=, and
27
+ // the #calls / #called-by / #unresolved CSV edge tables). Verify with
28
+ // geml codemap verify (geml check + profile reference checks).
29
+ //
30
+ // Adapters (docs/DESIGN-geml-code-graph.md §3):
31
+ // crg code-review-graph SQLite graph.db (tree-sitter level; everything
32
+ // honestly labelled resolution:"heuristic") [P0, default]
33
+ // joern Joern CPG export: run geml-parser/codemap/joern-export.sc inside
34
+ // joern first; --raw points at its outDir [P1]
35
+ //
36
+ // After building, run: geml codemap verify <out-dir>
37
+ import { writeFileSync, mkdirSync, existsSync, readFileSync, statSync } from "node:fs";
38
+ import { join, resolve, basename, dirname, relative } from "node:path";
39
+ import { fileURLToPath } from "node:url";
40
+ import { execFileSync, spawnSync } from "node:child_process";
41
+ import { emit } from "./emit.mjs";
42
+ import { makeExcluder } from "./exclude.mjs";
43
+ import { detectLanguages, indexerCommand, collectSourceFiles } from "./detect.mjs";
44
+
45
+ const args = process.argv.slice(2);
46
+ const flag = (name, dflt) => {
47
+ const i = args.indexOf(name);
48
+ return i >= 0 ? args[i + 1] : dflt;
49
+ };
50
+
51
+ const root = flag("--root");
52
+ const outDir = resolve(flag("--out", ".geml-code-graph"));
53
+ // Intermediates live INSIDE the codemap dir (alongside _index) so a build
54
+ // leaves nothing scattered at the repo root — `.geml-code-graph/_build/`.
55
+ const buildDir = resolve(flag("--build", join(outDir, "_build")));
56
+
57
+ // Adapter inputs are REPEATABLE — one codemap can merge several extractions
58
+ // (e.g. Joern for the Java modules + SCIP for the TypeScript ones). Each
59
+ // `--adapter X` opens a group; the following `--db`/`--raw` belongs to it.
60
+ // A bare `--db` without `--adapter` keeps the historical crg default.
61
+ const inputs = [];
62
+ {
63
+ let cur = null;
64
+ for (let i = 0; i < args.length; i++) {
65
+ if (args[i] === "--adapter") { cur = { adapter: args[++i] }; inputs.push(cur); }
66
+ else if (args[i] === "--db") { if (!cur) { cur = { adapter: "crg" }; inputs.push(cur); } cur.db = args[++i]; cur = null; }
67
+ else if (args[i] === "--raw") { if (!cur) { console.error("--raw needs a preceding --adapter"); process.exit(2); } cur.raw = args[++i]; cur = null; }
68
+ }
69
+ }
70
+ // ---- auto-detect mode -------------------------------------------------------
71
+ // A --root with NO --adapter and NO --db (inputs still empty): detect the
72
+ // languages ourselves, run the right indexer(s) into <out>/_build/, then push
73
+ // the results into `inputs` so the merge below runs UNCHANGED. This is the
74
+ // one-command onboarding path; the explicit --adapter/--db paths are untouched.
75
+ let recordRecipe = null; // { rootAbs, steps } — written to refresh.json after emit
76
+ if (root && !inputs.length) {
77
+ const rootAbs = resolve(root);
78
+ const excludeGlobs0 = args.flatMap((v, i) => (args[i - 1] === "--exclude" ? [v] : []));
79
+ const { files, manifests } = collectSourceFiles(rootAbs);
80
+ const excluder = makeExcluder({
81
+ root: rootAbs, globs: excludeGlobs0, gitignore: !args.includes("--no-gitignore"),
82
+ files: [...files, ...manifests], exec: execFileSync,
83
+ });
84
+ const jobs = detectLanguages(rootAbs, { files, manifests, excluder });
85
+
86
+ // --lang forces the Joern frontend (GEML_LANG) — the escape hatch for a
87
+ // mixed repo whose majority language isn't the one you want. Joern jobs only.
88
+ const langOverride = flag("--lang");
89
+ if (langOverride) {
90
+ const L = langOverride.toUpperCase();
91
+ const touched = jobs.filter((j) => j.indexer === "joern");
92
+ for (const j of touched) { j.gemlLang = L; j.signal += ` --lang ${L}`; }
93
+ if (!touched.length) console.error(`--lang ${langOverride}: no Joern language detected to override (ignored)`);
94
+ }
95
+
96
+ if (!jobs.length) {
97
+ console.error(`could not auto-detect a supported language under ${rootAbs}.`);
98
+ console.error("supported: TypeScript/JS (scip); Java, C, Python, Go, Kotlin (joern).");
99
+ console.error("pass an explicit --adapter scip|joern --raw <in> or --db <graph.db> instead (geml codemap build --help).");
100
+ process.exit(1);
101
+ }
102
+
103
+ // Shell-quote a single token so a spaced path (e.g. C:\Program Files\…) or
104
+ // the codemap dir survives cmd.exe / sh word-splitting.
105
+ const q = (s) => (/[\s"]/.test(String(s)) ? `"${String(s).replace(/"/g, '\\"')}"` : String(s));
106
+ // Run a command PORTABLY: on Windows go through cmd.exe (shell) so npx.cmd /
107
+ // joern.bat resolve, quoting each token so spaced paths survive; on unix exec
108
+ // the binary directly (execvp searches PATH, no quoting pitfalls). Mirrors the
109
+ // `shell: process.platform === "win32"` pattern verify.mjs uses.
110
+ const runCmd = (argv, opts = {}) =>
111
+ (process.platform === "win32"
112
+ ? spawnSync(argv.map(q).join(" "), { shell: true, ...opts })
113
+ : spawnSync(argv[0], argv.slice(1), opts));
114
+
115
+ // Resolve the Joern launcher, honoring an explicit install location so users
116
+ // (Windows especially) need not put joern on PATH. A --joern / GEML_JOERN
117
+ // value may be the launcher itself OR the unzipped joern-cli DIRECTORY holding
118
+ // it (joern.bat on Windows, joern on unix). Tried in order — --joern flag,
119
+ // then GEML_JOERN, then `joern` on PATH — and the first that answers
120
+ // `--version` wins. GEML_SRC/GEML_OUT/GEML_LANG always pass as ENV VARS, never
121
+ // --param (the Windows joern.bat -> repl-bridge hop mangles --param).
122
+ const launcherName = process.platform === "win32" ? "joern.bat" : "joern";
123
+ const asLauncher = (v) => {
124
+ try { if (statSync(v).isDirectory()) return join(v, launcherName); } catch { /* not a dir: a launcher path or bare command */ }
125
+ return v;
126
+ };
127
+ let joernBin = null;
128
+ const joernJobs = jobs.filter((j) => j.indexer === "joern");
129
+ if (joernJobs.length) {
130
+ for (const cand of [flag("--joern"), process.env.GEML_JOERN, "joern"].filter((v) => v)) {
131
+ const bin = asLauncher(cand);
132
+ const r = runCmd([bin, "--version"], { stdio: "ignore" });
133
+ if (!r.error && r.status === 0) { joernBin = bin; break; }
134
+ }
135
+ if (!joernBin) {
136
+ const langs = [...new Set(joernJobs.map((j) => j.language))].join(", ");
137
+ console.error(
138
+ `Joern is required for ${langs} but was not found (looked at --joern, GEML_JOERN, then PATH).\n`
139
+ + "Install one and retry:\n"
140
+ + " macOS/Linux:\n"
141
+ + " mkdir joern && cd joern\n"
142
+ + ' curl -L "https://github.com/joernio/joern/releases/latest/download/joern-install.sh" -o joern-install.sh\n'
143
+ + " chmod +x joern-install.sh && ./joern-install.sh\n"
144
+ + " Windows:\n"
145
+ + " download joern-cli.zip from https://github.com/joernio/joern/releases , unzip it, then\n"
146
+ + " add that folder to PATH or pass --joern <unzipped-folder>\n"
147
+ + "Docs: https://docs.joern.io/installation",
148
+ );
149
+ process.exit(1);
150
+ }
151
+ }
152
+
153
+ // Transparent plan before doing any slow work.
154
+ console.error(`detected: ${jobs.map((j) => `${j.language} (${j.signal}) -> ${j.indexer}${j.gemlLang ? `[${j.gemlLang}]` : ""}`).join("; ")}`);
155
+
156
+ const scriptPath = resolve(dirname(fileURLToPath(import.meta.url)), "joern-export.sc");
157
+ const scriptPosix = scriptPath.replace(/\\/g, "/");
158
+ const relToRoot = (p) => (relative(rootAbs, p).replace(/\\/g, "/") || ".");
159
+ mkdirSync(buildDir, { recursive: true });
160
+ const indexSteps = [];
161
+
162
+ console.error("indexing...");
163
+ for (const job of jobs) {
164
+ const cmd = indexerCommand(job, { root: rootAbs, buildDir, scriptPath });
165
+ // scip runs the npx launcher; joern runs the resolved launcher. Env
166
+ // (GEML_SRC/OUT/LANG for joern) rides through the spawn options.
167
+ const argv = [job.indexer === "joern" ? joernBin : cmd.argv[0], ...cmd.argv.slice(1)];
168
+ const r = runCmd(argv, {
169
+ cwd: cmd.cwd, stdio: "inherit",
170
+ env: cmd.env ? { ...process.env, ...cmd.env } : process.env,
171
+ });
172
+ if (r.error || r.status !== 0) {
173
+ console.error(`indexer failed for ${job.language} (${job.indexer}): ${r.error ? r.error.message : `exit ${r.status}`}`);
174
+ process.exit(1);
175
+ }
176
+ inputs.push({ adapter: cmd.adapter, raw: cmd.raw });
177
+ // Recipe step (paths relative to <root>, the cwd refresh replays in). The
178
+ // Joern env is written in the RECORDING host's native shell syntax —
179
+ // refresh.json is machine-local (it re-invokes locally-installed indexers),
180
+ // and cmd.exe ignores the POSIX `VAR=val cmd` prefix.
181
+ if (job.indexer === "scip") {
182
+ indexSteps.push(`npx --yes @sourcegraph/scip-typescript index --output ${relToRoot(cmd.raw)}`);
183
+ } else {
184
+ const relOut = relToRoot(cmd.raw);
185
+ indexSteps.push(process.platform === "win32"
186
+ ? `set "GEML_SRC=." && set "GEML_OUT=${relOut}" && set "GEML_LANG=${job.gemlLang}" && joern --script ${q(scriptPosix)}`
187
+ : `GEML_SRC=. GEML_OUT=${relOut} GEML_LANG=${job.gemlLang} joern --script ${q(scriptPosix)}`);
188
+ }
189
+ }
190
+ console.error("merging...");
191
+ recordRecipe = { rootAbs, indexSteps };
192
+ }
193
+
194
+ const bad = inputs.find((s) => !["crg", "joern", "scip"].includes(s.adapter) || (s.adapter === "crg" ? !s.db : !s.raw));
195
+ if (!root || !inputs.length || bad) {
196
+ console.error("usage: geml codemap build --root <repo-root> # auto-detect languages, index, and merge");
197
+ console.error(" or: geml codemap build (--db <graph.db> | --adapter joern|scip --raw <dir|index.scip>)+ --root <repo-root> [--out .geml-code-graph] [--build .geml-code-graph/_build] [--container module|dir|file] [--lang <LANG>] [--joern <path>] [--exclude <glob>]... [--no-gitignore] [--history [-m msg]]");
198
+ process.exit(2);
199
+ }
200
+
201
+ // Extract every input and concatenate — anchors are namespaced by language and
202
+ // path, so inputs don't collide; identical anchors across inputs are dropped
203
+ // with a warning (first input wins).
204
+ const symbols = [];
205
+ const edges = [];
206
+ const seenAnchors = new Set();
207
+ for (const spec of inputs) {
208
+ const { extract } = await import(`./adapters/${spec.adapter}.mjs`);
209
+ const r = extract(spec.adapter === "crg" ? { db: spec.db, root } : { raw: spec.raw, root });
210
+ let dropped = 0;
211
+ for (const s of r.symbols) {
212
+ if (seenAnchors.has(s.anchor)) { dropped++; continue; }
213
+ seenAnchors.add(s.anchor);
214
+ symbols.push(s);
215
+ }
216
+ for (const e of r.edges) edges.push(e);
217
+ console.error(`input ${spec.adapter}: ${r.symbols.length} symbols, ${r.edges.length} edges${dropped ? ` (${dropped} duplicate anchors dropped)` : ""}`);
218
+ }
219
+
220
+ // Exclusion: drop symbols whose source file is git-ignored (default) or
221
+ // matches an explicit --exclude glob. Vendored copies and third-party dumps
222
+ // belong out of the graph; the edge tables key on surviving anchors, so
223
+ // dangling references simply vanish (emit skips edges whose endpoints are
224
+ // gone). --no-gitignore turns off the git-driven half.
225
+ const excludeGlobs = args.flatMap((v, i) => (args[i - 1] === "--exclude" ? [v] : []));
226
+ const excluder = makeExcluder({
227
+ root: resolve(root),
228
+ globs: excludeGlobs,
229
+ gitignore: !args.includes("--no-gitignore"),
230
+ files: [...new Set(symbols.map((s) => s.file))],
231
+ exec: execFileSync,
232
+ });
233
+ const kept = symbols.filter((s) => !excluder(s.file));
234
+ const excludedCount = symbols.length - kept.length;
235
+ if (excludedCount) {
236
+ symbols.length = 0;
237
+ for (const s of kept) symbols.push(s);
238
+ console.error(
239
+ `excluded ${excludedCount} symbol(s) via ${!args.includes("--no-gitignore") ? ".gitignore" : "(gitignore off)"}`
240
+ + `${excludeGlobs.length ? ` + ${excludeGlobs.length} --exclude glob(s)` : ""}`,
241
+ );
242
+ }
243
+
244
+ // Exchange format on disk — the layer contract (§3). Deterministic order so
245
+ // the jsonl files diff cleanly across builds.
246
+ symbols.sort((a, b) => a.anchor.localeCompare(b.anchor));
247
+ edges.sort((a, b) =>
248
+ a.from.localeCompare(b.from) || a.kind.localeCompare(b.kind)
249
+ || String(a.to ?? a.to_text).localeCompare(String(b.to ?? b.to_text)));
250
+ mkdirSync(buildDir, { recursive: true });
251
+ const jsonl = (rows) => rows.map((r) => JSON.stringify(r)).join("\n") + "\n";
252
+ const writeIfChanged = (p, content) => {
253
+ if (existsSync(p) && readFileSync(p, "utf8") === content) return;
254
+ writeFileSync(p, content);
255
+ };
256
+ writeIfChanged(join(buildDir, "symbols.jsonl"), jsonl(symbols));
257
+ writeIfChanged(join(buildDir, "edges.jsonl"), jsonl(edges));
258
+
259
+ // Container granularity (codemap profile): module = first path segment,
260
+ // dir = containing directory (default), file = one document per source file.
261
+ const containerGranularity = flag("--container", "dir");
262
+ if (!["module", "dir", "file"].includes(containerGranularity)) {
263
+ console.error(`--container must be module|dir|file (got '${containerGranularity}')`);
264
+ process.exit(2);
265
+ }
266
+ // Best-effort commit stamp for index.geml meta.
267
+ let commit;
268
+ try {
269
+ commit = execFileSync("git", ["-C", resolve(root), "rev-parse", "--short", "HEAD"], { encoding: "utf8" }).trim();
270
+ } catch { /* not a git repo */ }
271
+
272
+ const stats = emit({
273
+ symbols, edges, outDir, buildDir,
274
+ repoName: basename(resolve(root)),
275
+ container: containerGranularity,
276
+ commit,
277
+ root: resolve(root),
278
+ });
279
+
280
+ console.error(
281
+ `geml-code-graph: ${stats.methods} methods (${stats.symbols} symbols), ${stats.edges} edges `
282
+ + `(${stats.resolved} resolved), ${stats.leaves} leaves, ${stats.entries} app entries -> `
283
+ + `${stats.containers} containers (${stats.written} of ${stats.docs} files written), `
284
+ + `${(stats.bytes / 1048576).toFixed(2)} MB -> ${outDir}`,
285
+ );
286
+
287
+ // --history: snapshot every changed document into its .gemlhistory sidecar —
288
+ // the graph's own architectural history (geml history log / revert per node).
289
+ // Targets = documents rewritten this build, plus any document that has no
290
+ // sidecar yet (first run, or --history adopted later).
291
+ if (args.includes("--history")) {
292
+ const histMod = resolve(dirname(fileURLToPath(import.meta.url)), "../dist/history.js");
293
+ if (!existsSync(histMod)) {
294
+ console.error("--history needs the built parser (cd geml-parser && npm install && npm run build)");
295
+ process.exit(1);
296
+ }
297
+ const { commit, isCurrent } = await import(`file://${histMod.replace(/\\/g, "/")}`);
298
+ const message = flag("-m", flag("--message", "graph build"));
299
+ const targets = new Set(stats.writtenDocs);
300
+ for (const d of stats.allDocs) {
301
+ if (targets.has(d)) continue;
302
+ const gemlPath = join(outDir, d);
303
+ const sidecar = gemlPath.replace(/\.geml$/, ".gemlhistory");
304
+ // No sidecar yet, or the sidecar tip drifted from the file (a previous
305
+ // commit attempt was refused): both need a snapshot even though this build
306
+ // did not rewrite the document.
307
+ if (!existsSync(sidecar) || !isCurrent(sidecar, gemlPath)) targets.add(d);
308
+ }
309
+ let committed = 0;
310
+ const histFailed = [];
311
+ for (const d of [...targets].sort()) {
312
+ const gemlPath = join(outDir, d);
313
+ try {
314
+ commit({ gemlPath, historyPath: gemlPath.replace(/\.geml$/, ".gemlhistory"), summary: message });
315
+ committed++;
316
+ } catch (e) {
317
+ // One document's history refusing a commit (e.g. the round-trip gate)
318
+ // must not abort the build or the other documents' snapshots. The
319
+ // failing document's previous revision stays intact.
320
+ histFailed.push(d);
321
+ console.error(`history: ${d}: ${e.message}`);
322
+ }
323
+ }
324
+ console.error(
325
+ `history: committed ${committed} document(s) (${stats.allDocs.length - committed - histFailed.length} unchanged, skipped)`
326
+ + (histFailed.length ? `; FAILED: ${histFailed.join(", ")}` : ""),
327
+ );
328
+ }
329
+
330
+ // Auto mode records the exact replay recipe (index → explicit build → verify)
331
+ // into _index/refresh.json on the FIRST build, so `geml codemap refresh` (and
332
+ // the commit hook) can reproduce it. An existing recipe is left untouched.
333
+ // Paths are relative to <root>, which is the cwd refresh runs each step in.
334
+ if (recordRecipe) {
335
+ const cfgPath = join(outDir, "_index", "refresh.json");
336
+ if (!existsSync(cfgPath)) {
337
+ const rel = (p) => (relative(recordRecipe.rootAbs, p).replace(/\\/g, "/") || ".");
338
+ const relOut = rel(outDir);
339
+ const buildStep = ["geml codemap build",
340
+ ...inputs.map((s) => `--adapter ${s.adapter} --raw ${rel(s.raw)}`),
341
+ "--root .", `--out ${relOut}`,
342
+ containerGranularity !== "dir" ? `--container ${containerGranularity}` : "",
343
+ args.includes("--history") ? "--history" : "",
344
+ ].filter(Boolean).join(" ");
345
+ const cfg = {
346
+ root: relative(outDir, recordRecipe.rootAbs).replace(/\\/g, "/") || "..",
347
+ steps: [...recordRecipe.indexSteps, buildStep, `geml codemap verify ${relOut}`],
348
+ };
349
+ mkdirSync(join(outDir, "_index"), { recursive: true });
350
+ writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + "\n");
351
+ console.error(`recorded build recipe -> ${cfgPath}`);
352
+ }
353
+ }
354
+ console.error(`next: geml codemap verify ${outDir}`);
@@ -0,0 +1,185 @@
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 } from "node:fs";
17
+ import { join, relative } from "node:path";
18
+
19
+ // Directories that never hold first-party source: pruned during the walk so a
20
+ // vendored dependency tree or build output can't swing the extension counts
21
+ // (and drag a whole Joern frontend into the build). Mirrors normalize.mjs
22
+ // SKIP_DIRS plus the codemap's own output dir.
23
+ export const SKIP_DIRS = new Set([
24
+ "node_modules", "target", "dist", "out", "build", ".git", "vendor",
25
+ ".geml-code-graph", ".geml-build", ".idea", ".gradle",
26
+ ]);
27
+
28
+ // Manifest filename -> the language it declares. Presence is a STRONG signal
29
+ // (no threshold): it fires even for a single source file, and takes priority
30
+ // over the extension count.
31
+ const MANIFEST_LANG = {
32
+ "tsconfig.json": "TypeScript",
33
+ "pom.xml": "Java",
34
+ "build.gradle": "Java",
35
+ "build.gradle.kts": "Java",
36
+ "go.mod": "Go",
37
+ };
38
+
39
+ // Source extension -> language. scip-typescript indexes JS as well as TS, so
40
+ // .js/.jsx map to the same TypeScript/scip job.
41
+ const EXT_LANG = {
42
+ ts: "TypeScript", tsx: "TypeScript", js: "TypeScript", jsx: "TypeScript",
43
+ java: "Java",
44
+ c: "C", h: "C",
45
+ py: "Python",
46
+ go: "Go",
47
+ kt: "Kotlin",
48
+ };
49
+
50
+ // A path counts as "source" if its extension maps to a language we index. Used
51
+ // by `refresh` to skip a rebuild when a commit touched only docs/config/CI —
52
+ // files that can't change the call graph.
53
+ export const isSourcePath = (p) => {
54
+ const dot = p.lastIndexOf(".");
55
+ return dot >= 0 && EXT_LANG[p.slice(dot + 1).toLowerCase()] !== undefined;
56
+ };
57
+
58
+ // Language -> indexer + Joern frontend. scip covers TypeScript/JS; everything
59
+ // else is a Joern frontend whose --language name (UPPERCASE) we pass as
60
+ // GEML_LANG so a mixed repo never falls back to Joern's majority-language
61
+ // autodetect.
62
+ export const LANG_JOB = {
63
+ TypeScript: { indexer: "scip", gemlLang: undefined },
64
+ Java: { indexer: "joern", gemlLang: "JAVASRC" },
65
+ C: { indexer: "joern", gemlLang: "NEWC" },
66
+ Python: { indexer: "joern", gemlLang: "PYTHONSRC" },
67
+ Go: { indexer: "joern", gemlLang: "GO" },
68
+ Kotlin: { indexer: "joern", gemlLang: "KOTLIN" },
69
+ };
70
+
71
+ // A language detected ONLY by file extension (no manifest) must clear a small
72
+ // presence bar, so a stray helper script (one .py in a big TS repo) can't drag
73
+ // a whole Joern frontend into the build. Manifests bypass the bar entirely.
74
+ export const MIN_EXT_SHARE = 0.05;
75
+
76
+ // Representative extension for a language (for the human-readable plan signal).
77
+ const extOf = (lang) => Object.keys(EXT_LANG).find((e) => EXT_LANG[e] === lang);
78
+
79
+ // Walk `root`, returning repo-relative POSIX source files and manifest files.
80
+ // SKIP_DIRS and dotdirs are pruned structurally (a gitignored path is dropped
81
+ // later by the caller's excluder). `readdir` is injectable for tests.
82
+ export function collectSourceFiles(root, { readdir = readdirSync } = {}) {
83
+ const files = []; // repo-relative POSIX source files
84
+ const manifests = []; // repo-relative POSIX manifest files
85
+ const walk = (dir) => {
86
+ let ents;
87
+ try { ents = readdir(dir, { withFileTypes: true }); } catch { return; }
88
+ for (const e of ents) {
89
+ if (e.isDirectory()) {
90
+ if (!SKIP_DIRS.has(e.name) && !e.name.startsWith(".")) walk(join(dir, e.name));
91
+ continue;
92
+ }
93
+ if (!e.isFile()) continue;
94
+ const rel = relative(root, join(dir, e.name)).replace(/\\/g, "/");
95
+ if (MANIFEST_LANG[e.name]) manifests.push(rel);
96
+ const dot = e.name.lastIndexOf(".");
97
+ const ext = dot > 0 ? e.name.slice(dot + 1).toLowerCase() : "";
98
+ if (EXT_LANG[ext]) files.push(rel);
99
+ }
100
+ };
101
+ walk(root);
102
+ return { files, manifests };
103
+ }
104
+
105
+ // Decide the indexer jobs for `root`. Returns [] when nothing supported is
106
+ // found. Each job: { language, indexer:"scip"|"joern", adapter, gemlLang?, signal }.
107
+ //
108
+ // Options:
109
+ // excluder (relPosixPath) => bool — drop gitignored/--exclude paths
110
+ // readdir injected fs.readdirSync (tests)
111
+ // files, manifests precomputed (from collectSourceFiles) to skip the walk
112
+ export function detectLanguages(root, { excluder = () => false, readdir, files, manifests } = {}) {
113
+ if (!files || !manifests) {
114
+ const c = collectSourceFiles(root, { readdir });
115
+ files = files ?? c.files;
116
+ manifests = manifests ?? c.manifests;
117
+ }
118
+ const keptFiles = files.filter((f) => !excluder(f));
119
+ const keptManifests = manifests.filter((m) => !excluder(m));
120
+
121
+ // 1. manifest languages — strong signal, priority over extension counts.
122
+ const detected = new Map(); // language -> signal string
123
+ for (const m of keptManifests) {
124
+ const name = m.slice(m.lastIndexOf("/") + 1);
125
+ const lang = MANIFEST_LANG[name];
126
+ if (lang && !detected.has(lang)) detected.set(lang, name);
127
+ }
128
+
129
+ // 2. extension counts across the surviving source files.
130
+ const counts = new Map(); // language -> file count
131
+ for (const f of keptFiles) {
132
+ const dot = f.lastIndexOf(".");
133
+ const lang = EXT_LANG[dot >= 0 ? f.slice(dot + 1).toLowerCase() : ""];
134
+ if (lang) counts.set(lang, (counts.get(lang) ?? 0) + 1);
135
+ }
136
+ const total = [...counts.values()].reduce((a, b) => a + b, 0);
137
+
138
+ // 3. extension-only languages that clear the presence bar and weren't
139
+ // already established by a manifest.
140
+ for (const [lang, n] of counts) {
141
+ if (detected.has(lang)) continue;
142
+ if (total > 0 && n / total >= MIN_EXT_SHARE) detected.set(lang, `.${extOf(lang)}`);
143
+ }
144
+
145
+ const jobs = [];
146
+ for (const [language, signal] of detected) {
147
+ const spec = LANG_JOB[language];
148
+ if (spec) jobs.push({ language, indexer: spec.indexer, adapter: spec.indexer, gemlLang: spec.gemlLang, signal });
149
+ }
150
+ // Deterministic order: scip before joern, then by GEML_LANG, then language.
151
+ jobs.sort((a, b) =>
152
+ (a.indexer === b.indexer ? 0 : a.indexer === "scip" ? -1 : 1)
153
+ || (a.gemlLang ?? "").localeCompare(b.gemlLang ?? "")
154
+ || a.language.localeCompare(b.language));
155
+ return jobs;
156
+ }
157
+
158
+ // Turn one detection job into the concrete command a subprocess runs. Pure:
159
+ // the caller supplies resolved absolute paths. `raw` is what the matching
160
+ // adapter consumes downstream — a .scip FILE for scip, the JSONL output DIR
161
+ // for joern (joern-export.sc writes methods.jsonl + calls.jsonl there).
162
+ // root resolved project root (scip cwd; joern GEML_SRC)
163
+ // buildDir where intermediates land (typically <out>/_build)
164
+ // scriptPath resolved path to joern-export.sc
165
+ export function indexerCommand(job, { root, buildDir, scriptPath }) {
166
+ if (job.indexer === "scip") {
167
+ const raw = join(buildDir, "index.scip");
168
+ return {
169
+ adapter: "scip",
170
+ raw,
171
+ argv: ["npx", "--yes", "@sourcegraph/scip-typescript", "index", "--output", raw],
172
+ env: undefined,
173
+ cwd: root,
174
+ };
175
+ }
176
+ // joern: one output dir per frontend so several Joern jobs never clash.
177
+ const raw = join(buildDir, `joern-${String(job.gemlLang).toLowerCase()}`);
178
+ return {
179
+ adapter: "joern",
180
+ raw,
181
+ argv: ["joern", "--script", scriptPath],
182
+ env: { GEML_SRC: root, GEML_OUT: raw, GEML_LANG: job.gemlLang },
183
+ cwd: root,
184
+ };
185
+ }