@cueloop/schema 0.1.0-alpha.5 → 0.1.0-alpha.50

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.5",
3
+ "version": "0.1.0-alpha.50",
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,21 +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
- },
24
- "homepage": "https://github.com/mmurakaru/cueloop#readme",
25
- "bugs": {
26
- "url": "https://github.com/mmurakaru/cueloop/issues"
27
- },
28
- "description": "The ReviewSession primitive: types, markdown block model, quote-primary anchors, feedback serialization"
29
- }
28
+ }
29
+ }
@@ -15,80 +15,194 @@ 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);
104
+ });
105
+
106
+ test("binds a quote that still carries its leading list marker", () => {
107
+ // Arrange
108
+ // an agent copied the source line verbatim, including the "- " bullet, but
109
+ // the parsed list block strips the marker from its text
110
+ const blocks = blocksOf("## Notes\n\n- This is a dummy plan - confirm the item");
111
+ const anchor = { quote: "- This is a dummy plan", prefix: "", suffix: "" };
112
+
113
+ // Act
114
+ const resolved = resolveAnchor(anchor, blocks)!;
115
+
116
+ // Assert
117
+ expect(resolved).not.toBeNull();
118
+ expect(resolved.strategy).toBe("normalized");
119
+ expect(blocks[resolved.blockIndex]!.text.slice(resolved.start, resolved.end)).toBe(
120
+ "This is a dummy plan",
121
+ );
122
+ });
123
+
124
+ test("binds a heading quote that still carries its leading hashes", () => {
125
+ // Arrange
126
+ const blocks = blocksOf(DOC);
127
+ const anchor = { quote: "## Storage", prefix: "", suffix: "" };
128
+
129
+ // Act
130
+ const resolved = resolveAnchor(anchor, blocks)!;
131
+
132
+ // Assert
133
+ expect(resolved.strategy).toBe("normalized");
134
+ expect(blocks[resolved.blockIndex]!.text).toBe("Storage");
135
+ });
136
+
137
+ test("fuzzy tier binds a lightly edited quote", () => {
138
+ // Arrange
139
+ const blocks = blocksOf(DOC);
140
+ const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
141
+ // a typo the reviewer never saw: "sesssions"
142
+ const anchor = { quote: "Review sesssions currently live", prefix: "", suffix: "" };
143
+
144
+ // Act
145
+ const resolved = resolveAnchor(anchor, blocks)!;
146
+
147
+ // Assert
148
+ expect(resolved.strategy).toBe("fuzzy");
149
+ expect(resolved.blockIndex).toBe(contextBlockIndex);
150
+ expect(resolved.approximate).toBe(true);
151
+ });
152
+
153
+ test("exact matches report the exact strategy", () => {
154
+ // Arrange
155
+ const blocks = blocksOf(DOC);
156
+ const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
157
+ const anchor = makeAnchor(blocks, contextBlockIndex, 0, 15);
158
+
159
+ // Act
160
+ const resolved = resolveAnchor(anchor, blocks)!;
161
+
162
+ // Assert
163
+ expect(resolved.strategy).toBe("exact");
164
+ expect(resolved.approximate).toBe(false);
70
165
  });
71
166
 
72
167
  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 };
168
+ // Arrange
169
+ const anchor = {
170
+ quote: " text ",
171
+ prefix: "some",
172
+ suffix: "here",
173
+ blockIndex: 0,
174
+ start: 4,
175
+ end: 10,
176
+ };
75
177
  const edited = blocksOf("some\ntext\nhere");
76
178
  // " text " (with spaces) is gone, "text" survives
77
- const r = resolveAnchor(a, edited);
78
- expect(r).not.toBeNull();
79
- expect(r!.approximate).toBe(true);
179
+
180
+ // Act
181
+ const resolved = resolveAnchor(anchor, edited);
182
+
183
+ // Assert
184
+ expect(resolved).not.toBeNull();
185
+ expect(resolved!.approximate).toBe(true);
80
186
  });
81
187
 
82
188
  test("orphans when the quote is gone entirely", () => {
189
+ // Arrange
83
190
  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();
191
+ const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
192
+ const anchor = makeAnchor(blocks, contextBlockIndex, 0, 15);
193
+ const edited = blocksOf(
194
+ DOC.replace(/Review sessions[^\n]*\n[^\n]*/, "Completely different text."),
195
+ );
196
+
197
+ // Assert
198
+ expect(resolveAnchor(anchor, edited)).toBeNull();
88
199
  });
89
200
 
90
201
  test("empty quote never binds", () => {
202
+ // Arrange
91
203
  const blocks = blocksOf(DOC);
204
+
205
+ // Assert
92
206
  expect(resolveAnchor({ quote: "", prefix: "", suffix: "" }, blocks)).toBeNull();
93
207
  });
94
208
  });
package/src/anchor.ts CHANGED
@@ -1,74 +1,174 @@
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:
4
- * 1. exact quote in a block (prefix/suffix selectors break ties,
5
- * position hint breaks remaining ties)
6
- * 2. trimmed quote
7
- * 3. orphan - keep the quote for display, drop the highlight.
8
- * Never silently bind to the wrong text.
2
+ * Anchor resolution: the quote-primary cascade. Anchors resolve against the
3
+ * current block list, each tier tried only when the one above it misses:
4
+ * 1. exact - the quote appears verbatim in a block
5
+ * 2. trimmed - the quote appears once surrounding whitespace is dropped
6
+ * 3. normalized - the quote appears once a leading markdown block marker
7
+ * (bullet, heading, ordered, quote) is stripped, matching how
8
+ * the parser strips block text
9
+ * 4. fuzzy - the quote was lightly edited but is still recognizably there
10
+ * 5. orphan - none matched; keep the quote for display, drop the highlight
11
+ * Prefix/suffix selectors and the recorded position break ties within a tier.
12
+ * Never silently bind to the wrong text: fuzzy needs a high similarity floor.
9
13
  */
10
14
 
11
15
  import type { Anchor } from "./types";
12
- import type { Block } from "./markdown";
16
+ import { type Block, stripLeadingBlockMarker } from "./markdown";
17
+ import { fuzzyFindBestMatch } from "./fuzzy";
13
18
 
14
- const CONTEXT = 24;
19
+ /** Characters of surrounding text captured as prefix/suffix selectors. */
20
+ const ANCHOR_CONTEXT_CHARS = 24;
21
+
22
+ /** Minimum similarity a fuzzy match needs before the resolver will trust it. */
23
+ const FUZZY_MINIMUM_SIMILARITY = 0.75;
24
+
25
+ /** How the quote bound to the block text, weakest binding named last. */
26
+ export type AnchorMatchStrategy = "exact" | "trimmed" | "normalized" | "fuzzy";
15
27
 
16
28
  export interface ResolvedAnchor {
17
29
  blockIndex: number;
18
30
  start: number;
19
31
  end: number;
20
- /** True when the quote had to be trimmed to bind. */
32
+ /** True whenever the binding was not an exact quote match. */
21
33
  approximate: boolean;
34
+ /** Which cascade tier bound the quote. */
35
+ strategy: AnchorMatchStrategy;
22
36
  }
23
37
 
24
- export function makeAnchor(blocks: Block[], blockIndex: number, start: number, end: number): Anchor {
25
- const t = blocks[blockIndex]?.text ?? "";
38
+ export function makeAnchor(
39
+ blocks: Block[],
40
+ blockIndex: number,
41
+ start: number,
42
+ end: number,
43
+ ): Anchor {
44
+ const blockText = blocks[blockIndex]?.text ?? "";
26
45
  return {
27
- quote: t.slice(start, end),
28
- prefix: t.slice(Math.max(0, start - CONTEXT), start),
29
- suffix: t.slice(end, end + CONTEXT),
46
+ quote: blockText.slice(start, end),
47
+ prefix: blockText.slice(Math.max(0, start - ANCHOR_CONTEXT_CHARS), start),
48
+ suffix: blockText.slice(end, end + ANCHOR_CONTEXT_CHARS),
30
49
  blockIndex,
31
50
  start,
32
51
  end,
33
52
  };
34
53
  }
35
54
 
36
- interface Candidate extends ResolvedAnchor {
55
+ interface Candidate {
56
+ blockIndex: number;
57
+ start: number;
58
+ end: number;
37
59
  score: number;
38
60
  }
39
61
 
40
- 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
- let score = 0;
48
- if (pre === anchor.prefix) score += 2;
49
- if (suf === anchor.suffix) score += 2;
50
- 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 });
62
+ /** Score a candidate window by how well its context and position agree. */
63
+ function contextScore(
64
+ anchor: Anchor,
65
+ blockIndex: number,
66
+ block: Block,
67
+ start: number,
68
+ end: number,
69
+ ): number {
70
+ const prefix = block.text.slice(Math.max(0, start - ANCHOR_CONTEXT_CHARS), start);
71
+ const suffix = block.text.slice(end, end + ANCHOR_CONTEXT_CHARS);
72
+ let score = 0;
73
+ if (prefix === anchor.prefix) score += 2;
74
+ if (suffix === anchor.suffix) score += 2;
75
+ if (anchor.blockIndex === blockIndex) score += 1;
76
+ if (anchor.blockIndex === blockIndex && anchor.start === start) score += 1;
77
+ return score;
78
+ }
79
+
80
+ /** Every verbatim occurrence of `quote` across the blocks, scored by context. */
81
+ function findExactCandidates(anchor: Anchor, blocks: Block[], quote: string): Candidate[] {
82
+ const candidates: Candidate[] = [];
83
+ blocks.forEach((block, blockIndex) => {
84
+ const text = block.text;
85
+ for (
86
+ let matchStart = text.indexOf(quote);
87
+ matchStart !== -1;
88
+ matchStart = text.indexOf(quote, matchStart + 1)
89
+ ) {
90
+ const matchEnd = matchStart + quote.length;
91
+ candidates.push({
92
+ blockIndex,
93
+ start: matchStart,
94
+ end: matchEnd,
95
+ score: contextScore(anchor, blockIndex, block, matchStart, matchEnd),
96
+ });
53
97
  }
54
98
  });
55
- return out;
99
+ return candidates;
100
+ }
101
+
102
+ /**
103
+ * How much a full context agreement (0-6) may nudge the fuzzy ranking. Kept far
104
+ * below any real similarity gap so text quality always leads and context only
105
+ * breaks near-ties - a stale position hint must never pull the anchor onto a
106
+ * meaningfully less-similar passage.
107
+ */
108
+ const FUZZY_CONTEXT_TIE_BREAK = 0.01;
109
+
110
+ /** The best fuzzy window per block, ranked by similarity with context as a tiebreak. */
111
+ function findFuzzyCandidates(
112
+ anchor: Anchor,
113
+ blocks: Block[],
114
+ needle: string,
115
+ minimumSimilarity: number,
116
+ ): Candidate[] {
117
+ const candidates: Candidate[] = [];
118
+ blocks.forEach((block, blockIndex) => {
119
+ const match = fuzzyFindBestMatch(needle, block.text, minimumSimilarity);
120
+ if (match === null) return;
121
+ const score =
122
+ match.similarity +
123
+ contextScore(anchor, blockIndex, block, match.start, match.end) * FUZZY_CONTEXT_TIE_BREAK;
124
+ candidates.push({ blockIndex, start: match.start, end: match.end, score });
125
+ });
126
+ return candidates;
127
+ }
128
+
129
+ /** Highest-scoring candidate, or null when there are none. */
130
+ function pickBest(candidates: Candidate[]): Candidate | null {
131
+ if (candidates.length === 0) return null;
132
+ candidates.sort((left, right) => right.score - left.score);
133
+ return candidates[0]!;
134
+ }
135
+
136
+ function resolved(candidate: Candidate, strategy: AnchorMatchStrategy): ResolvedAnchor {
137
+ return {
138
+ blockIndex: candidate.blockIndex,
139
+ start: candidate.start,
140
+ end: candidate.end,
141
+ approximate: strategy !== "exact",
142
+ strategy,
143
+ };
56
144
  }
57
145
 
58
146
  /** Resolve an anchor against the current blocks, or null when orphaned. */
59
147
  export function resolveAnchor(anchor: Anchor, blocks: Block[]): ResolvedAnchor | null {
60
148
  if (anchor.quote === "") return null;
61
- let candidates = findCandidates(anchor, blocks, anchor.quote);
62
- let approximate = false;
63
- if (candidates.length === 0) {
64
- const trimmed = anchor.quote.trim();
65
- if (trimmed !== "" && trimmed !== anchor.quote) {
66
- candidates = findCandidates(anchor, blocks, trimmed);
67
- approximate = true;
68
- }
149
+
150
+ const exact = pickBest(findExactCandidates(anchor, blocks, anchor.quote));
151
+ if (exact) return resolved(exact, "exact");
152
+
153
+ const trimmedQuote = anchor.quote.trim();
154
+ if (trimmedQuote !== "" && trimmedQuote !== anchor.quote) {
155
+ const trimmed = pickBest(findExactCandidates(anchor, blocks, trimmedQuote));
156
+ if (trimmed) return resolved(trimmed, "trimmed");
69
157
  }
70
- if (candidates.length === 0) return null;
71
- candidates.sort((a, b) => b.score - a.score);
72
- const best = candidates[0]!;
73
- return { ...best, approximate };
158
+
159
+ const normalizedQuote = stripLeadingBlockMarker(anchor.quote).trim();
160
+ if (normalizedQuote !== "" && normalizedQuote !== trimmedQuote) {
161
+ const normalized = pickBest(findExactCandidates(anchor, blocks, normalizedQuote));
162
+ if (normalized) return resolved(normalized, "normalized");
163
+ }
164
+
165
+ const fuzzyNeedle = normalizedQuote !== "" ? normalizedQuote : trimmedQuote;
166
+ if (fuzzyNeedle !== "") {
167
+ const fuzzy = pickBest(
168
+ findFuzzyCandidates(anchor, blocks, fuzzyNeedle, FUZZY_MINIMUM_SIMILARITY),
169
+ );
170
+ if (fuzzy) return resolved(fuzzy, "fuzzy");
171
+ }
172
+
173
+ return null;
74
174
  }
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
  });