@company-semantics/contracts 51.1.0 → 51.2.0

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.
@@ -0,0 +1,391 @@
1
+ /**
2
+ * The published resolver, held to the corpus it must agree with.
3
+ *
4
+ * WHAT THIS SUITE EXISTS TO KILL. The plausible-looking wrong resolver returns
5
+ * the FIRST match. It passes every "the quote is still there" test, it never
6
+ * throws, and it is wrong precisely when the document is repetitive — which is
7
+ * when comments matter most. Its symptom is not a broken highlight but a
8
+ * confident one, on a paragraph the comment's author never saw. Every
9
+ * `orphaned` expectation below is a case where that resolver returns a
10
+ * well-formed range, so a suite that only asserted the happy path would sit
11
+ * green over it.
12
+ *
13
+ * The second wrong resolver collapses `document` into `orphaned` — a thread on
14
+ * the subject AS A WHOLE draws no highlight either way, so the bug is invisible
15
+ * until the rail files it behind "this text is gone".
16
+ *
17
+ * THE TEXT-INSERTION BLOCK IS DRIVEN BY THE PINNED CORPUS, not by hand-copied
18
+ * cases. `./fixtures/comment-anchors.json` carries a `document` and an
19
+ * `expectedResolution` on each text-insertion accept row: ground truth for
20
+ * exactly this module, recorded contracts-first in PRD-00933. `anchor-corpus`
21
+ * re-derives those outcomes with its own seam scan; this suite drives the
22
+ * SHIPPED resolver over the same rows, which is what turns that hand-rolled
23
+ * cross-check into a check of the implementation rather than of itself. The
24
+ * corpus is read, never edited — its digest is pinned beside it.
25
+ */
26
+ import { readFileSync } from "node:fs";
27
+ import { dirname, join } from "node:path";
28
+ import { fileURLToPath } from "node:url";
29
+ import { describe, expect, it } from "vitest";
30
+
31
+ import { ANCHOR_CONTEXT_CHARS } from "../anchor-constants.js";
32
+ import { CommentAnchorSchema } from "../anchor.js";
33
+ import {
34
+ contextAfter,
35
+ contextBefore,
36
+ contextsMatchAt,
37
+ findSoleOccurrence,
38
+ resolveAnchorFromText,
39
+ } from "../resolve.js";
40
+
41
+ /** One named case from the corpus. */
42
+ interface AnchorCase {
43
+ name: string;
44
+ anchor: unknown;
45
+ }
46
+
47
+ /** The additive fields the text-insertion rows carry beside `anchor`. */
48
+ interface InsertionFixture extends AnchorCase {
49
+ anchor: { type: "text-insertion" };
50
+ document: string;
51
+ expectedResolution:
52
+ { outcome: "resolved"; offset: number } | { outcome: "orphaned" };
53
+ }
54
+
55
+ const isInsertionFixture = (c: AnchorCase): c is InsertionFixture => {
56
+ const anchor = c.anchor as { type?: unknown } | null;
57
+ return anchor !== null && anchor?.type === "text-insertion";
58
+ };
59
+
60
+ /**
61
+ * Read from disk rather than imported: `src/tsconfig.json` sets no
62
+ * `resolveJsonModule`, so a JSON import would fail the build the moment
63
+ * anything the typechecker reaches loaded it.
64
+ */
65
+ const CORPUS_PATH = join(
66
+ dirname(fileURLToPath(import.meta.url)),
67
+ "fixtures",
68
+ "comment-anchors.json",
69
+ );
70
+ const corpus = JSON.parse(readFileSync(CORPUS_PATH, "utf8")) as {
71
+ accept: AnchorCase[];
72
+ reject: AnchorCase[];
73
+ };
74
+
75
+ /** Parse through the published schema, so no fixture reaches the resolver
76
+ * having skipped the boundary a real anchor cannot skip. */
77
+ const parsed = (anchor: unknown) => CommentAnchorSchema.parse(anchor);
78
+
79
+ const textAnchor = (fields: {
80
+ quote: string;
81
+ prefix: string;
82
+ suffix: string;
83
+ }) => parsed({ type: "text", v: 1, ...fields });
84
+
85
+ const insertionAnchor = (fields: {
86
+ leftContext: string;
87
+ rightContext: string;
88
+ }) => parsed({ type: "text-insertion", v: 1, ...fields });
89
+
90
+ describe("resolveAnchorFromText against the pinned insertion corpus", () => {
91
+ const insertionCases = corpus.accept.filter(isInsertionFixture);
92
+
93
+ it("has the four recorded insertion fixtures, so the loop cannot pass vacuously", () => {
94
+ // An empty array iterates cleanly and reports as a passing suite. Position
95
+ // 0, document end, ambiguous repeated seam, surrogate-pair flanked.
96
+ expect(insertionCases.length).toBeGreaterThanOrEqual(4);
97
+ });
98
+
99
+ for (const { name, anchor, document, expectedResolution } of insertionCases) {
100
+ it(`resolves ${name} exactly as the corpus records`, () => {
101
+ const resolution = resolveAnchorFromText(parsed(anchor), document);
102
+
103
+ if (expectedResolution.outcome === "resolved") {
104
+ // Whole-object, including the rung: a rung-2 answer reported as rung 1
105
+ // is the bug, and an insertion resolution is always zero-width.
106
+ expect(resolution).toEqual({
107
+ status: "resolved",
108
+ start: expectedResolution.offset,
109
+ end: expectedResolution.offset,
110
+ rung: 2,
111
+ });
112
+ } else {
113
+ expect(resolution).toEqual({ status: "orphaned" });
114
+ }
115
+ });
116
+ }
117
+
118
+ it("resolves the surrogate-flanked seam between the pairs, not inside one", () => {
119
+ // The corpus row carries a `relPos` encoded against another document. This
120
+ // module has no Y.Text in reach and correctly ignores it, so the seam has
121
+ // to carry the anchor on its own — and 11 is a UTF-16 code-unit offset
122
+ // BETWEEN two astral pairs, where a code-point walk would land on 9.
123
+ const document = "Q3 goals 🎯🚀 shipped early";
124
+ const anchor = parsed({
125
+ type: "text-insertion",
126
+ v: 1,
127
+ relPos: "AQLmzsvNAgA=",
128
+ leftContext: "goals 🎯",
129
+ rightContext: "🚀 shipped",
130
+ });
131
+
132
+ expect(resolveAnchorFromText(anchor, document)).toEqual({
133
+ status: "resolved",
134
+ start: 11,
135
+ end: 11,
136
+ rung: 2,
137
+ });
138
+ expect(document.slice(0, 11)).toBe("Q3 goals 🎯");
139
+ });
140
+ });
141
+
142
+ describe("resolveAnchorFromText — ambiguity resolves orphaned, never the first match", () => {
143
+ it("orphans an anchor whose quote occurs ambiguously", () => {
144
+ // No context to disambiguate with, and the quote repeats. A first-match
145
+ // implementation returns index 3 here and is silently wrong half the time.
146
+ const anchor = textAnchor({ quote: "the plan", prefix: "", suffix: "" });
147
+ const text = "Is the plan agreed? We ship the plan on Friday.";
148
+
149
+ expect(resolveAnchorFromText(anchor, text)).toEqual({ status: "orphaned" });
150
+ });
151
+
152
+ it("orphans when even the context-qualified match is ambiguous", () => {
153
+ const anchor = textAnchor({
154
+ quote: "beta",
155
+ prefix: "alpha ",
156
+ suffix: " gamma",
157
+ });
158
+ const text = "alpha beta gamma / alpha beta gamma";
159
+
160
+ // Two context matches mean the CONTEXT failed to identify a candidate.
161
+ // Binding to the first occurrence — or quietly retrying the bare quote,
162
+ // which is equally ambiguous — is the shortcut this ladder forbids.
163
+ expect(resolveAnchorFromText(anchor, text)).toEqual({ status: "orphaned" });
164
+ });
165
+
166
+ it("resolves via context when the bare quote alone would be ambiguous", () => {
167
+ const anchor = textAnchor({ quote: "beta", prefix: "alpha ", suffix: "" });
168
+ const text = "zeta beta gamma / alpha beta gamma";
169
+
170
+ // The SECOND "beta" — the one the context identifies. This is the positive
171
+ // twin of the rule above: context DISAMBIGUATES, it does not merely veto.
172
+ expect(resolveAnchorFromText(anchor, text)).toEqual({
173
+ status: "resolved",
174
+ start: text.lastIndexOf("beta"),
175
+ end: text.lastIndexOf("beta") + "beta".length,
176
+ rung: 2,
177
+ });
178
+ });
179
+
180
+ it("falls back to the bare quote when the surrounding sentence was rewritten", () => {
181
+ const anchor = textAnchor({
182
+ quote: "materially understated",
183
+ prefix: "Q3 revenue was ",
184
+ suffix: " in the board pack.",
185
+ });
186
+ const text = "The figure was materially understated, we now believe.";
187
+
188
+ // Context ABSENT is not context AMBIGUOUS: the sentence moved, the quote
189
+ // survived, and a unique bare quote is still evidence enough.
190
+ expect(resolveAnchorFromText(anchor, text)).toEqual({
191
+ status: "resolved",
192
+ start: text.indexOf("materially understated"),
193
+ end:
194
+ text.indexOf("materially understated") +
195
+ "materially understated".length,
196
+ rung: 2,
197
+ });
198
+ });
199
+
200
+ it("counts overlapping occurrences as ambiguous", () => {
201
+ // "aa" occurs at 0 AND at 1. Resuming the second search at
202
+ // `first + needle.length` would miss the overlap and call this unique.
203
+ const anchor = textAnchor({ quote: "aa", prefix: "", suffix: "" });
204
+
205
+ expect(resolveAnchorFromText(anchor, "aaa")).toEqual({
206
+ status: "orphaned",
207
+ });
208
+ });
209
+
210
+ it("orphans when the quote is gone entirely", () => {
211
+ const anchor = textAnchor({
212
+ quote: "the original wording",
213
+ prefix: "before ",
214
+ suffix: " after",
215
+ });
216
+
217
+ expect(
218
+ resolveAnchorFromText(anchor, "Nothing of the sort remains."),
219
+ ).toEqual({ status: "orphaned" });
220
+ });
221
+ });
222
+
223
+ describe("resolveAnchorFromText — the insertion seam refuses ambiguity", () => {
224
+ it("orphans a repeated seam rather than taking the first row", () => {
225
+ const document = "item done, item done, item done";
226
+ const anchor = insertionAnchor({
227
+ leftContext: "item ",
228
+ rightContext: "done",
229
+ });
230
+
231
+ // A first-match resolver returns a perfectly well-formed caret at 5 here,
232
+ // two rows away from where the suggestion was written.
233
+ expect(document.indexOf("item done")).toBe(0);
234
+ expect(resolveAnchorFromText(anchor, document)).toEqual({
235
+ status: "orphaned",
236
+ });
237
+ });
238
+
239
+ it("orphans when the seam is gone entirely", () => {
240
+ const anchor = insertionAnchor({
241
+ leftContext: "approved ",
242
+ rightContext: "the plan.",
243
+ });
244
+
245
+ expect(
246
+ resolveAnchorFromText(anchor, "Nothing here resembles that sentence."),
247
+ ).toEqual({ status: "orphaned" });
248
+ });
249
+
250
+ it("resolves the empty seam on an empty document and orphans it once there is text", () => {
251
+ const anchor = insertionAnchor({ leftContext: "", rightContext: "" });
252
+
253
+ // On the empty document the anchor is complete and 0 is the only answer —
254
+ // the one case an empty seam is authorable at all.
255
+ expect(resolveAnchorFromText(anchor, "")).toEqual({
256
+ status: "resolved",
257
+ start: 0,
258
+ end: 0,
259
+ rung: 2,
260
+ });
261
+ // Once there is text it says nothing about WHERE among it the caret goes.
262
+ expect(
263
+ resolveAnchorFromText(anchor, "The board approved the plan."),
264
+ ).toEqual({ status: "orphaned" });
265
+ });
266
+
267
+ it("always resolves zero-width", () => {
268
+ const document = "We agreed to ship the quarterly goals.";
269
+ const anchor = insertionAnchor({
270
+ leftContext: "ship ",
271
+ rightContext: "the quarterly",
272
+ });
273
+
274
+ const resolution = resolveAnchorFromText(anchor, document);
275
+ expect(resolution.status).toBe("resolved");
276
+ if (resolution.status !== "resolved") return;
277
+ // A zero-width point that resolved to a RANGE would paint a highlight over
278
+ // text the suggestion never claimed.
279
+ expect(resolution.start).toBe(resolution.end);
280
+ });
281
+ });
282
+
283
+ describe("resolveAnchorFromText — the document anchor", () => {
284
+ it("reports a document anchor as document, never orphaned", () => {
285
+ const anchor = parsed({ type: "document", v: 1 });
286
+
287
+ // A thread on the subject AS A WHOLE draws no highlight, but it is
288
+ // correctly anchored — calling it orphaned would put it behind the rail's
289
+ // "this text is gone" affordance.
290
+ expect(resolveAnchorFromText(anchor, "any text at all")).toEqual({
291
+ status: "document",
292
+ });
293
+ expect(resolveAnchorFromText(anchor, "")).toEqual({ status: "document" });
294
+ });
295
+ });
296
+
297
+ describe("findSoleOccurrence", () => {
298
+ it("reports a lone occurrence with its index", () => {
299
+ expect(findSoleOccurrence("the plan is agreed", "plan")).toEqual({
300
+ kind: "unique",
301
+ index: 4,
302
+ });
303
+ });
304
+
305
+ it("reports an absent needle as absent, distinctly from ambiguous", () => {
306
+ // The resolver treats these two differently at the context level: absent
307
+ // falls through to the bare quote, ambiguous orphans immediately.
308
+ expect(findSoleOccurrence("the plan", "budget")).toEqual({
309
+ kind: "absent",
310
+ });
311
+ });
312
+
313
+ it("treats an empty needle as ambiguous, never as a match at 0", () => {
314
+ // `indexOf("")` is 0 on every string, so an implementation without this
315
+ // guard reports a confident unique match at the start of the document.
316
+ expect(findSoleOccurrence("the plan", "")).toEqual({ kind: "ambiguous" });
317
+ expect(findSoleOccurrence("", "")).toEqual({ kind: "ambiguous" });
318
+ });
319
+ });
320
+
321
+ describe("contextsMatchAt", () => {
322
+ const text = "The board approved the plan.";
323
+
324
+ it("confirms the contexts that actually surround the point", () => {
325
+ expect(contextsMatchAt(text, 19, "approved ", "the plan.")).toBe(true);
326
+ });
327
+
328
+ it("refuses a context that cannot FIT before the point", () => {
329
+ // THE LOAD-BEARING GUARD. `slice` reads a negative start from the END of
330
+ // the string, so without it "approved " would be compared against a
331
+ // substring taken from the far end and could answer true.
332
+ expect(contextsMatchAt(text, 2, "approved ", "e board")).toBe(false);
333
+ });
334
+
335
+ it("refuses a context that cannot FIT after the point", () => {
336
+ // The mirror case: `slice` CLAMPS a long end rather than failing.
337
+ expect(contextsMatchAt(text, text.length - 2, "n.", "the plan.")).toBe(
338
+ false,
339
+ );
340
+ });
341
+
342
+ it("refuses an out-of-bounds point outright", () => {
343
+ expect(contextsMatchAt(text, -1, "", "")).toBe(false);
344
+ expect(contextsMatchAt(text, text.length + 1, "", "")).toBe(false);
345
+ });
346
+
347
+ it("answers true at every in-bounds point for two empty contexts", () => {
348
+ // Not a bug — a fact about the question. It is why a consumer's
349
+ // relative-position rung must refuse an anchor with no contexts BEFORE it
350
+ // decodes anything, rather than treating this check as evidence.
351
+ expect(contextsMatchAt(text, 0, "", "")).toBe(true);
352
+ expect(contextsMatchAt(text, 7, "", "")).toBe(true);
353
+ });
354
+ });
355
+
356
+ describe("contextBefore / contextAfter", () => {
357
+ it("captures at most ANCHOR_CONTEXT_CHARS, the capture width", () => {
358
+ const text = "x".repeat(200);
359
+
360
+ // The CAPTURE width, deliberately narrower than what the schema ACCEPTS —
361
+ // capturing to the accept bound would leave a constructor no headroom.
362
+ expect(contextBefore(text, 150)).toHaveLength(ANCHOR_CONTEXT_CHARS);
363
+ expect(contextAfter(text, 50)).toHaveLength(ANCHOR_CONTEXT_CHARS);
364
+ });
365
+
366
+ it("clamps at the document edges instead of running off them", () => {
367
+ expect(contextBefore("short", 3)).toBe("sho");
368
+ expect(contextAfter("short", 3)).toBe("rt");
369
+ expect(contextBefore("short", 0)).toBe("");
370
+ expect(contextAfter("short", 5)).toBe("");
371
+ });
372
+
373
+ it("never opens on half a character", () => {
374
+ // The fixed-width slice would start exactly between the pair's two code
375
+ // units; the lone low surrogate that results is ill-formed on the wire.
376
+ const text = "🎯" + "a".repeat(ANCHOR_CONTEXT_CHARS - 1);
377
+ const before = contextBefore(text, text.length);
378
+
379
+ expect(before).toBe("a".repeat(ANCHOR_CONTEXT_CHARS - 1));
380
+ expect(before.charCodeAt(0)).toBeLessThan(0xd800);
381
+ });
382
+
383
+ it("never closes on half a character", () => {
384
+ const text = "a".repeat(ANCHOR_CONTEXT_CHARS - 1) + "🎯tail";
385
+ const after = contextAfter(text, 0);
386
+
387
+ // Trimming costs one code unit of context and changes nothing about
388
+ // matching — the document holds the same code units either way.
389
+ expect(after).toBe("a".repeat(ANCHOR_CONTEXT_CHARS - 1));
390
+ });
391
+ });
@@ -0,0 +1,262 @@
1
+ /**
2
+ * The two total functions the acceptance path runs before it writes.
3
+ *
4
+ * WHAT THIS SUITE EXISTS TO KILL. The plausible-looking wrong `plannedEdit`
5
+ * returns a well-formed edit for the no-op cases — `{ at, deleteLength: 0,
6
+ * insert: "" }` is a perfectly valid mutation, it applies without error, and it
7
+ * changes not one character. Its symptom is not a crash but a thread reported
8
+ * as `accepted` over a document that never received the proposal: the receipt
9
+ * says the edit landed, the body says it did not, and nothing anywhere throws.
10
+ * Every `toBeNull()` below is a case where that implementation returns an
11
+ * object, so a suite asserting only the three happy ops would sit green over it.
12
+ *
13
+ * The second wrong implementation is an `anchorStillReads` that answers `true`
14
+ * for a `document` anchor — "there is nothing to check, so nothing is wrong".
15
+ * That turns the whole-document thread, the one variant that names no
16
+ * characters at all, into a licence to rewrite whichever range the caller
17
+ * happened to pass.
18
+ *
19
+ * The third is an `anchorStillReads` that trusts the resolution instead of
20
+ * re-reading the text. It is invisible while the ladder is right and catastrophic
21
+ * exactly when it is not — which is the case this second assertion exists for.
22
+ */
23
+ import { describe, expect, it } from "vitest";
24
+
25
+ import { CommentAnchorSchema } from "../anchor.js";
26
+ import { resolveAnchorFromText } from "../resolve.js";
27
+ import { SuggestionSchema } from "../suggestion.js";
28
+ import { anchorStillReads, plannedEdit } from "../verify.js";
29
+
30
+ /** Parse through the published schemas, so no fixture reaches these functions
31
+ * having skipped a boundary a real payload cannot skip. */
32
+ const parsedAnchor = (anchor: unknown) => CommentAnchorSchema.parse(anchor);
33
+
34
+ const textAnchor = (fields: {
35
+ quote: string;
36
+ prefix: string;
37
+ suffix: string;
38
+ }) => parsedAnchor({ type: "text", v: 1, ...fields });
39
+
40
+ const insertionAnchor = (fields: {
41
+ leftContext: string;
42
+ rightContext: string;
43
+ }) => parsedAnchor({ type: "text-insertion", v: 1, ...fields });
44
+
45
+ const suggestion = (fields: { op: string; insertedText?: string }) =>
46
+ SuggestionSchema.parse({ v: 1, status: "open", ...fields });
47
+
48
+ /** A resolution over `quote` in `text`, obtained from the SHIPPED resolver so
49
+ * the geometry under test is the geometry the ladder actually produces. */
50
+ const resolveText = (text: string, quote: string) => {
51
+ const resolution = resolveAnchorFromText(
52
+ textAnchor({ quote, prefix: "", suffix: "" }),
53
+ text,
54
+ );
55
+ if (resolution.status !== "resolved") throw new Error("fixture unresolved");
56
+ return resolution;
57
+ };
58
+
59
+ describe("anchorStillReads — the second assertion, not the ladder's word", () => {
60
+ const text = "The board approved the plan on Friday.";
61
+
62
+ it("confirms a text anchor whose slice is still exactly the quote", () => {
63
+ const anchor = textAnchor({ quote: "the plan", prefix: "", suffix: "" });
64
+
65
+ expect(anchorStillReads(anchor, text, resolveText(text, "the plan"))).toBe(
66
+ true,
67
+ );
68
+ });
69
+
70
+ it("refuses a resolution whose slice no longer equals the quote", () => {
71
+ // The one-field mutation: the same well-formed resolution, shifted by one.
72
+ // An implementation that trusted the finder answers true here and rewrites
73
+ // a range off by a character from the one the author selected.
74
+ const anchor = textAnchor({ quote: "the plan", prefix: "", suffix: "" });
75
+ const resolution = resolveText(text, "the plan");
76
+
77
+ expect(
78
+ anchorStillReads(anchor, text, {
79
+ ...resolution,
80
+ start: resolution.start + 1,
81
+ end: resolution.end + 1,
82
+ }),
83
+ ).toBe(false);
84
+ });
85
+
86
+ it("refuses a slice that merely CONTAINS the quote", () => {
87
+ const anchor = textAnchor({ quote: "the plan", prefix: "", suffix: "" });
88
+ const resolution = resolveText(text, "the plan");
89
+
90
+ expect(
91
+ anchorStillReads(anchor, text, {
92
+ ...resolution,
93
+ end: resolution.end + 3,
94
+ }),
95
+ ).toBe(false);
96
+ });
97
+
98
+ it("confirms an insertion point whose two contexts still meet there", () => {
99
+ const anchor = insertionAnchor({
100
+ leftContext: "approved ",
101
+ rightContext: "the plan",
102
+ });
103
+ const resolution = resolveAnchorFromText(anchor, text);
104
+ expect(resolution.status).toBe("resolved");
105
+ if (resolution.status !== "resolved") return;
106
+
107
+ expect(anchorStillReads(anchor, text, resolution)).toBe(true);
108
+ });
109
+
110
+ it("refuses an insertion resolution that is not zero-width", () => {
111
+ // A zero-width anchor handed a RANGE means whoever produced it lost the
112
+ // distinction between a caret and a selection — and the edit that follows
113
+ // would delete text no suggestion claimed.
114
+ const anchor = insertionAnchor({
115
+ leftContext: "approved ",
116
+ rightContext: "the plan",
117
+ });
118
+ const resolution = resolveAnchorFromText(anchor, text);
119
+ if (resolution.status !== "resolved") throw new Error("fixture unresolved");
120
+
121
+ expect(
122
+ anchorStillReads(anchor, text, {
123
+ ...resolution,
124
+ end: resolution.end + 4,
125
+ }),
126
+ ).toBe(false);
127
+ });
128
+
129
+ it("refuses an insertion point whose contexts no longer surround it", () => {
130
+ const anchor = insertionAnchor({
131
+ leftContext: "approved ",
132
+ rightContext: "the plan",
133
+ });
134
+
135
+ expect(
136
+ anchorStillReads(anchor, text, {
137
+ status: "resolved",
138
+ start: 4,
139
+ end: 4,
140
+ rung: 2,
141
+ }),
142
+ ).toBe(false);
143
+ });
144
+
145
+ it("refuses a document anchor, which vouches for no range at all", () => {
146
+ // Unreachable through the shipped ladder — it answers { status: "document" }
147
+ // for this variant — so the resolution is built by hand, exactly as a
148
+ // consumer's own rung could hand one in. The refusal is what makes that
149
+ // harmless rather than a licence to rewrite an arbitrary range.
150
+ const anchor = parsedAnchor({ type: "document", v: 1 });
151
+
152
+ expect(
153
+ anchorStillReads(anchor, text, {
154
+ status: "resolved",
155
+ start: 0,
156
+ end: text.length,
157
+ rung: 2,
158
+ }),
159
+ ).toBe(false);
160
+ expect(resolveAnchorFromText(anchor, text)).toEqual({ status: "document" });
161
+ });
162
+ });
163
+
164
+ describe("plannedEdit — the mutation each op makes of the resolved range", () => {
165
+ const text = "The board approved the plan on Friday.";
166
+ const resolution = resolveText(text, "the plan");
167
+
168
+ it("inserts at END, where the redline draws the proposed text", () => {
169
+ // At `start` the inserted text would land BEFORE the quote, on the other
170
+ // side of the seam from the one the accepting user was shown.
171
+ expect(
172
+ plannedEdit(
173
+ suggestion({ op: "insert", insertedText: " urgently" }),
174
+ resolution,
175
+ ),
176
+ ).toEqual({ at: resolution.end, deleteLength: 0, insert: " urgently" });
177
+ });
178
+
179
+ it("deletes exactly the resolved span and inserts nothing", () => {
180
+ expect(plannedEdit(suggestion({ op: "delete" }), resolution)).toEqual({
181
+ at: resolution.start,
182
+ deleteLength: resolution.end - resolution.start,
183
+ insert: "",
184
+ });
185
+ });
186
+
187
+ it("replaces the resolved span with the payload's text", () => {
188
+ expect(
189
+ plannedEdit(
190
+ suggestion({ op: "replace", insertedText: "the revised plan" }),
191
+ resolution,
192
+ ),
193
+ ).toEqual({
194
+ at: resolution.start,
195
+ deleteLength: resolution.end - resolution.start,
196
+ insert: "the revised plan",
197
+ });
198
+ });
199
+
200
+ it("derives the span from the resolution, never from the quote's length", () => {
201
+ // A zero-width insertion resolution: `end - start` is 0, and the arithmetic
202
+ // must come from the position it was actually given.
203
+ const point = { status: "resolved", end: 9, start: 9, rung: 2 } as const;
204
+
205
+ expect(
206
+ plannedEdit(suggestion({ op: "insert", insertedText: "duly " }), point),
207
+ ).toEqual({ at: 9, deleteLength: 0, insert: "duly " });
208
+ });
209
+ });
210
+
211
+ describe("plannedEdit — a no-op is a refusal, not a cheap success", () => {
212
+ const text = "The board approved the plan on Friday.";
213
+ const span = resolveText(text, "the plan");
214
+ const collapsed = {
215
+ status: "resolved",
216
+ start: 19,
217
+ end: 19,
218
+ rung: 2,
219
+ } as const;
220
+
221
+ it("refuses an insert whose payload carries no text", () => {
222
+ // The READ schema keeps `insertedText` optional so a delete payload parses
223
+ // without it, which means an insert payload missing it arrives intact and
224
+ // reaches here. It proposes nothing; accepting it would mark the thread
225
+ // applied over a body that never changed.
226
+ expect(plannedEdit(suggestion({ op: "insert" }), span)).toBeNull();
227
+ });
228
+
229
+ it("refuses an insert whose payload carries an empty string", () => {
230
+ // One field of a well-formed payload, emptied. The schema refuses this at
231
+ // the boundary (min(1)); this function refuses it again, because the
232
+ // boundary is not the only door into an applier.
233
+ const emptied = {
234
+ ...suggestion({ op: "insert", insertedText: "x" }),
235
+ insertedText: "",
236
+ };
237
+
238
+ expect(plannedEdit(emptied, span)).toBeNull();
239
+ });
240
+
241
+ it("refuses a delete over a collapsed range", () => {
242
+ // The wrong implementation returns { at: 19, deleteLength: 0, insert: "" }
243
+ // here — a valid mutation that removes nothing.
244
+ expect(plannedEdit(suggestion({ op: "delete" }), collapsed)).toBeNull();
245
+ });
246
+
247
+ it("refuses a replace over a collapsed range", () => {
248
+ expect(
249
+ plannedEdit(
250
+ suggestion({ op: "replace", insertedText: "something" }),
251
+ collapsed,
252
+ ),
253
+ ).toBeNull();
254
+ });
255
+
256
+ it("refuses a replace whose payload carries no text", () => {
257
+ // Not a delete in disguise: the payload and the anchor disagree about what
258
+ // kind of edit this is, and settling that disagreement is not this
259
+ // function's job.
260
+ expect(plannedEdit(suggestion({ op: "replace" }), span)).toBeNull();
261
+ });
262
+ });