@geml/geml 1.8.1 → 1.8.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 CHANGED
@@ -108,6 +108,9 @@ geml delete doc.geml '#id' ['#id2' …] # remove one or more blocks
108
108
  geml rename doc.geml '#old' '#new' # rename an id + every reference to it
109
109
  geml revert doc.geml '#id' [--rev -1] # undo a block: splice / resurrect / remove
110
110
  geml check doc.geml [--root <dir>] # validate only: diagnostics + exit code (--json for the array)
111
+ # --root works on every verb above, not just check. A write is refused when the result
112
+ # would not parse, so a document whose ../sibling.md links resolve only from the repo
113
+ # root needs --root to be editable at all. The MCP server passes its own root for you.
111
114
  geml history <save|get|restore|verify> doc.geml [...] # .gemlhistory version sidecar (get = list revisions, or print one)
112
115
  geml codemap <build|verify|render|serve|refresh|find> # your codebase's call graph as GEML docs
113
116
  geml mcp --root <dir> [--graph <dir>] # serve documents (+ the code graph) over MCP
package/dist/attrs.d.ts CHANGED
@@ -3,7 +3,15 @@ export interface Attrs {
3
3
  id?: string;
4
4
  classes: string[];
5
5
  attrs: Record<string, Value>;
6
+ odd?: {
7
+ kind: "id" | "class" | "flag" | "key";
8
+ name: string;
9
+ }[];
6
10
  }
11
+ export declare function oddNames(a: Attrs): {
12
+ kind: "id" | "class" | "flag" | "key";
13
+ name: string;
14
+ }[];
7
15
  export declare function coerce(raw: string): Value;
8
16
  export declare function tokenize(s: string): string[];
9
17
  export declare function parseAttrs(src: string): Attrs;
package/dist/attrs.js CHANGED
@@ -1,5 +1,29 @@
1
1
  // Shared attribute-object and value typing (§4), used by the block scanner
2
2
  // and the inline parser.
3
+ // §4: NAME = NAME-CHAR+, NAME-CHAR = LETTER | DIGIT | "-" | "_", LETTER being
4
+ // any Unicode letter. Ids, classes and attribute keys are all NAMEs; only
5
+ // VALUES may hold anything else.
6
+ const NAME = /^[\p{L}\p{N}_-]+$/u;
7
+ // Why this is worth a diagnostic: the attribute object is whitespace-separated,
8
+ // so `{#Trade-offs & Laws}` parses as the id `Trade-offs` plus two boolean flags
9
+ // named `&` and `Laws` — a legal parse of a document nobody meant to write, and
10
+ // silent, because a bare word IS how a flag is spelled. The id you addressed is
11
+ // then not the id you have. Naming what cannot be a NAME turns that into a
12
+ // question the author can answer.
13
+ export function oddNames(a) {
14
+ const odd = [];
15
+ if (a.id !== undefined && !NAME.test(a.id))
16
+ odd.push({ kind: "id", name: a.id });
17
+ for (const c of a.classes)
18
+ if (!NAME.test(c))
19
+ odd.push({ kind: "class", name: c });
20
+ for (const [k, v] of Object.entries(a.attrs)) {
21
+ if (NAME.test(k))
22
+ continue;
23
+ odd.push({ kind: v === true ? "flag" : "key", name: k });
24
+ }
25
+ return odd;
26
+ }
3
27
  // §4 value typing: quoted -> string, true/false -> boolean, integer/float
4
28
  // syntax -> number, any other bare word -> string. No arrays/dates/tables.
5
29
  export function coerce(raw) {
package/dist/cli.js CHANGED
@@ -168,7 +168,12 @@ Usage:
168
168
  geml revert <file.geml> #id [--rev <sel>] [--head] undo one block to a past revision (splice / resurrect / remove)
169
169
  (sel: 0 | -N | id-prefix | changed; default -1)
170
170
  geml check <file.geml|-> [--root d] [--json] validate only: diagnostics + exit code
171
- (--root widens cross-doc refs to dir d, e.g. the repo root)
171
+ (--root widens cross-doc refs to dir d, e.g. the repo root;
172
+ every read and write verb takes it. A write is REFUSED when
173
+ the result would not parse, so a document whose ../x.md
174
+ links only resolve from the repo root needs --root to be
175
+ editable at all — otherwise the guard reads its own blind
176
+ spot as breakage)
172
177
  geml history <save|get|restore|verify> <file.geml> [...] .gemlhistory version sidecar
173
178
  (save = append the file as a revision · get = list revisions, or
174
179
  print one · restore = overwrite the file with one · verify = rebuild
@@ -197,15 +202,15 @@ Exit codes:
197
202
  // shown on misuse and the `<cmd> --help` text.
198
203
  const SUBHELP = {
199
204
  get: "usage: geml get <file.geml|-> [<selector>] [--head|--intro|--body] [--view [--root <dir>]] [--json] (selector = a filter over blocks: #id | '## Heading' (its whole section) | '=== type' (every block of that type — N matches print N contents, count on stderr) | '=== type@<hex>[~n]' or '@<hex>[~n]' (content address, for blocks with no #id) | L<n> or L<n>-<m> (position — the smallest block that fully contains those lines, so the `L27-58` the listing prints pastes straight back, and a line number from an editor, a linter or a diff hunk becomes a block); a section cuts three ways — --head = the heading line, --intro = its opening region: everything under it up to its FIRST SUBHEADING (empty when one follows immediately, the whole body when none does; a block has no intro and is refused), --body = everything under it; --view = read THROUGH an `embed` to the entity block it stands for, following a chain to its end (the identity on any other block, and on a section selector — it never splices two documents' bytes together); provenance goes to stderr as `view: <sel> -> <doc>[#<id>]`; read-only, `set` refuses it; chain reads are confined to --root (default: the document's own directory) and never fetched over the network; without a selector: list every addressable block with its shortest unique address, --json = array)",
200
- set: "usage: geml set <file.geml|-> <selector> [--head|--intro|--body] [--in F | --in F#src | --in -] [-o out.geml] (selector as in `get`, but it must match exactly ONE block — '=== type' matching several is refused; content: --in F takes F's block #id, --in F#src takes #src, else stdin raw; default = whole block, --head = head line — both normalize the id when the target has one — --body = body, --intro = a heading's opening region up to its first subheading (an empty region INSERTS there); guarded splice, refused if it breaks the doc — but a replacement that REMOVES blocks is carried out and reported on stderr, named ones and unnamed alike, with `geml revert` as the way back (the same stance `delete` takes; the ordinary read-edit-write cycle removes nothing, since `get` handed those blocks over); writing through an @<hex> address prints the new address on stderr)",
201
- add: "usage: geml add <file.geml|-> (--append | --before #id | --after #id) [--in F | --in F#src | --in -] [-o out.geml] (insert a GEML fragment — 1+ blocks and/or prose — at a position; --in F takes all of F, --in F#src takes #src, else stdin raw; content keeps its own ids, a collision is refused)",
202
- delete: "usage: geml delete <file.geml|-> #id [#id2 …] [-o out.geml] (remove one or more blocks; a missing id is skipped with a note, not an error; a reference left dangling is a warning, not a refusal — delete never fails on a live reference)",
203
- rename: "usage: geml rename <file.geml|-> #old #new [-o out.geml] (rewrite an id's declaration AND every reference — [[#id]], [text](#id), chart data=#id, footnote [^id] — id-boundary safe, skipping raw block bodies; #new must be free; refused if it breaks the doc)",
205
+ set: "usage: geml set <file.geml|-> <selector> [--head|--intro|--body] [--in F | --in F#src | --in -] [-o out.geml] [--root d] (selector as in `get`, but it must match exactly ONE block — '=== type' matching several is refused; content: --in F takes F's block #id, --in F#src takes #src, else stdin raw; default = whole block, --head = head line — both normalize the id when the target has one — --body = body, --intro = a heading's opening region up to its first subheading (an empty region INSERTS there); guarded splice, refused if it breaks the doc — but a replacement that REMOVES blocks is carried out and reported on stderr, named ones and unnamed alike, with `geml revert` as the way back (the same stance `delete` takes; the ordinary read-edit-write cycle removes nothing, since `get` handed those blocks over); writing through an @<hex> address prints the new address on stderr)",
206
+ add: "usage: geml add <file.geml|-> (--append | --before #id | --after #id) [--in F | --in F#src | --in -] [-o out.geml] [--root d] (insert a GEML fragment — 1+ blocks and/or prose — at a position; --in F takes all of F, --in F#src takes #src, else stdin raw; content keeps its own ids, a collision is refused)",
207
+ delete: "usage: geml delete <file.geml|-> #id [#id2 …] [-o out.geml] [--root d] (remove one or more blocks; a missing id is skipped with a note, not an error; a reference left dangling is a warning, not a refusal — delete never fails on a live reference)",
208
+ rename: "usage: geml rename <file.geml|-> #old #new [-o out.geml] [--root d] (rewrite an id's declaration AND every reference — [[#id]], [text](#id), chart data=#id, footnote [^id] — id-boundary safe, skipping raw block bodies; #new must be free; refused if it breaks the doc)",
204
209
  list: "usage: geml list <file.geml|-> [--json] (list every addressable block with its shortest unique address, its kind and its line range — the same listing `geml get <file>` prints with no selector, under the name the MCP surface already uses. Call it FIRST: the addresses it prints are what get/set/add/delete/rename/revert all take)",
205
210
  find: "usage: geml find <pattern> [<file|dir> …] [--json] [--case] [--head] (search block CONTENT and print `<file>TAB<address>` per hit — an address, never a line number, so a hit is `geml get <file> '<address>'` with no editing. The address is the INNERMOST block holding the match, never its enclosing section, and a block is reported once however many lines in it matched. Substring, case-insensitive unless --case; a file you NAME is searched whatever its extension, including Markdown, while a directory is walked for *.geml only; no path = the current directory; --head adds the matching line as a third column. Exit 1 when nothing matched, so `if geml find …` works in a script)",
206
- replace: "usage: geml replace <file.geml|-> <old> <new> [--within <selector>] [-o out.geml] (EXPERIMENTAL — this verb MAY BE WITHDRAWN in a later release; it is here to find out whether an addressed, checked replacement earns its place beside `sed`, and if it does not, it goes. Build nothing on it you cannot change, and say so in a discussion if it is doing real work for you. Swaps a LITERAL string — never a pattern, that is what `sed` is for and where the footguns are. Without --within the whole document; with it, only inside the blocks that selector matches, and unlike `set` it may match several: `--within '=== table'` means every table. What this buys over `sed -i`, at the same cost of two short strings and nothing read: the result is re-parsed and refused if it would break the document, the blocks it touched are NAMED on stderr, and the write lands in .gemlhistory where `revert` can undo it. An id is not text — a replacement that would rename one is refused and points at `geml rename`, which fixes every reference too. Exit 1 when nothing matched, so `if geml replace …` works in a script)",
211
+ replace: "usage: geml replace <file.geml|-> <old> <new> [--within <selector>] [-o out.geml] [--root d] (EXPERIMENTAL — this verb MAY BE WITHDRAWN in a later release; it is here to find out whether an addressed, checked replacement earns its place beside `sed`, and if it does not, it goes. Build nothing on it you cannot change, and say so in a discussion if it is doing real work for you. Swaps a LITERAL string — never a pattern, that is what `sed` is for and where the footguns are. Without --within the whole document; with it, only inside the blocks that selector matches, and unlike `set` it may match several: `--within '=== table'` means every table. What this buys over `sed -i`, at the same cost of two short strings and nothing read: the result is re-parsed and refused if it would break the document, the blocks it touched are NAMED on stderr, and the write lands in .gemlhistory where `revert` can undo it. An id is not text — a replacement that would rename one is refused and points at `geml rename`, which fixes every reference too. Exit 1 when nothing matched, so `if geml replace …` works in a script)",
207
212
  check: "usage: geml check <file.geml|-> [--root <dir>] [--json] (--root: resolve cross-doc refs within <dir> instead of the file's own directory)",
208
- revert: "usage: geml revert <file.geml> #id [--rev <sel>] [--append|--before #x|--after #x] [--head] [--dry-run] [-o out] (reconcile #id to a revision: splice / resurrect / remove; sel: 0 | -N | id-prefix | changed; default -1)",
213
+ revert: "usage: geml revert <file.geml> #id [--rev <sel>] [--append|--before #x|--after #x] [--head] [--dry-run] [-o out] [--root d] (reconcile #id to a revision: splice / resurrect / remove; sel: 0 | -N | id-prefix | changed; default -1)",
209
214
  history: `usage: geml history save <file.geml> [-m <msg>] append the working file as a new revision (identical to the tip = no-op)
210
215
  geml history get <file.geml> [<rev>] [--json] NO <rev>: every revision, newest first, first column = the selector; WITH <rev>: that revision's full text
211
216
  geml history restore <file.geml> <rev> [--force] overwrite the working file with a revision (--force discards unsaved changes)
@@ -249,13 +254,15 @@ const SUBHELP = {
249
254
  One command, three things, all user-global — so any Claude Code session can
250
255
  author, validate, and blockwise-edit GEML:
251
256
  1. the authoring skill -> <skillsDir>/geml (default ~/.claude/skills/geml)
252
- 2. the geml CLI -> npm i -g @geml/geml (skipped when already on PATH)
257
+ 2. the geml CLI -> npm i -g @geml/geml@<this version> (skipped when PATH already has it)
253
258
  3. the MCP server -> claude mcp add --scope user geml -- npx -y @geml/geml mcp --root .
254
- Touches no settings.json and installs no hooks. Idempotent — re-run after an
255
- upgrade to refresh the skill text alongside the CLI it teaches.
259
+ Touches no settings.json and installs no hooks. Idempotent — and it is the
260
+ whole upgrade: a re-run refreshes the skill text AND brings the global CLI to
261
+ the version that text documents, so "npx -y @geml/geml skill install" is one
262
+ step, not two.
256
263
 
257
264
  --dest <dir> install the skill under <dir> instead of ~/.claude/skills
258
- --no-global skip the global npm install
265
+ --no-global leave the global CLI alone — no install, no version change
259
266
  --no-mcp skip the MCP server registration
260
267
  --dry-run report what would be written, change nothing
261
268
 
@@ -426,7 +433,27 @@ function existsFor(file, root) {
426
433
  }
427
434
  // Both halves for a parse: every call site wants them together, and pairing
428
435
  // them here keeps a resolver from being wired up without its existence probe.
429
- function docOpts(file, root) {
436
+ // The root that cross-document references resolve against for the rest of this
437
+ // process, taken from `--root` by whichever verb is running.
438
+ //
439
+ // Why a module-level value rather than a parameter: the write verbs re-parse
440
+ // through `resolveSelector`, `spliceSpan`, `insertFragment`, `rewriteId` and
441
+ // `unitNode`, and threading a root through those signatures and their dozen call
442
+ // sites would deliver the same value to the same place by a longer route. It is
443
+ // safe to hold here because a verb is one process: `geml mcp` spawns the CLI per
444
+ // tool call rather than calling these functions in-process.
445
+ let CLI_ROOT;
446
+ // Read `--root` and make it this process's root. Verbs call this before they
447
+ // parse anything; `--root` present with no directory is a usage error, the same
448
+ // as it is on `check`.
449
+ function useRoot(args) {
450
+ const r = flag(args, "--root");
451
+ if (args.includes("--root") && r === undefined)
452
+ fail("--root needs a directory", 2);
453
+ CLI_ROOT = r;
454
+ return r;
455
+ }
456
+ function docOpts(file, root = CLI_ROOT) {
430
457
  return { resolveDoc: resolverFor(file, root), docExists: existsFor(file, root) };
431
458
  }
432
459
  // `geml check <file>` — validate only: diagnostics + exit code, no document
@@ -1088,6 +1115,7 @@ function reportMatches(type, units) {
1088
1115
  // descriptions agents are already reading. `get <file>` keeps working.
1089
1116
  function runList(args) {
1090
1117
  const [file, extra] = positionals(args, ["--root"]);
1118
+ useRoot(args);
1091
1119
  if (!file)
1092
1120
  fail(SUBHELP.list);
1093
1121
  // `list` IS the empty filter, so a selector here means the caller wanted
@@ -1135,7 +1163,10 @@ function gemlFilesUnder(path, out, explicit = false) {
1135
1163
  // being true the moment anything above it changes. `codemap find` already
1136
1164
  // resolves a substring to `doc#id` for symbols; this is the same move for prose.
1137
1165
  function runFind(args) {
1138
- const pos = positionals(args, []);
1166
+ // `--root` is declared here, and ignored, so that it cannot be mistaken for
1167
+ // one more path to search: `find` resolves no cross-document references, and
1168
+ // swallowing the directory as a search path widens what a caller narrowed.
1169
+ const pos = positionals(args, ["--root"]);
1139
1170
  const pattern = pos[0];
1140
1171
  if (pattern === undefined)
1141
1172
  fail(SUBHELP.find);
@@ -1220,6 +1251,7 @@ function runGet(args) {
1220
1251
  const introOnly = args.includes("--intro");
1221
1252
  const view = args.includes("--view");
1222
1253
  const [file, rawSel] = positionals(args, ["--root"]);
1254
+ useRoot(args);
1223
1255
  if (!file)
1224
1256
  fail(SUBHELP.get);
1225
1257
  const parts = [headOnly && "--head", introOnly && "--intro", bodyOnly && "--body"].filter(Boolean);
@@ -1339,7 +1371,8 @@ function runGet(args) {
1339
1371
  function runReplace(args) {
1340
1372
  const out = flag(args, "-o") ?? flag(args, "--out");
1341
1373
  const within = flag(args, "--within");
1342
- const [file, oldText, newText] = positionals(args, ["-o", "--out", "--within"]);
1374
+ const [file, oldText, newText] = positionals(args, ["-o", "--out", "--within", "--root"]);
1375
+ useRoot(args);
1343
1376
  if (!file || oldText === undefined || newText === undefined)
1344
1377
  fail(SUBHELP.replace);
1345
1378
  if (oldText === "")
@@ -1474,7 +1507,8 @@ function runSet(args) {
1474
1507
  if (args.includes("--view")) {
1475
1508
  fail("--view is read-only. To edit the target, read the frame's `src` and edit that document.", 2);
1476
1509
  }
1477
- const [file, rawSel] = positionals(args, ["-o", "--out", "--in"]);
1510
+ const [file, rawSel] = positionals(args, ["-o", "--out", "--in", "--root"]);
1511
+ useRoot(args);
1478
1512
  if (!file)
1479
1513
  fail(SUBHELP.set);
1480
1514
  // No selector: there is no block to replace. Point the way to discovery, not a
@@ -1649,7 +1683,8 @@ function runAdd(args) {
1649
1683
  const posCount = (append ? 1 : 0) + (before !== undefined ? 1 : 0) + (after !== undefined ? 1 : 0);
1650
1684
  if (posCount !== 1)
1651
1685
  fail("add needs exactly one position: --append | --before #id | --after #id", 2);
1652
- const [file] = positionals(args, ["-o", "--out", "--in", "--before", "--after"]);
1686
+ const [file] = positionals(args, ["-o", "--out", "--in", "--before", "--after", "--root"]);
1687
+ useRoot(args);
1653
1688
  if (!file)
1654
1689
  fail(SUBHELP.add);
1655
1690
  const rawChannel = from === undefined || from === "-";
@@ -1728,7 +1763,8 @@ function insertFragment(source, lines, at, fragment, file) {
1728
1763
  // the UNION of target lines, so a line is never spliced twice.
1729
1764
  function runDelete(args) {
1730
1765
  const out = flag(args, "-o") ?? flag(args, "--out");
1731
- const pos = positionals(args, ["-o", "--out"]);
1766
+ const pos = positionals(args, ["-o", "--out", "--root"]);
1767
+ useRoot(args);
1732
1768
  const file = pos[0];
1733
1769
  if (!file)
1734
1770
  fail(SUBHELP.delete);
@@ -1767,7 +1803,8 @@ function runDelete(args) {
1767
1803
  // free; the guarded re-parse refuses anything that would break the doc.
1768
1804
  function runRename(args) {
1769
1805
  const out = flag(args, "-o") ?? flag(args, "--out");
1770
- const [file, rawOld, rawNew] = positionals(args, ["-o", "--out"]);
1806
+ const [file, rawOld, rawNew] = positionals(args, ["-o", "--out", "--root"]);
1807
+ useRoot(args);
1771
1808
  if (!file || !rawOld || !rawNew)
1772
1809
  fail(SUBHELP.rename);
1773
1810
  const oldId = rawOld.replace(/^#/, "");
@@ -2051,7 +2088,8 @@ function runRevert(args) {
2051
2088
  if ((append ? 1 : 0) + (before !== undefined ? 1 : 0) + (after !== undefined ? 1 : 0) > 1) {
2052
2089
  fail("revert takes at most one position: --append | --before #id | --after #id", 2);
2053
2090
  }
2054
- const [file, rawId] = positionals(args, ["--rev", "--history", "-o", "--out", "--before", "--after"]);
2091
+ const [file, rawId] = positionals(args, ["--rev", "--history", "-o", "--out", "--before", "--after", "--root"]);
2092
+ useRoot(args);
2055
2093
  if (!file || !rawId)
2056
2094
  fail(SUBHELP.revert);
2057
2095
  if (file === "-")
@@ -2428,13 +2466,26 @@ function runSkill(args) {
2428
2466
  const sh = process.platform === "win32";
2429
2467
  const run = (cmd, a, inherit = false) => spawnSync(cmd, a, { shell: sh, encoding: "utf8", ...(inherit ? { stdio: "inherit" } : {}) });
2430
2468
  if (!noGlobal) {
2469
+ // The skill text just written came out of THIS package (`skill/` next to
2470
+ // dist/), so the CLI it teaches is this package's version. Anything else on
2471
+ // PATH is a mismatch — which is why the test is an equality and not an
2472
+ // ordering: "different version" and "not installed" both mean "put the
2473
+ // version the skill documents there", and pinning the spec is what makes
2474
+ // one `npx -y @geml/geml skill install` a complete upgrade.
2475
+ //
2476
+ // The version is read back out of `geml --version` ("geml 1.2.3 (GEML spec
2477
+ // …)") rather than trusted as a whole string: an unreadable or unexpected
2478
+ // line is simply not a match, and lands in the install branch.
2431
2479
  const have = run("geml", ["--version"]);
2432
- if (have.status === 0) {
2433
- console.log(`cli ${String(have.stdout ?? "").trim()} already on PATH`);
2480
+ const onPath = have.status === 0
2481
+ ? /\b\d+\.\d+\.\d+[^\s)]*/.exec(String(have.stdout ?? ""))?.[0]
2482
+ : undefined;
2483
+ if (onPath === PARSER_VERSION) {
2484
+ console.log(`cli ${PARSER_VERSION} already on PATH`);
2434
2485
  }
2435
2486
  else {
2436
- console.log("cli installing @geml/geml globally (npm i -g)...");
2437
- const r = run("npm", ["install", "-g", "@geml/geml", "--no-audit", "--no-fund", "--loglevel=error"], true);
2487
+ console.log(`cli installing @geml/geml globally (npm i -g)${onPath ? `, ${onPath} -> ${PARSER_VERSION}` : ""}...`);
2488
+ const r = run("npm", ["install", "-g", `@geml/geml@${PARSER_VERSION}`, "--no-audit", "--no-fund", "--loglevel=error"], true);
2438
2489
  if (r.status !== 0)
2439
2490
  console.error("cli global install failed — install later with: npm i -g @geml/geml");
2440
2491
  }
@@ -1,4 +1,4 @@
1
- export type DiagnosticCode = "unterminated-block" | "unknown-block-type" | "unknown-attribute" | "block-nesting-too-deep" | "list-nesting-too-deep" | "inline-nesting-too-deep" | "stray-labeled-fence" | "fence-like-line" | "unresolvable-code-source" | "bad-code-source" | "bad-source-range" | "code-src-and-body" | "duplicate-id" | "unresolved-reference" | "unresolved-footnote" | "unresolved-cross-document-reference" | "unresolvable-document" | "unchecked-cross-document-reference" | "embed-missing-src" | "ignored-embed-body" | "transclusion-cycle" | "embed-target-not-geml" | "media-target-is-document" | "inline-transclusion-not-inline" | "unsafe-embed-scheme" | "unresolvable-table-source" | "table-source-not-a-table" | "unknown-metadata-reference" | "duplicate-meta-key" | "table-src-and-body" | "unknown-table-format" | "bad-table-delimiter" | "ignored-table-delimiter" | "bad-compute-formula" | "unlexable-compute-formula" | "compute-error" | "compute-non-numeric-cell" | "compute-not-a-number" | "bad-summary-entry" | "summary-unknown-column" | "unlexable-summary-expression" | "summary-error" | "unknown-diagram-format" | "ignored-diagram-body" | "code-graph-missing-src" | "code-graph-unresolvable-document" | "chart-missing-data" | "chart-data-not-a-table" | "chart-missing-type" | "chart-unknown-type" | "chart-unknown-rows-scope" | "chart-missing-channel" | "chart-empty-channel" | "chart-unknown-column" | "chart-unused-channel" | "chart-missing-summary-row" | "chart-summary-row-unavailable" | "chart-non-numeric-value" | "chart-data-not-records" | "data-parse" | "unknown-data-format" | "data-format-no-engine" | "bad-data-schema" | "data-src-and-body" | "bad-data-source" | "unresolvable-data-source";
1
+ export type DiagnosticCode = "unterminated-block" | "unknown-block-type" | "unknown-attribute" | "block-nesting-too-deep" | "list-nesting-too-deep" | "inline-nesting-too-deep" | "stray-labeled-fence" | "fence-like-line" | "unresolvable-code-source" | "bad-code-source" | "bad-source-range" | "code-src-and-body" | "name-not-a-name" | "duplicate-id" | "unresolved-reference" | "unresolved-footnote" | "unresolved-cross-document-reference" | "unresolvable-document" | "unchecked-cross-document-reference" | "embed-missing-src" | "ignored-embed-body" | "transclusion-cycle" | "embed-target-not-geml" | "media-target-is-document" | "inline-transclusion-not-inline" | "unsafe-embed-scheme" | "unresolvable-table-source" | "table-source-not-a-table" | "unknown-metadata-reference" | "duplicate-meta-key" | "table-src-and-body" | "unknown-table-format" | "bad-table-delimiter" | "ignored-table-delimiter" | "bad-compute-formula" | "unlexable-compute-formula" | "compute-error" | "compute-non-numeric-cell" | "compute-not-a-number" | "bad-summary-entry" | "summary-unknown-column" | "unlexable-summary-expression" | "summary-error" | "unknown-diagram-format" | "ignored-diagram-body" | "code-graph-missing-src" | "code-graph-unresolvable-document" | "chart-missing-data" | "chart-data-not-a-table" | "chart-missing-type" | "chart-unknown-type" | "chart-unknown-rows-scope" | "chart-missing-channel" | "chart-empty-channel" | "chart-unknown-column" | "chart-unused-channel" | "chart-missing-summary-row" | "chart-summary-row-unavailable" | "chart-non-numeric-value" | "chart-data-not-records" | "data-parse" | "unknown-data-format" | "data-format-no-engine" | "bad-data-schema" | "data-src-and-body" | "bad-data-source" | "unresolvable-data-source";
2
2
  export interface Diagnostic {
3
3
  severity: "error" | "warning";
4
4
  code: DiagnosticCode;
@@ -25,6 +25,9 @@ export const SEVERITY = {
25
25
  "bad-code-source": "error",
26
26
  "bad-source-range": "error",
27
27
  "code-src-and-body": "error",
28
+ // A warning, not an error: this has always parsed, and documents rely on the
29
+ // leniency. What the author is missing is that it parsed as something else.
30
+ "name-not-a-name": "warning",
28
31
  "duplicate-id": "error",
29
32
  "unresolved-reference": "error",
30
33
  "unresolved-footnote": "error",
package/dist/geml.js CHANGED
@@ -18,7 +18,7 @@ import { readFileSync, realpathSync } from "node:fs";
18
18
  import { dirname, join, resolve as resolvePath } from "node:path";
19
19
  import { fileURLToPath } from "node:url";
20
20
  import { normalizeSource } from "./diagnostics.js";
21
- import { coerce, parseAttrs } from "./attrs.js";
21
+ import { coerce, oddNames, parseAttrs } from "./attrs.js";
22
22
  import { META_REF_SRC, parseInline, isSafeUrl, schemeOf } from "./inline.js";
23
23
  import { parseTable } from "./table.js";
24
24
  import { USES, buildChart } from "./chart.js";
@@ -142,6 +142,21 @@ export const FENCE_OPEN = /^(={3,})[ \t]+([A-Za-z][A-Za-z0-9_-]*)[ \t]*(?:(\{.*\
142
142
  // Together: the group runs to the end of the line and starts at the first `{`
143
143
  // after the last OTHER `}`. Returns the RegExpExecArray shape the call sites
144
144
  // already destructure.
145
+ // A name in the attribute object that is not a NAME (§4). A WARNING, not an
146
+ // error: it has always parsed, documents in the wild rely on the leniency, and
147
+ // what the author needs is to be told — `{#a & b}` gives the id `a` and two
148
+ // flags called `&` and `b`, which is a legal parse of something nobody wrote.
149
+ function reportOddNames(a, line, diags) {
150
+ for (const { kind, name } of oddNames(a)) {
151
+ diags.push({
152
+ severity: "warning",
153
+ code: "name-not-a-name",
154
+ message: `${kind} \`${name}\` is not a NAME (§4: letters, digits, \`-\`, \`_\`)`
155
+ + (kind === "flag" ? " — an attribute object is whitespace-separated, so a space in an id or class splits it into flags like this one" : ""),
156
+ line,
157
+ });
158
+ }
159
+ }
145
160
  const HEADING_HEAD = /^(#{1,6})[ \t]+/;
146
161
  function matchHeading(line) {
147
162
  const m = HEADING_HEAD.exec(line);
@@ -414,6 +429,7 @@ function scanBlocks(lines, base, ctx, depth = 0) {
414
429
  const type = open[2];
415
430
  const attrs = open[3] ? parseAttrs(open[3]) : { classes: [], attrs: {} };
416
431
  const openLineNo = base + i + 1;
432
+ reportOddNames(attrs, openLineNo, diags);
417
433
  // Collect the body. A block closes on the FIRST line that is a bare fence
418
434
  // of exactly the opening length, OR — when it has an id — a labeled fence
419
435
  // `=== #id` (a `=` run of any length ≥ 3 followed by the block's id). The
@@ -672,6 +688,7 @@ function scanBlocks(lines, base, ctx, depth = 0) {
672
688
  const level = h[1].length;
673
689
  const rawText = h[2];
674
690
  const a = parseAttrs(h[3] ?? "");
691
+ reportOddNames(a, lineNo, diags);
675
692
  const text = interpolate(rawText, lineNo, ctx);
676
693
  const id = a.id ?? slug(rawText);
677
694
  registerId(ctx, id, lineNo);
package/dist/mcp.js CHANGED
@@ -121,7 +121,43 @@ function resolveGraphDir(graphDir) {
121
121
  // Driving the CLI
122
122
  // ---------------------------------------------------------------------------
123
123
  const CLI = resolve(dirname(fileURLToPath(import.meta.url)), "geml.js");
124
+ // Verbs whose cross-document reference resolution takes a root. The write ones
125
+ // matter most: a write is refused when the result would not parse, so without a
126
+ // root the guard reads its own blind spot as breakage and a document whose
127
+ // `../sibling.md` links only resolve from the server root cannot be edited at
128
+ // all. The server has always known that root and simply never handed it over.
129
+ // NOT `find`: it searches block CONTENT and resolves no references, while its
130
+ // positionals are the places to look — a stray `--root` reads as one more of
131
+ // them, widening the very search the caller narrowed. A test caught exactly
132
+ // that when this list was written without the exception.
133
+ const ROOT_VERBS = new Set([
134
+ "get", "list", "check", "set", "replace", "add", "delete", "rename", "revert",
135
+ ]);
136
+ // The server root as the filesystem really spells it. Falls back to the stored
137
+ // value when it cannot be canonicalized: an unusable root is the caller's
138
+ // problem to hear about from the verb, not something to throw from here.
139
+ function rootReal() {
140
+ try {
141
+ return realpathSync(OPTS.root);
142
+ }
143
+ catch {
144
+ return OPTS.root;
145
+ }
146
+ }
124
147
  function runCli(args, input) {
148
+ // The server root IS the resolution root: every `file` already lives under it,
149
+ // so a reference reaching a sibling directory is in scope by definition.
150
+ if (ROOT_VERBS.has(args[0] ?? "") && !args.includes("--root")) {
151
+ // CANONICALIZED, because `resolveInRoot` already canonicalizes every `file`
152
+ // it hands over. Passing the root raw mixes the two: the CLI's cheap lexical
153
+ // gate compares the reference's absolute path against the root with
154
+ // `relative()`, and a root reached through a symlink is lexically outside a
155
+ // canonical target, so every cross-document reference in the workspace
156
+ // resolves to nothing. On macOS that is the default state of affairs —
157
+ // `os.tmpdir()` is `/var/folders/…`, a symlink to `/private/var/folders/…`
158
+ // — which is why this passed on Windows and failed in CI.
159
+ args = [...args, "--root", rootReal()];
160
+ }
125
161
  const r = spawnSync(process.execPath, [CLI, ...args], {
126
162
  input: input ?? "",
127
163
  encoding: "utf8",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geml/geml",
3
- "version": "1.8.1",
3
+ "version": "1.8.2",
4
4
  "mcpName": "io.github.geml-spec/geml",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -208,6 +208,10 @@ signal, and prints only diagnostics (cheap on context):
208
208
  geml check file.geml # diagnostics + exit code only
209
209
  geml check --json file.geml # machine-readable diagnostics array
210
210
  geml check --root . file.geml # widen cross-doc reference resolution to a dir
211
+ # EVERY read and write verb takes --root. A write is
212
+ # refused when the result would not parse, so a doc whose
213
+ # ../sibling.md links resolve only from the repo root needs
214
+ # it to be editable at all. `geml mcp` passes its own root.
211
215
  ===
212
216
 
213
217
  All commands accept `-` to read from stdin.