@geml/geml 1.7.0 → 1.7.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 +20 -4
- package/codemap/build.mjs +20 -6
- package/codemap/exclude.mjs +18 -14
- package/codemap/refresh.mjs +4 -1
- package/codemap/verify.mjs +4 -1
- package/dist/block-edit.js +15 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +2433 -0
- package/dist/from-md.js +7 -2
- package/dist/geml.d.ts +32 -0
- package/dist/geml.js +179 -2039
- package/dist/history.js +7 -3
- package/dist/mcp.js +46 -8
- package/dist/render-html.d.ts +1 -1
- package/dist/render.d.ts +1 -1
- package/dist/render.js +215 -17
- package/dist/selector.d.ts +7 -0
- package/dist/selector.js +41 -1
- package/dist/to-md.js +24 -2
- package/package.json +67 -67
- package/skill/SKILL.md +25 -0
- package/skill/references/authoring.geml +6 -1
package/README.md
CHANGED
|
@@ -96,8 +96,11 @@ Every command reads a file path, or `-` for stdin. Exit codes: `0` ok ·
|
|
|
96
96
|
```sh
|
|
97
97
|
geml doc.geml # document-model JSON (default --to json)
|
|
98
98
|
geml doc.geml --to md|html|geml # convert; geml notes.md -> GEML
|
|
99
|
+
geml list doc.geml # CALL FIRST: every block, its address, kind, line range
|
|
100
|
+
geml find "text" doc.geml|dir # search block CONTENT -> file<TAB>address; exit 1 = no hit
|
|
99
101
|
geml get doc.geml ['<selector>'] # list addressable blocks, or print what the selector matches
|
|
100
|
-
geml
|
|
102
|
+
geml get doc.geml '#sec' --intro # a section cuts three ways: --head | --intro | --body
|
|
103
|
+
geml set doc.geml '<selector>' [--head|--intro|--body] [--in F[#src]] # replace ONE block's content
|
|
101
104
|
geml add doc.geml (--append|--before #id|--after #id) [--in F[#src]] # insert a fragment
|
|
102
105
|
geml delete doc.geml '#id' ['#id2' …] # remove one or more blocks
|
|
103
106
|
geml rename doc.geml '#old' '#new' # rename an id + every reference to it
|
|
@@ -126,9 +129,22 @@ The agent loop: `geml get` a block → `set`/`add`/`delete`/`rename` it →
|
|
|
126
129
|
|
|
127
130
|
`get` answers with N contents when N match (document order, count on stderr);
|
|
128
131
|
`set` writes ONE block, so a selector matching several is refused (exit 2) with
|
|
129
|
-
the unique address of each candidate.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
+
the unique address of each candidate. A section cuts three ways: `--head` is the
|
|
133
|
+
heading line, `--intro` its opening region — everything under it up to its first
|
|
134
|
+
subheading — and `--body` everything under it, so `--body` always contains
|
|
135
|
+
`--intro`, and equals it when the section has no subheading. All three
|
|
136
|
+
round-trip — `geml get f X --body | geml set f X --body` leaves the file
|
|
137
|
+
byte-identical — and `--intro` is how a section's opening is edited without
|
|
138
|
+
pulling its subsections into context. A block has no intro; asking for one is a
|
|
139
|
+
usage error rather than a quiet fall back to the body.
|
|
140
|
+
|
|
141
|
+
A write is refused when it would break the document, never merely because it
|
|
142
|
+
removes something. A replacement that drops blocks is carried out and the
|
|
143
|
+
dropped blocks are named on stderr — unnamed ones counted, references left
|
|
144
|
+
dangling reported — with `geml revert` as the way back. That is the same stance
|
|
145
|
+
`delete` takes, so removing content has one rule rather than two, and no region
|
|
146
|
+
becomes uneditable because something inside it happens to carry an id. The
|
|
147
|
+
round trip above drops nothing: the blocks came back in the text you sent.
|
|
132
148
|
|
|
133
149
|
A `@<hex>` **content address** is the first 8 hex of the SHA-256 of the block's
|
|
134
150
|
own text (line endings normalized to LF, no trailing newline), with `~1`, `~2`…
|
package/codemap/build.mjs
CHANGED
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
//
|
|
37
37
|
// After building, run: geml codemap verify <out-dir>
|
|
38
38
|
import { writeFileSync, mkdirSync, existsSync, readFileSync, statSync } from "node:fs";
|
|
39
|
-
import { join, resolve, basename, dirname, relative } from "node:path";
|
|
39
|
+
import { join, resolve, basename, dirname, relative, sep } from "node:path";
|
|
40
40
|
import { fileURLToPath } from "node:url";
|
|
41
41
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
42
42
|
import { emit } from "./emit.mjs";
|
|
@@ -100,7 +100,7 @@ if (root && !inputs.length) {
|
|
|
100
100
|
const { files, manifests, pkgs } = collectSourceFiles(rootAbs);
|
|
101
101
|
const excluder = makeExcluder({
|
|
102
102
|
root: rootAbs, globs: excludeGlobs0, gitignore: !args.includes("--no-gitignore"),
|
|
103
|
-
files: [...files, ...manifests, ...pkgs],
|
|
103
|
+
files: [...files, ...manifests, ...pkgs], run: execFileSync,
|
|
104
104
|
});
|
|
105
105
|
const jobs = detectLanguages(rootAbs, { files, manifests, pkgs, excluder });
|
|
106
106
|
detectedLanguages = [...new Set(jobs.map((j) => j.language))];
|
|
@@ -136,7 +136,10 @@ if (root && !inputs.length) {
|
|
|
136
136
|
// PATH (npx / joern) whose .cmd/.bat shim uses %~dp0 breaks if the name is
|
|
137
137
|
// quoted — cmd then resolves %~dp0 against the cwd, not the shim's dir. A
|
|
138
138
|
// spaced launcher PATH is a full path, so quoting it keeps %~dp0 correct.
|
|
139
|
-
|
|
139
|
+
// WHEN it does quote, it defers to shq below: escaping only `"` left a
|
|
140
|
+
// trailing backslash (a path ending `...\`) escaping our own closing quote,
|
|
141
|
+
// which merges the next token into this one. Same CRT rules, one source.
|
|
142
|
+
const q = (s) => (/[\s"]/.test(String(s)) ? shq(s) : String(s));
|
|
140
143
|
// Hardened quote for command ARGUMENTS on win32. Node does NOT escape args
|
|
141
144
|
// under shell:true — it only concatenates them (Node DEP0190) — so an
|
|
142
145
|
// unquoted argument such as a source directory named `a&calc` reaching the
|
|
@@ -378,7 +381,7 @@ const excluder = makeExcluder({
|
|
|
378
381
|
globs: excludeGlobs,
|
|
379
382
|
gitignore: !args.includes("--no-gitignore"),
|
|
380
383
|
files: [...new Set(symbols.map((s) => s.file))],
|
|
381
|
-
|
|
384
|
+
run: execFileSync,
|
|
382
385
|
});
|
|
383
386
|
const kept = symbols.filter((s) => !excluder(s.file));
|
|
384
387
|
const excludedCount = symbols.length - kept.length;
|
|
@@ -391,7 +394,7 @@ if (root && !recordRecipe && !entryHints.length) {
|
|
|
391
394
|
const c = collectSourceFiles(rootAbs);
|
|
392
395
|
const excl = makeExcluder({
|
|
393
396
|
root: rootAbs, globs: excludeGlobs, gitignore: !args.includes("--no-gitignore"),
|
|
394
|
-
files: [...c.files, ...c.manifests, ...c.pkgs],
|
|
397
|
+
files: [...c.files, ...c.manifests, ...c.pkgs], run: execFileSync,
|
|
395
398
|
});
|
|
396
399
|
entryHints = detectEntries(rootAbs, {
|
|
397
400
|
files: c.files.filter((f) => !excl(f)),
|
|
@@ -420,7 +423,18 @@ try {
|
|
|
420
423
|
const { edges: httpEdges, audit } = buildCrossStackOverlay({
|
|
421
424
|
symbols,
|
|
422
425
|
files: scanFiles,
|
|
423
|
-
|
|
426
|
+
// `rel` is indexer OUTPUT, not a path this build authored, so a `..`
|
|
427
|
+
// segment must not turn a source read into an escape from the scanned
|
|
428
|
+
// root. Same gate the document resolver uses: resolve first, then require
|
|
429
|
+
// the result to BE the root or sit under it — compared with the separator,
|
|
430
|
+
// so a sibling named `<root>-evil` is not mistaken for a child.
|
|
431
|
+
readText: (rel) => {
|
|
432
|
+
try {
|
|
433
|
+
const p = resolve(rootAbs, ...String(rel).split("/"));
|
|
434
|
+
if (p !== rootAbs && !p.startsWith(rootAbs + sep)) return null;
|
|
435
|
+
return readFileSync(p, "utf8");
|
|
436
|
+
} catch { return null; }
|
|
437
|
+
},
|
|
424
438
|
});
|
|
425
439
|
for (const e of httpEdges) edges.push(e);
|
|
426
440
|
if (httpEdges.length) {
|
package/codemap/exclude.mjs
CHANGED
|
@@ -15,17 +15,17 @@ import { execFileSync as _execFileSync } from "node:child_process";
|
|
|
15
15
|
// Minimal gitignore-flavoured glob: `**` spans path separators, `*` stays
|
|
16
16
|
// within a segment, everything else is literal. Anchored to the whole path.
|
|
17
17
|
export function globToRegExp(glob) {
|
|
18
|
+
// The glob comes from a `--exclude` argument, so nothing in it may reach the
|
|
19
|
+
// compiled pattern as *syntax*. Split on the wildcards, keeping them (the
|
|
20
|
+
// capture group), which leaves the array strictly alternating: even indices
|
|
21
|
+
// are literal text, odd indices are `*`, `**` or `**/`. Literals go through a
|
|
22
|
+
// total regex-metacharacter escape; wildcards map to fixed patterns. Neither
|
|
23
|
+
// path can carry an unescaped metacharacter through.
|
|
24
|
+
const parts = String(glob).split(/(\*\*\/?|\*)/);
|
|
18
25
|
let re = "";
|
|
19
|
-
for (let i = 0; i <
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (glob[i + 1] === "*") { re += ".*"; i++; if (glob[i + 1] === "/") i++; }
|
|
23
|
-
else re += "[^/]*";
|
|
24
|
-
} else if ("\\^$+?.()|{}[]".includes(c)) {
|
|
25
|
-
re += "\\" + c;
|
|
26
|
-
} else {
|
|
27
|
-
re += c;
|
|
28
|
-
}
|
|
26
|
+
for (let i = 0; i < parts.length; i++) {
|
|
27
|
+
if (i % 2 === 0) re += parts[i].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
28
|
+
else re += parts[i] === "*" ? "[^/]*" : ".*"; // `**` and `**/` span separators
|
|
29
29
|
}
|
|
30
30
|
return new RegExp("^" + re + "$");
|
|
31
31
|
}
|
|
@@ -33,10 +33,14 @@ export function globToRegExp(glob) {
|
|
|
33
33
|
// Ask git which of `files` it ignores. Returns a Set of the ignored paths.
|
|
34
34
|
// check-ignore exits 1 when nothing matches and 128 when git is unavailable /
|
|
35
35
|
// the dir is not a repo — both mean "ignore nothing", not a build failure.
|
|
36
|
-
|
|
36
|
+
// The injected runner is named `run`, not `exec`: it is always an execFile-shaped
|
|
37
|
+
// (program, args[]) call that spawns NO shell, whereas a callback named `exec`
|
|
38
|
+
// reads — to a human skimming, and to a static analyser — as the shell-string
|
|
39
|
+
// child_process API. The name should not imply the dangerous one.
|
|
40
|
+
export function gitIgnored(root, files, run = _execFileSync) {
|
|
37
41
|
if (!files.length) return new Set();
|
|
38
42
|
try {
|
|
39
|
-
const out =
|
|
43
|
+
const out = run("git", ["-C", root, "check-ignore", "--stdin"], { input: files.join("\n"), encoding: "utf8" });
|
|
40
44
|
return new Set(out.split(/\r?\n/).filter(Boolean));
|
|
41
45
|
} catch (e) {
|
|
42
46
|
const out = e && e.stdout ? String(e.stdout) : "";
|
|
@@ -45,8 +49,8 @@ export function gitIgnored(root, files, exec = _execFileSync) {
|
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
// Build a predicate (file) => shouldExclude.
|
|
48
|
-
export function makeExcluder({ root, globs = [], gitignore = true, files = [],
|
|
52
|
+
export function makeExcluder({ root, globs = [], gitignore = true, files = [], run } = {}) {
|
|
49
53
|
const res = globs.map(globToRegExp);
|
|
50
|
-
const ignored = gitignore ? gitIgnored(root, files,
|
|
54
|
+
const ignored = gitignore ? gitIgnored(root, files, run) : new Set();
|
|
51
55
|
return (file) => ignored.has(file) || res.some((r) => r.test(file));
|
|
52
56
|
}
|
package/codemap/refresh.mjs
CHANGED
|
@@ -95,7 +95,10 @@ let trusted = isRecipeTrusted(fingerprint);
|
|
|
95
95
|
// full path is quoted), every argument via shq (ALWAYS double-quoted, so
|
|
96
96
|
// cmd.exe treats & | < > ( ) ^ and whitespace as literal). An injected
|
|
97
97
|
// metachar inside a dir-name argument is therefore inert.
|
|
98
|
-
|
|
98
|
+
// q quotes only when it must; WHEN it does it defers to shq, so a program path
|
|
99
|
+
// ending in a backslash cannot escape our own closing quote and swallow the
|
|
100
|
+
// next token. One set of CRT rules, in one place.
|
|
101
|
+
const q = (s) => (/[\s"]/.test(String(s)) ? shq(s) : String(s));
|
|
99
102
|
const shq = (s) => `"${String(s).replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1')}"`;
|
|
100
103
|
// Human-readable render of a step for the log / refusal message — DISPLAY ONLY,
|
|
101
104
|
// never executed. Falls back to String() for a stale (non-structured) step.
|
package/codemap/verify.mjs
CHANGED
|
@@ -37,7 +37,10 @@ if (!cli) cli = existsSync(localParser) ? localParser : "geml";
|
|
|
37
37
|
// `.geml` filename containing & | ( ) would otherwise break out and inject.
|
|
38
38
|
// cmd.exe treats those metacharacters and whitespace as literal inside quotes;
|
|
39
39
|
// CRT rules for embedded " / trailing \.
|
|
40
|
-
|
|
40
|
+
// q quotes only when it must; WHEN it does it defers to shq, so a program path
|
|
41
|
+
// ending in a backslash cannot escape our own closing quote and swallow the
|
|
42
|
+
// next token. One set of CRT rules, in one place.
|
|
43
|
+
const q = (s) => (/[\s"]/.test(String(s)) ? shq(s) : String(s));
|
|
41
44
|
const shq = (s) => `"${String(s).replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1')}"`;
|
|
42
45
|
// A codemap document's `src=` routes are written relative to the indexed
|
|
43
46
|
// SOURCE root (`geml-parser/src/attrs.ts` from a document two levels down), so
|
package/dist/block-edit.js
CHANGED
|
@@ -25,6 +25,20 @@ function splitLines(source) {
|
|
|
25
25
|
function stripEnding(line) {
|
|
26
26
|
return line.replace(/(\r\n|\r|\n)$/, "");
|
|
27
27
|
}
|
|
28
|
+
// A local mirror of geml.ts's trimSpaceTabEnd — geml.ts already imports THIS
|
|
29
|
+
// module (normalizeBlockId), so importing back would close a cycle. Linear on
|
|
30
|
+
// purpose: `/[ \t]+$/` restarts the run at every index inside a run that never
|
|
31
|
+
// reaches the end of the line, which is quadratic in the line's length.
|
|
32
|
+
function trimSpaceTabEnd(s) {
|
|
33
|
+
let i = s.length;
|
|
34
|
+
while (i > 0) {
|
|
35
|
+
const c = s.charCodeAt(i - 1);
|
|
36
|
+
if (c !== 0x20 && c !== 0x09)
|
|
37
|
+
break;
|
|
38
|
+
i--;
|
|
39
|
+
}
|
|
40
|
+
return i === s.length ? s : s.slice(0, i);
|
|
41
|
+
}
|
|
28
42
|
// Rewrite the id inside a `{…}` attribute block to `#newId`, keeping the braces
|
|
29
43
|
// and every other class/attr byte. If no id is present, insert `#newId` as the
|
|
30
44
|
// first token. The id token sits at a token boundary (`{` or whitespace) and
|
|
@@ -98,7 +112,7 @@ export function normalizeBlockId(blockSrc, newId) {
|
|
|
98
112
|
const openLen = /^=+/.exec(f[1])[0].length;
|
|
99
113
|
for (let j = hi + 1; j < lines.length; j++) {
|
|
100
114
|
const ct = stripEnding(lines[j]);
|
|
101
|
-
const trimmed = ct
|
|
115
|
+
const trimmed = trimSpaceTabEnd(ct);
|
|
102
116
|
if (/^=+$/.test(trimmed) && trimmed.length === openLen)
|
|
103
117
|
break; // plain close: done
|
|
104
118
|
const cm = /^(={3,}[ \t]+#)([^\s}]+)([ \t]*)$/.exec(ct);
|
package/dist/cli.d.ts
ADDED