@geml/geml 1.4.2 → 1.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +186 -155
- package/codemap/adapters/crg.mjs +120 -120
- package/codemap/adapters/joern.mjs +131 -131
- package/codemap/adapters/scip.mjs +658 -658
- package/codemap/browser-stub.mjs +29 -29
- package/codemap/build.mjs +609 -609
- package/codemap/cross-stack.mjs +303 -303
- package/codemap/detect.mjs +399 -399
- package/codemap/emit.mjs +480 -480
- package/codemap/entries.mjs +129 -129
- package/codemap/exclude.mjs +52 -52
- package/codemap/find.mjs +63 -63
- package/codemap/foldings.mjs +110 -110
- package/codemap/joern-export.sc +83 -83
- package/codemap/mcp-server.mjs +172 -172
- package/codemap/normalize.mjs +275 -275
- package/codemap/recipe-trust.mjs +103 -103
- package/codemap/refresh.mjs +310 -310
- package/codemap/render-all.mjs +64 -64
- package/codemap/serve.mjs +578 -578
- package/codemap/sfc-virtualize.mjs +367 -367
- package/codemap/verify.mjs +148 -148
- package/dist/chart.d.ts +2 -0
- package/dist/chart.js +15 -15
- package/dist/diagnostics.d.ts +9 -0
- package/dist/diagnostics.js +73 -0
- package/dist/geml.d.ts +3 -5
- package/dist/geml.js +261 -115
- package/dist/history.d.ts +10 -0
- package/dist/history.js +25 -24
- package/dist/inline.js +7 -7
- package/dist/mcp.d.ts +18 -0
- package/dist/mcp.js +543 -0
- package/dist/render-html.js +35 -35
- package/dist/render.js +136 -136
- package/dist/table.d.ts +2 -0
- package/dist/table.js +11 -11
- package/package.json +63 -62
package/codemap/normalize.mjs
CHANGED
|
@@ -1,275 +1,275 @@
|
|
|
1
|
-
// Container-path normalisation (GEP-0003 §4, "module root + common prefix").
|
|
2
|
-
//
|
|
3
|
-
// A container's raw path is its directory relative to the repo root, e.g.
|
|
4
|
-
// magic-api/src/main/java/org/ssssssss/magicapi/core/config
|
|
5
|
-
// The leading run `src/main/java/org/ssssssss/magicapi` is pure ceremony: the
|
|
6
|
-
// build-system source root (`src/main/java`) plus the group-id package root
|
|
7
|
-
// (`org/ssssssss/magicapi`) that EVERY container in the module shares. It
|
|
8
|
-
// carries no navigational information — it only buries the real structure.
|
|
9
|
-
//
|
|
10
|
-
// The fix is language-agnostic and data-driven: within each MODULE (a
|
|
11
|
-
// directory that declares itself one via pom.xml / build.gradle /
|
|
12
|
-
// package.json / tsconfig.json), strip the longest common directory prefix
|
|
13
|
-
// shared by all of that module's containers. `magic-api` then opens straight
|
|
14
|
-
// onto `core / backup / modules …` instead of a chain of empty ceremony
|
|
15
|
-
// layers. A module that vendored several group-ids (netty's shaded jars) has
|
|
16
|
-
// a short common prefix and keeps its `com / io / org` fork — that is real
|
|
17
|
-
// package structure, and such vendored trees are meant to be EXCLUDED, not
|
|
18
|
-
// normalised.
|
|
19
|
-
//
|
|
20
|
-
// A further layer of PURE ceremony can sit ABOVE the module root itself —
|
|
21
|
-
// Cargo's bare `crates/` directory (crates/foo -> foo), a vendored fork like
|
|
22
|
-
// `libs/vendor/`, or whatever a given repo's layout adds. That layer is no
|
|
23
|
-
// longer hardcoded: `foldPrefixes`, sourced from `foldings.geml` (see
|
|
24
|
-
// foldings.mjs), lists the leading segment-runs to fold off a module's
|
|
25
|
-
// display name — entries may be multi-segment (`libs/vendor`), and the
|
|
26
|
-
// longest match wins. The same collision guard applies: if folding would
|
|
27
|
-
// make two modules collide (crates/util next to a real util), both keep
|
|
28
|
-
// their full paths — ambiguity is worse than ceremony.
|
|
29
|
-
//
|
|
30
|
-
// Normalisation only rewrites the container's DISPLAY path (its `module=` and
|
|
31
|
-
// document name). Each block's `src=` / `anchor` keep the true file path.
|
|
32
|
-
|
|
33
|
-
import { readdirSync, readFileSync } from "node:fs";
|
|
34
|
-
import { join, relative } from "node:path";
|
|
35
|
-
|
|
36
|
-
// A file whose presence marks its directory as a module root. Beyond the
|
|
37
|
-
// per-module-manifest ecosystems (Maven pom.xml, Gradle build.gradle, npm
|
|
38
|
-
// package.json, TS tsconfig.json, Go go.mod, Cargo.toml), this also covers
|
|
39
|
-
// ecosystems whose manifest is otherwise unrecognized — Python
|
|
40
|
-
// (pyproject.toml / setup.py / setup.cfg), C/C++ (CMakeLists.txt / meson.build)
|
|
41
|
-
// and Bazel (BUILD.bazel / BUILD) — without which such repos collapse to a
|
|
42
|
-
// single repo-name module. Centrally-declared submodules that carry NO manifest
|
|
43
|
-
// (e.g. a Gradle `settings.gradle include`) are handled by declaredModuleRoots.
|
|
44
|
-
const MODULE_MARKERS = /^(pom\.xml|build\.gradle|build\.gradle\.kts|package\.json|tsconfig\.json|go\.mod|Cargo\.toml|pyproject\.toml|setup\.py|setup\.cfg|CMakeLists\.txt|meson\.build|BUILD\.bazel|BUILD)$/;
|
|
45
|
-
// Never descend into these — dependency dumps and build output are not source.
|
|
46
|
-
const SKIP_DIRS = new Set([
|
|
47
|
-
"node_modules", "target", "dist", "out", "build", ".git",
|
|
48
|
-
".geml-code-graph", ".geml-build", ".idea", ".gradle", "bin_",
|
|
49
|
-
]);
|
|
50
|
-
|
|
51
|
-
// Discover module roots under `root`: every directory holding a build manifest.
|
|
52
|
-
// Returned as repo-relative POSIX paths, DEEPEST first, so `moduleOf` can pick
|
|
53
|
-
// the most specific enclosing module.
|
|
54
|
-
export function findModuleRoots(root, { readdir = readdirSync } = {}) {
|
|
55
|
-
const roots = [];
|
|
56
|
-
const walk = (dir) => {
|
|
57
|
-
let ents;
|
|
58
|
-
try { ents = readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
59
|
-
let isRoot = false;
|
|
60
|
-
for (const e of ents) if (e.isFile() && MODULE_MARKERS.test(e.name)) { isRoot = true; break; }
|
|
61
|
-
if (isRoot) {
|
|
62
|
-
const rel = relative(root, dir).replace(/\\/g, "/");
|
|
63
|
-
roots.push(rel); // "" for the repo root itself
|
|
64
|
-
}
|
|
65
|
-
for (const e of ents) {
|
|
66
|
-
if (e.isDirectory() && !SKIP_DIRS.has(e.name) && !e.name.startsWith(".")) walk(join(dir, e.name));
|
|
67
|
-
}
|
|
68
|
-
};
|
|
69
|
-
walk(root);
|
|
70
|
-
return roots.filter((r) => r !== "").sort((a, b) => b.length - a.length);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Module roots a build tool DECLARES centrally in a root file, rather than by a
|
|
74
|
-
// manifest in each submodule directory. Covers the layouts where a submodule
|
|
75
|
-
// carries no manifest of its own — e.g. a Gradle multi-module build that
|
|
76
|
-
// configures its subprojects from the root (`settings.gradle include ':a'`,
|
|
77
|
-
// `subprojects {}`) so `agrona-agent/` has only `src/`. Pure string parsing
|
|
78
|
-
// (inject `readFile` for tests); returns repo-relative POSIX dirs. Best-effort:
|
|
79
|
-
// project-dir remapping (`project(':x').projectDir = …`) and multi-line include
|
|
80
|
-
// lists are not resolved — the manual `## module-roots` foldings section covers
|
|
81
|
-
// anything this misses.
|
|
82
|
-
export function declaredModuleRoots(root, { readFile = readFileSync } = {}) {
|
|
83
|
-
const roots = new Set();
|
|
84
|
-
const read = (name) => { try { return readFile(join(root, name), "utf8"); } catch { return null; } };
|
|
85
|
-
|
|
86
|
-
// Gradle: `include ':a', ':b:c'` (Groovy or Kotlin DSL). `\binclude\b` excludes
|
|
87
|
-
// `includeBuild`/`includeFlat` (no word boundary after "include"). A Gradle
|
|
88
|
-
// project path ':a:b' maps to the directory a/b; a leading ':' is optional.
|
|
89
|
-
for (const name of ["settings.gradle", "settings.gradle.kts"]) {
|
|
90
|
-
const raw = read(name);
|
|
91
|
-
if (!raw) continue;
|
|
92
|
-
const src = raw.replace(/\/\/[^\n]*/g, "").replace(/\/\*[\s\S]*?\*\//g, ""); // strip comments
|
|
93
|
-
for (const stmt of src.matchAll(/\binclude\b([^\n]*)/g)) {
|
|
94
|
-
for (const q of stmt[1].matchAll(/['"]([^'"]+)['"]/g)) {
|
|
95
|
-
const dir = q[1].trim().replace(/^:/, "").replace(/:/g, "/");
|
|
96
|
-
if (dir) roots.add(dir);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Maven: root pom.xml `<modules><module>svc</module></modules>`. Each module
|
|
102
|
-
// is a relative directory path.
|
|
103
|
-
const pom = read("pom.xml");
|
|
104
|
-
if (pom) {
|
|
105
|
-
// `[^<]*` (linear) + trim, NOT `\s*([^<]+?)\s*` — the latter's ambiguous
|
|
106
|
-
// whitespace partition backtracks super-linearly on a crafted root pom.xml
|
|
107
|
-
// (`<module>` + a long whitespace run and no close), hanging every build.
|
|
108
|
-
for (const m of pom.matchAll(/<module>([^<]*)<\/module>/g)) {
|
|
109
|
-
const dir = m[1].trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
|
110
|
-
if (dir) roots.add(dir);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return [...roots];
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// The complete module-root set for `root`: manifest-marked dirs (findModuleRoots)
|
|
118
|
-
// unioned with centrally-declared ones (declaredModuleRoots) and any explicit
|
|
119
|
-
// `extraRoots` (the foldings `## module-roots` override). Deduped, DEEPEST first
|
|
120
|
-
// so moduleOf picks the most specific enclosing module.
|
|
121
|
-
export function discoverModuleRoots(root, { extraRoots = [], ...deps } = {}) {
|
|
122
|
-
const all = new Set([
|
|
123
|
-
...findModuleRoots(root, deps),
|
|
124
|
-
...declaredModuleRoots(root, deps),
|
|
125
|
-
...extraRoots.map((r) => String(r).replace(/\\/g, "/").replace(/^\/+|\/+$/g, "")).filter(Boolean),
|
|
126
|
-
]);
|
|
127
|
-
return [...all].sort((a, b) => b.length - a.length);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
export const DEFAULT_SOURCE_ROOTS = ["src/main/*", "src/main", "src"];
|
|
131
|
-
export const DEFAULT_TEST_ROOTS = ["src/test/*", "test", "tests", "__tests__", "spec", "specs"];
|
|
132
|
-
export const LANG_FOLD_PREFIXES = { Rust: ["crates"] };
|
|
133
|
-
|
|
134
|
-
// Above-root ceremony: a top-level directory (direct repo-root child) that is
|
|
135
|
-
// NOT itself a module root but is the ancestor of one — integrations/, crates/,
|
|
136
|
-
// modules/, apps/, packages/. Seeded into foldings.geml; the human edits from
|
|
137
|
-
// there. Flat multi-module repos (core/, web/ each a module root) seed nothing.
|
|
138
|
-
export function deriveFoldLayers(moduleRoots) {
|
|
139
|
-
const isRoot = new Set(moduleRoots);
|
|
140
|
-
const seed = new Set();
|
|
141
|
-
for (const m of moduleRoots) {
|
|
142
|
-
const top = m.split("/")[0];
|
|
143
|
-
if (top && top !== m && !isRoot.has(top)) seed.add(top);
|
|
144
|
-
}
|
|
145
|
-
return [...seed].sort();
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Match a leading source/test root by PATTERN. A pattern is a "/"-joined run of
|
|
149
|
-
// segments; "*" matches exactly one segment, others match literally. Returns
|
|
150
|
-
// the number of leading segments consumed, or -1 for no match.
|
|
151
|
-
function matchLeading(segs, pattern) {
|
|
152
|
-
const p = pattern.split("/");
|
|
153
|
-
if (p.length > segs.length) return -1;
|
|
154
|
-
for (let i = 0; i < p.length; i++) if (p[i] !== "*" && p[i] !== segs[i]) return -1;
|
|
155
|
-
return p.length;
|
|
156
|
-
}
|
|
157
|
-
// Strip the build-system SOURCE ROOT from a module-relative path and classify
|
|
158
|
-
// the container as main vs test. Patterns come from foldings.geml (seeded from
|
|
159
|
-
// DEFAULT_SOURCE_ROOTS / DEFAULT_TEST_ROOTS) — the algorithm is unchanged, only
|
|
160
|
-
// the pattern lists moved out of hardcoded regexes. Longest leading match wins;
|
|
161
|
-
// a test-root match routes the container to the test branch.
|
|
162
|
-
export function splitSourceRoot(rel, { sourceRoots, testRoots }) {
|
|
163
|
-
const segs = rel.split("/").filter(Boolean);
|
|
164
|
-
const cands = [
|
|
165
|
-
...testRoots.map((p) => ({ p, kind: "test" })),
|
|
166
|
-
...sourceRoots.map((p) => ({ p, kind: "main" })),
|
|
167
|
-
]
|
|
168
|
-
.map((c) => ({ ...c, n: matchLeading(segs, c.p) }))
|
|
169
|
-
.filter((c) => c.n > 0)
|
|
170
|
-
.sort((a, b) => b.n - a.n); // longest match first
|
|
171
|
-
let kind = "main", tail = rel;
|
|
172
|
-
if (cands.length) { kind = cands[0].kind; tail = segs.slice(cands[0].n).join("/"); }
|
|
173
|
-
// A test dir sitting just inside the (main) source root reclassifies to test.
|
|
174
|
-
const tm = tail.match(/^(test|tests|__tests__|spec|specs)(\/|$)/);
|
|
175
|
-
if (tm) { kind = "test"; tail = tail.slice(tm[0].length); }
|
|
176
|
-
return { kind, tail };
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// dirs: iterable of container directory paths (repo-relative POSIX; the
|
|
180
|
-
// emitter's "(root)" sentinel is passed through untouched).
|
|
181
|
-
// moduleRoots: from findModuleRoots (deepest first).
|
|
182
|
-
// repoName: display name for the implicit root module (single-module repos
|
|
183
|
-
// whose only build manifest sits at the repo root).
|
|
184
|
-
// config: { foldPrefixes?, sourceRoots?, testRoots?, stripSharedPrefix? } — see
|
|
185
|
-
// foldings.mjs. Defaults reproduce the pre-config behaviour verbatim: no
|
|
186
|
-
// ceremony folding, the built-in source/test root patterns, common prefix
|
|
187
|
-
// stripped.
|
|
188
|
-
// -> Map<dir, normalizedDisplayPath>.
|
|
189
|
-
//
|
|
190
|
-
// Display path = [test?] / <module> / <package tail>. The module segment is the
|
|
191
|
-
// enclosing module root, or repoName when the container belongs to the repo
|
|
192
|
-
// root itself. Test containers get a leading `test` segment so they collect
|
|
193
|
-
// under one top-level branch, mirroring the main structure beneath it.
|
|
194
|
-
|
|
195
|
-
// Strip the leading run of ceremony segments matching any fold-prefix (entries
|
|
196
|
-
// may be multi-segment; longest match first), until the front no longer matches.
|
|
197
|
-
export function foldPrefix(modPath, foldPrefixes) {
|
|
198
|
-
let segs = modPath.split("/");
|
|
199
|
-
for (let changed = true; changed && segs.length > 1; ) {
|
|
200
|
-
changed = false;
|
|
201
|
-
const hit = foldPrefixes
|
|
202
|
-
.map((f) => f.split("/"))
|
|
203
|
-
.filter((f) => f.length < segs.length && f.every((s, i) => s === segs[i]))
|
|
204
|
-
.sort((a, b) => b.length - a.length)[0];
|
|
205
|
-
if (hit) { segs = segs.slice(hit.length); changed = true; }
|
|
206
|
-
}
|
|
207
|
-
return segs.join("/");
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
export function normalizeDirs(dirs, moduleRoots, repoName, fileMode, config = {}) {
|
|
211
|
-
const foldPrefixes = config.foldPrefixes ?? [];
|
|
212
|
-
const sourceRoots = config.sourceRoots ?? DEFAULT_SOURCE_ROOTS;
|
|
213
|
-
const testRoots = config.testRoots ?? DEFAULT_TEST_ROOTS;
|
|
214
|
-
const stripSharedPrefix = config.stripSharedPrefix ?? true;
|
|
215
|
-
const list = [...new Set(dirs)].filter((d) => d && d !== "(root)");
|
|
216
|
-
const moduleOf = (d) => {
|
|
217
|
-
for (const r of moduleRoots) if (d === r || d.startsWith(r + "/")) return r;
|
|
218
|
-
return "";
|
|
219
|
-
};
|
|
220
|
-
// Fold ceremony prefixes off each module-root DISPLAY name, then guard: if two
|
|
221
|
-
// modules fold to the same name, revert BOTH to their full paths.
|
|
222
|
-
const foldedCounts = new Map();
|
|
223
|
-
for (const r of moduleRoots) {
|
|
224
|
-
const f = foldPrefix(r, foldPrefixes);
|
|
225
|
-
foldedCounts.set(f, (foldedCounts.get(f) ?? 0) + 1);
|
|
226
|
-
}
|
|
227
|
-
const displayOf = (r) => {
|
|
228
|
-
const f = foldPrefix(r, foldPrefixes);
|
|
229
|
-
return f !== r && foldedCounts.get(f) > 1 ? r : f;
|
|
230
|
-
};
|
|
231
|
-
// Group by (module, main|test); each group strips its OWN common prefix so a
|
|
232
|
-
// module's main and test trees normalise independently.
|
|
233
|
-
const groups = new Map(); // key -> { mod, kind, members:[{dir, segs}] }
|
|
234
|
-
for (const d of list) {
|
|
235
|
-
const mod = moduleOf(d);
|
|
236
|
-
const rel = mod ? d.slice(mod.length + 1) : d;
|
|
237
|
-
const { kind, tail } = splitSourceRoot(rel, { sourceRoots, testRoots });
|
|
238
|
-
const key = mod + "\0" + kind;
|
|
239
|
-
if (!groups.has(key)) groups.set(key, { mod, kind, members: [] });
|
|
240
|
-
const segs0 = tail.split("/").filter(Boolean);
|
|
241
|
-
const leaf = fileMode && segs0.length ? segs0.pop() : null; // filename kept aside
|
|
242
|
-
groups.get(key).members.push({ dir: d, dirSegs: segs0, leaf });
|
|
243
|
-
}
|
|
244
|
-
const out = new Map();
|
|
245
|
-
for (const { mod, kind, members } of groups.values()) {
|
|
246
|
-
let common = stripSharedPrefix && members.length ? [...members[0].dirSegs] : [];
|
|
247
|
-
if (stripSharedPrefix) {
|
|
248
|
-
for (const { dirSegs } of members) {
|
|
249
|
-
let i = 0;
|
|
250
|
-
while (i < common.length && i < dirSegs.length && common[i] === dirSegs[i]) i++;
|
|
251
|
-
common.length = i;
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
const moduleSeg = (mod && displayOf(mod)) || repoName || "";
|
|
255
|
-
for (const { dir, dirSegs, leaf } of members) {
|
|
256
|
-
const tail = dirSegs.slice(common.length);
|
|
257
|
-
if (leaf !== null) tail.push(leaf);
|
|
258
|
-
const parts = [];
|
|
259
|
-
if (kind === "test") parts.push("test");
|
|
260
|
-
if (moduleSeg) parts.push(moduleSeg);
|
|
261
|
-
parts.push(...tail);
|
|
262
|
-
out.set(dir, parts.join("/") || mod || dir);
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
return out;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// Convenience: discover roots under `root` and normalise `dirs` in one call.
|
|
269
|
-
// Module roots come from all three layers: manifest-marked dirs, centrally
|
|
270
|
-
// declared submodules, and the foldings `## module-roots` override (threaded
|
|
271
|
-
// in via config.moduleRoots).
|
|
272
|
-
export function buildNormalizer(root, dirs, { repoName, fileMode, config, ...deps } = {}) {
|
|
273
|
-
const roots = discoverModuleRoots(root, { ...deps, extraRoots: config?.moduleRoots ?? [] });
|
|
274
|
-
return normalizeDirs(dirs, roots, repoName, fileMode, config);
|
|
275
|
-
}
|
|
1
|
+
// Container-path normalisation (GEP-0003 §4, "module root + common prefix").
|
|
2
|
+
//
|
|
3
|
+
// A container's raw path is its directory relative to the repo root, e.g.
|
|
4
|
+
// magic-api/src/main/java/org/ssssssss/magicapi/core/config
|
|
5
|
+
// The leading run `src/main/java/org/ssssssss/magicapi` is pure ceremony: the
|
|
6
|
+
// build-system source root (`src/main/java`) plus the group-id package root
|
|
7
|
+
// (`org/ssssssss/magicapi`) that EVERY container in the module shares. It
|
|
8
|
+
// carries no navigational information — it only buries the real structure.
|
|
9
|
+
//
|
|
10
|
+
// The fix is language-agnostic and data-driven: within each MODULE (a
|
|
11
|
+
// directory that declares itself one via pom.xml / build.gradle /
|
|
12
|
+
// package.json / tsconfig.json), strip the longest common directory prefix
|
|
13
|
+
// shared by all of that module's containers. `magic-api` then opens straight
|
|
14
|
+
// onto `core / backup / modules …` instead of a chain of empty ceremony
|
|
15
|
+
// layers. A module that vendored several group-ids (netty's shaded jars) has
|
|
16
|
+
// a short common prefix and keeps its `com / io / org` fork — that is real
|
|
17
|
+
// package structure, and such vendored trees are meant to be EXCLUDED, not
|
|
18
|
+
// normalised.
|
|
19
|
+
//
|
|
20
|
+
// A further layer of PURE ceremony can sit ABOVE the module root itself —
|
|
21
|
+
// Cargo's bare `crates/` directory (crates/foo -> foo), a vendored fork like
|
|
22
|
+
// `libs/vendor/`, or whatever a given repo's layout adds. That layer is no
|
|
23
|
+
// longer hardcoded: `foldPrefixes`, sourced from `foldings.geml` (see
|
|
24
|
+
// foldings.mjs), lists the leading segment-runs to fold off a module's
|
|
25
|
+
// display name — entries may be multi-segment (`libs/vendor`), and the
|
|
26
|
+
// longest match wins. The same collision guard applies: if folding would
|
|
27
|
+
// make two modules collide (crates/util next to a real util), both keep
|
|
28
|
+
// their full paths — ambiguity is worse than ceremony.
|
|
29
|
+
//
|
|
30
|
+
// Normalisation only rewrites the container's DISPLAY path (its `module=` and
|
|
31
|
+
// document name). Each block's `src=` / `anchor` keep the true file path.
|
|
32
|
+
|
|
33
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
34
|
+
import { join, relative } from "node:path";
|
|
35
|
+
|
|
36
|
+
// A file whose presence marks its directory as a module root. Beyond the
|
|
37
|
+
// per-module-manifest ecosystems (Maven pom.xml, Gradle build.gradle, npm
|
|
38
|
+
// package.json, TS tsconfig.json, Go go.mod, Cargo.toml), this also covers
|
|
39
|
+
// ecosystems whose manifest is otherwise unrecognized — Python
|
|
40
|
+
// (pyproject.toml / setup.py / setup.cfg), C/C++ (CMakeLists.txt / meson.build)
|
|
41
|
+
// and Bazel (BUILD.bazel / BUILD) — without which such repos collapse to a
|
|
42
|
+
// single repo-name module. Centrally-declared submodules that carry NO manifest
|
|
43
|
+
// (e.g. a Gradle `settings.gradle include`) are handled by declaredModuleRoots.
|
|
44
|
+
const MODULE_MARKERS = /^(pom\.xml|build\.gradle|build\.gradle\.kts|package\.json|tsconfig\.json|go\.mod|Cargo\.toml|pyproject\.toml|setup\.py|setup\.cfg|CMakeLists\.txt|meson\.build|BUILD\.bazel|BUILD)$/;
|
|
45
|
+
// Never descend into these — dependency dumps and build output are not source.
|
|
46
|
+
const SKIP_DIRS = new Set([
|
|
47
|
+
"node_modules", "target", "dist", "out", "build", ".git",
|
|
48
|
+
".geml-code-graph", ".geml-build", ".idea", ".gradle", "bin_",
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
// Discover module roots under `root`: every directory holding a build manifest.
|
|
52
|
+
// Returned as repo-relative POSIX paths, DEEPEST first, so `moduleOf` can pick
|
|
53
|
+
// the most specific enclosing module.
|
|
54
|
+
export function findModuleRoots(root, { readdir = readdirSync } = {}) {
|
|
55
|
+
const roots = [];
|
|
56
|
+
const walk = (dir) => {
|
|
57
|
+
let ents;
|
|
58
|
+
try { ents = readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
59
|
+
let isRoot = false;
|
|
60
|
+
for (const e of ents) if (e.isFile() && MODULE_MARKERS.test(e.name)) { isRoot = true; break; }
|
|
61
|
+
if (isRoot) {
|
|
62
|
+
const rel = relative(root, dir).replace(/\\/g, "/");
|
|
63
|
+
roots.push(rel); // "" for the repo root itself
|
|
64
|
+
}
|
|
65
|
+
for (const e of ents) {
|
|
66
|
+
if (e.isDirectory() && !SKIP_DIRS.has(e.name) && !e.name.startsWith(".")) walk(join(dir, e.name));
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
walk(root);
|
|
70
|
+
return roots.filter((r) => r !== "").sort((a, b) => b.length - a.length);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Module roots a build tool DECLARES centrally in a root file, rather than by a
|
|
74
|
+
// manifest in each submodule directory. Covers the layouts where a submodule
|
|
75
|
+
// carries no manifest of its own — e.g. a Gradle multi-module build that
|
|
76
|
+
// configures its subprojects from the root (`settings.gradle include ':a'`,
|
|
77
|
+
// `subprojects {}`) so `agrona-agent/` has only `src/`. Pure string parsing
|
|
78
|
+
// (inject `readFile` for tests); returns repo-relative POSIX dirs. Best-effort:
|
|
79
|
+
// project-dir remapping (`project(':x').projectDir = …`) and multi-line include
|
|
80
|
+
// lists are not resolved — the manual `## module-roots` foldings section covers
|
|
81
|
+
// anything this misses.
|
|
82
|
+
export function declaredModuleRoots(root, { readFile = readFileSync } = {}) {
|
|
83
|
+
const roots = new Set();
|
|
84
|
+
const read = (name) => { try { return readFile(join(root, name), "utf8"); } catch { return null; } };
|
|
85
|
+
|
|
86
|
+
// Gradle: `include ':a', ':b:c'` (Groovy or Kotlin DSL). `\binclude\b` excludes
|
|
87
|
+
// `includeBuild`/`includeFlat` (no word boundary after "include"). A Gradle
|
|
88
|
+
// project path ':a:b' maps to the directory a/b; a leading ':' is optional.
|
|
89
|
+
for (const name of ["settings.gradle", "settings.gradle.kts"]) {
|
|
90
|
+
const raw = read(name);
|
|
91
|
+
if (!raw) continue;
|
|
92
|
+
const src = raw.replace(/\/\/[^\n]*/g, "").replace(/\/\*[\s\S]*?\*\//g, ""); // strip comments
|
|
93
|
+
for (const stmt of src.matchAll(/\binclude\b([^\n]*)/g)) {
|
|
94
|
+
for (const q of stmt[1].matchAll(/['"]([^'"]+)['"]/g)) {
|
|
95
|
+
const dir = q[1].trim().replace(/^:/, "").replace(/:/g, "/");
|
|
96
|
+
if (dir) roots.add(dir);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Maven: root pom.xml `<modules><module>svc</module></modules>`. Each module
|
|
102
|
+
// is a relative directory path.
|
|
103
|
+
const pom = read("pom.xml");
|
|
104
|
+
if (pom) {
|
|
105
|
+
// `[^<]*` (linear) + trim, NOT `\s*([^<]+?)\s*` — the latter's ambiguous
|
|
106
|
+
// whitespace partition backtracks super-linearly on a crafted root pom.xml
|
|
107
|
+
// (`<module>` + a long whitespace run and no close), hanging every build.
|
|
108
|
+
for (const m of pom.matchAll(/<module>([^<]*)<\/module>/g)) {
|
|
109
|
+
const dir = m[1].trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
|
110
|
+
if (dir) roots.add(dir);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return [...roots];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The complete module-root set for `root`: manifest-marked dirs (findModuleRoots)
|
|
118
|
+
// unioned with centrally-declared ones (declaredModuleRoots) and any explicit
|
|
119
|
+
// `extraRoots` (the foldings `## module-roots` override). Deduped, DEEPEST first
|
|
120
|
+
// so moduleOf picks the most specific enclosing module.
|
|
121
|
+
export function discoverModuleRoots(root, { extraRoots = [], ...deps } = {}) {
|
|
122
|
+
const all = new Set([
|
|
123
|
+
...findModuleRoots(root, deps),
|
|
124
|
+
...declaredModuleRoots(root, deps),
|
|
125
|
+
...extraRoots.map((r) => String(r).replace(/\\/g, "/").replace(/^\/+|\/+$/g, "")).filter(Boolean),
|
|
126
|
+
]);
|
|
127
|
+
return [...all].sort((a, b) => b.length - a.length);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const DEFAULT_SOURCE_ROOTS = ["src/main/*", "src/main", "src"];
|
|
131
|
+
export const DEFAULT_TEST_ROOTS = ["src/test/*", "test", "tests", "__tests__", "spec", "specs"];
|
|
132
|
+
export const LANG_FOLD_PREFIXES = { Rust: ["crates"] };
|
|
133
|
+
|
|
134
|
+
// Above-root ceremony: a top-level directory (direct repo-root child) that is
|
|
135
|
+
// NOT itself a module root but is the ancestor of one — integrations/, crates/,
|
|
136
|
+
// modules/, apps/, packages/. Seeded into foldings.geml; the human edits from
|
|
137
|
+
// there. Flat multi-module repos (core/, web/ each a module root) seed nothing.
|
|
138
|
+
export function deriveFoldLayers(moduleRoots) {
|
|
139
|
+
const isRoot = new Set(moduleRoots);
|
|
140
|
+
const seed = new Set();
|
|
141
|
+
for (const m of moduleRoots) {
|
|
142
|
+
const top = m.split("/")[0];
|
|
143
|
+
if (top && top !== m && !isRoot.has(top)) seed.add(top);
|
|
144
|
+
}
|
|
145
|
+
return [...seed].sort();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Match a leading source/test root by PATTERN. A pattern is a "/"-joined run of
|
|
149
|
+
// segments; "*" matches exactly one segment, others match literally. Returns
|
|
150
|
+
// the number of leading segments consumed, or -1 for no match.
|
|
151
|
+
function matchLeading(segs, pattern) {
|
|
152
|
+
const p = pattern.split("/");
|
|
153
|
+
if (p.length > segs.length) return -1;
|
|
154
|
+
for (let i = 0; i < p.length; i++) if (p[i] !== "*" && p[i] !== segs[i]) return -1;
|
|
155
|
+
return p.length;
|
|
156
|
+
}
|
|
157
|
+
// Strip the build-system SOURCE ROOT from a module-relative path and classify
|
|
158
|
+
// the container as main vs test. Patterns come from foldings.geml (seeded from
|
|
159
|
+
// DEFAULT_SOURCE_ROOTS / DEFAULT_TEST_ROOTS) — the algorithm is unchanged, only
|
|
160
|
+
// the pattern lists moved out of hardcoded regexes. Longest leading match wins;
|
|
161
|
+
// a test-root match routes the container to the test branch.
|
|
162
|
+
export function splitSourceRoot(rel, { sourceRoots, testRoots }) {
|
|
163
|
+
const segs = rel.split("/").filter(Boolean);
|
|
164
|
+
const cands = [
|
|
165
|
+
...testRoots.map((p) => ({ p, kind: "test" })),
|
|
166
|
+
...sourceRoots.map((p) => ({ p, kind: "main" })),
|
|
167
|
+
]
|
|
168
|
+
.map((c) => ({ ...c, n: matchLeading(segs, c.p) }))
|
|
169
|
+
.filter((c) => c.n > 0)
|
|
170
|
+
.sort((a, b) => b.n - a.n); // longest match first
|
|
171
|
+
let kind = "main", tail = rel;
|
|
172
|
+
if (cands.length) { kind = cands[0].kind; tail = segs.slice(cands[0].n).join("/"); }
|
|
173
|
+
// A test dir sitting just inside the (main) source root reclassifies to test.
|
|
174
|
+
const tm = tail.match(/^(test|tests|__tests__|spec|specs)(\/|$)/);
|
|
175
|
+
if (tm) { kind = "test"; tail = tail.slice(tm[0].length); }
|
|
176
|
+
return { kind, tail };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// dirs: iterable of container directory paths (repo-relative POSIX; the
|
|
180
|
+
// emitter's "(root)" sentinel is passed through untouched).
|
|
181
|
+
// moduleRoots: from findModuleRoots (deepest first).
|
|
182
|
+
// repoName: display name for the implicit root module (single-module repos
|
|
183
|
+
// whose only build manifest sits at the repo root).
|
|
184
|
+
// config: { foldPrefixes?, sourceRoots?, testRoots?, stripSharedPrefix? } — see
|
|
185
|
+
// foldings.mjs. Defaults reproduce the pre-config behaviour verbatim: no
|
|
186
|
+
// ceremony folding, the built-in source/test root patterns, common prefix
|
|
187
|
+
// stripped.
|
|
188
|
+
// -> Map<dir, normalizedDisplayPath>.
|
|
189
|
+
//
|
|
190
|
+
// Display path = [test?] / <module> / <package tail>. The module segment is the
|
|
191
|
+
// enclosing module root, or repoName when the container belongs to the repo
|
|
192
|
+
// root itself. Test containers get a leading `test` segment so they collect
|
|
193
|
+
// under one top-level branch, mirroring the main structure beneath it.
|
|
194
|
+
|
|
195
|
+
// Strip the leading run of ceremony segments matching any fold-prefix (entries
|
|
196
|
+
// may be multi-segment; longest match first), until the front no longer matches.
|
|
197
|
+
export function foldPrefix(modPath, foldPrefixes) {
|
|
198
|
+
let segs = modPath.split("/");
|
|
199
|
+
for (let changed = true; changed && segs.length > 1; ) {
|
|
200
|
+
changed = false;
|
|
201
|
+
const hit = foldPrefixes
|
|
202
|
+
.map((f) => f.split("/"))
|
|
203
|
+
.filter((f) => f.length < segs.length && f.every((s, i) => s === segs[i]))
|
|
204
|
+
.sort((a, b) => b.length - a.length)[0];
|
|
205
|
+
if (hit) { segs = segs.slice(hit.length); changed = true; }
|
|
206
|
+
}
|
|
207
|
+
return segs.join("/");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function normalizeDirs(dirs, moduleRoots, repoName, fileMode, config = {}) {
|
|
211
|
+
const foldPrefixes = config.foldPrefixes ?? [];
|
|
212
|
+
const sourceRoots = config.sourceRoots ?? DEFAULT_SOURCE_ROOTS;
|
|
213
|
+
const testRoots = config.testRoots ?? DEFAULT_TEST_ROOTS;
|
|
214
|
+
const stripSharedPrefix = config.stripSharedPrefix ?? true;
|
|
215
|
+
const list = [...new Set(dirs)].filter((d) => d && d !== "(root)");
|
|
216
|
+
const moduleOf = (d) => {
|
|
217
|
+
for (const r of moduleRoots) if (d === r || d.startsWith(r + "/")) return r;
|
|
218
|
+
return "";
|
|
219
|
+
};
|
|
220
|
+
// Fold ceremony prefixes off each module-root DISPLAY name, then guard: if two
|
|
221
|
+
// modules fold to the same name, revert BOTH to their full paths.
|
|
222
|
+
const foldedCounts = new Map();
|
|
223
|
+
for (const r of moduleRoots) {
|
|
224
|
+
const f = foldPrefix(r, foldPrefixes);
|
|
225
|
+
foldedCounts.set(f, (foldedCounts.get(f) ?? 0) + 1);
|
|
226
|
+
}
|
|
227
|
+
const displayOf = (r) => {
|
|
228
|
+
const f = foldPrefix(r, foldPrefixes);
|
|
229
|
+
return f !== r && foldedCounts.get(f) > 1 ? r : f;
|
|
230
|
+
};
|
|
231
|
+
// Group by (module, main|test); each group strips its OWN common prefix so a
|
|
232
|
+
// module's main and test trees normalise independently.
|
|
233
|
+
const groups = new Map(); // key -> { mod, kind, members:[{dir, segs}] }
|
|
234
|
+
for (const d of list) {
|
|
235
|
+
const mod = moduleOf(d);
|
|
236
|
+
const rel = mod ? d.slice(mod.length + 1) : d;
|
|
237
|
+
const { kind, tail } = splitSourceRoot(rel, { sourceRoots, testRoots });
|
|
238
|
+
const key = mod + "\0" + kind;
|
|
239
|
+
if (!groups.has(key)) groups.set(key, { mod, kind, members: [] });
|
|
240
|
+
const segs0 = tail.split("/").filter(Boolean);
|
|
241
|
+
const leaf = fileMode && segs0.length ? segs0.pop() : null; // filename kept aside
|
|
242
|
+
groups.get(key).members.push({ dir: d, dirSegs: segs0, leaf });
|
|
243
|
+
}
|
|
244
|
+
const out = new Map();
|
|
245
|
+
for (const { mod, kind, members } of groups.values()) {
|
|
246
|
+
let common = stripSharedPrefix && members.length ? [...members[0].dirSegs] : [];
|
|
247
|
+
if (stripSharedPrefix) {
|
|
248
|
+
for (const { dirSegs } of members) {
|
|
249
|
+
let i = 0;
|
|
250
|
+
while (i < common.length && i < dirSegs.length && common[i] === dirSegs[i]) i++;
|
|
251
|
+
common.length = i;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const moduleSeg = (mod && displayOf(mod)) || repoName || "";
|
|
255
|
+
for (const { dir, dirSegs, leaf } of members) {
|
|
256
|
+
const tail = dirSegs.slice(common.length);
|
|
257
|
+
if (leaf !== null) tail.push(leaf);
|
|
258
|
+
const parts = [];
|
|
259
|
+
if (kind === "test") parts.push("test");
|
|
260
|
+
if (moduleSeg) parts.push(moduleSeg);
|
|
261
|
+
parts.push(...tail);
|
|
262
|
+
out.set(dir, parts.join("/") || mod || dir);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Convenience: discover roots under `root` and normalise `dirs` in one call.
|
|
269
|
+
// Module roots come from all three layers: manifest-marked dirs, centrally
|
|
270
|
+
// declared submodules, and the foldings `## module-roots` override (threaded
|
|
271
|
+
// in via config.moduleRoots).
|
|
272
|
+
export function buildNormalizer(root, dirs, { repoName, fileMode, config, ...deps } = {}) {
|
|
273
|
+
const roots = discoverModuleRoots(root, { ...deps, extraRoots: config?.moduleRoots ?? [] });
|
|
274
|
+
return normalizeDirs(dirs, roots, repoName, fileMode, config);
|
|
275
|
+
}
|