@cueloop/schema 0.1.0-alpha.2 → 0.1.0-alpha.21

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/schema",
3
- "version": "0.1.0-alpha.2",
3
+ "version": "0.1.0-alpha.21",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -20,5 +20,10 @@
20
20
  "type": "git",
21
21
  "url": "git+https://github.com/mmurakaru/cueloop.git",
22
22
  "directory": "packages/schema"
23
- }
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"
24
29
  }
@@ -15,76 +15,76 @@ 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
24
  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);
25
+ const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
26
+ const anchor = makeAnchor(blocks, contextBlockIndex, 0, 15); // "Review sessions"
27
+ expect(anchor.quote).toBe("Review sessions");
28
+ expect(anchor.prefix).toBe("");
29
+ expect(anchor.suffix.startsWith(" currently")).toBe(true);
30
30
  });
31
31
 
32
32
  test("resolves at the recorded position when unchanged", () => {
33
33
  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);
34
+ const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
35
+ const anchor = makeAnchor(blocks, contextBlockIndex, 7, 15); // "sessions"
36
+ const resolved = resolveAnchor(anchor, blocks)!;
37
+ expect(resolved.blockIndex).toBe(contextBlockIndex);
38
+ expect(resolved.start).toBe(7);
39
+ expect(resolved.approximate).toBe(false);
40
40
  });
41
41
 
42
42
  test("survives text changes before the quote (quote search)", () => {
43
43
  const blocks = blocksOf(DOC);
44
- const ctxIdx = blocks.findIndex((b) => b.text.startsWith("Review sessions"));
45
- const a = makeAnchor(blocks, ctxIdx, 0, 15);
44
+ const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
45
+ const anchor = makeAnchor(blocks, contextBlockIndex, 0, 15);
46
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");
47
+ const resolved = resolveAnchor(anchor, edited)!;
48
+ const editedBlockIndex = edited.findIndex((block) => block.text.includes("Review sessions"));
49
+ expect(resolved.blockIndex).toBe(editedBlockIndex);
50
+ expect(edited[resolved.blockIndex]!.text.slice(resolved.start, resolved.end)).toBe("Review sessions");
51
51
  });
52
52
 
53
53
  test("prefix/suffix selectors disambiguate repeated quotes", () => {
54
54
  const blocks = blocksOf(DOC);
55
- const storIdx = blocks.findIndex((b) => b.text.includes("atomically"));
55
+ const storageBlockIndex = blocks.findIndex((block) => block.text.includes("atomically"));
56
56
  // anchor on the SECOND "Sessions are written" occurrence
57
- const text = blocks[storIdx]!.text;
57
+ const text = blocks[storageBlockIndex]!.text;
58
58
  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);
59
+ const anchor = makeAnchor(blocks, storageBlockIndex, second, second + "Sessions are written".length);
60
+ const resolved = resolveAnchor(anchor, blocks)!;
61
+ expect(resolved.start).toBe(second);
62
62
  });
63
63
 
64
64
  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);
65
+ const repeatedWords = "word word word";
66
+ const blocks = blocksOf(repeatedWords);
67
+ const anchor = makeAnchor(blocks, 0, 5, 9); // middle "word"
68
+ const resolved = resolveAnchor(anchor, blocks)!;
69
+ expect(resolved.start).toBe(5);
70
70
  });
71
71
 
72
72
  test("trimmed-quote fallback marks the resolution approximate", () => {
73
73
  const blocks = blocksOf("some text here");
74
- const a = { quote: " text ", prefix: "some", suffix: "here", blockIndex: 0, start: 4, end: 10 };
74
+ const anchor = { quote: " text ", prefix: "some", suffix: "here", blockIndex: 0, start: 4, end: 10 };
75
75
  const edited = blocksOf("some\ntext\nhere");
76
76
  // " text " (with spaces) is gone, "text" survives
77
- const r = resolveAnchor(a, edited);
78
- expect(r).not.toBeNull();
79
- expect(r!.approximate).toBe(true);
77
+ const resolved = resolveAnchor(anchor, edited);
78
+ expect(resolved).not.toBeNull();
79
+ expect(resolved!.approximate).toBe(true);
80
80
  });
81
81
 
82
82
  test("orphans when the quote is gone entirely", () => {
83
83
  const blocks = blocksOf(DOC);
84
- const ctxIdx = blocks.findIndex((b) => b.text.startsWith("Review sessions"));
85
- const a = makeAnchor(blocks, ctxIdx, 0, 15);
84
+ const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
85
+ const anchor = makeAnchor(blocks, contextBlockIndex, 0, 15);
86
86
  const edited = blocksOf(DOC.replace(/Review sessions[^\n]*\n[^\n]*/, "Completely different text."));
87
- expect(resolveAnchor(a, edited)).toBeNull();
87
+ expect(resolveAnchor(anchor, edited)).toBeNull();
88
88
  });
89
89
 
90
90
  test("empty quote never binds", () => {
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;
@@ -22,11 +23,11 @@ export interface ResolvedAnchor {
22
23
  }
23
24
 
24
25
  export function makeAnchor(blocks: Block[], blockIndex: number, start: number, end: number): Anchor {
25
- const t = blocks[blockIndex]?.text ?? "";
26
+ const blockText = blocks[blockIndex]?.text ?? "";
26
27
  return {
27
- quote: t.slice(start, end),
28
- prefix: t.slice(Math.max(0, start - CONTEXT), start),
29
- suffix: t.slice(end, end + CONTEXT),
28
+ quote: blockText.slice(start, end),
29
+ prefix: blockText.slice(Math.max(0, start - ANCHOR_CONTEXT_CHARS), start),
30
+ suffix: blockText.slice(end, end + ANCHOR_CONTEXT_CHARS),
30
31
  blockIndex,
31
32
  start,
32
33
  end,
@@ -38,21 +39,21 @@ interface Candidate extends ResolvedAnchor {
38
39
  }
39
40
 
40
41
  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);
42
+ const candidates: Candidate[] = [];
43
+ blocks.forEach((block, blockIndex) => {
44
+ const text = block.text;
45
+ for (let matchStart = text.indexOf(quote); matchStart !== -1; matchStart = text.indexOf(quote, matchStart + 1)) {
46
+ const prefix = text.slice(Math.max(0, matchStart - ANCHOR_CONTEXT_CHARS), matchStart);
47
+ const suffix = text.slice(matchStart + quote.length, matchStart + quote.length + ANCHOR_CONTEXT_CHARS);
47
48
  let score = 0;
48
- if (pre === anchor.prefix) score += 2;
49
- if (suf === anchor.suffix) score += 2;
49
+ if (prefix === anchor.prefix) score += 2;
50
+ if (suffix === anchor.suffix) score += 2;
50
51
  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 });
52
+ if (anchor.blockIndex === blockIndex && anchor.start === matchStart) score += 1;
53
+ candidates.push({ blockIndex, start: matchStart, end: matchStart + quote.length, approximate: false, score });
53
54
  }
54
55
  });
55
- return out;
56
+ return candidates;
56
57
  }
57
58
 
58
59
  /** Resolve an anchor against the current blocks, or null when orphaned. */
@@ -68,7 +69,7 @@ export function resolveAnchor(anchor: Anchor, blocks: Block[]): ResolvedAnchor |
68
69
  }
69
70
  }
70
71
  if (candidates.length === 0) return null;
71
- candidates.sort((a, b) => b.score - a.score);
72
+ candidates.sort((left, right) => right.score - left.score);
72
73
  const best = candidates[0]!;
73
74
  return { ...best, approximate };
74
75
  }
package/src/diff.test.ts CHANGED
@@ -9,10 +9,10 @@ describe("unifiedDiff", () => {
9
9
  test("produces hunk headers with correct line numbers", () => {
10
10
  const oldText = ["l1", "l2", "l3", "l4", "l5", "l6", "l7", "l8"].join("\n");
11
11
  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([
12
+ const diffLines = unifiedDiff(oldText, newText, 1)!;
13
+ expect(diffLines[0]!.kind).toBe("hunk");
14
+ expect(diffLines[0]!.text).toBe("@@ -3,3 +3,3 @@");
15
+ expect(diffLines.map((line) => line.text)).toEqual([
16
16
  "@@ -3,3 +3,3 @@",
17
17
  " l3",
18
18
  "-l4",
@@ -26,25 +26,25 @@ describe("unifiedDiff", () => {
26
26
  const newLines = [...oldLines];
27
27
  newLines[1] = "first change";
28
28
  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);
29
+ const diffLines = unifiedDiff(oldLines.join("\n"), newLines.join("\n"), 2)!;
30
+ expect(diffLines.filter((line) => line.kind === "hunk").length).toBe(2);
31
31
  });
32
32
 
33
33
  test("applies cleanly: reconstructing new text from old + diff", () => {
34
34
  const oldText = "keep\ndrop\nkeep2\nkeep3";
35
35
  const newText = "keep\nadded\nkeep2\nkeep3\ntail";
36
- const d = unifiedDiff(oldText, newText, 50)!;
36
+ const diffLines = unifiedDiff(oldText, newText, 50)!;
37
37
  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));
38
+ for (const line of diffLines) {
39
+ if (line.kind === "hunk") continue;
40
+ if (line.kind === "ctx" || line.kind === "add") rebuilt.push(line.text.slice(1));
41
41
  }
42
42
  expect(rebuilt.join("\n")).toBe(newText);
43
43
  });
44
44
 
45
45
  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);
46
+ const diffText = unifiedDiffText("a", "b", "docs/plan.md")!;
47
+ expect(diffText.startsWith("--- a/docs/plan.md\n+++ b/docs/plan.md\n@@")).toBe(true);
48
48
  });
49
49
  });
50
50
 
@@ -57,8 +57,8 @@ describe("editStats", () => {
57
57
  describe("wordDiff", () => {
58
58
  test("preserves whitespace tokens and reconstructs both sides", () => {
59
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("");
60
+ const oldSide = ops.filter((op) => op.kind !== "add").map((op) => op.oldValue).join("");
61
+ const newSide = ops.filter((op) => op.kind !== "del").map((op) => op.newValue).join("");
62
62
  expect(oldSide).toBe("the quick brown fox");
63
63
  expect(newSide).toBe("the slow brown foxes");
64
64
  });
@@ -66,7 +66,7 @@ describe("wordDiff", () => {
66
66
 
67
67
  describe("lcsDiff", () => {
68
68
  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"]);
69
+ const ops = lcsDiff([{ v: 1 }, { v: 2 }], [{ v: 2 }, { v: 3 }], (oldItem, newItem) => oldItem.v === newItem.v);
70
+ expect(ops.map((op) => op.kind)).toEqual(["del", "ctx", "add"]);
71
71
  });
72
72
  });
package/src/diff.ts CHANGED
@@ -1,52 +1,58 @@
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 }, () => new Array<number>(newCount + 1).fill(0));
26
+ for (let oldIndex = oldCount - 1; oldIndex >= 0; oldIndex--) {
27
+ for (let newIndex = newCount - 1; newIndex >= 0; newIndex--) {
28
+ longestCommon[oldIndex]![newIndex] = equals(oldItems[oldIndex]!, newItems[newIndex]!)
29
+ ? longestCommon[oldIndex + 1]![newIndex + 1]! + 1
30
+ : Math.max(longestCommon[oldIndex + 1]![newIndex]!, longestCommon[oldIndex]![newIndex + 1]!);
25
31
  }
26
32
  }
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++;
33
+ const ops: DiffOp<T>[] = [];
34
+ let oldIndex = 0;
35
+ let newIndex = 0;
36
+ while (oldIndex < oldCount && newIndex < newCount) {
37
+ if (equals(oldItems[oldIndex]!, newItems[newIndex]!)) {
38
+ ops.push({ kind: "ctx", oldValue: oldItems[oldIndex], newValue: newItems[newIndex] });
39
+ oldIndex++;
40
+ newIndex++;
41
+ } else if (longestCommon[oldIndex + 1]![newIndex]! >= longestCommon[oldIndex]![newIndex + 1]!) {
42
+ ops.push({ kind: "del", oldValue: oldItems[oldIndex] });
43
+ oldIndex++;
38
44
  } else {
39
- out.push({ t: "add", b: b[j] });
40
- j++;
45
+ ops.push({ kind: "add", newValue: newItems[newIndex] });
46
+ newIndex++;
41
47
  }
42
48
  }
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;
49
+ while (oldIndex < oldCount) ops.push({ kind: "del", oldValue: oldItems[oldIndex++] });
50
+ while (newIndex < newCount) ops.push({ kind: "add", newValue: newItems[newIndex++] });
51
+ return ops;
46
52
  }
47
53
 
48
54
  export interface UnifiedLine {
49
- t: DiffOpType | "hunk";
55
+ kind: DiffOpKind | "hunk";
50
56
  text: string;
51
57
  }
52
58
 
@@ -56,58 +62,64 @@ export interface UnifiedLine {
56
62
  */
57
63
  export function unifiedDiff(oldText: string, newText: string, context = 3): UnifiedLine[] | null {
58
64
  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,
65
+ if (!ops.some((op) => op.kind !== "ctx")) return null;
66
+ let oldLineNumber = 1;
67
+ let newLineNumber = 1;
68
+ const rows = ops.map((op) => {
69
+ const row = {
70
+ kind: op.kind,
71
+ text: (op.kind === "add" ? op.newValue : op.oldValue) ?? "",
72
+ oldLine: op.kind !== "add" ? oldLineNumber : null,
73
+ newLine: op.kind !== "del" ? newLineNumber : null,
68
74
  };
69
- if (o.t !== "add") ol++;
70
- if (o.t !== "del") nl++;
71
- return r;
75
+ if (op.kind !== "add") oldLineNumber++;
76
+ if (op.kind !== "del") newLineNumber++;
77
+ return row;
72
78
  });
73
79
  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;
80
+ rows.forEach((row, rowIndex) => {
81
+ if (row.kind !== "ctx") {
82
+ for (
83
+ let keepIndex = Math.max(0, rowIndex - context);
84
+ keepIndex <= Math.min(rows.length - 1, rowIndex + context);
85
+ keepIndex++
86
+ ) {
87
+ keep[keepIndex] = true;
88
+ }
77
89
  }
78
90
  });
79
91
  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;
92
+ let openHunk: { rows: (typeof rows)[number][] } | null = null;
93
+ rows.forEach((row, rowIndex) => {
94
+ if (!keep[rowIndex]) {
95
+ openHunk = null;
84
96
  return;
85
97
  }
86
- if (!cur) {
87
- cur = { rows: [] };
88
- hunks.push(cur);
98
+ if (!openHunk) {
99
+ openHunk = { rows: [] };
100
+ hunks.push(openHunk);
89
101
  }
90
- cur.rows.push(r);
102
+ openHunk.rows.push(row);
91
103
  });
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 });
104
+ const lines: UnifiedLine[] = [];
105
+ for (const hunk of hunks) {
106
+ const oldStart = hunk.rows.find((row) => row.oldLine !== null)?.oldLine ?? 1;
107
+ const newStart = hunk.rows.find((row) => row.newLine !== null)?.newLine ?? 1;
108
+ const oldCount = hunk.rows.filter((row) => row.kind !== "add").length;
109
+ const newCount = hunk.rows.filter((row) => row.kind !== "del").length;
110
+ lines.push({ kind: "hunk", text: `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@` });
111
+ for (const row of hunk.rows) {
112
+ lines.push({ kind: row.kind, text: (row.kind === "add" ? "+" : row.kind === "del" ? "-" : " ") + row.text });
101
113
  }
102
114
  }
103
- return out;
115
+ return lines;
104
116
  }
105
117
 
106
118
  /** Render a unified diff as text with file headers. */
107
119
  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");
120
+ const lines = unifiedDiff(oldText, newText, context);
121
+ if (!lines) return null;
122
+ return [`--- a/${path}`, `+++ b/${path}`, ...lines.map((line) => line.text)].join("\n");
111
123
  }
112
124
 
113
125
  export interface EditStats {
@@ -116,16 +128,16 @@ export interface EditStats {
116
128
  }
117
129
 
118
130
  export function editStats(oldText: string, newText: string): EditStats {
119
- const d = unifiedDiff(oldText, newText, 0);
120
- if (!d) return { added: 0, removed: 0 };
131
+ const lines = unifiedDiff(oldText, newText, 0);
132
+ if (!lines) return { added: 0, removed: 0 };
121
133
  return {
122
- added: d.filter((r) => r.t === "add").length,
123
- removed: d.filter((r) => r.t === "del").length,
134
+ added: lines.filter((line) => line.kind === "add").length,
135
+ removed: lines.filter((line) => line.kind === "del").length,
124
136
  };
125
137
  }
126
138
 
127
139
  /** Word-level diff ops for tracked-changes rendering. Whitespace-preserving. */
128
140
  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));
141
+ const tokenize = (text: string) => text.split(/(\s+)/).filter((token) => token !== "");
142
+ return lcsDiff(tokenize(oldText), tokenize(newText));
131
143
  }
@@ -13,7 +13,7 @@ Review sessions live only in daemon memory today.
13
13
  Sessions are written as one JSON document per session.
14
14
  `;
15
15
 
16
- function ann(partial: Partial<Annotation> & { anchor: Annotation["anchor"] }): Annotation {
16
+ function makeAnnotation(partial: Partial<Annotation> & { anchor: Annotation["anchor"] }): Annotation {
17
17
  return {
18
18
  id: "a1",
19
19
  kind: "comment",
@@ -25,7 +25,7 @@ function ann(partial: Partial<Annotation> & { anchor: Annotation["anchor"] }): A
25
25
 
26
26
  describe("renderFeedback", () => {
27
27
  test("plan edits section carries the one unified diff, directive framing first", () => {
28
- const out = renderFeedback({
28
+ const feedback = renderFeedback({
29
29
  verdictKind: "request_changes",
30
30
  summary: "Tighten the storage section.",
31
31
  artifactContent: PLAN,
@@ -33,83 +33,102 @@ describe("renderFeedback", () => {
33
33
  annotations: [],
34
34
  planPath: "docs/plan.md",
35
35
  });
36
- expect(out).toContain("# Review: request changes");
37
- expect(out).toContain("Tighten the storage section.");
38
- expect(out).toContain("## Plan edits");
39
- expect(out).toContain("Apply this exact diff first");
40
- expect(out).toContain("--- a/docs/plan.md");
41
- expect(out).toContain("-Sessions are written as one JSON document per session.");
42
- expect(out).toContain("+Sessions are written as one JSON record per session.");
36
+ expect(feedback).toContain("# Review: request changes");
37
+ expect(feedback).toContain("Tighten the storage section.");
38
+ expect(feedback).toContain("## Plan edits");
39
+ expect(feedback).toContain("Apply this exact diff first");
40
+ expect(feedback).toContain("--- a/docs/plan.md");
41
+ expect(feedback).toContain("-Sessions are written as one JSON document per session.");
42
+ expect(feedback).toContain("+Sessions are written as one JSON record per session.");
43
43
  });
44
44
 
45
45
  test("annotations are located by quote and section", () => {
46
- const out = renderFeedback({
46
+ const feedback = renderFeedback({
47
47
  verdictKind: "comment",
48
48
  summary: "",
49
49
  artifactContent: PLAN,
50
50
  annotations: [
51
- ann({
51
+ makeAnnotation({
52
52
  anchor: { quote: "daemon memory", prefix: "live only in ", suffix: " today." },
53
53
  }),
54
54
  ],
55
55
  });
56
- expect(out).toContain("## Annotations (1)");
57
- expect(out).toContain("### 1. Comment (§ Context)");
58
- expect(out).toContain("> daemon memory");
59
- expect(out).toContain("Please clarify.");
56
+ expect(feedback).toContain("## Annotations (1)");
57
+ expect(feedback).toContain("### 1. Comment (§ Context)");
58
+ expect(feedback).toContain("> daemon memory");
59
+ expect(feedback).toContain("Please clarify.");
60
60
  });
61
61
 
62
62
  test("suggestions render as replace/with pairs", () => {
63
- const out = renderFeedback({
63
+ const feedback = renderFeedback({
64
64
  verdictKind: "request_changes",
65
65
  summary: "",
66
66
  artifactContent: PLAN,
67
67
  annotations: [
68
- ann({
68
+ makeAnnotation({
69
69
  kind: "suggestion",
70
70
  body: "one durable JSON record",
71
71
  anchor: { quote: "one JSON document", prefix: "written as ", suffix: " per session." },
72
72
  }),
73
73
  ],
74
74
  });
75
- expect(out).toContain("### 1. Suggested change (§ Storage)");
76
- expect(out).toContain("Replace:\n> one JSON document");
77
- expect(out).toContain("With:\n> one durable JSON record");
75
+ expect(feedback).toContain("### 1. Suggested change (§ Storage)");
76
+ expect(feedback).toContain("Replace:\n> one JSON document");
77
+ expect(feedback).toContain("With:\n> one durable JSON record");
78
78
  });
79
79
 
80
80
  test("orphaned anchors are flagged, never dropped", () => {
81
- const out = renderFeedback({
81
+ const feedback = renderFeedback({
82
82
  verdictKind: "comment",
83
83
  summary: "",
84
84
  artifactContent: PLAN,
85
- annotations: [ann({ anchor: { quote: "text that is gone", prefix: "", suffix: "" } })],
85
+ annotations: [makeAnnotation({ anchor: { quote: "text that is gone", prefix: "", suffix: "" } })],
86
86
  });
87
- expect(out).toContain("[orphaned anchor: the quoted text is no longer present]");
88
- expect(out).toContain("> text that is gone");
87
+ expect(feedback).toContain("[orphaned anchor: the quoted text is no longer present]");
88
+ expect(feedback).toContain("> text that is gone");
89
89
  });
90
90
 
91
91
  test("empty review says so explicitly", () => {
92
- const out = renderFeedback({
92
+ const feedback = renderFeedback({
93
93
  verdictKind: "approve",
94
94
  summary: "",
95
95
  artifactContent: PLAN,
96
96
  annotations: [],
97
97
  });
98
- expect(out).toContain("_No edits or annotations._");
98
+ expect(feedback).toContain("_No edits or annotations._");
99
+ });
100
+
101
+ test("agent notes never come back as feedback items", () => {
102
+ const note = makeAnnotation({
103
+ kind: "note",
104
+ anchor: { quote: "src/a.ts", prefix: "", suffix: "" },
105
+ body: "The agent's own explanation of the change.",
106
+ });
107
+ const noteOnly = renderFeedback({ verdictKind: "approve", summary: "", artifactContent: PLAN, annotations: [note] });
108
+ expect(noteOnly).toContain("_No edits or annotations._");
109
+ expect(noteOnly).not.toContain("The agent's own explanation of the change.");
110
+ const mixed = renderFeedback({
111
+ verdictKind: "request_changes",
112
+ summary: "",
113
+ artifactContent: PLAN,
114
+ annotations: [note, makeAnnotation({ anchor: { quote: "one JSON document", prefix: "written as ", suffix: " per session." } })],
115
+ });
116
+ expect(mixed).toContain("## Annotations (1)");
117
+ expect(mixed).not.toContain("The agent's own explanation of the change.");
99
118
  });
100
119
 
101
120
  test("anchors resolve against the working copy when present", () => {
102
121
  // annotation made on text that only exists in the edited copy
103
122
  const working = PLAN.replace("## Storage", "## Persistence");
104
- const out = renderFeedback({
123
+ const feedback = renderFeedback({
105
124
  verdictKind: "request_changes",
106
125
  summary: "",
107
126
  artifactContent: PLAN,
108
127
  workingCopy: working,
109
128
  annotations: [
110
- ann({ anchor: { quote: "one JSON document", prefix: "written as ", suffix: " per session." } }),
129
+ makeAnnotation({ anchor: { quote: "one JSON document", prefix: "written as ", suffix: " per session." } }),
111
130
  ],
112
131
  });
113
- expect(out).toContain("(§ Persistence)");
132
+ expect(feedback).toContain("(§ Persistence)");
114
133
  });
115
134
  });