@cueloop/schema 0.1.0-alpha.4 → 0.1.0-alpha.41

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/package.json CHANGED
@@ -1,7 +1,21 @@
1
1
  {
2
2
  "name": "@cueloop/schema",
3
- "version": "0.1.0-alpha.4",
3
+ "version": "0.1.0-alpha.41",
4
+ "description": "The ReviewSession primitive: types, markdown block model, quote-primary anchors, feedback serialization",
5
+ "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
+ "bugs": {
7
+ "url": "https://github.com/mmurakaru/cueloop/issues"
8
+ },
4
9
  "license": "Apache-2.0",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/mmurakaru/cueloop.git",
13
+ "directory": "packages/schema"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "README.md"
18
+ ],
5
19
  "type": "module",
6
20
  "exports": {
7
21
  ".": "./src/index.ts"
@@ -9,16 +23,7 @@
9
23
  "publishConfig": {
10
24
  "access": "public"
11
25
  },
12
- "files": [
13
- "src",
14
- "README.md"
15
- ],
16
26
  "engines": {
17
27
  "bun": ">=1.3.0"
18
- },
19
- "repository": {
20
- "type": "git",
21
- "url": "git+https://github.com/mmurakaru/cueloop.git",
22
- "directory": "packages/schema"
23
28
  }
24
- }
29
+ }
@@ -15,80 +15,133 @@ Sessions are written as one JSON document per session. Sessions are written
15
15
  atomically.
16
16
  `;
17
17
 
18
- function blocksOf(md: string) {
19
- return parseBlocks(md);
18
+ function blocksOf(markdown: string) {
19
+ return parseBlocks(markdown);
20
20
  }
21
21
 
22
22
  describe("anchor cascade", () => {
23
23
  test("makeAnchor captures quote + context selectors", () => {
24
+ // Arrange
24
25
  const blocks = blocksOf(DOC);
25
- const ctxIdx = blocks.findIndex((b) => b.text.startsWith("Review sessions"));
26
- const a = makeAnchor(blocks, ctxIdx, 0, 15); // "Review sessions"
27
- expect(a.quote).toBe("Review sessions");
28
- expect(a.prefix).toBe("");
29
- expect(a.suffix.startsWith(" currently")).toBe(true);
26
+ const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
27
+
28
+ // Act
29
+ const anchor = makeAnchor(blocks, contextBlockIndex, 0, 15); // "Review sessions"
30
+
31
+ // Assert
32
+ expect(anchor.quote).toBe("Review sessions");
33
+ expect(anchor.prefix).toBe("");
34
+ expect(anchor.suffix.startsWith(" currently")).toBe(true);
30
35
  });
31
36
 
32
37
  test("resolves at the recorded position when unchanged", () => {
38
+ // Arrange
33
39
  const blocks = blocksOf(DOC);
34
- const ctxIdx = blocks.findIndex((b) => b.text.startsWith("Review sessions"));
35
- const a = makeAnchor(blocks, ctxIdx, 7, 15); // "sessions"
36
- const r = resolveAnchor(a, blocks)!;
37
- expect(r.blockIndex).toBe(ctxIdx);
38
- expect(r.start).toBe(7);
39
- expect(r.approximate).toBe(false);
40
+ const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
41
+ const anchor = makeAnchor(blocks, contextBlockIndex, 7, 15); // "sessions"
42
+
43
+ // Act
44
+ const resolved = resolveAnchor(anchor, blocks)!;
45
+
46
+ // Assert
47
+ expect(resolved.blockIndex).toBe(contextBlockIndex);
48
+ expect(resolved.start).toBe(7);
49
+ expect(resolved.approximate).toBe(false);
40
50
  });
41
51
 
42
52
  test("survives text changes before the quote (quote search)", () => {
53
+ // Arrange
43
54
  const blocks = blocksOf(DOC);
44
- const ctxIdx = blocks.findIndex((b) => b.text.startsWith("Review sessions"));
45
- const a = makeAnchor(blocks, ctxIdx, 0, 15);
46
- const edited = blocksOf(DOC.replace("Review sessions currently", "PREFIX. Review sessions now"));
47
- const r = resolveAnchor(a, edited)!;
48
- const idx = edited.findIndex((b) => b.text.includes("Review sessions"));
49
- expect(r.blockIndex).toBe(idx);
50
- expect(edited[r.blockIndex]!.text.slice(r.start, r.end)).toBe("Review sessions");
55
+ const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
56
+ const anchor = makeAnchor(blocks, contextBlockIndex, 0, 15);
57
+ const edited = blocksOf(
58
+ DOC.replace("Review sessions currently", "PREFIX. Review sessions now"),
59
+ );
60
+
61
+ // Act
62
+ const resolved = resolveAnchor(anchor, edited)!;
63
+
64
+ // Assert
65
+ const editedBlockIndex = edited.findIndex((block) => block.text.includes("Review sessions"));
66
+ expect(resolved.blockIndex).toBe(editedBlockIndex);
67
+ expect(edited[resolved.blockIndex]!.text.slice(resolved.start, resolved.end)).toBe(
68
+ "Review sessions",
69
+ );
51
70
  });
52
71
 
53
72
  test("prefix/suffix selectors disambiguate repeated quotes", () => {
73
+ // Arrange
54
74
  const blocks = blocksOf(DOC);
55
- const storIdx = blocks.findIndex((b) => b.text.includes("atomically"));
75
+ const storageBlockIndex = blocks.findIndex((block) => block.text.includes("atomically"));
56
76
  // anchor on the SECOND "Sessions are written" occurrence
57
- const text = blocks[storIdx]!.text;
77
+ const text = blocks[storageBlockIndex]!.text;
58
78
  const second = text.indexOf("Sessions are written", text.indexOf("Sessions are written") + 1);
59
- const a = makeAnchor(blocks, storIdx, second, second + "Sessions are written".length);
60
- const r = resolveAnchor(a, blocks)!;
61
- expect(r.start).toBe(second);
79
+ const anchor = makeAnchor(
80
+ blocks,
81
+ storageBlockIndex,
82
+ second,
83
+ second + "Sessions are written".length,
84
+ );
85
+
86
+ // Act
87
+ const resolved = resolveAnchor(anchor, blocks)!;
88
+
89
+ // Assert
90
+ expect(resolved.start).toBe(second);
62
91
  });
63
92
 
64
93
  test("position hint breaks ties when context also repeats", () => {
65
- const md = "word word word";
66
- const blocks = blocksOf(md);
67
- const a = makeAnchor(blocks, 0, 5, 9); // middle "word"
68
- const r = resolveAnchor(a, blocks)!;
69
- expect(r.start).toBe(5);
94
+ // Arrange
95
+ const repeatedWords = "word word word";
96
+ const blocks = blocksOf(repeatedWords);
97
+ const anchor = makeAnchor(blocks, 0, 5, 9); // middle "word"
98
+
99
+ // Act
100
+ const resolved = resolveAnchor(anchor, blocks)!;
101
+
102
+ // Assert
103
+ expect(resolved.start).toBe(5);
70
104
  });
71
105
 
72
106
  test("trimmed-quote fallback marks the resolution approximate", () => {
73
- const blocks = blocksOf("some text here");
74
- const a = { quote: " text ", prefix: "some", suffix: "here", blockIndex: 0, start: 4, end: 10 };
107
+ // Arrange
108
+ const anchor = {
109
+ quote: " text ",
110
+ prefix: "some",
111
+ suffix: "here",
112
+ blockIndex: 0,
113
+ start: 4,
114
+ end: 10,
115
+ };
75
116
  const edited = blocksOf("some\ntext\nhere");
76
117
  // " text " (with spaces) is gone, "text" survives
77
- const r = resolveAnchor(a, edited);
78
- expect(r).not.toBeNull();
79
- expect(r!.approximate).toBe(true);
118
+
119
+ // Act
120
+ const resolved = resolveAnchor(anchor, edited);
121
+
122
+ // Assert
123
+ expect(resolved).not.toBeNull();
124
+ expect(resolved!.approximate).toBe(true);
80
125
  });
81
126
 
82
127
  test("orphans when the quote is gone entirely", () => {
128
+ // Arrange
83
129
  const blocks = blocksOf(DOC);
84
- const ctxIdx = blocks.findIndex((b) => b.text.startsWith("Review sessions"));
85
- const a = makeAnchor(blocks, ctxIdx, 0, 15);
86
- const edited = blocksOf(DOC.replace(/Review sessions[^\n]*\n[^\n]*/, "Completely different text."));
87
- expect(resolveAnchor(a, edited)).toBeNull();
130
+ const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
131
+ const anchor = makeAnchor(blocks, contextBlockIndex, 0, 15);
132
+ const edited = blocksOf(
133
+ DOC.replace(/Review sessions[^\n]*\n[^\n]*/, "Completely different text."),
134
+ );
135
+
136
+ // Assert
137
+ expect(resolveAnchor(anchor, edited)).toBeNull();
88
138
  });
89
139
 
90
140
  test("empty quote never binds", () => {
141
+ // Arrange
91
142
  const blocks = blocksOf(DOC);
143
+
144
+ // Assert
92
145
  expect(resolveAnchor({ quote: "", prefix: "", suffix: "" }, blocks)).toBeNull();
93
146
  });
94
147
  });
package/src/anchor.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Anchor resolution: the quote-primary cascade (map #2, #22, verified in the
3
- * edit-mode deep-dive). Anchors resolve against the current block list:
2
+ * Anchor resolution: the quote-primary cascade. Anchors resolve against the
3
+ * current block list:
4
4
  * 1. exact quote in a block (prefix/suffix selectors break ties,
5
5
  * position hint breaks remaining ties)
6
6
  * 2. trimmed quote
@@ -11,7 +11,8 @@
11
11
  import type { Anchor } from "./types";
12
12
  import type { Block } from "./markdown";
13
13
 
14
- const CONTEXT = 24;
14
+ /** Characters of surrounding text captured as prefix/suffix selectors. */
15
+ const ANCHOR_CONTEXT_CHARS = 24;
15
16
 
16
17
  export interface ResolvedAnchor {
17
18
  blockIndex: number;
@@ -21,12 +22,17 @@ export interface ResolvedAnchor {
21
22
  approximate: boolean;
22
23
  }
23
24
 
24
- export function makeAnchor(blocks: Block[], blockIndex: number, start: number, end: number): Anchor {
25
- const t = blocks[blockIndex]?.text ?? "";
25
+ export function makeAnchor(
26
+ blocks: Block[],
27
+ blockIndex: number,
28
+ start: number,
29
+ end: number,
30
+ ): Anchor {
31
+ const blockText = blocks[blockIndex]?.text ?? "";
26
32
  return {
27
- quote: t.slice(start, end),
28
- prefix: t.slice(Math.max(0, start - CONTEXT), start),
29
- suffix: t.slice(end, end + CONTEXT),
33
+ quote: blockText.slice(start, end),
34
+ prefix: blockText.slice(Math.max(0, start - ANCHOR_CONTEXT_CHARS), start),
35
+ suffix: blockText.slice(end, end + ANCHOR_CONTEXT_CHARS),
30
36
  blockIndex,
31
37
  start,
32
38
  end,
@@ -38,21 +44,34 @@ interface Candidate extends ResolvedAnchor {
38
44
  }
39
45
 
40
46
  function findCandidates(anchor: Anchor, blocks: Block[], quote: string): Candidate[] {
41
- const out: Candidate[] = [];
42
- blocks.forEach((b, blockIndex) => {
43
- const text = b.text;
44
- for (let i = text.indexOf(quote); i !== -1; i = text.indexOf(quote, i + 1)) {
45
- const pre = text.slice(Math.max(0, i - CONTEXT), i);
46
- const suf = text.slice(i + quote.length, i + quote.length + CONTEXT);
47
+ const candidates: Candidate[] = [];
48
+ blocks.forEach((block, blockIndex) => {
49
+ const text = block.text;
50
+ for (
51
+ let matchStart = text.indexOf(quote);
52
+ matchStart !== -1;
53
+ matchStart = text.indexOf(quote, matchStart + 1)
54
+ ) {
55
+ const prefix = text.slice(Math.max(0, matchStart - ANCHOR_CONTEXT_CHARS), matchStart);
56
+ const suffix = text.slice(
57
+ matchStart + quote.length,
58
+ matchStart + quote.length + ANCHOR_CONTEXT_CHARS,
59
+ );
47
60
  let score = 0;
48
- if (pre === anchor.prefix) score += 2;
49
- if (suf === anchor.suffix) score += 2;
61
+ if (prefix === anchor.prefix) score += 2;
62
+ if (suffix === anchor.suffix) score += 2;
50
63
  if (anchor.blockIndex === blockIndex) score += 1;
51
- if (anchor.blockIndex === blockIndex && anchor.start === i) score += 1;
52
- out.push({ blockIndex, start: i, end: i + quote.length, approximate: false, score });
64
+ if (anchor.blockIndex === blockIndex && anchor.start === matchStart) score += 1;
65
+ candidates.push({
66
+ blockIndex,
67
+ start: matchStart,
68
+ end: matchStart + quote.length,
69
+ approximate: false,
70
+ score,
71
+ });
53
72
  }
54
73
  });
55
- return out;
74
+ return candidates;
56
75
  }
57
76
 
58
77
  /** Resolve an anchor against the current blocks, or null when orphaned. */
@@ -68,7 +87,7 @@ export function resolveAnchor(anchor: Anchor, blocks: Block[]): ResolvedAnchor |
68
87
  }
69
88
  }
70
89
  if (candidates.length === 0) return null;
71
- candidates.sort((a, b) => b.score - a.score);
90
+ candidates.sort((left, right) => right.score - left.score);
72
91
  const best = candidates[0]!;
73
92
  return { ...best, approximate };
74
93
  }
package/src/diff.test.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { lcsDiff, unifiedDiff, unifiedDiffText, editStats, wordDiff } from "./diff";
2
+ import { lcsDiff, unifiedDiff, unifiedDiffText, editStats } from "./diff";
3
3
 
4
4
  describe("unifiedDiff", () => {
5
5
  test("equal inputs return null, not an empty diff", () => {
@@ -7,12 +7,17 @@ describe("unifiedDiff", () => {
7
7
  });
8
8
 
9
9
  test("produces hunk headers with correct line numbers", () => {
10
+ // Arrange
10
11
  const oldText = ["l1", "l2", "l3", "l4", "l5", "l6", "l7", "l8"].join("\n");
11
12
  const newText = ["l1", "l2", "l3", "CHANGED", "l5", "l6", "l7", "l8"].join("\n");
12
- const d = unifiedDiff(oldText, newText, 1)!;
13
- expect(d[0]!.t).toBe("hunk");
14
- expect(d[0]!.text).toBe("@@ -3,3 +3,3 @@");
15
- expect(d.map((l) => l.text)).toEqual([
13
+
14
+ // Act
15
+ const diffLines = unifiedDiff(oldText, newText, 1)!;
16
+
17
+ // Assert
18
+ expect(diffLines[0]!.kind).toBe("hunk");
19
+ expect(diffLines[0]!.text).toBe("@@ -3,3 +3,3 @@");
20
+ expect(diffLines.map((line) => line.text)).toEqual([
16
21
  "@@ -3,3 +3,3 @@",
17
22
  " l3",
18
23
  "-l4",
@@ -22,29 +27,42 @@ describe("unifiedDiff", () => {
22
27
  });
23
28
 
24
29
  test("distant changes split into separate hunks", () => {
30
+ // Arrange
25
31
  const oldLines = Array.from({ length: 30 }, (_, i) => `line ${i + 1}`);
26
32
  const newLines = [...oldLines];
27
33
  newLines[1] = "first change";
28
34
  newLines[27] = "second change";
29
- const d = unifiedDiff(oldLines.join("\n"), newLines.join("\n"), 2)!;
30
- expect(d.filter((l) => l.t === "hunk").length).toBe(2);
35
+
36
+ // Act
37
+ const diffLines = unifiedDiff(oldLines.join("\n"), newLines.join("\n"), 2)!;
38
+
39
+ // Assert
40
+ expect(diffLines.filter((line) => line.kind === "hunk").length).toBe(2);
31
41
  });
32
42
 
33
43
  test("applies cleanly: reconstructing new text from old + diff", () => {
44
+ // Arrange
34
45
  const oldText = "keep\ndrop\nkeep2\nkeep3";
35
46
  const newText = "keep\nadded\nkeep2\nkeep3\ntail";
36
- const d = unifiedDiff(oldText, newText, 50)!;
47
+
48
+ // Act
49
+ const diffLines = unifiedDiff(oldText, newText, 50)!;
50
+
51
+ // Assert
37
52
  const rebuilt: string[] = [];
38
- for (const l of d) {
39
- if (l.t === "hunk") continue;
40
- if (l.t === "ctx" || l.t === "add") rebuilt.push(l.text.slice(1));
53
+ for (const line of diffLines) {
54
+ if (line.kind === "hunk") continue;
55
+ if (line.kind === "ctx" || line.kind === "add") rebuilt.push(line.text.slice(1));
41
56
  }
42
57
  expect(rebuilt.join("\n")).toBe(newText);
43
58
  });
44
59
 
45
60
  test("unifiedDiffText carries file headers and path", () => {
46
- const t = unifiedDiffText("a", "b", "docs/plan.md")!;
47
- expect(t.startsWith("--- a/docs/plan.md\n+++ b/docs/plan.md\n@@")).toBe(true);
61
+ // Act
62
+ const diffText = unifiedDiffText("a", "b", "docs/plan.md")!;
63
+
64
+ // Assert
65
+ expect(diffText.startsWith("--- a/docs/plan.md\n+++ b/docs/plan.md\n@@")).toBe(true);
48
66
  });
49
67
  });
50
68
 
@@ -54,19 +72,16 @@ describe("editStats", () => {
54
72
  });
55
73
  });
56
74
 
57
- describe("wordDiff", () => {
58
- test("preserves whitespace tokens and reconstructs both sides", () => {
59
- const ops = wordDiff("the quick brown fox", "the slow brown foxes");
60
- const oldSide = ops.filter((o) => o.t !== "add").map((o) => o.a).join("");
61
- const newSide = ops.filter((o) => o.t !== "del").map((o) => o.b).join("");
62
- expect(oldSide).toBe("the quick brown fox");
63
- expect(newSide).toBe("the slow brown foxes");
64
- });
65
- });
66
-
67
75
  describe("lcsDiff", () => {
68
76
  test("custom equality", () => {
69
- const ops = lcsDiff([{ v: 1 }, { v: 2 }], [{ v: 2 }, { v: 3 }], (x, y) => x.v === y.v);
70
- expect(ops.map((o) => o.t)).toEqual(["del", "ctx", "add"]);
77
+ // Act
78
+ const ops = lcsDiff(
79
+ [{ v: 1 }, { v: 2 }],
80
+ [{ v: 2 }, { v: 3 }],
81
+ (oldItem, newItem) => oldItem.v === newItem.v,
82
+ );
83
+
84
+ // Assert
85
+ expect(ops.map((op) => op.kind)).toEqual(["del", "ctx", "add"]);
71
86
  });
72
87
  });
package/src/diff.ts CHANGED
@@ -1,52 +1,63 @@
1
1
  /**
2
2
  * Line/token diffing and unified-diff serialization. The working copy
3
- * serializes as ONE unified diff against the submitted revision (map #2).
3
+ * serializes as ONE unified diff against the submitted revision.
4
4
  * Pure algorithms, no IO.
5
5
  */
6
6
 
7
- export type DiffOpType = "ctx" | "del" | "add";
7
+ export type DiffOpKind = "ctx" | "del" | "add";
8
8
 
9
9
  export interface DiffOp<T = string> {
10
- t: DiffOpType;
10
+ kind: DiffOpKind;
11
11
  /** Present for ctx and del. */
12
- a?: T;
12
+ oldValue?: T;
13
13
  /** Present for ctx and add. */
14
- b?: T;
14
+ newValue?: T;
15
15
  }
16
16
 
17
17
  /** LCS diff over arrays. O(n*m) - fine for plan-sized documents. */
18
- export function lcsDiff<T>(a: T[], b: T[], eq: (x: T, y: T) => boolean = (x, y) => x === y): DiffOp<T>[] {
19
- const n = a.length;
20
- const m = b.length;
21
- const dp: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
22
- for (let i = n - 1; i >= 0; i--) {
23
- for (let j = m - 1; j >= 0; j--) {
24
- dp[i]![j] = eq(a[i]!, b[j]!) ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!);
18
+ export function lcsDiff<T>(
19
+ oldItems: T[],
20
+ newItems: T[],
21
+ equals: (oldItem: T, newItem: T) => boolean = (oldItem, newItem) => oldItem === newItem,
22
+ ): DiffOp<T>[] {
23
+ const oldCount = oldItems.length;
24
+ const newCount = newItems.length;
25
+ const longestCommon: number[][] = Array.from({ length: oldCount + 1 }, () =>
26
+ Array.from({ length: newCount + 1 }, () => 0),
27
+ );
28
+ for (let oldIndex = oldCount - 1; oldIndex >= 0; oldIndex--) {
29
+ for (let newIndex = newCount - 1; newIndex >= 0; newIndex--) {
30
+ longestCommon[oldIndex]![newIndex] = equals(oldItems[oldIndex]!, newItems[newIndex]!)
31
+ ? longestCommon[oldIndex + 1]![newIndex + 1]! + 1
32
+ : Math.max(
33
+ longestCommon[oldIndex + 1]![newIndex]!,
34
+ longestCommon[oldIndex]![newIndex + 1]!,
35
+ );
25
36
  }
26
37
  }
27
- const out: DiffOp<T>[] = [];
28
- let i = 0;
29
- let j = 0;
30
- while (i < n && j < m) {
31
- if (eq(a[i]!, b[j]!)) {
32
- out.push({ t: "ctx", a: a[i], b: b[j] });
33
- i++;
34
- j++;
35
- } else if (dp[i + 1]![j]! >= dp[i]![j + 1]!) {
36
- out.push({ t: "del", a: a[i] });
37
- i++;
38
+ const ops: DiffOp<T>[] = [];
39
+ let oldIndex = 0;
40
+ let newIndex = 0;
41
+ while (oldIndex < oldCount && newIndex < newCount) {
42
+ if (equals(oldItems[oldIndex]!, newItems[newIndex]!)) {
43
+ ops.push({ kind: "ctx", oldValue: oldItems[oldIndex], newValue: newItems[newIndex] });
44
+ oldIndex++;
45
+ newIndex++;
46
+ } else if (longestCommon[oldIndex + 1]![newIndex]! >= longestCommon[oldIndex]![newIndex + 1]!) {
47
+ ops.push({ kind: "del", oldValue: oldItems[oldIndex] });
48
+ oldIndex++;
38
49
  } else {
39
- out.push({ t: "add", b: b[j] });
40
- j++;
50
+ ops.push({ kind: "add", newValue: newItems[newIndex] });
51
+ newIndex++;
41
52
  }
42
53
  }
43
- while (i < n) out.push({ t: "del", a: a[i++] });
44
- while (j < m) out.push({ t: "add", b: b[j++] });
45
- return out;
54
+ while (oldIndex < oldCount) ops.push({ kind: "del", oldValue: oldItems[oldIndex++] });
55
+ while (newIndex < newCount) ops.push({ kind: "add", newValue: newItems[newIndex++] });
56
+ return ops;
46
57
  }
47
58
 
48
59
  export interface UnifiedLine {
49
- t: DiffOpType | "hunk";
60
+ kind: DiffOpKind | "hunk";
50
61
  text: string;
51
62
  }
52
63
 
@@ -56,58 +67,72 @@ export interface UnifiedLine {
56
67
  */
57
68
  export function unifiedDiff(oldText: string, newText: string, context = 3): UnifiedLine[] | null {
58
69
  const ops = lcsDiff(oldText.split("\n"), newText.split("\n"));
59
- if (!ops.some((o) => o.t !== "ctx")) return null;
60
- let ol = 1;
61
- let nl = 1;
62
- const rows = ops.map((o) => {
63
- const r = {
64
- t: o.t,
65
- text: (o.t === "add" ? o.b : o.a) ?? "",
66
- ol: o.t !== "add" ? ol : null,
67
- nl: o.t !== "del" ? nl : null,
70
+ if (!ops.some((op) => op.kind !== "ctx")) return null;
71
+ let oldLineNumber = 1;
72
+ let newLineNumber = 1;
73
+ const rows = ops.map((op) => {
74
+ const row = {
75
+ kind: op.kind,
76
+ text: (op.kind === "add" ? op.newValue : op.oldValue) ?? "",
77
+ oldLine: op.kind !== "add" ? oldLineNumber : null,
78
+ newLine: op.kind !== "del" ? newLineNumber : null,
68
79
  };
69
- if (o.t !== "add") ol++;
70
- if (o.t !== "del") nl++;
71
- return r;
80
+ if (op.kind !== "add") oldLineNumber++;
81
+ if (op.kind !== "del") newLineNumber++;
82
+ return row;
72
83
  });
73
- const keep = new Array<boolean>(rows.length).fill(false);
74
- rows.forEach((r, idx) => {
75
- if (r.t !== "ctx") {
76
- for (let k = Math.max(0, idx - context); k <= Math.min(rows.length - 1, idx + context); k++) keep[k] = true;
84
+ const keep = Array.from({ length: rows.length }, () => false);
85
+ rows.forEach((row, rowIndex) => {
86
+ if (row.kind !== "ctx") {
87
+ for (
88
+ let keepIndex = Math.max(0, rowIndex - context);
89
+ keepIndex <= Math.min(rows.length - 1, rowIndex + context);
90
+ keepIndex++
91
+ ) {
92
+ keep[keepIndex] = true;
93
+ }
77
94
  }
78
95
  });
79
96
  const hunks: { rows: (typeof rows)[number][] }[] = [];
80
- let cur: { rows: (typeof rows)[number][] } | null = null;
81
- rows.forEach((r, idx) => {
82
- if (!keep[idx]) {
83
- cur = null;
97
+ let openHunk: { rows: (typeof rows)[number][] } | null = null;
98
+ rows.forEach((row, rowIndex) => {
99
+ if (!keep[rowIndex]) {
100
+ openHunk = null;
84
101
  return;
85
102
  }
86
- if (!cur) {
87
- cur = { rows: [] };
88
- hunks.push(cur);
103
+ if (!openHunk) {
104
+ openHunk = { rows: [] };
105
+ hunks.push(openHunk);
89
106
  }
90
- cur.rows.push(r);
107
+ openHunk.rows.push(row);
91
108
  });
92
- const out: UnifiedLine[] = [];
93
- for (const h of hunks) {
94
- const oStart = h.rows.find((r) => r.ol !== null)?.ol ?? 1;
95
- const nStart = h.rows.find((r) => r.nl !== null)?.nl ?? 1;
96
- const oCount = h.rows.filter((r) => r.t !== "add").length;
97
- const nCount = h.rows.filter((r) => r.t !== "del").length;
98
- out.push({ t: "hunk", text: `@@ -${oStart},${oCount} +${nStart},${nCount} @@` });
99
- for (const r of h.rows) {
100
- out.push({ t: r.t, text: (r.t === "add" ? "+" : r.t === "del" ? "-" : " ") + r.text });
109
+ const lines: UnifiedLine[] = [];
110
+ for (const hunk of hunks) {
111
+ const oldStart = hunk.rows.find((row) => row.oldLine !== null)?.oldLine ?? 1;
112
+ const newStart = hunk.rows.find((row) => row.newLine !== null)?.newLine ?? 1;
113
+ const oldCount = hunk.rows.filter((row) => row.kind !== "add").length;
114
+ const newCount = hunk.rows.filter((row) => row.kind !== "del").length;
115
+ lines.push({ kind: "hunk", text: `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@` });
116
+ for (const row of hunk.rows) {
117
+ lines.push({
118
+ kind: row.kind,
119
+ text: (row.kind === "add" ? "+" : row.kind === "del" ? "-" : " ") + row.text,
120
+ });
101
121
  }
102
122
  }
103
- return out;
123
+ return lines;
104
124
  }
105
125
 
106
126
  /** Render a unified diff as text with file headers. */
107
- export function unifiedDiffText(oldText: string, newText: string, path = "plan.md", context = 3): string | null {
108
- const d = unifiedDiff(oldText, newText, context);
109
- if (!d) return null;
110
- return [`--- a/${path}`, `+++ b/${path}`, ...d.map((l) => l.text)].join("\n");
127
+ export function unifiedDiffText(
128
+ oldText: string,
129
+ newText: string,
130
+ path = "plan.md",
131
+ context = 3,
132
+ ): string | null {
133
+ const lines = unifiedDiff(oldText, newText, context);
134
+ if (!lines) return null;
135
+ return [`--- a/${path}`, `+++ b/${path}`, ...lines.map((line) => line.text)].join("\n");
111
136
  }
112
137
 
113
138
  export interface EditStats {
@@ -116,16 +141,10 @@ export interface EditStats {
116
141
  }
117
142
 
118
143
  export function editStats(oldText: string, newText: string): EditStats {
119
- const d = unifiedDiff(oldText, newText, 0);
120
- if (!d) return { added: 0, removed: 0 };
144
+ const lines = unifiedDiff(oldText, newText, 0);
145
+ if (!lines) return { added: 0, removed: 0 };
121
146
  return {
122
- added: d.filter((r) => r.t === "add").length,
123
- removed: d.filter((r) => r.t === "del").length,
147
+ added: lines.filter((line) => line.kind === "add").length,
148
+ removed: lines.filter((line) => line.kind === "del").length,
124
149
  };
125
150
  }
126
-
127
- /** Word-level diff ops for tracked-changes rendering. Whitespace-preserving. */
128
- export function wordDiff(oldText: string, newText: string): DiffOp[] {
129
- const tok = (s: string) => s.split(/(\s+)/).filter((x) => x !== "");
130
- return lcsDiff(tok(oldText), tok(newText));
131
- }