@geml/geml 1.0.0 → 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.
@@ -0,0 +1,272 @@
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
+ for (const m of pom.matchAll(/<module>\s*([^<]+?)\s*<\/module>/g)) {
106
+ const dir = m[1].trim().replace(/\\/g, "/").replace(/\/+$/, "");
107
+ if (dir) roots.add(dir);
108
+ }
109
+ }
110
+
111
+ return [...roots];
112
+ }
113
+
114
+ // The complete module-root set for `root`: manifest-marked dirs (findModuleRoots)
115
+ // unioned with centrally-declared ones (declaredModuleRoots) and any explicit
116
+ // `extraRoots` (the foldings `## module-roots` override). Deduped, DEEPEST first
117
+ // so moduleOf picks the most specific enclosing module.
118
+ export function discoverModuleRoots(root, { extraRoots = [], ...deps } = {}) {
119
+ const all = new Set([
120
+ ...findModuleRoots(root, deps),
121
+ ...declaredModuleRoots(root, deps),
122
+ ...extraRoots.map((r) => String(r).replace(/\\/g, "/").replace(/^\/+|\/+$/g, "")).filter(Boolean),
123
+ ]);
124
+ return [...all].sort((a, b) => b.length - a.length);
125
+ }
126
+
127
+ export const DEFAULT_SOURCE_ROOTS = ["src/main/*", "src/main", "src"];
128
+ export const DEFAULT_TEST_ROOTS = ["src/test/*", "test", "tests", "__tests__", "spec", "specs"];
129
+ export const LANG_FOLD_PREFIXES = { Rust: ["crates"] };
130
+
131
+ // Above-root ceremony: a top-level directory (direct repo-root child) that is
132
+ // NOT itself a module root but is the ancestor of one — integrations/, crates/,
133
+ // modules/, apps/, packages/. Seeded into foldings.geml; the human edits from
134
+ // there. Flat multi-module repos (core/, web/ each a module root) seed nothing.
135
+ export function deriveFoldLayers(moduleRoots) {
136
+ const isRoot = new Set(moduleRoots);
137
+ const seed = new Set();
138
+ for (const m of moduleRoots) {
139
+ const top = m.split("/")[0];
140
+ if (top && top !== m && !isRoot.has(top)) seed.add(top);
141
+ }
142
+ return [...seed].sort();
143
+ }
144
+
145
+ // Match a leading source/test root by PATTERN. A pattern is a "/"-joined run of
146
+ // segments; "*" matches exactly one segment, others match literally. Returns
147
+ // the number of leading segments consumed, or -1 for no match.
148
+ function matchLeading(segs, pattern) {
149
+ const p = pattern.split("/");
150
+ if (p.length > segs.length) return -1;
151
+ for (let i = 0; i < p.length; i++) if (p[i] !== "*" && p[i] !== segs[i]) return -1;
152
+ return p.length;
153
+ }
154
+ // Strip the build-system SOURCE ROOT from a module-relative path and classify
155
+ // the container as main vs test. Patterns come from foldings.geml (seeded from
156
+ // DEFAULT_SOURCE_ROOTS / DEFAULT_TEST_ROOTS) — the algorithm is unchanged, only
157
+ // the pattern lists moved out of hardcoded regexes. Longest leading match wins;
158
+ // a test-root match routes the container to the test branch.
159
+ export function splitSourceRoot(rel, { sourceRoots, testRoots }) {
160
+ const segs = rel.split("/").filter(Boolean);
161
+ const cands = [
162
+ ...testRoots.map((p) => ({ p, kind: "test" })),
163
+ ...sourceRoots.map((p) => ({ p, kind: "main" })),
164
+ ]
165
+ .map((c) => ({ ...c, n: matchLeading(segs, c.p) }))
166
+ .filter((c) => c.n > 0)
167
+ .sort((a, b) => b.n - a.n); // longest match first
168
+ let kind = "main", tail = rel;
169
+ if (cands.length) { kind = cands[0].kind; tail = segs.slice(cands[0].n).join("/"); }
170
+ // A test dir sitting just inside the (main) source root reclassifies to test.
171
+ const tm = tail.match(/^(test|tests|__tests__|spec|specs)(\/|$)/);
172
+ if (tm) { kind = "test"; tail = tail.slice(tm[0].length); }
173
+ return { kind, tail };
174
+ }
175
+
176
+ // dirs: iterable of container directory paths (repo-relative POSIX; the
177
+ // emitter's "(root)" sentinel is passed through untouched).
178
+ // moduleRoots: from findModuleRoots (deepest first).
179
+ // repoName: display name for the implicit root module (single-module repos
180
+ // whose only build manifest sits at the repo root).
181
+ // config: { foldPrefixes?, sourceRoots?, testRoots?, stripSharedPrefix? } — see
182
+ // foldings.mjs. Defaults reproduce the pre-config behaviour verbatim: no
183
+ // ceremony folding, the built-in source/test root patterns, common prefix
184
+ // stripped.
185
+ // -> Map<dir, normalizedDisplayPath>.
186
+ //
187
+ // Display path = [test?] / <module> / <package tail>. The module segment is the
188
+ // enclosing module root, or repoName when the container belongs to the repo
189
+ // root itself. Test containers get a leading `test` segment so they collect
190
+ // under one top-level branch, mirroring the main structure beneath it.
191
+
192
+ // Strip the leading run of ceremony segments matching any fold-prefix (entries
193
+ // may be multi-segment; longest match first), until the front no longer matches.
194
+ export function foldPrefix(modPath, foldPrefixes) {
195
+ let segs = modPath.split("/");
196
+ for (let changed = true; changed && segs.length > 1; ) {
197
+ changed = false;
198
+ const hit = foldPrefixes
199
+ .map((f) => f.split("/"))
200
+ .filter((f) => f.length < segs.length && f.every((s, i) => s === segs[i]))
201
+ .sort((a, b) => b.length - a.length)[0];
202
+ if (hit) { segs = segs.slice(hit.length); changed = true; }
203
+ }
204
+ return segs.join("/");
205
+ }
206
+
207
+ export function normalizeDirs(dirs, moduleRoots, repoName, fileMode, config = {}) {
208
+ const foldPrefixes = config.foldPrefixes ?? [];
209
+ const sourceRoots = config.sourceRoots ?? DEFAULT_SOURCE_ROOTS;
210
+ const testRoots = config.testRoots ?? DEFAULT_TEST_ROOTS;
211
+ const stripSharedPrefix = config.stripSharedPrefix ?? true;
212
+ const list = [...new Set(dirs)].filter((d) => d && d !== "(root)");
213
+ const moduleOf = (d) => {
214
+ for (const r of moduleRoots) if (d === r || d.startsWith(r + "/")) return r;
215
+ return "";
216
+ };
217
+ // Fold ceremony prefixes off each module-root DISPLAY name, then guard: if two
218
+ // modules fold to the same name, revert BOTH to their full paths.
219
+ const foldedCounts = new Map();
220
+ for (const r of moduleRoots) {
221
+ const f = foldPrefix(r, foldPrefixes);
222
+ foldedCounts.set(f, (foldedCounts.get(f) ?? 0) + 1);
223
+ }
224
+ const displayOf = (r) => {
225
+ const f = foldPrefix(r, foldPrefixes);
226
+ return f !== r && foldedCounts.get(f) > 1 ? r : f;
227
+ };
228
+ // Group by (module, main|test); each group strips its OWN common prefix so a
229
+ // module's main and test trees normalise independently.
230
+ const groups = new Map(); // key -> { mod, kind, members:[{dir, segs}] }
231
+ for (const d of list) {
232
+ const mod = moduleOf(d);
233
+ const rel = mod ? d.slice(mod.length + 1) : d;
234
+ const { kind, tail } = splitSourceRoot(rel, { sourceRoots, testRoots });
235
+ const key = mod + "\0" + kind;
236
+ if (!groups.has(key)) groups.set(key, { mod, kind, members: [] });
237
+ const segs0 = tail.split("/").filter(Boolean);
238
+ const leaf = fileMode && segs0.length ? segs0.pop() : null; // filename kept aside
239
+ groups.get(key).members.push({ dir: d, dirSegs: segs0, leaf });
240
+ }
241
+ const out = new Map();
242
+ for (const { mod, kind, members } of groups.values()) {
243
+ let common = stripSharedPrefix && members.length ? [...members[0].dirSegs] : [];
244
+ if (stripSharedPrefix) {
245
+ for (const { dirSegs } of members) {
246
+ let i = 0;
247
+ while (i < common.length && i < dirSegs.length && common[i] === dirSegs[i]) i++;
248
+ common.length = i;
249
+ }
250
+ }
251
+ const moduleSeg = (mod && displayOf(mod)) || repoName || "";
252
+ for (const { dir, dirSegs, leaf } of members) {
253
+ const tail = dirSegs.slice(common.length);
254
+ if (leaf !== null) tail.push(leaf);
255
+ const parts = [];
256
+ if (kind === "test") parts.push("test");
257
+ if (moduleSeg) parts.push(moduleSeg);
258
+ parts.push(...tail);
259
+ out.set(dir, parts.join("/") || mod || dir);
260
+ }
261
+ }
262
+ return out;
263
+ }
264
+
265
+ // Convenience: discover roots under `root` and normalise `dirs` in one call.
266
+ // Module roots come from all three layers: manifest-marked dirs, centrally
267
+ // declared submodules, and the foldings `## module-roots` override (threaded
268
+ // in via config.moduleRoots).
269
+ export function buildNormalizer(root, dirs, { repoName, fileMode, config, ...deps } = {}) {
270
+ const roots = discoverModuleRoots(root, { ...deps, extraRoots: config?.moduleRoots ?? [] });
271
+ return normalizeDirs(dirs, roots, repoName, fileMode, config);
272
+ }
@@ -0,0 +1,103 @@
1
+ // Shared TRUST GATE for codemap recipes (security fix C2 — RCE).
2
+ //
3
+ // A codemap's _index/refresh.json is COMMITTED DATA whose `steps[]` are run
4
+ // through a shell by `geml codemap refresh` (spawnSync(step,{shell:true})).
5
+ // Cloning a hostile repo and running `geml codemap refresh` — which the
6
+ // geml-code-graph skill, `serve --watch`, and a PostToolUse hook all trigger —
7
+ // would otherwise execute arbitrary commands. The old "up-to-date" guard is
8
+ // bypassable and does not gate execution.
9
+ //
10
+ // The fix content-addresses each recipe (a stable fingerprint of its steps)
11
+ // and records which fingerprints the user has EXPLICITLY approved in a store
12
+ // kept OUTSIDE any repo (so a repo can never pre-approve itself). refresh
13
+ // refuses to execute a recipe whose fingerprint is not in the store; build
14
+ // auto-trusts the recipe it just authored (the user ran it locally).
15
+ //
16
+ // Trust gates WHO may run a recipe. Security fix R2-1 additionally changed HOW
17
+ // steps are stored: a step is now a STRUCTURED object { cwd?, env?, argv:[...] }
18
+ // so attacker-controllable paths are never interpolated into a shell string at
19
+ // rest, and refresh executes them without an attacker-influenced command line.
20
+ // The fingerprint below canonicalizes that structured form.
21
+ import { createHash } from "node:crypto";
22
+ import { homedir } from "node:os";
23
+ import { join, dirname } from "node:path";
24
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
25
+
26
+ export const RECIPE_VERSION = 1; // on-disk step schema; bump ONLY on a real format change
27
+
28
+ // Canonicalize ONE recipe step for fingerprinting. Since security fix R2-1 a
29
+ // step is a STRUCTURED object { cwd?, env?, argv:[...] } (no shell string is
30
+ // ever stored). We emit a FIXED key order (cwd, env, argv), env keys SORTED so
31
+ // the fingerprint is independent of how the env map was built, and every value
32
+ // coerced to a string. Anything that is NOT a structured step (a legacy
33
+ // pre-R2-1 shell string, or a malformed entry) coerces to its String() form, so
34
+ // pre-existing string recipes keep the EXACT fingerprint they had before this
35
+ // change (backward compatible), while structured recipes get a stable identity.
36
+ function canonicalStep(step) {
37
+ if (step && typeof step === "object" && Array.isArray(step.argv)) {
38
+ const out = {};
39
+ if (step.cwd != null) out.cwd = String(step.cwd);
40
+ if (step.env && typeof step.env === "object") {
41
+ const env = {};
42
+ for (const k of Object.keys(step.env).sort()) env[k] = String(step.env[k]);
43
+ out.env = env;
44
+ }
45
+ out.argv = step.argv.map((a) => String(a));
46
+ return out;
47
+ }
48
+ return String(step);
49
+ }
50
+
51
+ // Fingerprint = sha256 over a canonical JSON of {root, steps}. build (when it
52
+ // records refresh.json) and refresh (when it is about to run it) both call
53
+ // this on the same parsed recipe object, so they agree exactly. `root` is
54
+ // included because it is the base dir every step runs under — the same steps
55
+ // under a different root are a different execution and deserve a different
56
+ // identity. Deterministic: fixed key order, canonicalized steps, no timestamps.
57
+ export function recipeFingerprint(recipe) {
58
+ const steps = Array.isArray(recipe?.steps) ? recipe.steps.map(canonicalStep) : [];
59
+ const root = recipe?.root == null ? "" : String(recipe.root);
60
+ const canonical = JSON.stringify({ root, steps });
61
+ return createHash("sha256").update(canonical, "utf8").digest("hex");
62
+ }
63
+
64
+ // Where the trust store lives — NEVER inside a repo. GEML_TRUST_STORE is an
65
+ // explicit override (test isolation / unusual homes). Otherwise it sits under
66
+ // the XDG config dir, falling back to ~/.config/geml, which is a sane
67
+ // cross-platform home (on Windows homedir() is C:\Users\<name>).
68
+ export function trustStorePath() {
69
+ if (process.env.GEML_TRUST_STORE) return process.env.GEML_TRUST_STORE;
70
+ const cfgHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
71
+ return join(cfgHome, "geml", "trusted-recipes.json");
72
+ }
73
+
74
+ // Read the store DEFENSIVELY: a missing, unreadable, or malformed store means
75
+ // "nothing is trusted". A broken store must never silently trust a recipe.
76
+ export function readTrustStore() {
77
+ try {
78
+ const obj = JSON.parse(readFileSync(trustStorePath(), "utf8"));
79
+ if (obj && typeof obj === "object" && obj.recipes && typeof obj.recipes === "object") {
80
+ return { version: obj.version || 1, recipes: obj.recipes };
81
+ }
82
+ } catch { /* missing / unreadable / malformed: treat as empty */ }
83
+ return { version: 1, recipes: {} };
84
+ }
85
+
86
+ // True only when this exact recipe fingerprint has been approved.
87
+ export function isRecipeTrusted(fingerprint) {
88
+ const store = readTrustStore();
89
+ return Object.prototype.hasOwnProperty.call(store.recipes, fingerprint);
90
+ }
91
+
92
+ // Record a fingerprint as trusted, MERGING into any existing store (never
93
+ // clobbering other approvals). Creates parent dirs. Returns the store path.
94
+ // THROWS on write failure: a caller that meant to trust must learn it did NOT,
95
+ // rather than proceed on the false belief that the recipe is now safe.
96
+ export function trustRecipe(fingerprint, graphDir) {
97
+ const store = readTrustStore();
98
+ store.recipes[fingerprint] = { graphDir: graphDir ? String(graphDir) : undefined, addedAt: Date.now() };
99
+ const p = trustStorePath();
100
+ mkdirSync(dirname(p), { recursive: true });
101
+ writeFileSync(p, JSON.stringify(store, null, 2) + "\n");
102
+ return p;
103
+ }