@geml/geml 1.1.1 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,8 +13,9 @@
13
13
  // turns one job into the argv/env/raw a subprocess (and the refresh recipe)
14
14
  // needs — also pure. Neither ever spawns a process.
15
15
 
16
- import { readdirSync } from "node:fs";
16
+ import { readdirSync, readFileSync } from "node:fs";
17
17
  import { join, relative } from "node:path";
18
+ import { globToRegExp } from "./exclude.mjs";
18
19
 
19
20
  // Directories that never hold first-party source: pruned during the walk so a
20
21
  // vendored dependency tree or build output can't swing the extension counts
@@ -30,6 +31,7 @@ export const SKIP_DIRS = new Set([
30
31
  // over the extension count.
31
32
  const MANIFEST_LANG = {
32
33
  "tsconfig.json": "TypeScript",
34
+ "Cargo.toml": "Rust",
33
35
  "pom.xml": "Java",
34
36
  "build.gradle": "Java",
35
37
  "build.gradle.kts": "Java",
@@ -37,9 +39,13 @@ const MANIFEST_LANG = {
37
39
  };
38
40
 
39
41
  // Source extension -> language. scip-typescript indexes JS as well as TS, so
40
- // .js/.jsx map to the same TypeScript/scip job.
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.
41
45
  const EXT_LANG = {
42
46
  ts: "TypeScript", tsx: "TypeScript", js: "TypeScript", jsx: "TypeScript",
47
+ vue: "TypeScript", svelte: "TypeScript",
48
+ rs: "Rust",
43
49
  java: "Java",
44
50
  c: "C", h: "C",
45
51
  py: "Python",
@@ -55,12 +61,14 @@ export const isSourcePath = (p) => {
55
61
  return dot >= 0 && EXT_LANG[p.slice(dot + 1).toLowerCase()] !== undefined;
56
62
  };
57
63
 
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.
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.
62
69
  export const LANG_JOB = {
63
70
  TypeScript: { indexer: "scip", gemlLang: undefined },
71
+ Rust: { indexer: "scip", gemlLang: undefined },
64
72
  Java: { indexer: "joern", gemlLang: "JAVASRC" },
65
73
  C: { indexer: "joern", gemlLang: "NEWC" },
66
74
  Python: { indexer: "joern", gemlLang: "PYTHONSRC" },
@@ -82,6 +90,7 @@ const extOf = (lang) => Object.keys(EXT_LANG).find((e) => EXT_LANG[e] === lang);
82
90
  export function collectSourceFiles(root, { readdir = readdirSync } = {}) {
83
91
  const files = []; // repo-relative POSIX source files
84
92
  const manifests = []; // repo-relative POSIX manifest files
93
+ const pkgs = []; // repo-relative POSIX package.json files (TS/JS project roots)
85
94
  const walk = (dir) => {
86
95
  let ents;
87
96
  try { ents = readdir(dir, { withFileTypes: true }); } catch { return; }
@@ -93,30 +102,97 @@ export function collectSourceFiles(root, { readdir = readdirSync } = {}) {
93
102
  if (!e.isFile()) continue;
94
103
  const rel = relative(root, join(dir, e.name)).replace(/\\/g, "/");
95
104
  if (MANIFEST_LANG[e.name]) manifests.push(rel);
105
+ if (e.name === "package.json") pkgs.push(rel);
96
106
  const dot = e.name.lastIndexOf(".");
97
107
  const ext = dot > 0 ? e.name.slice(dot + 1).toLowerCase() : "";
98
108
  if (EXT_LANG[ext]) files.push(rel);
99
109
  }
100
110
  };
101
111
  walk(root);
102
- return { files, manifests };
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;
103
171
  }
104
172
 
105
173
  // Decide the indexer jobs for `root`. Returns [] when nothing supported is
106
- // found. Each job: { language, indexer:"scip"|"joern", adapter, gemlLang?, signal }.
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.
107
177
  //
108
178
  // Options:
109
179
  // excluder (relPosixPath) => bool — drop gitignored/--exclude paths
110
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
111
183
  // files, manifests precomputed (from collectSourceFiles) to skip the walk
112
- export function detectLanguages(root, { excluder = () => false, readdir, files, manifests } = {}) {
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");
113
187
  if (!files || !manifests) {
114
188
  const c = collectSourceFiles(root, { readdir });
115
189
  files = files ?? c.files;
116
190
  manifests = manifests ?? c.manifests;
191
+ pkgs = pkgs ?? c.pkgs;
117
192
  }
118
193
  const keptFiles = files.filter((f) => !excluder(f));
119
194
  const keptManifests = manifests.filter((m) => !excluder(m));
195
+ const keptPkgs = (pkgs ?? []).filter((p) => !excluder(p));
120
196
 
121
197
  // 1. manifest languages — strong signal, priority over extension counts.
122
198
  const detected = new Map(); // language -> signal string
@@ -145,16 +221,99 @@ export function detectLanguages(root, { excluder = () => false, readdir, files,
145
221
  const jobs = [];
146
222
  for (const [language, signal] of detected) {
147
223
  const spec = LANG_JOB[language];
148
- if (spec) jobs.push({ language, indexer: spec.indexer, adapter: spec.indexer, gemlLang: spec.gemlLang, signal });
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 });
149
296
  }
150
- // Deterministic order: scip before joern, then by GEML_LANG, then language.
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.
151
301
  jobs.sort((a, b) =>
152
302
  (a.indexer === b.indexer ? 0 : a.indexer === "scip" ? -1 : 1)
153
303
  || (a.gemlLang ?? "").localeCompare(b.gemlLang ?? "")
154
- || a.language.localeCompare(b.language));
304
+ || a.language.localeCompare(b.language)
305
+ || (b.subroot ?? "").length - (a.subroot ?? "").length
306
+ || (a.subroot ?? "").localeCompare(b.subroot ?? ""));
155
307
  return jobs;
156
308
  }
157
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
+
158
317
  // Turn one detection job into the concrete command a subprocess runs. Pure:
159
318
  // the caller supplies resolved absolute paths. `raw` is what the matching
160
319
  // adapter consumes downstream — a .scip FILE for scip, the JSONL output DIR
@@ -162,24 +321,79 @@ export function detectLanguages(root, { excluder = () => false, readdir, files,
162
321
  // root resolved project root (scip cwd; joern GEML_SRC)
163
322
  // buildDir where intermediates land (typically <out>/_build)
164
323
  // scriptPath resolved path to joern-export.sc
165
- export function indexerCommand(job, { root, buildDir, scriptPath }) {
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 }) {
166
331
  if (job.indexer === "scip") {
167
- const raw = join(buildDir, "index.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.
168
377
  return {
169
378
  adapter: "scip",
170
379
  raw,
171
380
  argv: ["npx", "--yes", "@sourcegraph/scip-typescript", "index", "--output", raw],
172
381
  env: undefined,
173
- cwd: root,
382
+ cwd: job.subroot ? subrootAbs : root,
174
383
  };
175
384
  }
176
385
  // joern: one output dir per frontend so several Joern jobs never clash.
177
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.
178
392
  return {
179
393
  adapter: "joern",
180
394
  raw,
181
395
  argv: ["joern", "--script", scriptPath],
182
396
  env: { GEML_SRC: root, GEML_OUT: raw, GEML_LANG: job.gemlLang },
183
- cwd: root,
397
+ cwd: buildDir,
184
398
  };
185
399
  }
package/codemap/emit.mjs CHANGED
@@ -18,7 +18,13 @@ import { dirname, join, posix } from "node:path";
18
18
  import { buildNormalizer } from "./normalize.mjs";
19
19
 
20
20
  const esc = (s) => String(s).replace(/`/g, "'");
21
- const attrVal = (s) => String(s).replace(/"/g, "'");
21
+ // Attribute values live on the block-header LINE: a newline inside one (e.g.
22
+ // scip's anonymous-type-literal descriptors embed the literal's multi-line
23
+ // text in the symbol) would truncate the header mid-value — the block still
24
+ // parses as raw text but its #id never registers, and every edge to it
25
+ // dangles (found on next.js: `recursiveCopy().({ filter... })`). Collapse all
26
+ // whitespace runs to single spaces alongside the quote swap.
27
+ const attrVal = (s) => String(s).replace(/"/g, "'").replace(/\s+/g, " ");
22
28
  // Plain-text cells: no commas/newlines (CSV), and no square brackets — table
23
29
  // cells are inline-parsed, so `f[i](&x)` would otherwise read as a LINK with an
24
30
  // unresolvable target. Brackets become parens: still readable, never markup.
@@ -42,11 +48,35 @@ const slugPath = (p) => (p === "" || p === "(root)" ? "root" : p.replace(/\//g,
42
48
  const dirOf = (rel) => { const i = rel.lastIndexOf("/"); return i < 0 ? "(root)" : rel.slice(0, i); };
43
49
  const topOf = (rel) => { const i = rel.indexOf("/"); return i < 0 ? "(root)" : rel.slice(0, i); };
44
50
 
45
- export function emit({ symbols, edges, outDir, buildDir, repoName, container = "dir", commit, root }) {
51
+ export function emit({ symbols, edges, outDir, buildDir, repoName, container = "dir", commit, root, foldings, entryHints }) {
46
52
  const byAnchor = new Map(symbols.map((s) => [s.anchor, s]));
47
53
  const methods = symbols.filter((s) => s.kind === "Function" || s.kind === "Test");
48
54
  const files = symbols.filter((s) => s.kind === "File");
49
55
 
56
+ // ---- app-entry hints (codemap/entries.mjs) ----
57
+ // A named hint marks its method; a file-level hint (SPA bootstrap, Nuxt app
58
+ // shell…) marks the file's only method, or — when the entry is top-level
59
+ // code with no function symbol at all — falls through to a doc-level
60
+ // `app-entry-file` note. The adapters' own name==="main" flag rides along;
61
+ // every marked entry carries `via` (the convention that identified it).
62
+ const fileHints = [];
63
+ {
64
+ const methodsByFile = new Map();
65
+ for (const s of methods) {
66
+ if (!methodsByFile.has(s.file)) methodsByFile.set(s.file, []);
67
+ methodsByFile.get(s.file).push(s);
68
+ }
69
+ for (const h of entryHints ?? []) {
70
+ const list = methodsByFile.get(h.file) ?? [];
71
+ let hit;
72
+ if (h.name) hit = list.find((s) => s.name === h.name || s.name.endsWith(`::${h.name}`) || s.name.endsWith(`.${h.name}`));
73
+ else if (list.length === 1) hit = list[0];
74
+ if (hit) { hit.entry = true; hit.entryVia ??= h.via; }
75
+ else fileHints.push(h);
76
+ }
77
+ for (const s of methods) if (s.entry && !s.entryVia) s.entryVia = "main";
78
+ }
79
+
50
80
  // ---- containers ----
51
81
  const containerOf = (s) =>
52
82
  container === "file" ? s.file : container === "module" ? topOf(s.file) : dirOf(s.file);
@@ -57,7 +87,7 @@ export function emit({ symbols, edges, outDir, buildDir, repoName, container = "
57
87
  // keys on the TRUE path (containerOf) and `src=` stays the true path — only
58
88
  // the displayed module path shortens. root may be absent (older callers / crg
59
89
  // tier): then displayOf is the identity.
60
- const normMap = root ? buildNormalizer(root, methods.map(containerOf), { repoName, fileMode: container === "file" }) : new Map();
90
+ const normMap = root ? buildNormalizer(root, methods.map(containerOf), { repoName, fileMode: container === "file", config: foldings }) : new Map();
61
91
  const displayOf = (name) => normMap.get(name) ?? name;
62
92
  const containers = new Map(); // name -> { docName, methods[], files[] }
63
93
  const taken = new Set(["index.geml"]);
@@ -79,6 +109,16 @@ export function emit({ symbols, edges, outDir, buildDir, repoName, container = "
79
109
  for (const [name, c] of containers) {
80
110
  for (const s of [...c.methods, ...c.files]) docOfAnchor.set(s.anchor, c.docName);
81
111
  }
112
+ // File-level app-entry hints, grouped under the container that holds the
113
+ // file (a hint whose file grew no container at all has nowhere honest to
114
+ // land and is dropped).
115
+ const fileHintsByDoc = new Map(); // docName -> [{file, via}]
116
+ for (const h of fileHints) {
117
+ const c = containers.get(containerOf({ file: h.file }));
118
+ if (!c) continue;
119
+ if (!fileHintsByDoc.has(c.docName)) fileHintsByDoc.set(c.docName, []);
120
+ fileHintsByDoc.get(c.docName).push(h);
121
+ }
82
122
 
83
123
  // ---- block ids: short name when unique in its doc, else name-<sha6(anchor)> ----
84
124
  const idOf = new Map(); // anchor -> id
@@ -212,6 +252,13 @@ export function emit({ symbols, edges, outDir, buildDir, repoName, container = "
212
252
  + `module = ${csvCell(dispLabel)}\n`
213
253
  + (srcDir ? `src = ${csvCell(srcDir)}\n` : "")
214
254
  + (entries.length ? `entry = ${entries.map((s) => `#${idOf.get(s.anchor)}`).join(" ")}\n` : "")
255
+ // app-entry: WHERE the program starts (main, a mount, a worker handler)
256
+ // — a separate, much rarer list than entry= (the container's inbound
257
+ // call surface). File-level entries (top-level bootstrap code with no
258
+ // function symbol) are named by path instead of a block reference.
259
+ + (c.methods.some((s) => s.entry)
260
+ ? `app-entry = ${c.methods.filter((s) => s.entry).map((s) => `#${idOf.get(s.anchor)} (${s.entryVia})`).join(" ")}\n` : "")
261
+ + (fileHintsByDoc.get(doc) ?? []).map((h) => `app-entry-file = ${csvCell(h.file)} (${h.via})\n`).join("")
215
262
  + `resolution-default = ${RESOLUTION_DEFAULT}\n===\n`,
216
263
  `# ${esc(dispLabel)}\n`,
217
264
  ];
@@ -232,14 +279,15 @@ export function emit({ symbols, edges, outDir, buildDir, repoName, container = "
232
279
  chunks.push(fileSym ? `## ${esc(base)} {#${idOf.get(fileSym.anchor)}}\n` : `## ${esc(base)}\n`);
233
280
  }
234
281
  for (const s of list) {
235
- const cls = `${isTestPath(s.file) ? " .test" : ""}${isLeaf(s) ? " .leaf" : ""}${isAccessor(s) ? " .accessor" : ""}${s.flow_crit ? " .flow-entry" : ""}`;
282
+ const cls = `${isTestPath(s.file) ? " .test" : ""}${isLeaf(s) ? " .leaf" : ""}${isAccessor(s) ? " .accessor" : ""}${s.flow_crit ? " .flow-entry" : ""}${s.entry ? " .app-entry" : ""}`;
236
283
  const src = `${s.file}${s.line_start !== undefined ? `#L${s.line_start}-${s.line_end ?? s.line_start}` : ""}`;
237
284
  // The display name rides along whenever id sanitisation changed it
238
285
  // ("RenderCtx.block" -> id RenderCtx-block): renderers label nodes
239
286
  // with the real name, ids stay reference-grammar clean.
240
287
  const id = idOf.get(s.anchor);
241
288
  const nameAttr = s.name !== id ? ` name="${attrVal(s.name)}"` : "";
242
- chunks.push(`=== code {#${id}${cls}${nameAttr} src=${attrVal(src)} anchor="${attrVal(s.anchor)}"}\n===\n`);
289
+ const viaAttr = s.entry && s.entryVia ? ` entry-via="${attrVal(s.entryVia)}"` : "";
290
+ chunks.push(`=== code {#${id}${cls}${nameAttr}${viaAttr} src=${attrVal(src)} anchor="${attrVal(s.anchor)}"}\n===\n`);
243
291
  }
244
292
  }
245
293
 
@@ -301,6 +349,9 @@ export function emit({ symbols, edges, outDir, buildDir, repoName, container = "
301
349
  + (commit ? `commit = ${csvCell(commit)}\n` : "")
302
350
  + `container = ${container}\n`
303
351
  + (appEntries.length ? `entry = ${appEntries.map((a) => `${docOfAnchor.get(a)}#${idOf.get(a)}`).join(" ")}\n` : "")
352
+ // Documents whose app entry is FILE-level (top-level bootstrap code, no
353
+ // function symbol) — its own key so entry= keeps its doc#id grammar.
354
+ + (fileHintsByDoc.size ? `app-entry-docs = ${[...fileHintsByDoc.keys()].sort().join(" ")}\n` : "")
304
355
  + `resolution-default = ${RESOLUTION_DEFAULT}\n===\n`,
305
356
  `# Code map — ${esc(repoName)}\n`,
306
357
  csv("modules", ["module", "doc", "methods", "entries", "tests"],
@@ -326,6 +377,11 @@ export function emit({ symbols, edges, outDir, buildDir, repoName, container = "
326
377
  addLookup(s.name, s);
327
378
  const dot = s.name.indexOf(".");
328
379
  if (dot > 0 && dot < s.name.length - 1) addLookup(s.name.slice(dot + 1), s);
380
+ else {
381
+ // Rust members qualify with "::" (Widget::area) — same bare-name alias.
382
+ const c = s.name.indexOf("::");
383
+ if (c > 0 && c < s.name.length - 2) addLookup(s.name.slice(c + 2), s);
384
+ }
329
385
  }
330
386
  const sortedLookup = {};
331
387
  for (const name of [...lookup.keys()].sort()) {
@@ -333,6 +389,17 @@ export function emit({ symbols, edges, outDir, buildDir, repoName, container = "
333
389
  }
334
390
  writeIfChanged("_index/name-lookup.json", JSON.stringify(sortedLookup, null, 2) + "\n");
335
391
 
392
+ // Compact search index for the viewer's name -> node typeahead: a flat
393
+ // [name, doc, id] list with the (bulky) anchors dropped, so it stays small
394
+ // even on huge repos. Emitted as a JS global so a STATIC page can load it via
395
+ // `<script src>` (a file:// page can't fetch() a sibling file, but a script
396
+ // tag is exempt); serve also searches it server-side for big graphs.
397
+ const searchIndex = [];
398
+ for (const name of Object.keys(sortedLookup)) {
399
+ for (const c of sortedLookup[name]) searchIndex.push([name, c.doc, c.id]);
400
+ }
401
+ writeIfChanged("_index/search-index.js", "window.__gemlSearch=" + JSON.stringify(searchIndex) + ";\n");
402
+
336
403
  // ---- edges-manifest (internal) ----
337
404
  if (buildDir) {
338
405
  const manifest = {};
@@ -354,8 +421,12 @@ export function emit({ symbols, edges, outDir, buildDir, repoName, container = "
354
421
  symbols: symbols.length,
355
422
  methods: methods.length,
356
423
  edges: edges.length,
357
- resolved: calls.filter((e) => e.to !== undefined && docOfAnchor.has(e.to)).length,
424
+ // A `calls` edge is "resolved" only when it actually yields a table row —
425
+ // that needs BOTH endpoints known (the calls loop above skips any edge
426
+ // whose FROM anchor is unknown). Counting by target alone inflated the
427
+ // figure for a dangling-FROM edge that produced no row.
428
+ resolved: calls.filter((e) => docOfAnchor.has(e.from) && e.to !== undefined && docOfAnchor.has(e.to)).length,
358
429
  leaves: methods.filter((s) => isLeaf(s)).length,
359
- entries: appEntries.length,
430
+ entries: appEntries.length + [...fileHintsByDoc.values()].reduce((n, a) => n + a.length, 0),
360
431
  };
361
432
  }
@@ -0,0 +1,129 @@
1
+ // geml-code-graph app-entry detection — WHERE does this repo start running?
2
+ //
3
+ // Emits entry HINTS ({ file, via, name? }) from three signal tiers, each
4
+ // carrying an honest `via` label (the codemap never claims an entry without
5
+ // saying what convention identified it):
6
+ // L2 manifest/layout Cargo [[bin]] & src/main.rs & src/bin/*, package.json
7
+ // bin, wrangler.toml main, Nuxt app.vue, Next root
8
+ // page, SvelteKit root route, Django manage.py,
9
+ // python __main__.py
10
+ // L3 source markers workers-rs #[event(...)], createApp().mount() /
11
+ // createRoot() / svelte mount (SPA bootstraps),
12
+ // .listen() (node servers), export default { fetch }
13
+ // (JS workers), Flask()/FastAPI() apps,
14
+ // @SpringBootApplication
15
+ // (L1 — a function literally named `main` — is already flagged by the scip
16
+ // and joern adapters at extraction time; hints here ADD to it.)
17
+ //
18
+ // Pure by design: given precomputed { files, manifests, pkgs } lists it walks
19
+ // nothing; `readText`/`readJson` are injectable, and every source peek is
20
+ // bounded to a handful of conventional entry files per project — never a
21
+ // repo-wide grep. A hint is only emitted for files the build actually indexes
22
+ // (present in `files`), so a pkg-bin pointing at dist/ never leaks in.
23
+ import { readFileSync } from "node:fs";
24
+ import { join } from "node:path";
25
+
26
+ const dirOf = (p) => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "");
27
+
28
+ export function detectEntries(root, { files = [], manifests = [], pkgs = [], readText, readJson } = {}) {
29
+ readText ??= (p) => readFileSync(p, "utf8");
30
+ readJson ??= (p) => JSON.parse(readText(p));
31
+ const fileSet = new Set(files);
32
+ const hints = [];
33
+ const seen = new Set();
34
+ const add = (file, via, name) => {
35
+ if (!file || !fileSet.has(file)) return;
36
+ const k = `${file}${via}${name ?? ""}`;
37
+ if (seen.has(k)) return;
38
+ seen.add(k);
39
+ hints.push(name ? { file, via, name } : { file, via });
40
+ };
41
+ const tryText = (rel) => {
42
+ try { return readText(join(root, ...rel.split("/"))); } catch { return null; }
43
+ };
44
+
45
+ // ---- Rust: cargo bin targets + workers-rs event handlers ----
46
+ for (const m of manifests.filter((x) => x.endsWith("Cargo.toml"))) {
47
+ const dir = dirOf(m);
48
+ const at = (rel) => (dir ? `${dir}/${rel}` : rel);
49
+ add(at("src/main.rs"), "cargo-bin", "main");
50
+ for (const f of files) if (f.startsWith(at("src/bin/")) && f.endsWith(".rs")) add(f, "cargo-bin", "main");
51
+ const toml = tryText(m);
52
+ if (toml) {
53
+ for (const b of toml.matchAll(/^\[\[bin\]\][^[]*/gm)) {
54
+ const p = /path\s*=\s*"([^"]+)"/.exec(b[0]);
55
+ if (p) add(at(p[1].replace(/\\/g, "/")), "cargo-bin", "main");
56
+ }
57
+ }
58
+ for (const rel of ["src/main.rs", "src/lib.rs"]) {
59
+ const t = fileSet.has(at(rel)) ? tryText(at(rel)) : null;
60
+ if (!t) continue;
61
+ for (const ev of t.matchAll(/#\[event\((\w+)[^)]*\)\]\s*(?:pub\s+)?(?:async\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)/g)) {
62
+ add(at(rel), `worker-${ev[1]}`, ev[2]);
63
+ }
64
+ }
65
+ }
66
+
67
+ // ---- Node/TS/frontends: one look per package ----
68
+ for (const p of pkgs) {
69
+ const dir = dirOf(p);
70
+ const at = (rel) => (dir ? `${dir}/${rel}` : rel);
71
+ let pkg = {};
72
+ try { pkg = readJson(join(root, ...p.split("/"))) ?? {}; } catch { /* unreadable manifest */ }
73
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
74
+ const norm = (v) => (typeof v === "string" ? v.replace(/^\.\//, "").replace(/\\/g, "/") : null);
75
+ const bins = typeof pkg.bin === "string" ? [pkg.bin] : Object.values(pkg.bin ?? {});
76
+ for (const b of bins) { const f = norm(b); if (f) add(at(f), "pkg-bin"); }
77
+ const wrangler = tryText(at("wrangler.toml"));
78
+ if (wrangler) {
79
+ const mm = /^\s*main\s*=\s*"([^"]+)"/m.exec(wrangler);
80
+ if (mm) add(at(norm(mm[1])), "worker-fetch");
81
+ }
82
+ // Nuxt: the app shell is the entry; individual pages are routes, not
83
+ // program starts — deliberately NOT flooded into app-entries.
84
+ if (deps.nuxt || fileSet.has(at("nuxt.config.ts")) || fileSet.has(at("nuxt.config.js"))) {
85
+ if (fileSet.has(at("app.vue"))) add(at("app.vue"), "nuxt-app");
86
+ else add(at("pages/index.vue"), "nuxt-page");
87
+ }
88
+ if (deps.next) {
89
+ for (const rel of ["app/page.tsx", "app/page.jsx", "src/app/page.tsx", "pages/index.tsx", "pages/index.jsx", "src/pages/index.tsx"]) {
90
+ if (fileSet.has(at(rel))) { add(at(rel), "next-page"); break; }
91
+ }
92
+ }
93
+ if (deps["@sveltejs/kit"]) add(at("src/routes/+page.svelte"), "kit-route");
94
+ // SPA bootstrap / server start markers — conventional entry files only.
95
+ for (const rel of ["src/main.ts", "src/main.tsx", "src/main.js", "src/main.jsx",
96
+ "src/index.ts", "src/index.tsx", "src/index.js", "index.ts", "index.js",
97
+ "src/server.ts", "src/server.js", "server.js", "src/app.ts", "app.js"]) {
98
+ const f = at(rel);
99
+ if (!fileSet.has(f)) continue;
100
+ const t = tryText(f);
101
+ if (!t) continue;
102
+ if (/createApp\s*\(/.test(t) && /\.mount\s*\(/.test(t)) add(f, "vue-mount");
103
+ else if (/createRoot\s*\(|ReactDOM\.render\s*\(/.test(t)) add(f, "react-mount");
104
+ else if (deps.svelte && /\bnew\s+\w+\s*\(\s*\{[^}]*target|\bmount\s*\(/.test(t)) add(f, "svelte-mount");
105
+ if (/\.listen\s*\(/.test(t)) add(f, "server-listen");
106
+ if (/export\s+default\s*\{[^}]*\bfetch\b/s.test(t)) add(f, "worker-fetch");
107
+ }
108
+ }
109
+
110
+ // ---- Python ----
111
+ for (const f of files) {
112
+ if (/(^|\/)manage\.py$/.test(f)) add(f, "django-manage");
113
+ else if (/(^|\/)__main__\.py$/.test(f)) add(f, "py-main");
114
+ else if (/(^|\/)(app|main|wsgi|asgi)\.py$/.test(f)) {
115
+ const t = tryText(f);
116
+ if (t && /\bFlask\s*\(|\bFastAPI\s*\(/.test(t)) add(f, "wsgi-app");
117
+ }
118
+ }
119
+
120
+ // ---- Java: Spring Boot (convention-named files only, never a repo grep) ----
121
+ for (const f of files) {
122
+ if (/Application\.java$/.test(f)) {
123
+ const t = tryText(f);
124
+ if (t && /@SpringBootApplication/.test(t)) add(f, "spring-boot", "main");
125
+ }
126
+ }
127
+
128
+ return hints;
129
+ }