@company-semantics/contracts 51.0.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.
- package/package.json +3 -3
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +67 -4
- package/src/comments/README.md +175 -32
- package/src/comments/__tests__/README.md +60 -1
- package/src/comments/__tests__/anchor-constants.test.ts +161 -0
- package/src/comments/__tests__/create.test.ts +300 -0
- package/src/comments/__tests__/receipt.test.ts +143 -0
- package/src/comments/__tests__/resolve.test.ts +391 -0
- package/src/comments/__tests__/schemas.test.ts +29 -1
- package/src/comments/__tests__/verify.test.ts +262 -0
- package/src/comments/anchor-constants.ts +61 -0
- package/src/comments/anchor.ts +20 -11
- package/src/comments/create.ts +128 -0
- package/src/comments/index.ts +36 -0
- package/src/comments/receipt.ts +146 -0
- package/src/comments/resolve.ts +261 -0
- package/src/comments/schemas.ts +27 -8
- package/src/comments/verify.ts +128 -0
- package/src/execution/__tests__/registry.test.ts +89 -0
- package/src/execution/kinds.ts +21 -1
- package/src/execution/registry.ts +73 -0
- package/src/generated/openapi-routes.ts +1 -0
- package/src/index.ts +73 -0
- package/src/message-parts/__tests__/confirmation.test.ts +3 -0
- package/src/message-parts/confirmation.ts +3 -0
|
@@ -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
|
+
});
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
CommentThreadListResponseSchema,
|
|
18
18
|
CommentThreadSchema,
|
|
19
19
|
CommentThreadStatusSchema,
|
|
20
|
+
CommentThreadSummarySchema,
|
|
20
21
|
MentionableResponseSchema,
|
|
21
22
|
} from "../schemas.js";
|
|
22
23
|
|
|
@@ -68,6 +69,7 @@ function makeThread(over: Record<string, unknown> = {}) {
|
|
|
68
69
|
createdByUserId: USER_ID,
|
|
69
70
|
resolvedByUserId: null,
|
|
70
71
|
resolvedAt: null,
|
|
72
|
+
lastAnchoredOffset: null,
|
|
71
73
|
createdAt: "2026-08-01T12:00:00.000Z",
|
|
72
74
|
updatedAt: "2026-08-01T12:00:00.000Z",
|
|
73
75
|
comments: [makeComment()],
|
|
@@ -224,7 +226,13 @@ describe("CommentThreadSchema", () => {
|
|
|
224
226
|
).toBe(false);
|
|
225
227
|
});
|
|
226
228
|
|
|
227
|
-
it("
|
|
229
|
+
it("parses a redacted comment inside a thread — the schema still admits one", () => {
|
|
230
|
+
// This test once asserted that a reader SEES tombstones in a conversation.
|
|
231
|
+
// ADR-BE-545 ended that: the list read omits deleted comments outright. What
|
|
232
|
+
// survives is the SHAPE — the redacted projection is still a legal
|
|
233
|
+
// `CommentProjection`, because it is what the DELETE acknowledgement returns
|
|
234
|
+
// to the caller who performed the delete. So this pins the parse, not a read
|
|
235
|
+
// behaviour the server no longer has.
|
|
228
236
|
const thread = CommentThreadSchema.parse(
|
|
229
237
|
makeThread({
|
|
230
238
|
comments: [
|
|
@@ -243,6 +251,26 @@ describe("CommentThreadSchema", () => {
|
|
|
243
251
|
});
|
|
244
252
|
|
|
245
253
|
describe("CommentThreadSummarySchema kind and suggestion", () => {
|
|
254
|
+
it("carries a lastAnchoredOffset, and null is the ordinary answer", () => {
|
|
255
|
+
// An ORDERING HINT and nothing else (ADR-BE-548): where an orphaned thread's
|
|
256
|
+
// passage used to be, so a client can keep the card in the reader's list
|
|
257
|
+
// rather than piling every orphan at the top. It must never yield a range.
|
|
258
|
+
expect(
|
|
259
|
+
CommentThreadSummarySchema.parse(makeThread()).lastAnchoredOffset,
|
|
260
|
+
"a thread nobody has watched lose its passage carries null",
|
|
261
|
+
).toBeNull();
|
|
262
|
+
expect(
|
|
263
|
+
CommentThreadSummarySchema.parse(makeThread({ lastAnchoredOffset: 412 }))
|
|
264
|
+
.lastAnchoredOffset,
|
|
265
|
+
).toBe(412);
|
|
266
|
+
expect(
|
|
267
|
+
CommentThreadSummarySchema.safeParse(
|
|
268
|
+
makeThread({ lastAnchoredOffset: 3.5 }),
|
|
269
|
+
).success,
|
|
270
|
+
"a fractional offset is not a source position",
|
|
271
|
+
).toBe(false);
|
|
272
|
+
});
|
|
273
|
+
|
|
246
274
|
it("a zero-comment suggestion thread parses through both response shapes", () => {
|
|
247
275
|
// The full suggestion projection: kind `suggestion`, a v1 payload, a
|
|
248
276
|
// zero-width text-insertion anchor, and NO comments. The backend creates a
|