@geml/geml 1.7.1 → 1.7.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.
package/README.md CHANGED
@@ -24,7 +24,8 @@ print("hi")
24
24
  - **Addressable** — every block can be named: an `#id`, or a content address for
25
25
  the ones nobody named; `geml get` / `geml set '<selector>'`
26
26
  read or patch one section without re-emitting the whole file (on this repo's
27
- own spec, ~**66× less context** than shipping the whole document).
27
+ own spec, ~**120× less context** than shipping the whole document — the block
28
+ is ~590 chars whatever the document grows to).
28
29
  - **Verifiable** — references are checked at build time (a dangling `#id` is an
29
30
  error, not a silent dead link), and the parser emits a document-model JSON
30
31
  with a `diagnostics` array, so agents and CI get a structured pass/fail signal.
@@ -96,15 +97,20 @@ Every command reads a file path, or `-` for stdin. Exit codes: `0` ok ·
96
97
  ```sh
97
98
  geml doc.geml # document-model JSON (default --to json)
98
99
  geml doc.geml --to md|html|geml # convert; geml notes.md -> GEML
100
+ geml list doc.geml # CALL FIRST: every block, its address, kind, line range
101
+ geml find "text" doc.geml|dir # search block CONTENT -> file<TAB>address; exit 1 = no hit
99
102
  geml get doc.geml ['<selector>'] # list addressable blocks, or print what the selector matches
100
- geml set doc.geml '<selector>' [--head|--body] [--in F[#src]] # replace ONE block's content
103
+ geml get doc.geml '#sec' --intro # a section cuts three ways: --head | --intro | --body
104
+ geml set doc.geml '<selector>' [--head|--intro|--body] [--in F[#src]] # replace ONE block's content
105
+ geml replace doc.geml OLD NEW [--within '<selector>'] # EXPERIMENTAL: literal swap, checked and reported
101
106
  geml add doc.geml (--append|--before #id|--after #id) [--in F[#src]] # insert a fragment
102
107
  geml delete doc.geml '#id' ['#id2' …] # remove one or more blocks
103
108
  geml rename doc.geml '#old' '#new' # rename an id + every reference to it
104
109
  geml revert doc.geml '#id' [--rev -1] # undo a block: splice / resurrect / remove
105
110
  geml check doc.geml [--root <dir>] # validate only: diagnostics + exit code (--json for the array)
106
111
  geml history <save|get|restore|verify> doc.geml [...] # .gemlhistory version sidecar (get = list revisions, or print one)
107
- geml codemap <build|verify|render|serve|refresh|find|mcp> # your codebase's call graph as GEML docs
112
+ geml codemap <build|verify|render|serve|refresh|find> # your codebase's call graph as GEML docs
113
+ geml mcp --root <dir> [--graph <dir>] # serve documents (+ the code graph) over MCP
108
114
  geml --help | --version # --version --json prints {"parser","spec"}
109
115
  ```
110
116
 
@@ -126,9 +132,31 @@ The agent loop: `geml get` a block → `set`/`add`/`delete`/`rename` it →
126
132
 
127
133
  `get` answers with N contents when N match (document order, count on stderr);
128
134
  `set` writes ONE block, so a selector matching several is refused (exit 2) with
129
- the unique address of each candidate. `--head` is the head line, `--body` the
130
- body; both round-trip — `geml get f X --body | geml set f X --body` leaves the
131
- file byte-identical.
135
+ the unique address of each candidate. A section cuts three ways: `--head` is the
136
+ heading line, `--intro` its opening region everything under it up to its first
137
+ subheading — and `--body` everything under it, so `--body` always contains
138
+ `--intro`, and equals it when the section has no subheading. All three
139
+ round-trip — `geml get f X --body | geml set f X --body` leaves the file
140
+ byte-identical — and `--intro` is how a section's opening is edited without
141
+ pulling its subsections into context. A block has no intro; asking for one is a
142
+ usage error rather than a quiet fall back to the body.
143
+
144
+ `replace` is the cheap path when the exact old text is already known and nothing
145
+ needs reading — a version string in six places, a term renamed. It is the one
146
+ operation where GEML can beat `sed` outright rather than imitate it: the same
147
+ two short strings, but the result is re-parsed before it lands, the blocks it
148
+ touched are named back to you, and it is in `.gemlhistory` to revert. It swaps a
149
+ LITERAL, never a pattern, and refuses a swap that would rename an id — that is
150
+ `geml rename`, which fixes the references too. **It is EXPERIMENTAL and may be
151
+ withdrawn**; build nothing on it that cannot change.
152
+
153
+ A write is refused when it would break the document, never merely because it
154
+ removes something. A replacement that drops blocks is carried out and the
155
+ dropped blocks are named on stderr — unnamed ones counted, references left
156
+ dangling reported — with `geml revert` as the way back. That is the same stance
157
+ `delete` takes, so removing content has one rule rather than two, and no region
158
+ becomes uneditable because something inside it happens to carry an id. The
159
+ round trip above drops nothing: the blocks came back in the text you sent.
132
160
 
133
161
  A `@<hex>` **content address** is the first 8 hex of the SHA-256 of the block's
134
162
  own text (line endings normalized to LF, no trailing newline), with `~1`, `~2`…
@@ -218,9 +246,13 @@ Add to your `claude_desktop_config.json`:
218
246
  ### Claude Code / CLI Clients
219
247
  Run the following command to add the server:
220
248
  ```sh
221
- /mcp add npx -y @geml/geml@latest mcp --root /absolute/path/to/your/docs
249
+ claude mcp add geml -- npx -y @geml/geml@latest mcp --root /absolute/path/to/your/docs
222
250
  ```
223
251
 
252
+ With a code graph under `--root` (`geml codemap build`), the same server also
253
+ serves four read-only `geml_codemap_*` tools. Every tool and option:
254
+ [`docs/mcp-guide.md`](https://github.com/geml-spec/geml/blob/main/docs/mcp-guide.md).
255
+
224
256
  ## Library
225
257
 
226
258
  ```js
@@ -243,6 +275,11 @@ Full normative spec, history-sidecar spec, and format comparison live in the
243
275
  [repository](https://github.com/geml-spec/geml). The spec is itself
244
276
  written in GEML (`GEML-spec.geml`) and parsed clean on every test run.
245
277
 
278
+ What changed between releases:
279
+ [`CHANGELOG.md`](https://github.com/geml-spec/geml/blob/main/CHANGELOG.md).
280
+ The parser and the specification version independently — `geml --version --json`
281
+ prints both.
282
+
246
283
  ## License
247
284
 
248
285
  MIT.
package/codemap/build.mjs CHANGED
@@ -513,6 +513,12 @@ console.error(
513
513
  + `${stats.containers} containers (${stats.written} of ${stats.docs} files written), `
514
514
  + `${(stats.bytes / 1048576).toFixed(2)} MB -> ${outDir}`,
515
515
  );
516
+ // A build that deletes files says which ones. Silence here is how the orphans
517
+ // accumulated in the first place — name them, so a rename that drops a whole
518
+ // naming scheme is visible in the log rather than three renamings later.
519
+ if (stats.pruned?.length) {
520
+ console.error(` pruned ${stats.pruned.length} document(s) no longer produced: ${stats.pruned.join(", ")}`);
521
+ }
516
522
 
517
523
  // --history: snapshot every changed document into its .gemlhistory sidecar —
518
524
  // the graph's own architectural history (geml history get / revert per node).
package/codemap/emit.mjs CHANGED
@@ -13,7 +13,7 @@
13
13
  // Emission is deterministic: stable sort orders everywhere; a file is only
14
14
  // written when its bytes changed (mtime = "what a change touched").
15
15
  import { createHash } from "node:crypto";
16
- import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
16
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
17
17
  import { dirname, join, posix } from "node:path";
18
18
  import { buildNormalizer } from "./normalize.mjs";
19
19
 
@@ -461,10 +461,40 @@ export function emit({ symbols, edges, outDir, buildDir, repoName, container = "
461
461
  if (!existsSync(p) || readFileSync(p, "utf8") !== content) writeFileSync(p, content);
462
462
  }
463
463
 
464
+ // ---- prune documents this build no longer produces ----
465
+ // A container that stops yielding symbols simply gets no document; without
466
+ // this, the one written by an earlier build stays behind describing code that
467
+ // is gone. Those orphans are not inert: `geml check` reads their `src=` line
468
+ // ranges and fails the build-time reference check, which is how nine of them
469
+ // — across two renamings of the naming scheme — went unnoticed until one
470
+ // orphan's source file happened to SHRINK past its recorded line numbers.
471
+ //
472
+ // `allDocs` is the authoritative set: every .geml this run emitted, written
473
+ // or byte-identical. Anything else at the top level of outDir is an orphan.
474
+ // Two guards keep this from eating a file it does not own: only the top level
475
+ // is scanned (never _index/, _build/, or any subtree), and a candidate must
476
+ // carry the generated-document marker `resolution-default` in its head — a
477
+ // hand-placed .geml parked in the directory is left alone.
478
+ const pruned = [];
479
+ const keep = new Set(allDocs);
480
+ let present = [];
481
+ try { present = readdirSync(outDir); } catch { present = []; }
482
+ for (const f of present) {
483
+ if (!f.endsWith(".geml") || keep.has(f)) continue;
484
+ const p = join(outDir, f);
485
+ try {
486
+ if (!statSync(p).isFile()) continue;
487
+ if (!/^===\s*meta\b[\s\S]*?\bresolution-default\s*=/.test(readFileSync(p, "utf8").slice(0, 2000))) continue;
488
+ unlinkSync(p);
489
+ pruned.push(f);
490
+ } catch { /* unreadable or already gone — not this build's problem */ }
491
+ }
492
+
464
493
  return {
465
494
  ...stats,
466
495
  allDocs,
467
496
  writtenDocs,
497
+ pruned,
468
498
  containers: containers.size,
469
499
  symbols: symbols.length,
470
500
  methods: methods.length,
@@ -8,7 +8,7 @@
8
8
  // graph area (nested frame), so the whole map is browsable offline — this is
9
9
  // the "copy the folder to someone" mode. For a live view that never goes
10
10
  // stale, use `geml codemap serve` instead.
11
- import { readdirSync, readFileSync, writeFileSync, realpathSync } from "node:fs";
11
+ import { readdirSync, readFileSync, writeFileSync, realpathSync, unlinkSync } from "node:fs";
12
12
  import { join, basename, sep, resolve as resolvePath } from "node:path";
13
13
  import { parse, renderHtml } from "../dist/geml.js";
14
14
 
@@ -73,5 +73,18 @@ for (const f of files) {
73
73
  console.error(`render: ${f}: ${e.message}`);
74
74
  }
75
75
  }
76
+ // Each tool prunes what it owns: build removes the documents it no longer
77
+ // produces, and this removes the pages whose document is gone. An orphan page
78
+ // is worse than a stale one — it is unreachable from index.html yet still
79
+ // served, so a copied folder ships a page describing deleted code with no way
80
+ // to notice. Only a `<base>.html` whose `<base>.geml` is absent qualifies, so
81
+ // nothing that has a document behind it is ever touched.
82
+ const prunedPages = [];
83
+ for (const f of files) {
84
+ if (!f.endsWith(".html")) continue;
85
+ if (files.includes(f.replace(/\.html$/, ".geml"))) continue;
86
+ try { unlinkSync(join(dir, f)); prunedPages.push(f); } catch { /* already gone */ }
87
+ }
76
88
  console.error(`rendered ${n} page(s) -> ${dir}${failed.length ? `; FAILED: ${failed.join(", ")}` : ""}`);
89
+ if (prunedPages.length) console.error(` pruned ${prunedPages.length} orphan page(s): ${prunedPages.join(", ")}`);
77
90
  process.exit(failed.length ? 1 : 0);
@@ -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.replace(/[ \t]+$/, "");
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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};