@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.
package/dist/geml.js CHANGED
@@ -8,19 +8,21 @@
8
8
  // M2: inline parsing of flow blocks (§5 — emphasis/strong/strike, code, math,
9
9
  // media embeds, links, auto-references, footnotes) and build-time reference
10
10
  // validation (§8 — unique ids, resolvable internal/cross-document references).
11
- import { readFileSync, writeFileSync } from "node:fs";
12
- import { basename, dirname, resolve as resolvePath } from "node:path";
13
- import { commit, restore, verify } from "./history.js";
14
- import { renderHtml } from "./render.js";
11
+ import { readFileSync, writeFileSync, realpathSync, statSync } from "node:fs";
12
+ import { basename, dirname, isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { spawnSync } from "node:child_process";
15
+ import { commit, restore, verify, listRevisions, resolveContent, firstChangedContent } from "./history.js";
16
+ import { renderHtml } from "./render-html.js";
15
17
  import { coerce, parseAttrs } from "./attrs.js";
16
- import { parseInline } from "./inline.js";
18
+ import { META_REF_SRC, parseInline } from "./inline.js";
17
19
  import { parseTable } from "./table.js";
18
20
  import { buildChart } from "./chart.js";
19
21
  import { mdToGeml } from "./from-md.js";
20
22
  import { serialize } from "./serialize.js";
21
23
  import { gemlToMd } from "./to-md.js";
22
24
  export { mdToGeml } from "./from-md.js";
23
- export { renderHtml } from "./render.js";
25
+ export { renderHtml } from "./render-html.js";
24
26
  export { serialize } from "./serialize.js";
25
27
  export { gemlToMd } from "./to-md.js";
26
28
  // Type registry: which body mode each typed block uses. Unknown types are a
@@ -32,18 +34,23 @@ const REGISTRY = {
32
34
  table: "raw", // structured table parsing lands in M3
33
35
  output: "raw", // captured result of a code block (stored, never executed)
34
36
  note: "flow",
35
- aside: "flow",
37
+ text: "flow", // addressable prose container: an id/attrs for a run of flow, no callout chrome
36
38
  meta: "data",
37
39
  };
38
40
  // §7: built-in diagram renderer registry. Unknown formats are a warning (the
39
41
  // processor keeps the body raw rather than interpreting it).
40
- const DIAGRAM_RENDERERS = new Set(["mermaid", "graphviz", "dot", "d2", "plantuml", "geml-chart"]);
42
+ const DIAGRAM_RENDERERS = new Set(["mermaid", "graphviz", "dot", "d2", "plantuml", "geml-chart", "geml-code-graph"]);
41
43
  // ---------------------------------------------------------------------------
42
44
  // Lexical helpers
43
45
  // ---------------------------------------------------------------------------
44
46
  const FENCE_OPEN = /^(={3,})[ \t]+([A-Za-z][A-Za-z0-9_-]*)[ \t]*(\{.*\})?[ \t]*$/;
45
47
  const HEADING = /^(#{1,6})[ \t]+(.*?)[ \t]*(\{[^}]*\})?[ \t]*$/;
46
48
  const LIST_ITEM = /^[ \t]*(?:[-*]|\d+\.)[ \t]+(.*)$/;
49
+ // Maximum block/list nesting depth the recursive-descent scanner will build
50
+ // before emitting a diagnostic instead of recursing further. Guards parse()
51
+ // (scanBlocks / parseList) and, in step, the renderer against a deeply nested
52
+ // document overflowing the call stack (DoS). 256 is far past any real document.
53
+ const MAX_NESTING = 256;
47
54
  function isCloseFence(line, openLen) {
48
55
  const t = line.replace(/\s+$/, "");
49
56
  return /^=+$/.test(t) && t.length === openLen;
@@ -61,15 +68,67 @@ function slug(text) {
61
68
  // ---------------------------------------------------------------------------
62
69
  // §4: substitute `{{key}}` in flow text with the matching `=== meta` value.
63
70
  // An unknown key is a build error (single-source-of-truth, fail loudly).
71
+ // The scan mirrors the §5.3(1) verbatim atoms: a `{{key}}` inside a code span
72
+ // or inline math is left untouched (so GEML prose can document this very
73
+ // syntax), and a backslash-escaped character can neither open a span nor a
74
+ // `{{…}}` reference — `\{{key}}` renders as the literal text `{{key}}`.
75
+ const META_REF = new RegExp(META_REF_SRC, "y");
64
76
  function interpolate(text, line, ctx) {
65
77
  if (!text.includes("{{"))
66
78
  return text;
67
- return text.replace(/\{\{\s*([A-Za-z_][A-Za-z0-9_-]*)\s*\}\}/g, (full, key) => {
68
- if (ctx.meta.has(key))
69
- return ctx.meta.get(key);
70
- ctx.diags.push({ severity: "error", message: `unknown metadata reference \`{{${key}}}\``, line });
71
- return full;
72
- });
79
+ let out = "";
80
+ let i = 0;
81
+ while (i < text.length) {
82
+ const c = text[i];
83
+ if (c === "\\" && i + 1 < text.length) {
84
+ out += c + text[i + 1];
85
+ i += 2;
86
+ continue;
87
+ }
88
+ if (c === "`") {
89
+ let n = 0;
90
+ while (text[i + n] === "`")
91
+ n++;
92
+ const close = text.indexOf("`".repeat(n), i + n);
93
+ if (close >= 0) {
94
+ out += text.slice(i, close + n);
95
+ i = close + n;
96
+ continue;
97
+ }
98
+ out += text.slice(i, i + n); // unclosed run: literal, keep scanning
99
+ i += n;
100
+ continue;
101
+ }
102
+ if (c === "$") {
103
+ const close = text.indexOf("$", i + 1);
104
+ if (close > i + 1) {
105
+ out += text.slice(i, close + 1);
106
+ i = close + 1;
107
+ continue;
108
+ }
109
+ out += c;
110
+ i++;
111
+ continue;
112
+ }
113
+ if (c === "{" && text[i + 1] === "{") {
114
+ META_REF.lastIndex = i;
115
+ const m = META_REF.exec(text);
116
+ if (m) {
117
+ const key = m[1];
118
+ if (ctx.meta.has(key))
119
+ out += ctx.meta.get(key);
120
+ else {
121
+ ctx.diags.push({ severity: "error", message: `unknown metadata reference \`{{${key}}}\``, line });
122
+ out += m[0];
123
+ }
124
+ i = META_REF.lastIndex;
125
+ continue;
126
+ }
127
+ }
128
+ out += c;
129
+ i++;
130
+ }
131
+ return out;
73
132
  }
74
133
  // Register a block id, flagging duplicates as errors (§4: ids unique per doc).
75
134
  function registerId(ctx, id, line) {
@@ -121,6 +180,7 @@ function parseList(lines, i, base, ctx) {
121
180
  const root = mkList(matchMarker(lines[i]));
122
181
  const stack = [{ list: root, indent: matchMarker(lines[i]).indent }];
123
182
  let prevBlank = false;
183
+ let tooDeep = false;
124
184
  while (i < lines.length) {
125
185
  if (lines[i].trim() === "") {
126
186
  prevBlank = true;
@@ -138,11 +198,28 @@ function parseList(lines, i, base, ctx) {
138
198
  const parent = top.list.items[top.list.items.length - 1];
139
199
  if (!parent)
140
200
  break; // deeper indent with no parent item: defensive stop
141
- cur = mkList(mk);
142
- (parent.children ??= []).push(cur);
143
- stack.push({ list: cur, indent: mk.indent });
201
+ if (stack.length >= MAX_NESTING) {
202
+ // Refuse to nest deeper than the cap: keep the item at the current level
203
+ // rather than building a model that overflows the renderer (DoS). One
204
+ // diagnostic per over-deep list; content is preserved, just flattened.
205
+ if (!tooDeep) {
206
+ ctx.diags.push({ severity: "error", message: `list nesting too deep (max ${MAX_NESTING})`, line: base + i + 1 });
207
+ tooDeep = true;
208
+ }
209
+ cur = top.list;
210
+ }
211
+ else {
212
+ cur = mkList(mk);
213
+ (parent.children ??= []).push(cur);
214
+ stack.push({ list: cur, indent: mk.indent });
215
+ }
144
216
  }
145
217
  else {
218
+ // §5: a change of marker type (bullet ↔ ordered) at the same level ends
219
+ // this list; scanBlocks then opens a fresh one at this marker. Without it,
220
+ // `- a` then `1. b` would merge into one mis-typed list (CommonMark §5.3).
221
+ if (mk.ordered !== top.list.ordered)
222
+ break;
146
223
  cur = top.list;
147
224
  }
148
225
  if (prevBlank && cur.items.length > 0)
@@ -153,7 +230,7 @@ function parseList(lines, i, base, ctx) {
153
230
  }
154
231
  return { block: root, next: i };
155
232
  }
156
- function scanBlocks(lines, base, ctx) {
233
+ function scanBlocks(lines, base, ctx, depth = 0) {
157
234
  const blocks = [];
158
235
  const diags = ctx.diags;
159
236
  let i = 0;
@@ -236,7 +313,16 @@ function scanBlocks(lines, base, ctx) {
236
313
  ctx.refs.push({ kind: "internal", anchor: of.slice(1), line: openLineNo });
237
314
  }
238
315
  if (mode === "flow") {
239
- block.children = scanBlocks(body, base + i + 1, ctx);
316
+ if (depth >= MAX_NESTING) {
317
+ // Refuse to recurse past the cap: emit a diagnostic and keep the body
318
+ // as raw so the parser returns cleanly instead of overflowing the
319
+ // call stack on a pathologically nested document (DoS).
320
+ diags.push({ severity: "error", message: `block nesting too deep (max ${MAX_NESTING}); body kept as raw`, line: openLineNo });
321
+ block.raw = body;
322
+ }
323
+ else {
324
+ block.children = scanBlocks(body, base + i + 1, ctx, depth + 1);
325
+ }
240
326
  }
241
327
  else if (mode === "data") {
242
328
  block.data = parseData(body);
@@ -265,6 +351,21 @@ function scanBlocks(lines, base, ctx) {
265
351
  }
266
352
  (ctx.charts ??= []).push({ block, line: openLineNo });
267
353
  }
354
+ else if (fmt === "geml-code-graph") {
355
+ // Code-graph embed (GEP-0003): the ONLY attribute is src=, pointing
356
+ // at a codemap document; roots/depth come from that document's meta
357
+ // ("view config travels with the data"). Body is empty.
358
+ const src = attrs.attrs["src"];
359
+ if (typeof src !== "string" || src === "") {
360
+ diags.push({ severity: "warning", message: "geml-code-graph: missing `src=` (nothing to render)", line: openLineNo });
361
+ }
362
+ else if (ctx.resolveDoc && ctx.resolveDoc(src) === null) {
363
+ diags.push({ severity: "warning", message: `geml-code-graph: cannot resolve document \`${src}\``, line: openLineNo });
364
+ }
365
+ if (body.length > 0 && body.some((l) => l.trim() !== "")) {
366
+ diags.push({ severity: "warning", message: "geml-code-graph body is ignored; the embed is configured by `src=` alone", line: openLineNo });
367
+ }
368
+ }
268
369
  else if (typeof fmt === "string" && !DIAGRAM_RENDERERS.has(fmt)) {
269
370
  // §7: warn on a diagram format with no registered renderer.
270
371
  diags.push({ severity: "warning", message: `no registered renderer for diagram format \`${fmt}\`; body kept raw`, line: openLineNo });
@@ -424,12 +525,182 @@ function resolveCharts(ctx) {
424
525
  }
425
526
  export function parse(source, opts = {}) {
426
527
  const lines = source.replace(/\r\n?/g, "\n").split("\n");
427
- const ctx = { diags: [], ids: new Map(), refs: [], meta: collectMeta(lines) };
528
+ const ctx = { diags: [], ids: new Map(), refs: [], meta: collectMeta(lines), resolveDoc: opts.resolveDoc };
428
529
  const children = scanBlocks(lines, 0, ctx);
429
530
  resolveCharts(ctx);
430
531
  validateRefs(ctx, opts);
431
532
  return { kind: "document", children, ids: [...ctx.ids.keys()], diagnostics: ctx.diags };
432
533
  }
534
+ // The id that a fence/heading line defines, matching how scanBlocks derives it
535
+ // (parseAttrs for the attribute object; heading text slug when no explicit id).
536
+ // The slug MUST come from the INTERPOLATED text — scanBlocks slugs after
537
+ // interpolate(), so `# {{title}} Setup` registers the substituted slug; slugging
538
+ // the raw text here would create a phantom id the parser never registered and
539
+ // make the real one unaddressable. `ctx` is an inert context carrying the
540
+ // document's meta (diagnostics are discarded — spans never report).
541
+ function idOfHeading(braces, text, line, ctx) {
542
+ return (braces ? parseAttrs(braces).id : undefined) ?? slug(interpolate(text, line, ctx));
543
+ }
544
+ // The matching close of the fence opened at lines[i] (equal-length run, or the
545
+ // labeled `=== #id` close when the block carries an id): the index just past
546
+ // the close line, and whether one was found — an unterminated block runs to
547
+ // the end of the scope.
548
+ function fenceClose(lines, i, open) {
549
+ const openLen = open[1].length;
550
+ const id = open[3] ? parseAttrs(open[3]).id : undefined;
551
+ const labeled = id !== undefined ? new RegExp(`^={3,}[ \\t]+#${id}[ \\t]*$`) : null;
552
+ for (let j = i + 1; j < lines.length; j++) {
553
+ if (isCloseFence(lines[j], openLen) || (labeled && labeled.test(lines[j])))
554
+ return { end: j + 1, closed: true };
555
+ }
556
+ return { end: lines.length, closed: false };
557
+ }
558
+ // A heading's span covers its whole SECTION: the heading line through the line
559
+ // just before the next heading of same-or-higher level (fewer-or-equal `#`) in
560
+ // the current scope, or end-of-scope. Fenced blocks are skipped whole — a `#`
561
+ // line inside a `=== code` body is content, never a boundary.
562
+ function sectionEnd(lines, i, level) {
563
+ let j = i + 1;
564
+ while (j < lines.length) {
565
+ const open = FENCE_OPEN.exec(lines[j]);
566
+ if (open) {
567
+ j = fenceClose(lines, j, open).end;
568
+ continue;
569
+ }
570
+ const h = HEADING.exec(lines[j]);
571
+ if (h && h[1].length <= level)
572
+ return j;
573
+ j++;
574
+ }
575
+ return lines.length;
576
+ }
577
+ // Walk `lines` exactly as scanBlocks does — same fence close rules (equal-length
578
+ // or labeled `=== #id`), same flow-only recursion via REGISTRY — recording the
579
+ // source span of every addressable id (typed block, heading, footnote def).
580
+ // First definition wins, mirroring ctx.ids (a duplicate id is a build error, so
581
+ // `get`/`set` operate on the one the parser actually registered). `base` is the
582
+ // absolute line offset of this slice within the whole document.
583
+ function collectSpans(lines, base, out, ctx, depth = 0) {
584
+ const add = (id, start, end) => {
585
+ if (!out.has(id))
586
+ out.set(id, { start, end });
587
+ };
588
+ let i = 0;
589
+ while (i < lines.length) {
590
+ const line = lines[i];
591
+ if (line.trim() === "") {
592
+ i++;
593
+ continue;
594
+ }
595
+ const fndef = /^\[\^([^\]]+)\]:[ \t]?(.*)$/.exec(line);
596
+ if (fndef) {
597
+ add(fndef[1].trim(), base + i, base + i + 1);
598
+ i++;
599
+ continue;
600
+ }
601
+ if (/^[ \t]*%%/.test(line)) {
602
+ i++;
603
+ continue;
604
+ } // hidden line: no id
605
+ const open = FENCE_OPEN.exec(line);
606
+ if (open) {
607
+ const type = open[2];
608
+ const id = open[3] ? parseAttrs(open[3]).id : undefined;
609
+ const { end, closed } = fenceClose(lines, i, open);
610
+ if (id !== undefined)
611
+ add(id, base + i, base + end);
612
+ // Only a flow body is scanned for nested blocks (raw/data bodies are
613
+ // opaque), so an id inside a `code` body is *not* addressable — exactly
614
+ // the parser's contract.
615
+ if ((REGISTRY[type] ?? "raw") === "flow" && depth < MAX_NESTING) {
616
+ collectSpans(lines.slice(i + 1, closed ? end - 1 : end), base + i + 1, out, ctx, depth + 1);
617
+ }
618
+ i = end;
619
+ continue;
620
+ }
621
+ const h = HEADING.exec(line);
622
+ if (h) {
623
+ // Section span (heading through its prose and nested blocks). The walk
624
+ // still advances one line at a time so every nested id inside the
625
+ // section registers its own span — spans intentionally OVERLAP: #sec
626
+ // contains #code, and each remains addressable on its own.
627
+ add(idOfHeading(h[3], h[2], base + i + 1, ctx), base + i, base + sectionEnd(lines, i, h[1].length));
628
+ i++;
629
+ continue;
630
+ }
631
+ i++;
632
+ }
633
+ }
634
+ // Map every addressable id in `source` to its source span. Line indices align
635
+ // with the physical lines produced by splitLines(source).
636
+ export function blockSpans(source) {
637
+ const out = new Map();
638
+ const lines = source.replace(/\r\n?/g, "\n").split("\n");
639
+ // Inert context: heading auto-ids slug the interpolated text (parser parity);
640
+ // its diagnostics are discarded — the span scan never reports.
641
+ const ctx = { diags: [], ids: new Map(), refs: [], meta: collectMeta(lines) };
642
+ collectSpans(lines, 0, out, ctx);
643
+ return out;
644
+ }
645
+ // Split into physical lines while *keeping* each line's terminator, so
646
+ // join("") is byte-exact and slicing by span never rewrites line endings.
647
+ // A line ends at `\n` or at a LONE `\r` (old-Mac style) — the same boundaries
648
+ // the span scan's `\r\n?` -> `\n` normalization sees, so span indices always
649
+ // address the same lines the parser counted.
650
+ function splitLines(source) {
651
+ return source.split(/(?<=\n|\r(?!\n))/);
652
+ }
653
+ // `--head`: narrow any id's span to its HEAD line — the single declaring line
654
+ // (a heading's `# … {#id}` line, a typed block's opening fence, a footnote's
655
+ // `[^id]:` line). The head is by construction the FIRST line of the span, so
656
+ // the narrowing is parse-free and needs no type check. Main use: `set --head`
657
+ // edits a block's attributes (caption/compute/lang/…) without re-sending its
658
+ // body, or renames a heading without rewriting its section.
659
+ function narrowToHead(span) {
660
+ return { start: span.start, end: span.start + 1 };
661
+ }
662
+ // Depth-first search for the document-model node carrying `id`, descending into
663
+ // flow-block children (and list-item children) so a nested id is found too.
664
+ // Returns the containing sibling array and index, not just the node: the model
665
+ // is FLAT — a heading does not own its section; the section's prose and blocks
666
+ // are its FOLLOWING SIBLINGS — so a section consumer needs the array.
667
+ function findBlockSite(blocks, id) {
668
+ for (let i = 0; i < blocks.length; i++) {
669
+ const b = blocks[i];
670
+ if ((b.kind === "heading" || b.kind === "block") && b.id === id)
671
+ return { siblings: blocks, index: i };
672
+ if (b.kind === "block" && b.children) {
673
+ const hit = findBlockSite(b.children, id);
674
+ if (hit)
675
+ return hit;
676
+ }
677
+ if (b.kind === "list") {
678
+ for (const it of b.items) {
679
+ if (it.children) {
680
+ const hit = findBlockSite(it.children, id);
681
+ if (hit)
682
+ return hit;
683
+ }
684
+ }
685
+ }
686
+ }
687
+ return undefined;
688
+ }
689
+ // Model-side section boundary: within one sibling array, the section opened by
690
+ // the heading at index k runs to the next sibling heading of same-or-higher
691
+ // level, or the array end. This is the SAME rule sectionEnd() applies to raw
692
+ // source lines (where skipping fenced bodies makes "next heading" well-defined)
693
+ // — the two sides must stay in lockstep; the get-set suite pins their parity
694
+ // (ids covered by the raw slice == ids covered by the --json envelope).
695
+ function sectionEndIndex(siblings, k) {
696
+ const level = siblings[k].level;
697
+ for (let m = k + 1; m < siblings.length; m++) {
698
+ const b = siblings[m];
699
+ if (b.kind === "heading" && b.level <= level)
700
+ return m;
701
+ }
702
+ return siblings.length;
703
+ }
433
704
  // ---------------------------------------------------------------------------
434
705
  // CLI
435
706
  // ---------------------------------------------------------------------------
@@ -447,42 +718,66 @@ function parseStamp(s) {
447
718
  const [, y, mo, d, h, mi, se] = m;
448
719
  return new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +se));
449
720
  }
450
- const VERSION = "1.0-draft"; // GEML spec version this CLI targets
451
- const PARSER_VERSION = "1.0.0"; // reference implementation; keep in sync with package.json
452
- const USAGE = `geml — GEML reference CLI
453
-
454
- Usage:
455
- geml <file.geml|-> parse -> document-model JSON (stdout)
456
- geml check <file.geml|-> [--json] validate only: diagnostics + exit code
457
- geml render <file.geml|-> [-o out.html] render to one self-contained HTML file
458
- geml fmt <file.geml|-> [-o out.geml] re-serialize to canonical GEML
459
- geml convert <file.md|-> [-o out.geml] Markdown -> GEML
460
- geml export <file.geml|-> [-o out.md] GEML -> Markdown (lossy)
461
- geml history <commit|verify|show|restore> <file.geml> [...]
462
- geml --help | --version [--json]
463
-
464
- Use '-' as the file to read from stdin.
465
- Exit codes: 0 ok · 1 document/operation error · 2 usage error.`;
721
+ const VERSION = "1.0"; // GEML spec version this CLI targets
722
+ const PARSER_VERSION = "1.3.2"; // reference implementation; keep in sync with package.json
723
+ const USAGE = `geml — GEML reference CLI
724
+
725
+ Usage:
726
+ geml <file.geml|-> parse -> document-model JSON (stdout)
727
+ geml get <file.geml|-> #id [--json][--head] print ONE block by id (a heading id = its section;
728
+ --head narrows any id to its head line; --json = model node)
729
+ geml set <file.geml|-> #id [--from f][-o f][--head] replace ONE block by id (new content: --from/stdin)
730
+ geml revert <file.geml> #id [--to <sel>][--head] restore ONE block to a past revision (sel: -N|latest|id)
731
+ geml check <file.geml|-> [--root d][--json] validate only: diagnostics + exit code
732
+ (--root widens cross-doc refs to dir d, e.g. the repo root)
733
+ geml render <file.geml|-> [-o out.html] render to one self-contained HTML file
734
+ geml fmt <file.geml|-> [-o out.geml] re-serialize to canonical GEML
735
+ geml convert <file.md|-> [-o out.geml] Markdown -> GEML
736
+ geml export <file.geml|-> [-o out.md] GEML -> Markdown (lossy)
737
+ geml history <commit|verify|show|restore|log> <file.geml> [...]
738
+ geml codemap <build|verify|render|serve|refresh|find|mcp> [...] code-graph toolkit (alias: codegraph)
739
+ geml --help | --version [--json]
740
+
741
+ Use '-' as the file to read from stdin.
742
+ Exit codes:
743
+ 0 ok
744
+ 1 document/operation error
745
+ 2 command usage error.
746
+ `;
466
747
  // One-line usage for each subcommand — the single source for both the error
467
748
  // shown on misuse and the `<cmd> --help` text.
468
749
  const SUBHELP = {
469
- check: "usage: geml check <file.geml|-> [--json]",
750
+ get: "usage: geml get <file.geml|-> #id [--json] [--head] (a heading id = its whole section; --head narrows any id to its head line)",
751
+ set: "usage: geml set <file.geml|-> #id [--from FILE] [-o out.geml] [--head]",
752
+ check: "usage: geml check <file.geml|-> [--root <dir>] [--json] (--root: resolve cross-doc refs within <dir> instead of the file's own directory)",
470
753
  render: "usage: geml render <file.geml|-> [-o out.html]",
471
754
  convert: "usage: geml convert <file.md|-> [-o out.geml]",
472
755
  export: "usage: geml export <file.geml|-> [-o out.md]",
473
756
  fmt: "usage: geml fmt <file.geml|-> [-o out.geml]",
474
- history: "usage: geml history <commit|verify|show|restore> <file.geml> [...]",
757
+ revert: "usage: geml revert <file.geml> #id [--to <sel>] [--changed] [--dry-run] [-o out] [--head] (sel: -N | latest | id-prefix; default -1)",
758
+ history: "usage: geml history <commit|verify|show|restore|log> <file.geml> [...]",
759
+ codemap: `usage: geml codemap build [--root <repo>] # auto-detect languages, run the indexer(s), and merge into one codemap (--root defaults to the current directory)
760
+ geml codemap build (--db <graph.db> | --adapter joern|scip --raw <in>)+ [--root <repo>] [--out .geml-code-graph] [--container module|dir|file] [--lang <JAVASRC|NEWC|…>] [--joern <path>] [--history [-m msg]]
761
+ geml codemap verify [dir] geml check + profile reference checks
762
+ geml codemap render [dir] every doc -> sibling .html (open index.html from disk)
763
+ geml codemap serve [dir] [--port 8140] [--watch] [--background|--stop] live viewer: pages render from .geml on request; --watch re-runs the recipe when sources change
764
+ geml codemap refresh [dir] [--force] [--commit] [--background|--hook] re-run the recorded build recipe (_index/refresh.json); --commit lands it as its own commit
765
+ geml codemap find <name> [dir] locate a symbol by substring name -> doc#id + src (stdout, no browser)
766
+ geml codemap mcp stdio MCP server (GEML_GRAPH_DIR or graph_dir arg)
767
+ (<dir> for verify/render/serve/refresh/find defaults to ./.geml-code-graph; codegraph and code-graph are accepted as aliases of codemap)`,
475
768
  };
476
769
  // Set from argv at dispatch time; when true, errors are emitted as a JSON
477
770
  // envelope so an agent that standardizes on --json never has to parse text.
478
771
  let jsonMode = false;
479
- // Clean one-line error + non-zero exit — never a raw Node stack trace.
480
- function fail(msg) {
772
+ // Clean one-line error + non-zero exit — never a raw Node stack trace. `code`
773
+ // is the process exit status: 2 for a usage error (the default), 1 for a
774
+ // document/operation error. `--json` wraps it in the same {error, code} envelope.
775
+ function fail(msg, code = 2) {
481
776
  if (jsonMode)
482
- console.error(JSON.stringify({ error: msg, code: 2 }));
777
+ console.error(JSON.stringify({ error: msg, code }));
483
778
  else
484
779
  console.error(`error: ${msg}`);
485
- process.exit(2);
780
+ process.exit(code);
486
781
  }
487
782
  // Read a file, or stdin when the path is "-". On failure emit a clean error.
488
783
  function readInput(file) {
@@ -493,12 +788,66 @@ function readInput(file) {
493
788
  fail(file === "-" ? "cannot read stdin" : `cannot read ${file}`);
494
789
  }
495
790
  }
496
- // A cross-document resolver rooted at the input's directory (cwd for stdin).
497
- function resolverFor(file) {
498
- const baseDir = file === "-" ? "." : dirname(file);
791
+ // A cross-document resolver rooted at the input's directory (cwd for stdin),
792
+ // CONFINED to that directory's subtree. A reference that resolves outside the
793
+ // base via a `..` escape, an absolute path, or (on Windows) a different drive
794
+ // — is refused (returns null, i.e. an unresolvable ref) so a crafted document
795
+ // cannot turn `geml check`/parse into an arbitrary local-file read oracle. §8.
796
+ //
797
+ // A purely LEXICAL check is not enough: a symlink that sits lexically inside the
798
+ // subtree but points to `../../outside.geml` passes `path.relative` yet reads an
799
+ // external target. So after the cheap lexical gate we resolve BOTH the base and
800
+ // the target through `realpathSync` (following every symlink component) and
801
+ // re-check that the REAL target still lies within the REAL base subtree before
802
+ // reading. A target that does not exist makes `realpathSync` throw — handled as
803
+ // an ordinary unresolvable ref (null), never a crash.
804
+ //
805
+ // `root` (CLI `--root`, an explicit per-invocation user grant — never
806
+ // document-controlled) widens the confinement base from the input's own
807
+ // directory to an ancestor the user names, so repo-relative `../` references
808
+ // between sibling directories can be checked. It moves WHERE the boundary
809
+ // stands, never whether it is enforced: both gates below run against the
810
+ // widened base, so escapes past the root are refused exactly as above. The
811
+ // viewer/web surfaces never pass a root — their boundary is unchanged.
812
+ function resolverFor(file, root) {
813
+ const dirAbs = resolvePath(file === "-" ? "." : dirname(file));
814
+ const baseAbs = root === undefined ? dirAbs : resolvePath(root);
815
+ // Canonicalise the base once. If the base itself cannot be realpath'd, no
816
+ // cross-doc ref can be safely confined — resolve nothing.
817
+ let realBase = null;
818
+ try {
819
+ realBase = realpathSync(baseAbs);
820
+ }
821
+ catch {
822
+ realBase = null;
823
+ }
824
+ const outside = (from, to) => {
825
+ const rel = relative(from, to);
826
+ return rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel);
827
+ };
499
828
  return (d) => {
829
+ if (realBase === null)
830
+ return null;
831
+ // References resolve FROM the document's own directory; the gates below
832
+ // confine them to the (possibly widened) base.
833
+ const targetAbs = resolvePath(dirAbs, d);
834
+ // Cheap lexical gate: reject an obvious `..`/absolute/other-drive escape
835
+ // before touching the filesystem.
836
+ if (outside(baseAbs, targetAbs))
837
+ return null;
838
+ // Real (symlink-resolved) gate: a symlink pointing out of the subtree
839
+ // resolves to a real path outside `realBase` and is refused here.
840
+ let realTarget;
841
+ try {
842
+ realTarget = realpathSync(targetAbs);
843
+ }
844
+ catch {
845
+ return null;
846
+ }
847
+ if (outside(realBase, realTarget))
848
+ return null;
500
849
  try {
501
- return readFileSync(resolvePath(baseDir, d), "utf8");
850
+ return readFileSync(realTarget, "utf8");
502
851
  }
503
852
  catch {
504
853
  return null;
@@ -509,10 +858,22 @@ function resolverFor(file) {
509
858
  // dump (cheap for agents). `--json` prints the diagnostics array for machines.
510
859
  function runCheck(args) {
511
860
  const json = args.includes("--json");
512
- const file = args.find((a) => a === "-" || !a.startsWith("-"));
861
+ const root = flag(args, "--root");
862
+ const file = args.find((a) => a === "-" || (!a.startsWith("-") && a !== root));
513
863
  if (!file)
514
864
  fail(SUBHELP.check);
515
- const doc = parse(readInput(file), { resolveDoc: resolverFor(file) });
865
+ // A mistyped --root must be a usage error (exit 2), not a wall of misleading
866
+ // "cannot resolve document" errors from a resolver confined to nothing.
867
+ if (root !== undefined) {
868
+ let isDir = false;
869
+ try {
870
+ isDir = statSync(root).isDirectory();
871
+ }
872
+ catch { /* missing -> not a dir */ }
873
+ if (!isDir)
874
+ fail(`--root ${root} is not a directory`);
875
+ }
876
+ const doc = parse(readInput(file), { resolveDoc: resolverFor(file, root) });
516
877
  if (json) {
517
878
  console.log(JSON.stringify(doc.diagnostics, null, 2));
518
879
  }
@@ -580,6 +941,14 @@ function runHistory(args) {
580
941
  restore({ historyPath, gemlPath: file, revision: rev, write: true, force: args.includes("--force") });
581
942
  console.log(`restored ${file} to ${rev}`);
582
943
  }
944
+ else if (sub === "log") {
945
+ // Newest-first, with the `--to` selector for each row in the first column
946
+ // (`latest` for the tip, then `-1`, `-2`, …) so the output is copy-paste.
947
+ for (const r of listRevisions(historyPath)) {
948
+ const sel = r.current ? "latest" : `-${r.offset}`;
949
+ console.log(`${sel.padEnd(7)} ${r.id} ${r.author ?? "-"} ${r.summary ?? ""}`.replace(/\s+$/, ""));
950
+ }
951
+ }
583
952
  else {
584
953
  fail(`unknown history subcommand: ${sub}. Run 'geml --help'.`);
585
954
  }
@@ -638,7 +1007,12 @@ function runRender(args) {
638
1007
  if (!file)
639
1008
  fail(SUBHELP.render);
640
1009
  const doc = parse(readInput(file), { resolveDoc: resolverFor(file) });
641
- const html = renderHtml(doc, { source: file === "-" ? "stdin" : basename(file) });
1010
+ const html = renderHtml(doc, {
1011
+ source: file === "-" ? "stdin" : basename(file),
1012
+ // geml-code-graph embeds load + parse sibling codemap documents on demand.
1013
+ loadDoc: resolverFor(file),
1014
+ parseDoc: (s) => parse(s),
1015
+ });
642
1016
  if (out) {
643
1017
  writeFileSync(out, html);
644
1018
  console.error(`wrote ${out}`);
@@ -673,10 +1047,272 @@ function runFmt(args) {
673
1047
  if (doc.diagnostics.some((d) => d.severity === "error"))
674
1048
  process.exit(1);
675
1049
  }
676
- const entry = process.argv[1] ?? "";
677
- if (entry.endsWith("geml.js") || entry.endsWith("geml.ts")) {
1050
+ // Positional args (a file, an id) are the non-flag tokens that aren't the value
1051
+ // of a value-taking flag. `-` (stdin) is a positional, not a flag. An id may be
1052
+ // written `#id` or `id`; a leading `-` never begins an id, so this stays
1053
+ // unambiguous. `valued` lists the flags that consume the following token.
1054
+ function positionals(args, valued) {
1055
+ const out = [];
1056
+ for (let i = 0; i < args.length; i++) {
1057
+ const a = args[i];
1058
+ if (valued.includes(a)) {
1059
+ i++;
1060
+ continue;
1061
+ } // skip the flag *and* its value
1062
+ if (a === "-") {
1063
+ out.push(a);
1064
+ continue;
1065
+ }
1066
+ if (a.startsWith("-"))
1067
+ continue; // a bare flag (e.g. --json)
1068
+ out.push(a);
1069
+ }
1070
+ return out;
1071
+ }
1072
+ // `geml get <file.geml|-> #id [--json]` — print ONE block, addressed by id,
1073
+ // without loading the rest of the document into context. Default output is the
1074
+ // block's exact source bytes: a typed block's full `=== … ===` span, a
1075
+ // footnote's line, or — for a heading — its whole SECTION (heading line through
1076
+ // the line before the next same-or-higher heading). `--json` covers the same
1077
+ // content: a block/footnote id prints its document-model node; a heading id
1078
+ // prints a section envelope `{kind:"section", id, level, blocks:[heading,
1079
+ // …siblings up to the boundary]}`.
1080
+ function runGet(args) {
1081
+ const json = args.includes("--json");
1082
+ const headOnly = args.includes("--head");
1083
+ const [file, rawId] = positionals(args, []);
1084
+ if (!file || !rawId)
1085
+ fail(SUBHELP.get);
1086
+ const id = rawId.replace(/^#/, "");
1087
+ const source = readInput(file);
1088
+ if (json) {
1089
+ // The model node(s) — same shapes `geml <file>` emits. Parsing is needed
1090
+ // to resolve the tree (and nested-block ids), but only the target prints.
1091
+ const doc = parse(source, { resolveDoc: resolverFor(file) });
1092
+ const site = findBlockSite(doc.children, id);
1093
+ if (!site)
1094
+ fail(`no block with id \`${id}\``, 1);
1095
+ const block = site.siblings[site.index];
1096
+ // `--head` on a heading suppresses the section envelope (the lone heading
1097
+ // node IS the head). On a block/footnote id there is nothing finer than
1098
+ // the single node — the model has no sub-node for "just the fence line" —
1099
+ // so --head refines only the RAW output there.
1100
+ if (block.kind === "heading" && !headOnly) {
1101
+ // A heading id addresses its SECTION, so `--json` covers the same
1102
+ // content as the raw span: a self-describing envelope whose blocks[0]
1103
+ // is the heading node followed by its siblings up to the boundary.
1104
+ // `kind: "section"` lets a consumer branch — a block/footnote id still
1105
+ // yields the single model node (the model itself stays flat).
1106
+ const end = sectionEndIndex(site.siblings, site.index);
1107
+ console.log(JSON.stringify({ kind: "section", id: block.id, level: block.level, blocks: site.siblings.slice(site.index, end) }, null, 2));
1108
+ return;
1109
+ }
1110
+ console.log(JSON.stringify(block, null, 2));
1111
+ return;
1112
+ }
1113
+ // Raw: slice the source span byte-for-byte. No parse required, so `get` still
1114
+ // returns the exact bytes even if the document has diagnostics elsewhere.
1115
+ const found = blockSpans(source).get(id);
1116
+ if (!found)
1117
+ fail(`no block with id \`${id}\``, 1);
1118
+ const span = headOnly ? narrowToHead(found) : found;
1119
+ process.stdout.write(splitLines(source).slice(span.start, span.end).join(""));
1120
+ }
1121
+ // `geml set <file.geml|-> #id [--from FILE] [-o out]` — replace ONLY that
1122
+ // block's source span with new content (from --from or stdin), preserving every
1123
+ // other byte. Prints the full updated document, or writes in place with -o. The
1124
+ // splice is re-parsed and rejected if it broke the doc: `set` never writes a
1125
+ // corrupt file.
1126
+ function runSet(args) {
1127
+ const out = flag(args, "-o") ?? flag(args, "--out");
1128
+ const from = flag(args, "--from");
1129
+ const headOnly = args.includes("--head");
1130
+ const [file, rawId] = positionals(args, ["-o", "--out", "--from"]);
1131
+ if (!file || !rawId)
1132
+ fail(SUBHELP.set);
1133
+ const id = rawId.replace(/^#/, "");
1134
+ // Both the document and the replacement can't come from stdin. Reject that up
1135
+ // front — before consuming stdin — so the document read below is unambiguous.
1136
+ if (file === "-" && from === undefined) {
1137
+ fail("reading the document from stdin needs --from for the new content", 2);
1138
+ }
1139
+ const source = readInput(file);
1140
+ // New content: an explicit --from file, else stdin.
1141
+ let replacement;
1142
+ if (from !== undefined) {
1143
+ replacement = readInput(from);
1144
+ }
1145
+ else {
1146
+ replacement = readInput("-");
1147
+ if (replacement === "")
1148
+ fail("no replacement content (use --from FILE or pipe it on stdin)", 1);
1149
+ }
1150
+ const updated = spliceBlock(source, id, replacement, file, headOnly);
1151
+ if (out) {
1152
+ writeFileSync(out, updated);
1153
+ console.error(`wrote ${out}`);
1154
+ }
1155
+ else
1156
+ process.stdout.write(updated);
1157
+ }
1158
+ // Replace block #id's source span in `source` with `replacement`, preserving
1159
+ // every other byte, and GUARD the result: the re-parse must be error-free, #id
1160
+ // must survive, and no other pre-existing id may vanish (a malformed replacement
1161
+ // can silently swallow a neighbour). Returns the updated document text; on any
1162
+ // violation it calls fail() and never returns a corrupt document. Shared by
1163
+ // `set` and `revert`.
1164
+ function spliceBlock(source, id, replacement, file, headOnly = false) {
1165
+ const found = blockSpans(source).get(id);
1166
+ if (!found)
1167
+ fail(`no block with id \`${id}\``, 1);
1168
+ const beforeIds = parse(source, { resolveDoc: resolverFor(file) }).ids;
1169
+ // Keep the bytes before and after the target span exactly; give the new block
1170
+ // a single trailing newline so the following block still starts on its own
1171
+ // line (unless it is the file's last line, which may legitimately lack one).
1172
+ const orig = splitLines(source);
1173
+ // `--head`: splice only the id's head line; everything below stays
1174
+ // byte-identical. The guard below still applies — the replacement must
1175
+ // re-declare `{#id}` and, for a typed block, keep the fence pairing intact
1176
+ // (an opening line that no longer matches the untouched close fence breaks
1177
+ // the re-parse), or the splice is refused.
1178
+ const span = headOnly ? narrowToHead(found) : found;
1179
+ const before = orig.slice(0, span.start);
1180
+ const after = orig.slice(span.end);
1181
+ let inject = replacement.replace(/\r\n?/g, "\n");
1182
+ const lastLine = span.end >= orig.length;
1183
+ if (!inject.endsWith("\n") && !lastLine)
1184
+ inject += "\n";
1185
+ const updated = before.join("") + inject + after.join("");
1186
+ // Re-parse and refuse a broken result. A parse error or a duplicate id both
1187
+ // surface as error diagnostics (registerId flags dups); one check covers both.
1188
+ // Then require the target id to survive, and — because a malformed replacement
1189
+ // can swallow a neighbour — that every other pre-existing id survives too.
1190
+ const reparsed = parse(updated, { resolveDoc: resolverFor(file) });
1191
+ const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
1192
+ if (errs.length) {
1193
+ const first = errs[0];
1194
+ fail(`replacement would break the document: ${first.message} (line ${first.line}); not written`, 1);
1195
+ }
1196
+ const now = new Set(reparsed.ids);
1197
+ if (!now.has(id))
1198
+ fail(`replacement removes id \`${id}\`; not written`, 1);
1199
+ const dropped = beforeIds.find((x) => x !== id && !now.has(x));
1200
+ if (dropped !== undefined) {
1201
+ fail(`replacement would drop block \`#${dropped}\` (malformed content?); not written`, 1);
1202
+ }
1203
+ return updated;
1204
+ }
1205
+ // `geml revert <file.geml> #id [--to <sel>] [--changed] [--dry-run] [-o out] [--history PATH]`
1206
+ // Restore ONE block to a past revision's version — a targeted, guarded splice
1207
+ // that leaves the rest of the document untouched. <sel> (default `-1`): `-N` (N
1208
+ // revisions back from current), `latest`, or an id prefix/suffix. `--changed`
1209
+ // skips revisions that never touched the block, landing on its previous
1210
+ // *distinct* version. `--dry-run` prints what would be spliced in, writing
1211
+ // nothing. Writes in place by default (revert is a mutation); `-o` redirects.
1212
+ function runRevert(args) {
1213
+ const changed = args.includes("--changed");
1214
+ const dryRun = args.includes("--dry-run");
1215
+ const headOnly = args.includes("--head");
1216
+ const out = flag(args, "-o") ?? flag(args, "--out");
1217
+ const to = flag(args, "--to") ?? "-1";
1218
+ const [file, rawId] = positionals(args, ["--to", "--history", "-o", "--out"]);
1219
+ if (!file || !rawId)
1220
+ fail(SUBHELP.revert);
1221
+ if (file === "-")
1222
+ fail("revert needs a real file (it reads that file's .gemlhistory)", 2);
1223
+ const id = rawId.replace(/^#/, "");
1224
+ const historyPath = flag(args, "--history") ?? historyPathFor(file);
1225
+ const source = readInput(file);
1226
+ const found = blockSpans(source).get(id);
1227
+ if (!found)
1228
+ fail(`no block with id \`${id}\` in ${file}`, 1);
1229
+ const curSpan = headOnly ? narrowToHead(found) : found;
1230
+ const curBlock = splitLines(source).slice(curSpan.start, curSpan.end).join("");
1231
+ // Extract block #id's source from a reconstructed revision (undefined if the
1232
+ // block did not exist there). Under `--head`, extract only the head line.
1233
+ const pick = (text) => {
1234
+ const s = blockSpans(text).get(id);
1235
+ if (!s)
1236
+ return undefined;
1237
+ const span = headOnly ? narrowToHead(s) : s;
1238
+ return splitLines(text).slice(span.start, span.end).join("");
1239
+ };
1240
+ // Resolve the source revision, formatting any history-layer error cleanly.
1241
+ const target = (() => {
1242
+ try {
1243
+ if (changed) {
1244
+ const found = firstChangedContent(historyPath, curBlock, pick);
1245
+ if (!found)
1246
+ fail(`no earlier revision changes \`${id}\``, 1);
1247
+ return found;
1248
+ }
1249
+ return resolveContent(historyPath, to);
1250
+ }
1251
+ catch (e) {
1252
+ fail(historyError(e, file, historyPath), 1);
1253
+ }
1254
+ })();
1255
+ const oldBlock = pick(target.text);
1256
+ if (oldBlock === undefined)
1257
+ fail(`block \`${id}\` does not exist at revision ${target.id}`, 1);
1258
+ if (oldBlock === curBlock) {
1259
+ console.error(`#${id} is unchanged at ${target.id}; nothing to revert${changed ? "" : " (try --to -2, or --changed)"}`);
1260
+ return;
1261
+ }
1262
+ if (dryRun) {
1263
+ console.error(`would revert #${id} to ${target.id}:`);
1264
+ process.stdout.write(oldBlock.endsWith("\n") ? oldBlock : oldBlock + "\n");
1265
+ return;
1266
+ }
1267
+ const updated = spliceBlock(source, id, oldBlock, file, headOnly);
1268
+ const dest = out ?? file;
1269
+ writeFileSync(dest, updated);
1270
+ console.error(`reverted #${id} to ${target.id}${dest === file ? "" : ` -> ${dest}`}`);
1271
+ }
1272
+ // geml codemap <sub>: the code-graph toolkit ships as plain scripts in the
1273
+ // package's codemap/ directory (they are argv-driven programs, some
1274
+ // long-running like `serve`) — dispatch = run the script in a child node
1275
+ // with the remaining arguments, propagating the exit code.
1276
+ function runCodemap(args) {
1277
+ const scripts = {
1278
+ build: "build.mjs",
1279
+ verify: "verify.mjs",
1280
+ render: "render-all.mjs",
1281
+ serve: "serve.mjs",
1282
+ refresh: "refresh.mjs",
1283
+ find: "find.mjs",
1284
+ mcp: "mcp-server.mjs",
1285
+ };
1286
+ const sub = args[0] ?? "";
1287
+ const script = scripts[sub];
1288
+ if (!script)
1289
+ fail(`unknown codemap subcommand '${sub}'.\n${SUBHELP.codemap}`);
1290
+ const mod = join(dirname(fileURLToPath(import.meta.url)), "..", "codemap", script);
1291
+ const r = spawnSync(process.execPath, [mod, ...args.slice(1)], { stdio: "inherit" });
1292
+ process.exit(r.status ?? 1);
1293
+ }
1294
+ // npm's unix bin shim is a symlink named plain `geml`, so detect "run as a
1295
+ // CLI" by resolving argv[1] to its real path, not by its spelling.
1296
+ const entry = (() => {
1297
+ const argv1 = process.argv[1];
1298
+ if (!argv1)
1299
+ return "";
1300
+ try {
1301
+ return realpathSync(argv1);
1302
+ }
1303
+ catch {
1304
+ return argv1;
1305
+ }
1306
+ })();
1307
+ // `entry` must be non-empty: in a browser bundle both sides degenerate to ""
1308
+ // (esbuild defines process.argv=[] and import.meta.url="", and the node-stub's
1309
+ // fileURLToPath is String()), which would run the CLI at import time and crash
1310
+ // the page. A real CLI invocation always has argv[1].
1311
+ if (entry && (entry === fileURLToPath(import.meta.url) || entry.endsWith("geml.ts"))) {
678
1312
  const argv = process.argv.slice(2);
679
- const cmd = argv[0];
1313
+ // The on-disk artifact is `.geml-code-graph/`, so people reconstruct the
1314
+ // command from the directory name — accept those spellings as `codemap`.
1315
+ const cmd = argv[0] === "codegraph" || argv[0] === "code-graph" ? "codemap" : argv[0];
680
1316
  jsonMode = argv.includes("--json");
681
1317
  const rest = argv.slice(1);
682
1318
  if (cmd === "--help" || cmd === "-h") {
@@ -697,6 +1333,15 @@ if (entry.endsWith("geml.js") || entry.endsWith("geml.ts")) {
697
1333
  // stdout, exit 0 — never the `error:`-prefixed exit-2 path.
698
1334
  console.log(SUBHELP[cmd]);
699
1335
  }
1336
+ else if (cmd === "get") {
1337
+ runGet(argv.slice(1));
1338
+ }
1339
+ else if (cmd === "set") {
1340
+ runSet(argv.slice(1));
1341
+ }
1342
+ else if (cmd === "revert") {
1343
+ runRevert(argv.slice(1));
1344
+ }
700
1345
  else if (cmd === "history") {
701
1346
  runHistory(argv.slice(1));
702
1347
  }
@@ -715,6 +1360,9 @@ if (entry.endsWith("geml.js") || entry.endsWith("geml.ts")) {
715
1360
  else if (cmd === "check") {
716
1361
  runCheck(argv.slice(1));
717
1362
  }
1363
+ else if (cmd === "codemap") {
1364
+ runCodemap(argv.slice(1));
1365
+ }
718
1366
  else if (cmd !== "-" && !/[.\/\\]/.test(cmd)) {
719
1367
  // A bare word that is neither a known command nor a path is almost always
720
1368
  // a mistyped command — say so, don't try to read it as a file.