@geml/geml 1.4.2 → 1.4.3

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.
@@ -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
+ }