@cueloop/schema 0.1.0-alpha.7 → 0.1.0-alpha.70

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.7",
3
+ "version": "0.1.0-alpha.70",
4
+ "description": "The Thread 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
+ }
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { parseBlocks } from "./markdown";
3
- import { makeAnchor, resolveAnchor } from "./anchor";
3
+ import { BLOCK_SEPARATOR, makeAnchor, resolveAnchor } from "./anchor";
4
4
 
5
5
  const DOC = `# Plan
6
6
 
@@ -15,80 +15,308 @@ 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
+
67
+ expect(resolved.blockIndex).toBe(editedBlockIndex);
68
+ expect(edited[resolved.blockIndex]!.text.slice(resolved.start, resolved.end)).toBe(
69
+ "Review sessions",
70
+ );
51
71
  });
52
72
 
53
73
  test("prefix/suffix selectors disambiguate repeated quotes", () => {
74
+ // Arrange
54
75
  const blocks = blocksOf(DOC);
55
- const storIdx = blocks.findIndex((b) => b.text.includes("atomically"));
76
+ const storageBlockIndex = blocks.findIndex((block) => block.text.includes("atomically"));
56
77
  // anchor on the SECOND "Sessions are written" occurrence
57
- const text = blocks[storIdx]!.text;
78
+ const text = blocks[storageBlockIndex]!.text;
58
79
  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);
80
+ const anchor = makeAnchor(
81
+ blocks,
82
+ storageBlockIndex,
83
+ second,
84
+ second + "Sessions are written".length,
85
+ );
86
+
87
+ // Act
88
+ const resolved = resolveAnchor(anchor, blocks)!;
89
+
90
+ // Assert
91
+ expect(resolved.start).toBe(second);
62
92
  });
63
93
 
64
94
  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);
95
+ // Arrange
96
+ const repeatedWords = "word word word";
97
+ const blocks = blocksOf(repeatedWords);
98
+ const anchor = makeAnchor(blocks, 0, 5, 9); // middle "word"
99
+
100
+ // Act
101
+ const resolved = resolveAnchor(anchor, blocks)!;
102
+
103
+ // Assert
104
+ expect(resolved.start).toBe(5);
105
+ });
106
+
107
+ test("binds a quote that still carries its leading list marker", () => {
108
+ // Arrange
109
+ // an agent copied the source line verbatim, including the "- " bullet, but
110
+ // the parsed list block strips the marker from its text
111
+ const blocks = blocksOf("## Notes\n\n- This is a dummy plan - confirm the item");
112
+ const anchor = { quote: "- This is a dummy plan", prefix: "", suffix: "" };
113
+
114
+ // Act
115
+ const resolved = resolveAnchor(anchor, blocks)!;
116
+
117
+ // Assert
118
+ expect(resolved).not.toBeNull();
119
+ expect(resolved.strategy).toBe("normalized");
120
+ expect(blocks[resolved.blockIndex]!.text.slice(resolved.start, resolved.end)).toBe(
121
+ "This is a dummy plan",
122
+ );
123
+ });
124
+
125
+ test("binds a heading quote that still carries its leading hashes", () => {
126
+ // Arrange
127
+ const blocks = blocksOf(DOC);
128
+ const anchor = { quote: "## Storage", prefix: "", suffix: "" };
129
+
130
+ // Act
131
+ const resolved = resolveAnchor(anchor, blocks)!;
132
+
133
+ // Assert
134
+ expect(resolved.strategy).toBe("normalized");
135
+ expect(blocks[resolved.blockIndex]!.text).toBe("Storage");
136
+ });
137
+
138
+ test("fuzzy tier binds a lightly edited quote", () => {
139
+ // Arrange
140
+ const blocks = blocksOf(DOC);
141
+ const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
142
+ // a typo the reviewer never saw: "sesssions"
143
+ const anchor = {
144
+ quote: "Review sesssions currently live",
145
+ prefix: "",
146
+ suffix: "",
147
+ };
148
+
149
+ // Act
150
+ const resolved = resolveAnchor(anchor, blocks)!;
151
+
152
+ // Assert
153
+ expect(resolved.strategy).toBe("fuzzy");
154
+ expect(resolved.blockIndex).toBe(contextBlockIndex);
155
+ expect(resolved.approximate).toBe(true);
156
+ });
157
+
158
+ test("exact matches report the exact strategy", () => {
159
+ // Arrange
160
+ const blocks = blocksOf(DOC);
161
+ const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
162
+ const anchor = makeAnchor(blocks, contextBlockIndex, 0, 15);
163
+
164
+ // Act
165
+ const resolved = resolveAnchor(anchor, blocks)!;
166
+
167
+ // Assert
168
+ expect(resolved.strategy).toBe("exact");
169
+ expect(resolved.approximate).toBe(false);
70
170
  });
71
171
 
72
172
  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 };
173
+ // Arrange
174
+ const anchor = {
175
+ quote: " text ",
176
+ prefix: "some",
177
+ suffix: "here",
178
+ blockIndex: 0,
179
+ start: 4,
180
+ end: 10,
181
+ };
75
182
  const edited = blocksOf("some\ntext\nhere");
76
183
  // " text " (with spaces) is gone, "text" survives
77
- const r = resolveAnchor(a, edited);
78
- expect(r).not.toBeNull();
79
- expect(r!.approximate).toBe(true);
184
+
185
+ // Act
186
+ const resolved = resolveAnchor(anchor, edited);
187
+
188
+ // Assert
189
+ expect(resolved).not.toBeNull();
190
+ expect(resolved!.approximate).toBe(true);
80
191
  });
81
192
 
82
193
  test("orphans when the quote is gone entirely", () => {
194
+ // Arrange
83
195
  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();
196
+ const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
197
+ const anchor = makeAnchor(blocks, contextBlockIndex, 0, 15);
198
+ const edited = blocksOf(
199
+ DOC.replace(/Review sessions[^\n]*\n[^\n]*/, "Completely different text."),
200
+ );
201
+
202
+ // Assert
203
+ expect(resolveAnchor(anchor, edited)).toBeNull();
88
204
  });
89
205
 
90
206
  test("empty quote never binds", () => {
207
+ // Arrange
91
208
  const blocks = blocksOf(DOC);
209
+
210
+ // Assert
92
211
  expect(resolveAnchor({ quote: "", prefix: "", suffix: "" }, blocks)).toBeNull();
93
212
  });
94
213
  });
214
+
215
+ describe("anchors spanning blocks", () => {
216
+ const SPANNING_DOC = `## Phase 1
217
+
218
+ All persistence lives in a new module,
219
+ so a crash can never leave a torn file on disk.
220
+
221
+ - server/storage/store.ts - the ThreadStore class
222
+ - server/storage/schema.ts - the on-disk record shape
223
+
224
+ ## Phase 2
225
+ `;
226
+ const blocks = parseBlocks(SPANNING_DOC);
227
+ const paragraph = blocks.findIndex((block) => block.text.startsWith("All persistence"));
228
+ const secondBullet = blocks.findIndex((block) => block.text.startsWith("server/storage/schema"));
229
+ const start = blocks[paragraph]!.text.indexOf("so a crash");
230
+ const end = "server/storage/schema.ts".length;
231
+ const anchor = makeAnchor(blocks, paragraph, start, end, secondBullet);
232
+
233
+ test("the quote is the spanned text joined by a blank line, with an end block hint", () => {
234
+ // Assert
235
+ expect(anchor.quote).toBe(
236
+ [
237
+ "so a crash can never leave a torn file on disk.",
238
+ "server/storage/store.ts - the ThreadStore class",
239
+ "server/storage/schema.ts",
240
+ ].join(BLOCK_SEPARATOR),
241
+ );
242
+ expect(anchor.blockIndex).toBe(paragraph);
243
+ expect(anchor.endBlockIndex).toBe(secondBullet);
244
+ expect(anchor.suffix).toBe(" - the on-disk record sh");
245
+ });
246
+
247
+ test("a single-block anchor carries no end block hint", () => {
248
+ // Assert
249
+ expect(makeAnchor(blocks, paragraph, 0, 3)).not.toHaveProperty("endBlockIndex");
250
+ });
251
+
252
+ test("resolves exactly back onto the first and last block offsets", () => {
253
+ // Act
254
+ const resolved = resolveAnchor(anchor, blocks);
255
+
256
+ // Assert
257
+ expect(resolved).toEqual({
258
+ blockIndex: paragraph,
259
+ start,
260
+ endBlockIndex: secondBullet,
261
+ end,
262
+ approximate: false,
263
+ strategy: "exact",
264
+ });
265
+ });
266
+
267
+ test("follows the span when blocks are inserted above it", () => {
268
+ // Arrange: a new paragraph shifts every block index by one
269
+ const shifted = parseBlocks(
270
+ SPANNING_DOC.replace("## Phase 1\n", "## Phase 1\n\nA new intro.\n"),
271
+ );
272
+
273
+ // Act
274
+ const resolved = resolveAnchor(anchor, shifted);
275
+
276
+ // Assert
277
+ expect(resolved?.blockIndex).toBe(paragraph + 1);
278
+ expect(resolved?.endBlockIndex).toBe(secondBullet + 1);
279
+ expect(resolved?.start).toBe(start);
280
+ expect(resolved?.end).toBe(end);
281
+ });
282
+
283
+ test("re-binds fuzzily after a light edit inside the span", () => {
284
+ // Arrange
285
+ const edited = parseBlocks(SPANNING_DOC.replace("ThreadStore class", "ThreadStore type"));
286
+
287
+ // Act
288
+ const resolved = resolveAnchor(anchor, edited);
289
+
290
+ // Assert: still one span from the paragraph to the second bullet
291
+ expect(resolved?.strategy).toBe("fuzzy");
292
+ expect(resolved?.approximate).toBe(true);
293
+ expect(resolved?.blockIndex).toBe(paragraph);
294
+ expect(resolved?.endBlockIndex).toBe(secondBullet);
295
+ });
296
+
297
+ test("orphans when the spanned passage is gone", () => {
298
+ // Arrange
299
+ const gutted = parseBlocks("## Phase 1\n\nSomething else entirely.\n");
300
+
301
+ // Assert
302
+ expect(resolveAnchor(anchor, gutted)).toBeNull();
303
+ });
304
+
305
+ test("a match landing on a separator edge is pulled onto the neighbouring block", () => {
306
+ // Arrange: a quote whose exact text starts with the separator only exists
307
+ // when its first block portion is empty, which makeAnchor never produces;
308
+ // simulate the stale case directly
309
+ const staleAnchor = {
310
+ ...anchor,
311
+ quote: `${BLOCK_SEPARATOR}server/storage/store.ts - the ThreadStore class`,
312
+ };
313
+
314
+ // Act
315
+ const resolved = resolveAnchor(staleAnchor, blocks);
316
+
317
+ // Assert: the span opens at the bullet, not on the blank line above it
318
+ expect(resolved?.blockIndex).toBe(secondBullet - 1);
319
+ expect(resolved?.start).toBe(0);
320
+ expect(resolved?.endBlockIndex).toBe(secondBullet - 1);
321
+ });
322
+ });