@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,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
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The anchor size bounds, published once.
|
|
3
|
+
*
|
|
4
|
+
* CAPTURE AND ACCEPT ARE DIFFERENT NUMBERS AND MUST STAY THAT WAY. A
|
|
5
|
+
* constructor captures ANCHOR_CONTEXT_CHARS of surrounding text; the schema
|
|
6
|
+
* ACCEPTS up to ANCHOR_AFFIX_MAX_CHARS, because an anchor written by an older
|
|
7
|
+
* client (or a future one with a wider capture) must still parse. Collapsing
|
|
8
|
+
* them would silently reject valid stored anchors.
|
|
9
|
+
*
|
|
10
|
+
* These are the SAME numbers `CommentAnchorSchema` reads — that schema carries
|
|
11
|
+
* no inline size literal, so a bound can no longer be changed in one of the two
|
|
12
|
+
* places. They are published rather than kept file-local because the writing
|
|
13
|
+
* client and the resolving client are independent implementations with no
|
|
14
|
+
* server-side arbiter between them (ADR-CONTRACTS-116): a constructor that
|
|
15
|
+
* captures more than the schema accepts composes an anchor the route boundary
|
|
16
|
+
* then refuses, losing a comment after it was written.
|
|
17
|
+
*
|
|
18
|
+
* Measured in UTF-16 code units (plain JavaScript `String#length`), like every
|
|
19
|
+
* other offset on the comment path. No Unicode normalization happens anywhere
|
|
20
|
+
* here, so a two-unit emoji costs two.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* How much surrounding text a CONSTRUCTOR captures either side of a range —
|
|
25
|
+
* `prefix`/`suffix` on a text anchor, `leftContext`/`rightContext` on a
|
|
26
|
+
* text-insertion point.
|
|
27
|
+
*
|
|
28
|
+
* Deliberately SMALLER than {@link ANCHOR_AFFIX_MAX_CHARS}, and the gap is the
|
|
29
|
+
* point: this is a write-side choice that may change, while the accept bound is
|
|
30
|
+
* a promise made to every anchor already in storage.
|
|
31
|
+
*/
|
|
32
|
+
export const ANCHOR_CONTEXT_CHARS = 32;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The largest affix `CommentAnchorSchema` will ACCEPT — `prefix`, `suffix`,
|
|
36
|
+
* `leftContext` and `rightContext` alike.
|
|
37
|
+
*
|
|
38
|
+
* Not redundant with {@link ANCHOR_CONTEXT_CHARS}: narrowing this to the
|
|
39
|
+
* capture width would refuse anchors written by a client that captured more,
|
|
40
|
+
* and those anchors are already durable jsonb the server never re-validates.
|
|
41
|
+
*/
|
|
42
|
+
export const ANCHOR_AFFIX_MAX_CHARS = 64;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The largest `quote` a text anchor may carry.
|
|
46
|
+
*
|
|
47
|
+
* The quote is the durable fallback the passage is re-found by, so it is bounded
|
|
48
|
+
* generously — but bounded, because an anchor must never become a second copy of
|
|
49
|
+
* the document in a column nothing on the server reads.
|
|
50
|
+
*/
|
|
51
|
+
export const QUOTE_MAX_CHARS = 2000;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Cap on ONE encoded `Y.RelativePosition` payload, in base64 chars — `relStart`,
|
|
55
|
+
* `relEnd` and `relPos` alike.
|
|
56
|
+
*
|
|
57
|
+
* The backend never decodes these; the bound and the base64 charset are the only
|
|
58
|
+
* things anyone checks, which is why both have to be checked at the boundary or
|
|
59
|
+
* a client can write a position no other client can decode.
|
|
60
|
+
*/
|
|
61
|
+
export const RELATIVE_POSITION_MAX_CHARS = 512;
|
package/src/comments/anchor.ts
CHANGED
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
* Zod-canonical: the schema is the source of truth, the type is inferred.
|
|
20
20
|
*
|
|
21
21
|
* MIRRORED, NOT INVENTED. Every bound below is the backend route boundary's
|
|
22
|
-
* (`src/api/http/routes/comments/comments.schemas.ts`)
|
|
22
|
+
* (`src/api/http/routes/comments/comments.schemas.ts`) — they are named in
|
|
23
|
+
* `./anchor-constants` and read from there, so this file carries no size
|
|
24
|
+
* literal a constructor could drift away from. The accept/reject
|
|
23
25
|
* corpus at `./__tests__/fixtures/comment-anchors.json` is copied byte-for-byte
|
|
24
26
|
* from that suite's fixtures. Editing that copy HERE fails the anchor suite —
|
|
25
27
|
* its digest is pinned beside it. A change made at the BOUNDARY is not caught
|
|
@@ -29,6 +31,12 @@
|
|
|
29
31
|
*/
|
|
30
32
|
import { z } from "zod";
|
|
31
33
|
|
|
34
|
+
import {
|
|
35
|
+
ANCHOR_AFFIX_MAX_CHARS,
|
|
36
|
+
QUOTE_MAX_CHARS,
|
|
37
|
+
RELATIVE_POSITION_MAX_CHARS,
|
|
38
|
+
} from "./anchor-constants";
|
|
39
|
+
|
|
32
40
|
/**
|
|
33
41
|
* A base64 `Y.RelativePosition` payload, encoded by the client.
|
|
34
42
|
*
|
|
@@ -40,7 +48,7 @@ import { z } from "zod";
|
|
|
40
48
|
const RelativePositionSchema = z
|
|
41
49
|
.string()
|
|
42
50
|
.min(1)
|
|
43
|
-
.max(
|
|
51
|
+
.max(RELATIVE_POSITION_MAX_CHARS)
|
|
44
52
|
.regex(/^[A-Za-z0-9+/]+={0,2}$/, "must be base64");
|
|
45
53
|
|
|
46
54
|
/**
|
|
@@ -90,9 +98,9 @@ const TextAnchorSchema = z.strictObject({
|
|
|
90
98
|
v: z.literal(1),
|
|
91
99
|
relStart: RelativePositionSchema.optional(),
|
|
92
100
|
relEnd: RelativePositionSchema.optional(),
|
|
93
|
-
quote: z.string().min(1).max(
|
|
94
|
-
prefix: z.string().max(
|
|
95
|
-
suffix: z.string().max(
|
|
101
|
+
quote: z.string().min(1).max(QUOTE_MAX_CHARS),
|
|
102
|
+
prefix: z.string().max(ANCHOR_AFFIX_MAX_CHARS),
|
|
103
|
+
suffix: z.string().max(ANCHOR_AFFIX_MAX_CHARS),
|
|
96
104
|
});
|
|
97
105
|
|
|
98
106
|
/**
|
|
@@ -110,10 +118,11 @@ const TextAnchorSchema = z.strictObject({
|
|
|
110
118
|
*
|
|
111
119
|
* `leftContext`/`rightContext` are the durable fallback — the text
|
|
112
120
|
* immediately before and after the point. They are required-but-may-be-empty
|
|
113
|
-
* (max
|
|
114
|
-
* empty or short at a document edge, and an
|
|
115
|
-
* nothing on that side", which must stay
|
|
116
|
-
* forgot to send it". No Unicode
|
|
121
|
+
* (max {@link ANCHOR_AFFIX_MAX_CHARS} UTF-16 code units each, like
|
|
122
|
+
* `prefix`/`suffix`): either may be empty or short at a document edge, and an
|
|
123
|
+
* empty string means "there is nothing on that side", which must stay
|
|
124
|
+
* distinguishable from "this client forgot to send it". No Unicode
|
|
125
|
+
* normalization happens anywhere on this path.
|
|
117
126
|
*
|
|
118
127
|
* RESOLUTION SEMANTICS (implemented client-side in the app; the backend is a
|
|
119
128
|
* passthrough that interprets nothing):
|
|
@@ -144,8 +153,8 @@ const TextInsertionAnchorSchema = z.strictObject({
|
|
|
144
153
|
type: z.literal("text-insertion"),
|
|
145
154
|
v: z.literal(1),
|
|
146
155
|
relPos: RelativePositionSchema.optional(),
|
|
147
|
-
leftContext: z.string().max(
|
|
148
|
-
rightContext: z.string().max(
|
|
156
|
+
leftContext: z.string().max(ANCHOR_AFFIX_MAX_CHARS),
|
|
157
|
+
rightContext: z.string().max(ANCHOR_AFFIX_MAX_CHARS),
|
|
149
158
|
});
|
|
150
159
|
|
|
151
160
|
/**
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composing the anchor — the WRITE half of the contract `./resolve` reads
|
|
3
|
+
* (ADR-CONTRACTS-116, ADR-CONTRACTS-127).
|
|
4
|
+
*
|
|
5
|
+
* WHY BOTH DIRECTIONS ARE PUBLISHED, NOT JUST ONE. Publishing anchor → text
|
|
6
|
+
* while leaving text → anchor browser-only would be half an extraction: the
|
|
7
|
+
* side that COMPOSES an anchor is the side that decides how much context to
|
|
8
|
+
* capture and what counts as a quote, and a second description of that is the
|
|
9
|
+
* same silent-drift hazard the resolver was published to end. A constructor
|
|
10
|
+
* that captured more context than {@link CommentAnchorSchema} accepts composes
|
|
11
|
+
* an anchor the route boundary then refuses — a comment lost after it was
|
|
12
|
+
* written — and one that captured a DIFFERENT amount composes an anchor that
|
|
13
|
+
* parses and then resolves somewhere else.
|
|
14
|
+
*
|
|
15
|
+
* REFUSE RATHER THAN GUESS, in the same direction as the ladder. Range → anchor
|
|
16
|
+
* is a write capability and inherits the ladder's discipline exactly: every
|
|
17
|
+
* degenerate input returns `null` rather than a plausible anchor. Truncating an
|
|
18
|
+
* over-long quote is the sharpest case — the resulting anchor is well-formed,
|
|
19
|
+
* parses, resolves, and highlights a range the caller never selected. `null`
|
|
20
|
+
* means "compose no comment", which the caller can report; a truncated quote
|
|
21
|
+
* means "compose a comment about something else", which nobody can.
|
|
22
|
+
*
|
|
23
|
+
* VALIDATED BEFORE IT IS RETURNED. Both constructors run the object they built
|
|
24
|
+
* through the PUBLISHED schema rather than asserting the shape, so the only
|
|
25
|
+
* anchors this module can emit are anchors the route boundary will accept. The
|
|
26
|
+
* bounds come from `./anchor-constants` for the same reason: a literal here
|
|
27
|
+
* would be a fifth copy of a number whose whole point is having one.
|
|
28
|
+
*
|
|
29
|
+
* NO YJS HERE, DELIBERATELY — the same seam `./resolve` holds. `relStart`,
|
|
30
|
+
* `relEnd` and `relPos` are NOT produced: encoding a `Y.RelativePosition` needs
|
|
31
|
+
* `yjs`, which the vocabulary-guard forbids this package from importing, and it
|
|
32
|
+
* needs a live replica besides. A consumer that holds a `Y.Text` merges the
|
|
33
|
+
* encoded pair onto the anchor returned here; the anchor is useful without it,
|
|
34
|
+
* because the quote and the contexts are the durable fallback anyway.
|
|
35
|
+
*
|
|
36
|
+
* All arithmetic is in UTF-16 code units (plain JavaScript string coordinates),
|
|
37
|
+
* like every other offset on the comment path, and no Unicode normalization
|
|
38
|
+
* happens anywhere here.
|
|
39
|
+
*
|
|
40
|
+
* SPELLING DEVIATION FROM THE AUTHORITATIVE REFERENCE (PRD-00941, must_log).
|
|
41
|
+
* Nothing semantic. The reference writes
|
|
42
|
+
* `export function createTextAnchorFromRange(range: TextRange): CommentAnchor | null {`
|
|
43
|
+
* on one line, which is 82 columns against this repo's `printWidth: 80`, so
|
|
44
|
+
* `pnpm format:check` reflows the signature onto three lines. Same precedent as
|
|
45
|
+
* the notes in `./anchor.ts`: a different spelling of the same function, and
|
|
46
|
+
* nothing about what is accepted or refused changed.
|
|
47
|
+
*/
|
|
48
|
+
import { CommentAnchorSchema, type CommentAnchor } from "./anchor";
|
|
49
|
+
import { QUOTE_MAX_CHARS } from "./anchor-constants";
|
|
50
|
+
import { contextAfter, contextBefore } from "./resolve";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A selected range, in the coordinate space of the text it was selected in.
|
|
54
|
+
*
|
|
55
|
+
* `text` travels WITH the offsets rather than being passed separately, because
|
|
56
|
+
* a start/end pair means nothing without the string it indexes — and a caller
|
|
57
|
+
* holding the pair from one revision and the string from another is the bug
|
|
58
|
+
* this shape makes hard to write.
|
|
59
|
+
*/
|
|
60
|
+
export interface TextRange {
|
|
61
|
+
readonly text: string;
|
|
62
|
+
readonly start: number;
|
|
63
|
+
readonly end: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* range → anchor, headlessly. The mirror of `resolveAnchorFromText`, and a
|
|
68
|
+
* WRITE capability, so it refuses rather than guesses on every degenerate
|
|
69
|
+
* input.
|
|
70
|
+
*
|
|
71
|
+
* Returns `null` for: a non-integer offset, an out-of-order or EMPTY range
|
|
72
|
+
* (`end <= start` — the zero-width case has its own constructor below, and
|
|
73
|
+
* silently promoting one here would produce a text anchor whose quote is the
|
|
74
|
+
* empty string), a range reaching past the end of `text`, or a quote longer
|
|
75
|
+
* than {@link QUOTE_MAX_CHARS}.
|
|
76
|
+
*
|
|
77
|
+
* The relative-position pair is NOT produced here: it needs `yjs`, which this
|
|
78
|
+
* package may not import. A consumer holding a `Y.Text` merges it onto the
|
|
79
|
+
* result.
|
|
80
|
+
*/
|
|
81
|
+
export function createTextAnchorFromRange(
|
|
82
|
+
range: TextRange,
|
|
83
|
+
): CommentAnchor | null {
|
|
84
|
+
const { text, start, end } = range;
|
|
85
|
+
if (!Number.isInteger(start) || !Number.isInteger(end)) return null;
|
|
86
|
+
if (start < 0 || end <= start || end > text.length) return null;
|
|
87
|
+
|
|
88
|
+
const quote = text.slice(start, end);
|
|
89
|
+
// Refusing beats truncating: a truncated quote anchors a range nobody chose.
|
|
90
|
+
if (quote.length === 0 || quote.length > QUOTE_MAX_CHARS) return null;
|
|
91
|
+
|
|
92
|
+
const parsed = CommentAnchorSchema.safeParse({
|
|
93
|
+
type: "text",
|
|
94
|
+
v: 1,
|
|
95
|
+
quote,
|
|
96
|
+
prefix: contextBefore(text, start),
|
|
97
|
+
suffix: contextAfter(text, end),
|
|
98
|
+
});
|
|
99
|
+
return parsed.success ? parsed.data : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The zero-width variant: a point BETWEEN two characters.
|
|
104
|
+
*
|
|
105
|
+
* Refuses two empty contexts on a non-empty document — that anchor identifies
|
|
106
|
+
* nothing, and `resolveInsertionBySeam` would (correctly) orphan it, so
|
|
107
|
+
* composing it at all would only defer the loss until after the suggestion was
|
|
108
|
+
* written. On an EMPTY document both contexts are empty and that is the one
|
|
109
|
+
* point there is, so it is allowed.
|
|
110
|
+
*/
|
|
111
|
+
export function createTextInsertionAnchorAt(
|
|
112
|
+
text: string,
|
|
113
|
+
point: number,
|
|
114
|
+
): CommentAnchor | null {
|
|
115
|
+
if (!Number.isInteger(point) || point < 0 || point > text.length) return null;
|
|
116
|
+
const leftContext = contextBefore(text, point);
|
|
117
|
+
const rightContext = contextAfter(text, point);
|
|
118
|
+
// Two empty contexts identify nothing on a non-empty document.
|
|
119
|
+
if (leftContext === "" && rightContext === "" && text.length > 0) return null;
|
|
120
|
+
|
|
121
|
+
const parsed = CommentAnchorSchema.safeParse({
|
|
122
|
+
type: "text-insertion",
|
|
123
|
+
v: 1,
|
|
124
|
+
leftContext,
|
|
125
|
+
rightContext,
|
|
126
|
+
});
|
|
127
|
+
return parsed.success ? parsed.data : null;
|
|
128
|
+
}
|
package/src/comments/index.ts
CHANGED
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
* bodies, per ADR-CONT-029).
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
export {
|
|
12
|
+
ANCHOR_AFFIX_MAX_CHARS,
|
|
13
|
+
ANCHOR_CONTEXT_CHARS,
|
|
14
|
+
QUOTE_MAX_CHARS,
|
|
15
|
+
RELATIVE_POSITION_MAX_CHARS,
|
|
16
|
+
} from "./anchor-constants";
|
|
17
|
+
|
|
11
18
|
export {
|
|
12
19
|
COMMENT_ANCHOR_TYPES,
|
|
13
20
|
CommentAnchorTypeSchema,
|
|
@@ -16,6 +23,16 @@ export {
|
|
|
16
23
|
|
|
17
24
|
export type { CommentAnchor, CommentAnchorType } from "./anchor";
|
|
18
25
|
|
|
26
|
+
export {
|
|
27
|
+
contextAfter,
|
|
28
|
+
contextBefore,
|
|
29
|
+
contextsMatchAt,
|
|
30
|
+
findSoleOccurrence,
|
|
31
|
+
resolveAnchorFromText,
|
|
32
|
+
} from "./resolve";
|
|
33
|
+
|
|
34
|
+
export type { AnchorResolution, Occurrence } from "./resolve";
|
|
35
|
+
|
|
19
36
|
export {
|
|
20
37
|
COMMENT_SUBJECT_TYPES,
|
|
21
38
|
COMMENT_THREAD_STATUSES,
|
|
@@ -55,3 +72,22 @@ export type {
|
|
|
55
72
|
SuggestionOp,
|
|
56
73
|
SuggestionStatus,
|
|
57
74
|
} from "./suggestion";
|
|
75
|
+
|
|
76
|
+
export { anchorStillReads, plannedEdit } from "./verify";
|
|
77
|
+
|
|
78
|
+
export type { PlannedEdit } from "./verify";
|
|
79
|
+
|
|
80
|
+
export {
|
|
81
|
+
createTextAnchorFromRange,
|
|
82
|
+
createTextInsertionAnchorAt,
|
|
83
|
+
} from "./create";
|
|
84
|
+
|
|
85
|
+
export type { TextRange } from "./create";
|
|
86
|
+
|
|
87
|
+
export {
|
|
88
|
+
SUGGESTION_RECEIPTS_MAP,
|
|
89
|
+
SuggestionReceiptSchema,
|
|
90
|
+
readSuggestionReceipt,
|
|
91
|
+
} from "./receipt";
|
|
92
|
+
|
|
93
|
+
export type { SuggestionReceipt } from "./receipt";
|