@cueloop/schema 0.1.0-alpha.9 → 0.1.0-alpha.90
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 +17 -17
- package/src/anchor.test.ts +340 -40
- package/src/anchor.ts +378 -43
- package/src/diff.test.ts +42 -25
- package/src/diff.ts +112 -77
- package/src/feedback.test.ts +318 -41
- package/src/feedback.ts +224 -54
- package/src/fuzzy.test.ts +110 -0
- package/src/fuzzy.ts +132 -0
- package/src/herdr.test.ts +50 -0
- package/src/herdr.ts +54 -0
- package/src/history.test.ts +401 -0
- package/src/history.ts +425 -0
- package/src/index.ts +9 -0
- package/src/inline.test.ts +195 -0
- package/src/inline.ts +240 -0
- package/src/markdown.test.ts +138 -22
- package/src/markdown.ts +333 -84
- package/src/participants.test.ts +105 -0
- package/src/participants.ts +51 -0
- package/src/path-view.test.ts +206 -0
- package/src/path-view.ts +90 -0
- package/src/share-access.test.ts +20 -0
- package/src/share-access.ts +13 -0
- package/src/share-links.test.ts +63 -0
- package/src/share-links.ts +29 -0
- package/src/thread-surface.test.ts +6 -0
- package/src/thread-surface.ts +6 -0
- package/src/types.test.ts +58 -5
- package/src/types.ts +341 -24
- package/src/working-copy.test.ts +209 -24
- package/src/working-copy.ts +148 -12
package/package.json
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/schema",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.90",
|
|
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
|
-
|
|
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
|
+
}
|
package/src/anchor.test.ts
CHANGED
|
@@ -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,380 @@ Sessions are written as one JSON document per session. Sessions are written
|
|
|
15
15
|
atomically.
|
|
16
16
|
`;
|
|
17
17
|
|
|
18
|
-
function blocksOf(
|
|
19
|
-
return parseBlocks(
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
45
|
-
const
|
|
46
|
-
const edited = blocksOf(
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
76
|
+
const storageBlockIndex = blocks.findIndex((block) => block.text.includes("atomically"));
|
|
56
77
|
// anchor on the SECOND "Sessions are written" occurrence
|
|
57
|
-
const text = blocks[
|
|
78
|
+
const text = blocks[storageBlockIndex]!.text;
|
|
58
79
|
const second = text.indexOf("Sessions are written", text.indexOf("Sessions are written") + 1);
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
|
|
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
|
-
|
|
74
|
-
const
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
85
|
-
const
|
|
86
|
-
const edited = blocksOf(
|
|
87
|
-
|
|
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
|
+
});
|
|
323
|
+
|
|
324
|
+
describe("fuzzy work budget", () => {
|
|
325
|
+
function largePlan(paragraphs: number): string {
|
|
326
|
+
const lines = ["# Plan", ""];
|
|
327
|
+
|
|
328
|
+
for (let index = 0; index < paragraphs; index++) {
|
|
329
|
+
lines.push(
|
|
330
|
+
`This is paragraph number ${index} describing the migration of the session store and its atomic write path in enough prose to be a realistic block a reviewer might quote.`,
|
|
331
|
+
"",
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return lines.join("\n");
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
test("a stale anchor over a large plan stays bounded across all its blocks", () => {
|
|
339
|
+
// Arrange - a 160-block plan whose quoted paragraph was edited so the exact text is gone,
|
|
340
|
+
// forcing the fuzzy tier over every block; a per-block budget would freeze for many seconds
|
|
341
|
+
const blocks = parseBlocks(largePlan(160));
|
|
342
|
+
const quoted = Math.floor(blocks.length / 2);
|
|
343
|
+
const anchor = makeAnchor(blocks, quoted, 0, Math.min(blocks[quoted]!.text.length, 140));
|
|
344
|
+
const edited = blocks.map((block) => ({
|
|
345
|
+
...block,
|
|
346
|
+
text: block.text.replaceAll("migration", "rollout").replaceAll("session", "record"),
|
|
347
|
+
}));
|
|
348
|
+
|
|
349
|
+
// Act
|
|
350
|
+
const started = Date.now();
|
|
351
|
+
const resolved = resolveAnchor(anchor, edited);
|
|
352
|
+
const elapsed = Date.now() - started;
|
|
353
|
+
|
|
354
|
+
// Assert - it rebinds via the fuzzy tier, and one shared budget keeps it far under a freeze
|
|
355
|
+
expect(resolved?.strategy).toBe("fuzzy");
|
|
356
|
+
expect(elapsed).toBeLessThan(2000);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("still binds when a distinctive quote moved down a few blocks and was edited", () => {
|
|
360
|
+
// Arrange - a distinctive quote among unrelated blocks; the agent inserts paragraphs above it
|
|
361
|
+
// (shifting it down) and edits one word, so exact/normalized miss and the outward scan must
|
|
362
|
+
// reach the shifted block before the shared budget runs out
|
|
363
|
+
const original =
|
|
364
|
+
"The canonical migration store commits every session write atomically along one path.";
|
|
365
|
+
const plan = (target: string): string => {
|
|
366
|
+
const lines = ["# Plan", ""];
|
|
367
|
+
|
|
368
|
+
for (let index = 0; index < 40; index++) {
|
|
369
|
+
lines.push(
|
|
370
|
+
index === 20
|
|
371
|
+
? target
|
|
372
|
+
: `Filler paragraph ${index} covers an unrelated topic in its own words.`,
|
|
373
|
+
"",
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return lines.join("\n");
|
|
378
|
+
};
|
|
379
|
+
const before = parseBlocks(plan(original));
|
|
380
|
+
const quoted = before.findIndex((block) => block.text === original);
|
|
381
|
+
const anchor = makeAnchor(before, quoted, 0, original.length);
|
|
382
|
+
const edited = original.replace("atomically", "carefully");
|
|
383
|
+
const shifted = parseBlocks(
|
|
384
|
+
plan(edited).replace("# Plan\n", "# Plan\n\nInserted A.\n\nInserted B.\n\nInserted C.\n"),
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
// Act
|
|
388
|
+
const resolved = resolveAnchor(anchor, shifted);
|
|
389
|
+
|
|
390
|
+
// Assert - rebinds via fuzzy onto the shifted block, not orphaned or bound to a neighbour
|
|
391
|
+
expect(resolved?.strategy).toBe("fuzzy");
|
|
392
|
+
expect(shifted[resolved!.blockIndex]!.text).toBe(edited);
|
|
393
|
+
});
|
|
394
|
+
});
|