@geml/geml 1.0.0 → 1.1.1

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/history.d.ts CHANGED
@@ -15,6 +15,7 @@ interface Revision {
15
15
  author?: string;
16
16
  summary?: string;
17
17
  hash: string;
18
+ newline?: string;
18
19
  ops: Op[];
19
20
  }
20
21
  interface History {
@@ -52,4 +53,33 @@ export interface RestoreOpts {
52
53
  force?: boolean;
53
54
  }
54
55
  export declare function restore(o: RestoreOpts): string;
56
+ export interface RevisionInfo {
57
+ id: string;
58
+ parent?: string;
59
+ author?: string;
60
+ summary?: string;
61
+ hash: string;
62
+ offset: number;
63
+ current: boolean;
64
+ }
65
+ /** Is the working file byte-identical to the sidecar's tip revision? False
66
+ * means uncommitted drift (e.g. an earlier commit attempt was refused). */
67
+ export declare function isCurrent(historyPath: string, gemlPath: string): boolean;
68
+ /** Revisions newest-first, each tagged with the `-N` offset that selects it. */
69
+ export declare function listRevisions(historyPath: string): RevisionInfo[];
70
+ /** Resolve a revision selector to its id + reconstructed full text. Selectors:
71
+ * `-N` (N revisions back from current; `-0` is the tip), `latest`/`current`, or
72
+ * an unambiguous id prefix/suffix (the same forms `restore` accepts). */
73
+ export declare function resolveContent(historyPath: string, selector: string): {
74
+ id: string;
75
+ text: string;
76
+ };
77
+ /** Walk the chain newest→oldest; return the first revision whose block (as
78
+ * extracted by `pick`) differs from `currentBlock` — i.e. the block's previous
79
+ * *distinct* version, skipping revisions that never touched it. Used by
80
+ * `revert --changed`. `undefined` if no earlier revision changed the block. */
81
+ export declare function firstChangedContent(historyPath: string, currentBlock: string, pick: (fullText: string) => string | undefined): {
82
+ id: string;
83
+ text: string;
84
+ } | undefined;
55
85
  export {};
package/dist/history.js CHANGED
@@ -80,9 +80,12 @@ function fenceFor(contentLf) {
80
80
  // ---------------------------------------------------------------------------
81
81
  // Document units & reverse-patch engine (gap-aware)
82
82
  // ---------------------------------------------------------------------------
83
- // Unit-key = `#id` (explicit), or `@<8hex content hash>` (derived) with `~n`
84
- // disambiguating equal-content units by document-order occurrence (§4).
85
- const KEY = String.raw `(#[A-Za-z][A-Za-z0-9_-]*|@[0-9a-f]+(?:~\d+)?)`;
83
+ // Unit-key = `#id` (explicit), or `@<8hex content hash>` (derived), with `~n`
84
+ // disambiguating equal keys by document-order occurrence (§4). `~n` on an #id
85
+ // key only arises for OUT-OF-SPEC documents that repeat an id — without it the
86
+ // key is ambiguous, reverse-patch ops hit the wrong occurrence, and commit()'s
87
+ // round-trip gate (correctly) aborts. Well-formed documents never emit it.
88
+ const KEY = String.raw `(#[A-Za-z][A-Za-z0-9_-]*(?:~\d+)?|@[0-9a-f]+(?:~\d+)?)`;
86
89
  function sha8(s) {
87
90
  return createHash("sha256").update(Buffer.from(s, "utf8")).digest("hex").slice(0, 8);
88
91
  }
@@ -127,9 +130,11 @@ function tile(lines) {
127
130
  function keyedUnits(lines) {
128
131
  const counts = new Map();
129
132
  return tile(lines).map((u) => {
130
- if (u.id)
131
- return { u, key: `#${u.id}` };
132
- const base = `@${sha8(lines.slice(u.start, u.bodyEnd).join("\n"))}`;
133
+ // Both key kinds get the ~n occurrence suffix: content keys collide by
134
+ // nature (equal blank runs, repeated paragraphs); #id keys only collide in
135
+ // out-of-spec documents that repeat an id — but those exist in the wild,
136
+ // and an ambiguous key sends reverse-patch ops to the wrong occurrence.
137
+ const base = u.id ? `#${u.id}` : `@${sha8(lines.slice(u.start, u.bodyEnd).join("\n"))}`;
133
138
  const n = counts.get(base) ?? 0;
134
139
  counts.set(base, n + 1);
135
140
  return { u, key: n === 0 ? base : `${base}~${n}` };
@@ -221,14 +226,59 @@ function applyReverse(textLf, ops, blobs) {
221
226
  }
222
227
  return lines.join("\n");
223
228
  }
224
- /** LCS alignment of unit-key sequences; aMatch[i] = matched index in b, or -1. */
229
+ /** LCS alignment of unit-key sequences; aMatch[i] = matched index in b, or -1.
230
+ *
231
+ * Fast path: content-keyed units (`@hash~n`) are unique by construction, and in
232
+ * a well-formed document `#id` keys are unique too — and the LCS of two
233
+ * all-unique sequences is exactly the longest increasing subsequence of a's
234
+ * keys mapped to b's positions: O(n log n) instead of the O(n·m) DP table.
235
+ * That difference is GEP-0002's measured bottleneck (seconds at 10⁴ units,
236
+ * minutes at 10⁵ — code-graph documents live there).
237
+ *
238
+ * A document with DUPLICATE `#id`s (a GEML error, but history never parses) can
239
+ * break uniqueness, so the DP remains as the fallback for that case. Either
240
+ * path yields *a* maximal alignment; commit()'s byte-exact round-trip gate
241
+ * rejects any diff that fails to reproduce the parent, whichever path ran. */
225
242
  function lcsMatch(a, b) {
226
243
  const n = a.length, m = b.length;
244
+ const aMatch = new Array(n).fill(-1);
245
+ if (new Set(a).size === n && new Set(b).size === m) {
246
+ const posInB = new Map();
247
+ for (let j = 0; j < m; j++)
248
+ posInB.set(b[j], j);
249
+ const ai = [], bj = []; // a-index / b-position of common keys, in a-order
250
+ for (let i = 0; i < n; i++) {
251
+ const j = posInB.get(a[i]);
252
+ if (j !== undefined) {
253
+ ai.push(i);
254
+ bj.push(j);
255
+ }
256
+ }
257
+ // Patience LIS over bj (strictly increasing) with predecessor links.
258
+ const tails = []; // index into bj of the smallest tail per LIS length
259
+ const prev = new Array(bj.length).fill(-1);
260
+ for (let x = 0; x < bj.length; x++) {
261
+ let lo = 0, hi = tails.length;
262
+ while (lo < hi) {
263
+ const mid = (lo + hi) >> 1;
264
+ if (bj[tails[mid]] < bj[x])
265
+ lo = mid + 1;
266
+ else
267
+ hi = mid;
268
+ }
269
+ prev[x] = lo > 0 ? tails[lo - 1] : -1;
270
+ tails[lo] = x;
271
+ }
272
+ for (let cur = tails.length ? tails[tails.length - 1] : -1; cur >= 0; cur = prev[cur]) {
273
+ aMatch[ai[cur]] = bj[cur];
274
+ }
275
+ return aMatch;
276
+ }
277
+ // Fallback (duplicate keys): classic DP.
227
278
  const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
228
279
  for (let i = n - 1; i >= 0; i--)
229
280
  for (let j = m - 1; j >= 0; j--)
230
281
  dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
231
- const aMatch = new Array(n).fill(-1);
232
282
  let i = 0, j = 0;
233
283
  while (i < n && j < m) {
234
284
  if (a[i] === b[j]) {
@@ -289,6 +339,22 @@ function opLine(op) {
289
339
  return `insert <- blob:${op.blob} ${anchorStr(op.anchor)}`;
290
340
  return `move ${op.key} ${anchorStr(op.anchor)}`;
291
341
  }
342
+ // §8 hashes are over "the exact UTF-8 bytes of that version" — which includes
343
+ // its newline style. The sidecar's file-level nl is a SINGLE value that later
344
+ // commits overwrite, so a document whose line endings flipped (LF↔CRLF is
345
+ // routine on Windows) would leave older revisions' recorded hashes
346
+ // unreproducible. Each revision therefore records its own `newline`; for
347
+ // legacy revisions that never recorded one, accept either byte encoding of
348
+ // the reconstructed text — both still pin the CONTENT exactly.
349
+ const nlNamed = (nl) => (nl === "\r\n" ? "crlf" : "lf");
350
+ const nlOf = (name) => name === "crlf" ? "\r\n" : name === "lf" ? "\n" : undefined;
351
+ function hashMatchesRecorded(lf, r, fileNl) {
352
+ const own = nlOf(r.newline);
353
+ if (own)
354
+ return fullHash(lf, own) === r.hash;
355
+ return fullHash(lf, fileNl) === r.hash
356
+ || fullHash(lf, fileNl === "\r\n" ? "\n" : "\r\n") === r.hash;
357
+ }
292
358
  function parseHistory(path) {
293
359
  const { lf, nl } = loadBytes(path);
294
360
  const lines = lf.split("\n");
@@ -315,6 +381,7 @@ function parseHistory(path) {
315
381
  author: attr(b.attrLine, "author"),
316
382
  summary: attr(b.attrLine, "summary"),
317
383
  hash: attr(b.attrLine, "hash") ?? "",
384
+ newline: attr(b.attrLine, "newline"),
318
385
  ops: parseOps(body),
319
386
  });
320
387
  }
@@ -386,6 +453,7 @@ function renderHistory(h, baseName) {
386
453
  r.author ? `author="${r.author}"` : "",
387
454
  r.summary ? `summary="${r.summary}"` : "",
388
455
  `hash="${r.hash}"`,
456
+ r.newline ? `newline="${r.newline}"` : "",
389
457
  ].filter(Boolean).join(" ");
390
458
  parts.push(`=== revision {${at}}\n${r.ops.map(opLine).join("\n")}${r.ops.length ? "\n" : ""}===\n`);
391
459
  // blobs referenced by this revision
@@ -417,9 +485,23 @@ export function commit(o) {
417
485
  if (bytesOf(back, nl).compare(bytesOf(prevContent, nl)) !== 0) {
418
486
  throw new Error("history: reverse patch does NOT round-trip to the previous revision; aborting commit");
419
487
  }
488
+ // Blob ids are minted per-diff (b1, b2, …). Renumber this commit's blobs to
489
+ // start past the highest id already stored, so a later commit never reuses
490
+ // an earlier revision's blob id — an overwrite in the shared store silently
491
+ // corrupts reconstruction of older revisions, whose `replace … <- blob:bN`
492
+ // would then resolve to the wrong (newer) content.
493
+ let maxBlob = 0;
494
+ for (const k of h.blobs.keys()) {
495
+ const mm = /^b(\d+)$/.exec(k);
496
+ if (mm)
497
+ maxBlob = Math.max(maxBlob, Number(mm[1]));
498
+ }
499
+ const remap = new Map(patch.blobs.map((b, i) => [b.id, `b${maxBlob + i + 1}`]));
500
+ patch.ops = patch.ops.map((op) => (op.blob ? { ...op, blob: remap.get(op.blob) } : op));
501
+ patch.blobs = patch.blobs.map((b) => ({ id: remap.get(b.id), payload: b.payload }));
420
502
  for (const b of patch.blobs)
421
503
  h.blobs.set(b.id, b.payload);
422
- h.revisions.set(id, { id, parent: prevId, author: o.author, summary: o.summary, hash, ops: patch.ops });
504
+ h.revisions.set(id, { id, parent: prevId, author: o.author, summary: o.summary, hash, newline: nlNamed(nl), ops: patch.ops });
423
505
  h.keyframes.delete(prevId); // demote previous tip mirror (keyframes at intervals only)
424
506
  h.keyframes.set(id, working);
425
507
  h.current = id;
@@ -428,7 +510,7 @@ export function commit(o) {
428
510
  h = {
429
511
  nl, current: id,
430
512
  keyframes: new Map([[id, working]]),
431
- revisions: new Map([[id, { id, author: o.author, summary: o.summary, hash, ops: [] }]]),
513
+ revisions: new Map([[id, { id, author: o.author, summary: o.summary, hash, newline: nlNamed(nl), ops: [] }]]),
432
514
  blobs: new Map(),
433
515
  };
434
516
  }
@@ -450,9 +532,9 @@ export function verify(historyPath, gemlPath) {
450
532
  for (const r of chain) {
451
533
  try {
452
534
  const content = reconstruct(h, r.id);
453
- const got = fullHash(content, h.nl);
454
- if (got !== r.hash)
455
- errors.push(`revision ${r.id}: reconstructed hash ${got} != recorded ${r.hash}`);
535
+ if (!hashMatchesRecorded(content, r, h.nl)) {
536
+ errors.push(`revision ${r.id}: reconstructed hash ${fullHash(content, nlOf(r.newline) ?? h.nl)} != recorded ${r.hash}`);
537
+ }
456
538
  checked++;
457
539
  }
458
540
  catch (e) {
@@ -505,3 +587,61 @@ export function restore(o) {
505
587
  }
506
588
  return content;
507
589
  }
590
+ /** Is the working file byte-identical to the sidecar's tip revision? False
591
+ * means uncommitted drift (e.g. an earlier commit attempt was refused). */
592
+ export function isCurrent(historyPath, gemlPath) {
593
+ const h = parseHistory(historyPath);
594
+ const tip = h.revisions.get(h.current);
595
+ if (!tip)
596
+ return false;
597
+ const { lf, nl } = loadBytes(gemlPath);
598
+ return fullHash(lf, nl) === tip.hash;
599
+ }
600
+ /** Revisions newest-first, each tagged with the `-N` offset that selects it. */
601
+ export function listRevisions(historyPath) {
602
+ const h = parseHistory(historyPath);
603
+ return chainFrom(h).map((r, i) => ({
604
+ id: r.id, parent: r.parent, author: r.author, summary: r.summary, hash: r.hash,
605
+ offset: i, current: i === 0,
606
+ }));
607
+ }
608
+ /** Resolve a revision selector to its id + reconstructed full text. Selectors:
609
+ * `-N` (N revisions back from current; `-0` is the tip), `latest`/`current`, or
610
+ * an unambiguous id prefix/suffix (the same forms `restore` accepts). */
611
+ export function resolveContent(historyPath, selector) {
612
+ const h = parseHistory(historyPath);
613
+ const chain = chainFrom(h); // chain[0] = current tip
614
+ let id;
615
+ const off = /^-(\d+)$/.exec(selector);
616
+ if (off) {
617
+ const n = Number(off[1]);
618
+ if (n >= chain.length)
619
+ throw new Error(`history: offset -${n} is out of range (only ${chain.length} revision(s))`);
620
+ id = chain[n].id;
621
+ }
622
+ else if (selector === "latest" || selector === "current") {
623
+ id = chain[0].id;
624
+ }
625
+ else {
626
+ const ids = [...h.revisions.keys()];
627
+ const matches = ids.filter((x) => x === selector || x.startsWith(selector) || x.endsWith(selector));
628
+ if (matches.length !== 1)
629
+ throw new Error(`history: revision selector "${selector}" matched ${matches.length} revisions`);
630
+ id = matches[0];
631
+ }
632
+ return { id, text: reconstruct(h, id) };
633
+ }
634
+ /** Walk the chain newest→oldest; return the first revision whose block (as
635
+ * extracted by `pick`) differs from `currentBlock` — i.e. the block's previous
636
+ * *distinct* version, skipping revisions that never touched it. Used by
637
+ * `revert --changed`. `undefined` if no earlier revision changed the block. */
638
+ export function firstChangedContent(historyPath, currentBlock, pick) {
639
+ const h = parseHistory(historyPath);
640
+ for (const r of chainFrom(h)) {
641
+ const text = reconstruct(h, r.id);
642
+ const b = pick(text);
643
+ if (b !== undefined && b !== currentBlock)
644
+ return { id: r.id, text };
645
+ }
646
+ return undefined;
647
+ }
package/dist/render.d.ts CHANGED
@@ -2,5 +2,64 @@ import { type Document } from "./geml.js";
2
2
  export interface RenderOptions {
3
3
  title?: string;
4
4
  source?: string;
5
+ loadDoc?: (relPath: string) => string | null;
6
+ parseDoc?: (source: string) => Document;
7
+ tableRows?: number;
8
+ liveGraph?: string;
9
+ graphSidecar?: string;
5
10
  }
11
+ interface CGNode {
12
+ n: string;
13
+ doc?: string;
14
+ src?: string;
15
+ leaf?: boolean | number;
16
+ test?: boolean;
17
+ acc?: boolean;
18
+ more?: boolean;
19
+ grp?: string[];
20
+ ext?: number;
21
+ }
22
+ interface CGData {
23
+ start: string;
24
+ depth: number;
25
+ roots: string[];
26
+ nodes: Record<string, CGNode>;
27
+ edges: [string, string, string, string][];
28
+ mode?: "modules";
29
+ module?: string;
30
+ dir?: "up";
31
+ focus?: string;
32
+ partial?: number;
33
+ mods?: {
34
+ p: string;
35
+ doc: string;
36
+ m?: number;
37
+ }[];
38
+ medges?: [string, string, number][];
39
+ entryDocs?: string[];
40
+ gpath?: string[];
41
+ }
42
+ declare function buildCodeGraph(startRel: string, opts: RenderOptions, view?: {
43
+ dir?: "up" | "down";
44
+ node?: string;
45
+ }): {
46
+ data?: CGData;
47
+ error?: string;
48
+ truncated?: boolean;
49
+ };
50
+ export declare function codeGraphRuntime(root: {
51
+ querySelectorAll(sel: string): ArrayLike<Element>;
52
+ }): void;
53
+ export declare function codeGraphWaves(fetchDoc: (rel: string) => Promise<string | null>, parseFn: (s: string) => Document): {
54
+ build: (src: string, view?: {
55
+ dir?: "up" | "down";
56
+ node?: string;
57
+ }) => Promise<{
58
+ data?: CGData;
59
+ error?: string;
60
+ truncated?: boolean;
61
+ }>;
62
+ seed: (name: string, text: string | null) => void;
63
+ };
64
+ export { buildCodeGraph };
6
65
  export declare function renderHtml(doc: Document, opts?: RenderOptions): string;