@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.
- package/README.md +41 -11
- package/codemap/adapters/crg.mjs +11 -0
- package/codemap/adapters/scip.mjs +481 -52
- package/codemap/browser-stub.mjs +5 -0
- package/codemap/build.mjs +270 -45
- package/codemap/detect.mjs +230 -16
- package/codemap/emit.mjs +78 -7
- package/codemap/entries.mjs +129 -0
- package/codemap/find.mjs +63 -0
- package/codemap/foldings.mjs +110 -0
- package/codemap/mcp-server.mjs +44 -15
- package/codemap/normalize.mjs +0 -0
- package/codemap/recipe-trust.mjs +103 -0
- package/codemap/refresh.mjs +158 -8
- package/codemap/serve.mjs +428 -228
- package/codemap/sfc-virtualize.mjs +367 -0
- package/codemap/verify.mjs +20 -3
- package/dist/from-md.js +66 -7
- package/dist/geml.d.ts +2 -1
- package/dist/geml.js +369 -97
- package/dist/history.js +102 -62
- package/dist/inline.d.ts +2 -1
- package/dist/inline.js +61 -8
- package/dist/render-html.d.ts +3 -0
- package/dist/render-html.js +95 -0
- package/dist/render.d.ts +32 -2
- package/dist/render.js +308 -116
- package/dist/serialize.js +22 -2
- package/dist/table.js +40 -5
- package/dist/to-md.js +4 -0
- package/package.json +63 -58
package/codemap/detect.mjs
CHANGED
|
@@ -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
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
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?,
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|