@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/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
- }
@@ -13,7 +13,9 @@ 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(
17
+ partial: Partial<Annotation> & { anchor: Annotation["anchor"] },
18
+ ): Annotation {
17
19
  return {
18
20
  id: "a1",
19
21
  kind: "comment",
@@ -25,91 +27,200 @@ function ann(partial: Partial<Annotation> & { anchor: Annotation["anchor"] }): A
25
27
 
26
28
  describe("renderFeedback", () => {
27
29
  test("plan edits section carries the one unified diff, directive framing first", () => {
28
- const out = renderFeedback({
30
+ // Act
31
+ const feedback = renderFeedback({
29
32
  verdictKind: "request_changes",
30
33
  summary: "Tighten the storage section.",
31
34
  artifactContent: PLAN,
32
35
  workingCopy: PLAN.replace("one JSON document", "one JSON record"),
33
36
  annotations: [],
34
- planPath: "docs/plan.md",
37
+ artifactPath: "docs/plan.md",
35
38
  });
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.");
39
+
40
+ // Assert
41
+ expect(feedback).toContain("# Review: request changes");
42
+ expect(feedback).toContain("Tighten the storage section.");
43
+ expect(feedback).toContain("## Plan edits");
44
+ expect(feedback).toContain("Apply this exact diff first");
45
+ expect(feedback).toContain("--- a/docs/plan.md");
46
+ expect(feedback).toContain("-Sessions are written as one JSON document per session.");
47
+ expect(feedback).toContain("+Sessions are written as one JSON record per session.");
48
+ });
49
+
50
+ test("a diff working copy is handed back as the curated patch, not a diff of diffs", () => {
51
+ // Arrange
52
+ const submittedPatch = `--- a/src/x.ts
53
+ +++ b/src/x.ts
54
+ @@ -1,1 +1,1 @@
55
+ -const limit = 100;
56
+ +const limit = 250;
57
+ `;
58
+ const curatedPatch = `--- a/src/x.ts
59
+ +++ b/src/x.ts
60
+ @@ -1,1 +1,1 @@
61
+ -const limit = 100;
62
+ +const limit = 175;
63
+ `;
64
+
65
+ // Act
66
+ const feedback = renderFeedback({
67
+ verdictKind: "request_changes",
68
+ summary: "",
69
+ artifactContent: submittedPatch,
70
+ workingCopy: curatedPatch,
71
+ artifactType: "diff",
72
+ annotations: [],
73
+ });
74
+
75
+ // Assert
76
+ expect(feedback).toContain("## Curated changes");
77
+ expect(feedback).toContain("+const limit = 175;");
78
+ // the submitted patch is not re-diffed against the curated one
79
+ expect(feedback).not.toContain("Plan edits");
80
+ expect(feedback).not.toContain("++const");
81
+ });
82
+
83
+ test("a diff working copy that rejected everything says so", () => {
84
+ // Act
85
+ const feedback = renderFeedback({
86
+ verdictKind: "request_changes",
87
+ summary: "",
88
+ artifactContent: "--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b\n",
89
+ workingCopy: "",
90
+ artifactType: "diff",
91
+ annotations: [],
92
+ });
93
+
94
+ // Assert
95
+ expect(feedback).toContain("## Curated changes");
96
+ expect(feedback).toContain("rejected all of your proposed changes");
43
97
  });
44
98
 
45
99
  test("annotations are located by quote and section", () => {
46
- const out = renderFeedback({
100
+ // Act
101
+ const feedback = renderFeedback({
47
102
  verdictKind: "comment",
48
103
  summary: "",
49
104
  artifactContent: PLAN,
50
105
  annotations: [
51
- ann({
106
+ makeAnnotation({
52
107
  anchor: { quote: "daemon memory", prefix: "live only in ", suffix: " today." },
53
108
  }),
54
109
  ],
55
110
  });
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.");
111
+
112
+ // Assert
113
+ expect(feedback).toContain("## Annotations (1)");
114
+ expect(feedback).toContain("### 1. Comment (§ Context)");
115
+ expect(feedback).toContain("> daemon memory");
116
+ expect(feedback).toContain("Please clarify.");
60
117
  });
61
118
 
62
- test("suggestions render as replace/with pairs", () => {
63
- const out = renderFeedback({
64
- verdictKind: "request_changes",
119
+ test("orphaned anchors are flagged, never dropped", () => {
120
+ // Act
121
+ const feedback = renderFeedback({
122
+ verdictKind: "comment",
65
123
  summary: "",
66
124
  artifactContent: PLAN,
67
125
  annotations: [
68
- ann({
69
- kind: "suggestion",
70
- body: "one durable JSON record",
71
- anchor: { quote: "one JSON document", prefix: "written as ", suffix: " per session." },
72
- }),
126
+ makeAnnotation({ anchor: { quote: "text that is gone", prefix: "", suffix: "" } }),
73
127
  ],
74
128
  });
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");
129
+
130
+ // Assert
131
+ expect(feedback).toContain("[orphaned anchor: the quoted text is no longer present]");
132
+ expect(feedback).toContain("> text that is gone");
78
133
  });
79
134
 
80
- test("orphaned anchors are flagged, never dropped", () => {
81
- const out = renderFeedback({
82
- verdictKind: "comment",
135
+ test("empty review says so explicitly", () => {
136
+ // Act
137
+ const feedback = renderFeedback({
138
+ verdictKind: "approve",
83
139
  summary: "",
84
140
  artifactContent: PLAN,
85
- annotations: [ann({ anchor: { quote: "text that is gone", prefix: "", suffix: "" } })],
141
+ annotations: [],
86
142
  });
87
- expect(out).toContain("[orphaned anchor: the quoted text is no longer present]");
88
- expect(out).toContain("> text that is gone");
143
+
144
+ // Assert
145
+ expect(feedback).toContain("_No edits or annotations._");
89
146
  });
90
147
 
91
- test("empty review says so explicitly", () => {
92
- const out = renderFeedback({
148
+ test("agent notes never come back as feedback items", () => {
149
+ // Arrange
150
+ const note = makeAnnotation({
151
+ kind: "note",
152
+ anchor: { quote: "src/a.ts", prefix: "", suffix: "" },
153
+ body: "The agent's own explanation of the change.",
154
+ });
155
+
156
+ // Act
157
+ const noteOnly = renderFeedback({
93
158
  verdictKind: "approve",
94
159
  summary: "",
95
160
  artifactContent: PLAN,
96
- annotations: [],
161
+ annotations: [note],
162
+ });
163
+
164
+ // Assert
165
+ expect(noteOnly).toContain("_No edits or annotations._");
166
+ expect(noteOnly).not.toContain("The agent's own explanation of the change.");
167
+ const mixed = renderFeedback({
168
+ verdictKind: "request_changes",
169
+ summary: "",
170
+ artifactContent: PLAN,
171
+ annotations: [
172
+ note,
173
+ makeAnnotation({
174
+ anchor: { quote: "one JSON document", prefix: "written as ", suffix: " per session." },
175
+ }),
176
+ ],
97
177
  });
98
- expect(out).toContain("_No edits or annotations._");
178
+ expect(mixed).toContain("## Annotations (1)");
179
+ expect(mixed).not.toContain("The agent's own explanation of the change.");
99
180
  });
100
181
 
101
182
  test("anchors resolve against the working copy when present", () => {
183
+ // Arrange
102
184
  // annotation made on text that only exists in the edited copy
103
185
  const working = PLAN.replace("## Storage", "## Persistence");
104
- const out = renderFeedback({
186
+
187
+ // Act
188
+ const feedback = renderFeedback({
105
189
  verdictKind: "request_changes",
106
190
  summary: "",
107
191
  artifactContent: PLAN,
108
192
  workingCopy: working,
109
193
  annotations: [
110
- ann({ anchor: { quote: "one JSON document", prefix: "written as ", suffix: " per session." } }),
194
+ makeAnnotation({
195
+ anchor: { quote: "one JSON document", prefix: "written as ", suffix: " per session." },
196
+ }),
197
+ ],
198
+ });
199
+
200
+ // Assert
201
+ expect(feedback).toContain("(§ Persistence)");
202
+ });
203
+
204
+ test("a prototype annotation locates by its selector and is never orphan-flagged", () => {
205
+ // Act
206
+ const feedback = renderFeedback({
207
+ verdictKind: "request_changes",
208
+ summary: "",
209
+ artifactType: "prototype",
210
+ artifactContent: "<main><div class='card'>Pricing</div></main>",
211
+ annotations: [
212
+ makeAnnotation({
213
+ body: "Tighten the padding.",
214
+ anchor: { quote: "Pricing", prefix: "", suffix: "", selector: "main > div.card" },
215
+ }),
111
216
  ],
217
+ artifactPath: "proto.html",
112
218
  });
113
- expect(out).toContain("(§ Persistence)");
219
+
220
+ // Assert
221
+ expect(feedback).toContain("(main > div.card)");
222
+ expect(feedback).toContain("Tighten the padding.");
223
+ expect(feedback).toContain("by its CSS selector");
224
+ expect(feedback).not.toContain("orphaned anchor");
114
225
  });
115
226
  });