@company-semantics/contracts 45.2.0 → 45.3.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 +9 -2
- package/src/__tests__/resource-keys.test.ts +59 -0
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +93 -2
- package/src/comments/README.md +115 -0
- package/src/comments/__tests__/README.md +49 -0
- package/src/comments/__tests__/anchor-corpus.test.ts +202 -0
- package/src/comments/__tests__/fixtures/README.md +58 -0
- package/src/comments/__tests__/fixtures/comment-anchors.json +94 -0
- package/src/comments/__tests__/fixtures/comment-anchors.provenance.json +9 -0
- package/src/comments/__tests__/schemas.test.ts +274 -0
- package/src/comments/anchor.ts +120 -0
- package/src/comments/index.ts +43 -0
- package/src/comments/schemas.ts +227 -0
- package/src/generated/openapi-routes.ts +1 -0
- package/src/index.ts +42 -0
- package/src/notifications/__tests__/__snapshots__/registry.test.ts.snap +4 -0
- package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +741 -0
- package/src/notifications/__tests__/definition.test.ts +4 -3
- package/src/notifications/__tests__/fixtures.ts +34 -0
- package/src/notifications/__tests__/kinds.test.ts +11 -4
- package/src/notifications/__tests__/registry.test.ts +7 -5
- package/src/notifications/__tests__/render-snapshot.test.ts +36 -0
- package/src/notifications/kinds/comment-mention.ts +82 -0
- package/src/notifications/kinds/comment-reply.ts +79 -0
- package/src/notifications/kinds/index.ts +2 -0
- package/src/notifications/kinds.ts +12 -3
- package/src/notifications/payloads.ts +49 -0
- package/src/notifications/registry.ts +6 -2
- package/src/resource-keys.ts +68 -0
- package/src/user-notifications/kinds.ts +13 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@company-semantics/contracts",
|
|
3
|
-
"version": "45.
|
|
3
|
+
"version": "45.3.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -48,6 +48,10 @@
|
|
|
48
48
|
"types": "./src/chat/index.ts",
|
|
49
49
|
"default": "./src/chat/index.ts"
|
|
50
50
|
},
|
|
51
|
+
"./comments": {
|
|
52
|
+
"types": "./src/comments/index.ts",
|
|
53
|
+
"default": "./src/comments/index.ts"
|
|
54
|
+
},
|
|
51
55
|
"./api": {
|
|
52
56
|
"types": "./src/api/index.ts",
|
|
53
57
|
"default": "./src/api/index.ts"
|
|
@@ -105,6 +109,8 @@
|
|
|
105
109
|
"test": "NODE_OPTIONS='--max-old-space-size=4096' bash scripts/vitest-run.sh run",
|
|
106
110
|
"test:run": "NODE_OPTIONS='--max-old-space-size=4096' bash scripts/vitest-run.sh run",
|
|
107
111
|
"color": "tsx scripts/color.ts",
|
|
112
|
+
"anchor-corpus:check": "tsx scripts/anchor-corpus.ts",
|
|
113
|
+
"anchor-corpus:sync": "tsx scripts/anchor-corpus.ts --sync",
|
|
108
114
|
"generate:spec-hash": "tsx scripts/generate-spec-hash.ts",
|
|
109
115
|
"generate:spec-hash:check": "tsx scripts/generate-spec-hash.ts --check",
|
|
110
116
|
"generate:api": "pnpm generate:api-types && pnpm generate:spec-hash && pnpm generate:openapi-routes",
|
|
@@ -149,6 +155,7 @@
|
|
|
149
155
|
"overrides": {
|
|
150
156
|
"brace-expansion@<5.0.9": ">=5.0.9",
|
|
151
157
|
"minimatch@<10.2.3": ">=10.2.3",
|
|
158
|
+
"nanoid@<3.3.17": ">=3.3.17",
|
|
152
159
|
"js-yaml@>=4.0.0 <4.3.1": ">=4.3.1 <5.0.0",
|
|
153
160
|
"js-yaml@>=5.0.0 <5.2.2": ">=5.2.2 <6.0.0",
|
|
154
161
|
"picomatch@<4.0.4": ">=4.0.4",
|
|
@@ -163,5 +170,5 @@
|
|
|
163
170
|
"*.md": "markdownlint-cli2",
|
|
164
171
|
"package.json": "node -e \"JSON.parse(require('fs').readFileSync('package.json'))\""
|
|
165
172
|
},
|
|
166
|
-
"securityRequirementsVersion": "
|
|
173
|
+
"securityRequirementsVersion": "dfe68368cb5b7fdc67290fba5cd5a920c9a2ca9c20a9e297232a467b2022e5b5"
|
|
167
174
|
}
|
|
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
|
|
|
2
2
|
import {
|
|
3
3
|
toQueryKey,
|
|
4
4
|
fromQueryKey,
|
|
5
|
+
isResourceKeyShape,
|
|
5
6
|
matchesResourceKey,
|
|
6
7
|
type ResourceKey,
|
|
7
8
|
} from "../resource-keys.js";
|
|
@@ -172,3 +173,61 @@ describe("resource-keys: companyMdAccessRequests (per-doc identity)", () => {
|
|
|
172
173
|
expect(matchesResourceKey(toQueryKey(key), docKey)).toBe(false);
|
|
173
174
|
});
|
|
174
175
|
});
|
|
176
|
+
|
|
177
|
+
describe("resource-keys: commentThreads (composite subject identity)", () => {
|
|
178
|
+
// The FIRST member of the union whose identity needs two segments. Every
|
|
179
|
+
// other key is [type, scope] or [type, scope, oneId], and `fromQueryKey`'s
|
|
180
|
+
// single-field path hard-asserts exactly two trailing segments — so this key
|
|
181
|
+
// exercises machinery nothing else does. See ADR-CONTRACTS-116.
|
|
182
|
+
const SUBJECT_ID = "77777777-7777-4777-8777-777777777777";
|
|
183
|
+
const key: ResourceKey = {
|
|
184
|
+
type: "commentThreads",
|
|
185
|
+
orgId: ORG_ID,
|
|
186
|
+
subjectType: "company_md",
|
|
187
|
+
subjectId: SUBJECT_ID,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
it("serialises to [type, orgId, subjectType, subjectId]", () => {
|
|
191
|
+
const qk = toQueryKey(key);
|
|
192
|
+
expect(qk).toEqual(["commentThreads", ORG_ID, "company_md", SUBJECT_ID]);
|
|
193
|
+
expect(qk).toHaveLength(4);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("round-trips through fromQueryKey", () => {
|
|
197
|
+
expect(fromQueryKey(toQueryKey(key))).toEqual(key);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("is recognised by isResourceKeyShape", () => {
|
|
201
|
+
// The wire-boundary predicate the user-event schema gates
|
|
202
|
+
// `resource.invalidated` payloads on. It counts scope discriminators, and a
|
|
203
|
+
// key with two identity segments must still present exactly ONE scope —
|
|
204
|
+
// otherwise the coalescer drops every comment invalidation frame.
|
|
205
|
+
expect(isResourceKeyShape(key)).toBe(true);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("discriminates two subjects of the same CLASS", () => {
|
|
209
|
+
const other: ResourceKey = {
|
|
210
|
+
...key,
|
|
211
|
+
subjectId: "88888888-8888-4888-8888-888888888888",
|
|
212
|
+
};
|
|
213
|
+
expect(matchesResourceKey(toQueryKey(key), key)).toBe(true);
|
|
214
|
+
expect(matchesResourceKey(toQueryKey(other), key)).toBe(false);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("discriminates two subjects that share an ID across CLASSES", () => {
|
|
218
|
+
// The reason subjectType is in the key at all. Subject ids are per-class
|
|
219
|
+
// uuids; without the class segment, invalidating one class's thread list
|
|
220
|
+
// would flush another's for a colliding id.
|
|
221
|
+
const other: ResourceKey = { ...key, subjectType: "work_item" };
|
|
222
|
+
expect(matchesResourceKey(toQueryKey(other), key)).toBe(false);
|
|
223
|
+
expect(matchesResourceKey(toQueryKey(key), other)).toBe(false);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("refuses a query key that is missing a segment", () => {
|
|
227
|
+
// A three-segment key would otherwise parse as some OTHER shape and hand
|
|
228
|
+
// the caller a ResourceKey with an undefined subjectId.
|
|
229
|
+
expect(() =>
|
|
230
|
+
fromQueryKey(["commentThreads", ORG_ID, "company_md"]),
|
|
231
|
+
).toThrow(/commentThreads/);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// AUTO-GENERATED — do not edit. Run pnpm generate:spec-hash to regenerate.
|
|
2
|
-
export const SPEC_HASH = '
|
|
3
|
-
export const SPEC_HASH_FULL = '
|
|
2
|
+
export const SPEC_HASH = '6859374aa9bc' as const;
|
|
3
|
+
export const SPEC_HASH_FULL = '6859374aa9bcdd861ecd151d6500ccc3a611774ffbce7830fcacbfd873393f95' as const;
|
package/src/api/generated.ts
CHANGED
|
@@ -1531,6 +1531,23 @@ export interface paths {
|
|
|
1531
1531
|
patch?: never;
|
|
1532
1532
|
trace?: never;
|
|
1533
1533
|
};
|
|
1534
|
+
"/api/company-md/docs/{id}/mentionable": {
|
|
1535
|
+
parameters: {
|
|
1536
|
+
query?: never;
|
|
1537
|
+
header?: never;
|
|
1538
|
+
path?: never;
|
|
1539
|
+
cookie?: never;
|
|
1540
|
+
};
|
|
1541
|
+
/** List the document readers the caller may mention */
|
|
1542
|
+
get: operations["listCompanyMdMentionableUsers"];
|
|
1543
|
+
put?: never;
|
|
1544
|
+
post?: never;
|
|
1545
|
+
delete?: never;
|
|
1546
|
+
options?: never;
|
|
1547
|
+
head?: never;
|
|
1548
|
+
patch?: never;
|
|
1549
|
+
trace?: never;
|
|
1550
|
+
};
|
|
1534
1551
|
"/api/company-md/extract": {
|
|
1535
1552
|
parameters: {
|
|
1536
1553
|
query?: never;
|
|
@@ -4891,6 +4908,10 @@ export interface components {
|
|
|
4891
4908
|
seq: string;
|
|
4892
4909
|
update: string;
|
|
4893
4910
|
};
|
|
4911
|
+
/** @description The document readers the caller may mention, ordered and bounded. */
|
|
4912
|
+
CompanyMdMentionableResponse: {
|
|
4913
|
+
items: components["schemas"]["CompanyMdMentionableCandidate"][];
|
|
4914
|
+
};
|
|
4894
4915
|
CompanyMdContextBankResponse: {
|
|
4895
4916
|
items: {
|
|
4896
4917
|
id: string;
|
|
@@ -5737,7 +5758,7 @@ export interface components {
|
|
|
5737
5758
|
notification: {
|
|
5738
5759
|
id: string;
|
|
5739
5760
|
/** @enum {string} */
|
|
5740
|
-
kind: "companyMd.access_requested" | "companyMd.access_request_approved" | "companyMd.access_request_denied";
|
|
5761
|
+
kind: "companyMd.access_requested" | "companyMd.access_request_approved" | "companyMd.access_request_denied" | "comment.mention" | "comment.reply";
|
|
5741
5762
|
target: {
|
|
5742
5763
|
/** @enum {string} */
|
|
5743
5764
|
type: "company_md" | "org_unit" | "org" | "execution";
|
|
@@ -6278,6 +6299,12 @@ export interface components {
|
|
|
6278
6299
|
subjectId: string;
|
|
6279
6300
|
anchor: components["schemas"]["CommentAnchor"];
|
|
6280
6301
|
body: string;
|
|
6302
|
+
mentions?: {
|
|
6303
|
+
/** Format: uuid */
|
|
6304
|
+
userId: string;
|
|
6305
|
+
startOffset: number;
|
|
6306
|
+
endOffset: number;
|
|
6307
|
+
}[];
|
|
6281
6308
|
};
|
|
6282
6309
|
/** @description The written reply, and whether it auto-reopened a resolved thread. */
|
|
6283
6310
|
CommentReplyResponse: {
|
|
@@ -6286,6 +6313,12 @@ export interface components {
|
|
|
6286
6313
|
};
|
|
6287
6314
|
CommentReplyRequest: {
|
|
6288
6315
|
body: string;
|
|
6316
|
+
mentions?: {
|
|
6317
|
+
/** Format: uuid */
|
|
6318
|
+
userId: string;
|
|
6319
|
+
startOffset: number;
|
|
6320
|
+
endOffset: number;
|
|
6321
|
+
}[];
|
|
6289
6322
|
};
|
|
6290
6323
|
/** @description The comment after the mutation (redacted projection after delete). */
|
|
6291
6324
|
CommentResponse: {
|
|
@@ -6293,6 +6326,12 @@ export interface components {
|
|
|
6293
6326
|
};
|
|
6294
6327
|
EditCommentRequest: {
|
|
6295
6328
|
body: string;
|
|
6329
|
+
mentions?: {
|
|
6330
|
+
/** Format: uuid */
|
|
6331
|
+
userId: string;
|
|
6332
|
+
startOffset: number;
|
|
6333
|
+
endOffset: number;
|
|
6334
|
+
}[];
|
|
6296
6335
|
};
|
|
6297
6336
|
/** @description The thread after a resolve/reopen transition. */
|
|
6298
6337
|
CommentThreadSummaryResponse: {
|
|
@@ -6303,6 +6342,12 @@ export interface components {
|
|
|
6303
6342
|
maxUpdateBytes: number;
|
|
6304
6343
|
maxDocTextBytes: number;
|
|
6305
6344
|
};
|
|
6345
|
+
/** @description A principal who can read the document and may therefore be mentioned. */
|
|
6346
|
+
CompanyMdMentionableCandidate: {
|
|
6347
|
+
userId: string;
|
|
6348
|
+
displayName: string;
|
|
6349
|
+
avatarUrl: string | null;
|
|
6350
|
+
};
|
|
6306
6351
|
/** @description An ACL-admitted context-doc discovery hit. */
|
|
6307
6352
|
ContextDocDiscoveryHit: {
|
|
6308
6353
|
id: string;
|
|
@@ -6360,7 +6405,14 @@ export interface components {
|
|
|
6360
6405
|
editedAt: string | null;
|
|
6361
6406
|
deletedAt: string | null;
|
|
6362
6407
|
createdAt: string;
|
|
6363
|
-
mentions:
|
|
6408
|
+
mentions: components["schemas"]["CommentMention"][];
|
|
6409
|
+
};
|
|
6410
|
+
/** @description One mention on a comment: the mentioned user id, its zero-based exclusive-end UTF-16 range (null until the offset columns exist), and the display name resolved at read time. */
|
|
6411
|
+
CommentMention: {
|
|
6412
|
+
userId: string;
|
|
6413
|
+
startOffset: number | null;
|
|
6414
|
+
endOffset: number | null;
|
|
6415
|
+
displayName: string;
|
|
6364
6416
|
};
|
|
6365
6417
|
/** @description A comment thread without its comments. */
|
|
6366
6418
|
CommentThreadSummary: {
|
|
@@ -8774,6 +8826,45 @@ export interface operations {
|
|
|
8774
8826
|
};
|
|
8775
8827
|
};
|
|
8776
8828
|
};
|
|
8829
|
+
listCompanyMdMentionableUsers: {
|
|
8830
|
+
parameters: {
|
|
8831
|
+
query?: {
|
|
8832
|
+
q?: string;
|
|
8833
|
+
limit?: number;
|
|
8834
|
+
};
|
|
8835
|
+
header?: never;
|
|
8836
|
+
path: {
|
|
8837
|
+
id: string;
|
|
8838
|
+
};
|
|
8839
|
+
cookie?: never;
|
|
8840
|
+
};
|
|
8841
|
+
requestBody?: never;
|
|
8842
|
+
responses: {
|
|
8843
|
+
/** @description Document readers the caller may mention, ordered and bounded */
|
|
8844
|
+
200: {
|
|
8845
|
+
headers: {
|
|
8846
|
+
[name: string]: unknown;
|
|
8847
|
+
};
|
|
8848
|
+
content: {
|
|
8849
|
+
"application/json": components["schemas"]["CompanyMdMentionableResponse"];
|
|
8850
|
+
};
|
|
8851
|
+
};
|
|
8852
|
+
/** @description Invalid query parameters */
|
|
8853
|
+
400: {
|
|
8854
|
+
headers: {
|
|
8855
|
+
[name: string]: unknown;
|
|
8856
|
+
};
|
|
8857
|
+
content?: never;
|
|
8858
|
+
};
|
|
8859
|
+
/** @description Document not found, or the caller may not comment on it */
|
|
8860
|
+
404: {
|
|
8861
|
+
headers: {
|
|
8862
|
+
[name: string]: unknown;
|
|
8863
|
+
};
|
|
8864
|
+
content?: never;
|
|
8865
|
+
};
|
|
8866
|
+
};
|
|
8867
|
+
};
|
|
8777
8868
|
extractCompanyMd: {
|
|
8778
8869
|
parameters: {
|
|
8779
8870
|
query?: never;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# comments/
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
The published **read** vocabulary of the comment surface: where a thread hangs
|
|
6
|
+
(`CommentAnchorSchema`), what a thread and its comments look like on the wire
|
|
7
|
+
(`CommentThreadSchema`, `CommentSchema`, `CommentMentionSchema`), and who a
|
|
8
|
+
comment may name (`MentionableResponseSchema`).
|
|
9
|
+
|
|
10
|
+
This exists as a published contract rather than an app-local type because two
|
|
11
|
+
independent clients have to agree about it without a server in the middle to
|
|
12
|
+
arbitrate — see the first invariant.
|
|
13
|
+
|
|
14
|
+
## Invariants
|
|
15
|
+
|
|
16
|
+
- **The anchor is the contract between the client that WRITES it and the client
|
|
17
|
+
that later RESOLVES it, and the backend is a passthrough that cannot catch
|
|
18
|
+
drift in it.** The route boundary validates the anchor's SHAPE and stores the
|
|
19
|
+
result as opaque jsonb; the service, the database and every projection treat
|
|
20
|
+
it as bytes and interpret nothing. So if the writing client and the resolving
|
|
21
|
+
client disagree about what these fields mean, the write succeeds, the read
|
|
22
|
+
succeeds, and the comment silently lands on the wrong text. There is no
|
|
23
|
+
server-side tripwire, and there cannot be one — which is precisely why the
|
|
24
|
+
shape is published here instead of being declared twice.
|
|
25
|
+
- **`v` is the evolution hatch, and it is the only thing that bounds that
|
|
26
|
+
risk.** A client that meets an anchor it cannot resolve sees an unfamiliar `v`
|
|
27
|
+
and degrades on purpose instead of guessing at fields it half-recognises. New
|
|
28
|
+
anchor shapes arrive as new `v` variants, never as a migration of an existing
|
|
29
|
+
one.
|
|
30
|
+
- **`quote` is required on a text anchor; `relStart`/`relEnd` are not.** The
|
|
31
|
+
quote is the durable fallback a resolving client re-finds the passage by once
|
|
32
|
+
the relative positions stop resolving. A comment authored from a card has no
|
|
33
|
+
attached collab session and can only produce the quote path — optional there
|
|
34
|
+
means "this client could not", never "this client chose not to".
|
|
35
|
+
- **The anchor variants are STRICT.** A document anchor carrying `quote` or
|
|
36
|
+
`relStart` is refused rather than accepted with the extras dropped: a client
|
|
37
|
+
that sent them meant to anchor to text and got the discriminator wrong, and
|
|
38
|
+
storing the mistake yields a thread that can never be placed.
|
|
39
|
+
- **A comment's `body` is null exactly when `deletedAt` is set, and such a
|
|
40
|
+
comment carries no mentions.** A soft-deleted comment is a REDACTED
|
|
41
|
+
PROJECTION, not a removed row — the tombstone keeps its position in the
|
|
42
|
+
`(createdAt ASC, id ASC)` sequence so the surrounding replies still read in
|
|
43
|
+
order, and a thread whose comments were all deleted stays distinguishable from
|
|
44
|
+
one that never had any. The rule is documented and writer-enforced rather than
|
|
45
|
+
expressed as a Zod refinement: a refinement would make this package refuse a
|
|
46
|
+
response the server is willing to emit, and it cannot appear in the OpenAPI
|
|
47
|
+
surface, so it would become a third description of the wire shape that no
|
|
48
|
+
parity guard can check.
|
|
49
|
+
- **Mention offsets are ZERO-BASED, EXCLUSIVE-END UTF-16 CODE UNITS** into the
|
|
50
|
+
exact body string — plain JavaScript string coordinates. An emoji counts as
|
|
51
|
+
two units, so a client measuring in code points slides off by one per
|
|
52
|
+
preceding emoji, and **no Unicode normalization happens anywhere on this
|
|
53
|
+
path**: normalising before measuring changes the length and desynchronises
|
|
54
|
+
every offset after the first composed character.
|
|
55
|
+
- **Mention identity renders from the mention row, never from the body
|
|
56
|
+
characters under the range.** A stale or forged range can only
|
|
57
|
+
mis-**highlight**; it can never misattribute a mention to the wrong person.
|
|
58
|
+
Offsets are nullable (and null on every row today, pending the offset
|
|
59
|
+
columns) — treat null as "highlight nothing", not as an error.
|
|
60
|
+
- **These shapes are MIRRORED from the backend route boundary, not authored
|
|
61
|
+
here.** Every bound matches
|
|
62
|
+
`company-semantics-backend/src/api/http/routes/comments/comments.schemas.ts`,
|
|
63
|
+
and therefore matches the generated `components["schemas"]` view in `../api`.
|
|
64
|
+
The accept/reject corpus in `__tests__/fixtures/comment-anchors.json` is
|
|
65
|
+
copied **byte-for-byte** from that repo's `tests/fixtures/`. Only this side of
|
|
66
|
+
that copy is gated: its digest is pinned in
|
|
67
|
+
`__tests__/fixtures/comment-anchors.provenance.json` and re-derived on every
|
|
68
|
+
test run, so the corpus cannot be edited here quietly. A change made at the
|
|
69
|
+
boundary is NOT caught from this repo — nothing in a single-repo CI run can
|
|
70
|
+
see that file, and this package must not depend on the backend to look. See
|
|
71
|
+
ADR-CONTRACTS-116 for the split and for where the upstream gate belongs.
|
|
72
|
+
- **Request bodies are NOT here** (ADR-CONT-029), and one of them must never
|
|
73
|
+
be: the mention set on a write is validated against a candidate list the
|
|
74
|
+
server derives from `effective_acl_grants`, so nothing a client sends may
|
|
75
|
+
widen who can be mentioned.
|
|
76
|
+
|
|
77
|
+
## Public API
|
|
78
|
+
|
|
79
|
+
| Export | Description |
|
|
80
|
+
| --------------------------------- | --------------------------------------------------------------------------- |
|
|
81
|
+
| `CommentAnchorSchema` | `{document,v:1}` \| `{text,v:1,…}`, strict, discriminated on `type` |
|
|
82
|
+
| `COMMENT_ANCHOR_TYPES` | The anchor discriminants, as a vocabulary tuple |
|
|
83
|
+
| `CommentAnchorTypeSchema` | Zod mirror of `COMMENT_ANCHOR_TYPES` — what `anchorType` is checked against |
|
|
84
|
+
| `COMMENT_THREAD_STATUSES` | `open` \| `resolved` — there is no third state |
|
|
85
|
+
| `CommentThreadStatusSchema` | Zod mirror of `COMMENT_THREAD_STATUSES` |
|
|
86
|
+
| `COMMENT_SUBJECT_TYPES` | What a thread can hang off — closed, sized to what admits comments |
|
|
87
|
+
| `CommentSubjectTypeSchema` | Zod mirror of `COMMENT_SUBJECT_TYPES` |
|
|
88
|
+
| `CommentMentionSchema` | One mention: user id, nullable UTF-16 range, read-time display name |
|
|
89
|
+
| `CommentSchema` | One comment; `body` null iff soft-deleted (infers `CommentProjection`) |
|
|
90
|
+
| `CommentThreadSummarySchema` | A thread without its comments — the resolve/reopen response |
|
|
91
|
+
| `CommentThreadSchema` | A thread with all its comments, oldest-first, tombstones included |
|
|
92
|
+
| `CommentThreadListResponseSchema` | `GET /api/comments` |
|
|
93
|
+
| `MentionableCandidateSchema` | One offerable mention target — no email, no access level |
|
|
94
|
+
| `MentionableResponseSchema` | `GET /api/company-md/docs/{id}/mentionable` |
|
|
95
|
+
|
|
96
|
+
## Dependencies
|
|
97
|
+
|
|
98
|
+
- `zod` — schemas are canonical, types are inferred.
|
|
99
|
+
- `./anchor` ← `./schemas`. Nothing outside this directory is imported: the
|
|
100
|
+
comment vocabulary binds to the existing `commenter` band of
|
|
101
|
+
`../permissions`'s `AccessLevel` and introduces no access level of its own, so
|
|
102
|
+
it needs no import to say so.
|
|
103
|
+
|
|
104
|
+
## Related vocabulary elsewhere in this package
|
|
105
|
+
|
|
106
|
+
- `../resource-keys` carries `commentThreads` — the SSE coalescer's key for "the
|
|
107
|
+
threads on this subject". It lives there rather than app-locally because the
|
|
108
|
+
key registry **is** the coalescer's vocabulary; an unregistered key cannot be
|
|
109
|
+
routed through it.
|
|
110
|
+
- `../user-notifications` carries `comment.mention` and `comment.reply` — the
|
|
111
|
+
durable inbox rows a comment produces.
|
|
112
|
+
- `../notifications` carries the same two names as composable notifications —
|
|
113
|
+
the message that leaves the building. The overlap is expected and is the same
|
|
114
|
+
one `companyMd.access_requested` already has; the two unions are not derived
|
|
115
|
+
from each other.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# comments/\_\_tests\_\_/
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Contract tests for the published comment vocabulary: the anchor union against
|
|
6
|
+
the mirrored corpus (`anchor-corpus.test.ts`), and the thread/comment/mention
|
|
7
|
+
projections against the invariants their JSDoc claims (`schemas.test.ts`).
|
|
8
|
+
|
|
9
|
+
## Invariants
|
|
10
|
+
|
|
11
|
+
- **The anchor suite is DRIVEN BY THE CORPUS, not by hand-copied cases.** It
|
|
12
|
+
iterates every `accept` and every `reject` entry in
|
|
13
|
+
`./fixtures/comment-anchors.json` — a byte-for-byte copy of the backend's
|
|
14
|
+
fixture file — so a case added at the boundary is covered here as soon as the
|
|
15
|
+
file is re-copied, with no test edit. Hand-copying the cases would let the two
|
|
16
|
+
suites diverge silently, which is exactly the failure this domain exists to
|
|
17
|
+
prevent.
|
|
18
|
+
- **The corpus's digest is pinned, and that pin is the only mechanical part of
|
|
19
|
+
the anti-drift story that runs in this repo's CI.** `the corpus is still the
|
|
20
|
+
backend's bytes` re-derives the sha256 of `./fixtures/comment-anchors.json`
|
|
21
|
+
and compares it to `./fixtures/comment-anchors.provenance.json`, so editing
|
|
22
|
+
the corpus here — rather than upstream, then re-copying — fails. It does not
|
|
23
|
+
and cannot notice a change made in the backend file: a single-repo CI checkout
|
|
24
|
+
never sees it. `pnpm anchor-corpus:check` covers that half only where a
|
|
25
|
+
backend working tree is reachable, and reports when it is not.
|
|
26
|
+
- **The named rejection tests are additional, not a substitute.** `rejects a
|
|
27
|
+
text anchor with no quote` and `rejects a document anchor carrying
|
|
28
|
+
text-anchor fields` name the two failure modes the strict variants exist for,
|
|
29
|
+
in prose, so a reader of the test list can see them without decoding fixture
|
|
30
|
+
names. The corpus covers the same ground mechanically; both are wanted.
|
|
31
|
+
- **The corpus file is read with `readFileSync` + `JSON.parse`, not imported.**
|
|
32
|
+
`src/tsconfig.json` does not set `resolveJsonModule`, so a JSON import from
|
|
33
|
+
anything the typechecker reaches would fail the build. Reading it keeps the
|
|
34
|
+
fixture inert regardless of which file loads it. `node:fs` is fine in here —
|
|
35
|
+
the vocabulary-guard skips `__tests__/`.
|
|
36
|
+
- **Negative tests mutate ONE field of a well-formed factory result.** A
|
|
37
|
+
hand-built broken object can pass for the wrong reason: it fails because of
|
|
38
|
+
the field nobody was testing.
|
|
39
|
+
|
|
40
|
+
## Public API
|
|
41
|
+
|
|
42
|
+
None — test-only.
|
|
43
|
+
|
|
44
|
+
## Dependencies
|
|
45
|
+
|
|
46
|
+
- `vitest` — `describe` / `it` / `expect`.
|
|
47
|
+
- `node:fs`, `node:path`, `node:url` — to read the corpus and its pin from disk.
|
|
48
|
+
- `node:crypto` — to re-derive the corpus digest.
|
|
49
|
+
- `../anchor`, `../schemas` — the schemas under test.
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The anchor union, held to the SAME corpus the backend route boundary is held
|
|
3
|
+
* to (ADR-CONTRACTS-116).
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS SUITE IS FIXTURE-DRIVEN RATHER THAN HAND-WRITTEN. The backend
|
|
6
|
+
* validates an anchor's shape once, at the route boundary, and then stores
|
|
7
|
+
* opaque jsonb it never interprets. So nothing on the server can notice if this
|
|
8
|
+
* package's idea of a valid anchor drifts from that boundary's — the write
|
|
9
|
+
* succeeds, the read succeeds, and a comment lands on the wrong text.
|
|
10
|
+
* `./fixtures/comment-anchors.json` is therefore a byte-for-byte copy of the
|
|
11
|
+
* backend's fixture file, and this file runs `CommentAnchorSchema` over every
|
|
12
|
+
* case in it. A case added at the boundary is covered here the moment the file
|
|
13
|
+
* is re-copied. Hand-copying the cases would reintroduce exactly the drift the
|
|
14
|
+
* copy exists to prevent.
|
|
15
|
+
*
|
|
16
|
+
* The pinned-digest test below is the part of that defence that is MECHANICAL
|
|
17
|
+
* in this repo's CI: it re-derives the corpus's sha256 and compares it to
|
|
18
|
+
* `./fixtures/comment-anchors.provenance.json`, so an edit made on THIS side
|
|
19
|
+
* fails here. Drift introduced on the BACKEND side is NOT gated from contracts
|
|
20
|
+
* CI — a single-repo checkout cannot see that file, and this package must not
|
|
21
|
+
* depend on the backend to look. `pnpm anchor-corpus:check` diffs the live
|
|
22
|
+
* upstream file where a backend tree is reachable, `pnpm anchor-corpus:sync`
|
|
23
|
+
* re-copies, and ADR-CONTRACTS-116 records that the upstream half is still a
|
|
24
|
+
* documented promise rather than a gate.
|
|
25
|
+
*
|
|
26
|
+
* The named rejection tests below are additional rather than a substitute: the
|
|
27
|
+
* corpus covers the same ground by fixture name, and these say in prose what
|
|
28
|
+
* the strict variants are FOR.
|
|
29
|
+
*/
|
|
30
|
+
import { createHash } from "node:crypto";
|
|
31
|
+
import { readFileSync } from "node:fs";
|
|
32
|
+
import { dirname, join } from "node:path";
|
|
33
|
+
import { fileURLToPath } from "node:url";
|
|
34
|
+
import { describe, expect, it } from "vitest";
|
|
35
|
+
|
|
36
|
+
import { CommentAnchorSchema } from "../anchor.js";
|
|
37
|
+
|
|
38
|
+
/** One named case from the corpus. */
|
|
39
|
+
interface AnchorCase {
|
|
40
|
+
name: string;
|
|
41
|
+
anchor: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The subset of `comment-anchors.provenance.json` this suite enforces. */
|
|
45
|
+
interface CorpusPin {
|
|
46
|
+
source: { repo: string; path: string };
|
|
47
|
+
bytes: number;
|
|
48
|
+
sha256: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Read from disk rather than imported: `src/tsconfig.json` sets no
|
|
53
|
+
* `resolveJsonModule`, so a JSON import would fail the build the moment
|
|
54
|
+
* anything the typechecker reaches loaded it.
|
|
55
|
+
*/
|
|
56
|
+
const CORPUS_PATH = join(
|
|
57
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
58
|
+
"fixtures",
|
|
59
|
+
"comment-anchors.json",
|
|
60
|
+
);
|
|
61
|
+
const corpus = JSON.parse(readFileSync(CORPUS_PATH, "utf8")) as {
|
|
62
|
+
accept: AnchorCase[];
|
|
63
|
+
reject: AnchorCase[];
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const PIN_PATH = join(
|
|
67
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
68
|
+
"fixtures",
|
|
69
|
+
"comment-anchors.provenance.json",
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
describe("the corpus is still the backend's bytes", () => {
|
|
73
|
+
it("matches the digest pinned when it was copied", () => {
|
|
74
|
+
// The one check in this repo that notices the corpus being EDITED rather
|
|
75
|
+
// than re-copied. Without it, "byte-for-byte copy" is a comment: a case
|
|
76
|
+
// hand-tuned here to make a red test green would pass, and the published
|
|
77
|
+
// contract would quietly become its own authority instead of the
|
|
78
|
+
// boundary's mirror. Bytes, not parsed JSON — a reflow that preserves the
|
|
79
|
+
// parse still breaks the copy, which is why the file is in /.prettierignore.
|
|
80
|
+
//
|
|
81
|
+
// This does NOT see a change made upstream; nothing in a single-repo CI run
|
|
82
|
+
// can. See the file header and ADR-CONTRACTS-116.
|
|
83
|
+
const pin = JSON.parse(readFileSync(PIN_PATH, "utf8")) as CorpusPin;
|
|
84
|
+
const bytes = readFileSync(CORPUS_PATH);
|
|
85
|
+
|
|
86
|
+
expect(bytes.byteLength).toBe(pin.bytes);
|
|
87
|
+
expect(createHash("sha256").update(bytes).digest("hex")).toBe(pin.sha256);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("names the upstream file it was copied from", () => {
|
|
91
|
+
// The pin is also the pointer a reader needs to run the diff by hand, so an
|
|
92
|
+
// emptied or reshaped source block is a failure, not a formality.
|
|
93
|
+
const pin = JSON.parse(readFileSync(PIN_PATH, "utf8")) as CorpusPin;
|
|
94
|
+
|
|
95
|
+
expect(pin.source.repo).toBe("company-semantics-backend");
|
|
96
|
+
expect(pin.source.path).toBe("tests/fixtures/comment-anchors.json");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("CommentAnchorSchema against the mirrored corpus", () => {
|
|
101
|
+
it("has both halves of the corpus, so neither loop can pass vacuously", () => {
|
|
102
|
+
// An empty array iterates cleanly and reports as a passing suite. This is
|
|
103
|
+
// the only assertion that notices a corpus copied as an empty shell.
|
|
104
|
+
expect(corpus.accept.length).toBeGreaterThan(0);
|
|
105
|
+
expect(corpus.reject.length).toBeGreaterThan(0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
for (const { name, anchor } of corpus.accept) {
|
|
109
|
+
it(`accepts ${name}`, () => {
|
|
110
|
+
expect(CommentAnchorSchema.safeParse(anchor).success).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const { name, anchor } of corpus.reject) {
|
|
115
|
+
it(`rejects ${name}`, () => {
|
|
116
|
+
expect(CommentAnchorSchema.safeParse(anchor).success).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("the anchor's load-bearing refusals, named", () => {
|
|
122
|
+
it("rejects a text anchor with no quote", () => {
|
|
123
|
+
// `quote` is the DURABLE FALLBACK — what a resolving client re-finds the
|
|
124
|
+
// passage by once the relative positions stop resolving, and the composer's
|
|
125
|
+
// `Comment on "…"` label. An anchor without one is unplaceable the first
|
|
126
|
+
// time the document is edited, so it must never reach storage.
|
|
127
|
+
expect(
|
|
128
|
+
CommentAnchorSchema.safeParse({
|
|
129
|
+
type: "text",
|
|
130
|
+
v: 1,
|
|
131
|
+
prefix: "agreed to ",
|
|
132
|
+
suffix: " by March",
|
|
133
|
+
}).success,
|
|
134
|
+
).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("rejects a document anchor carrying text-anchor fields", () => {
|
|
138
|
+
// The variants are STRICT on purpose. A client that sent `quote` on a
|
|
139
|
+
// document anchor meant to anchor to text and got the discriminator wrong;
|
|
140
|
+
// accepting it with the extras dropped stores a thread that can never be
|
|
141
|
+
// placed, and nothing downstream would ever flag it.
|
|
142
|
+
expect(
|
|
143
|
+
CommentAnchorSchema.safeParse({
|
|
144
|
+
type: "document",
|
|
145
|
+
v: 1,
|
|
146
|
+
quote: "stray quote",
|
|
147
|
+
prefix: "",
|
|
148
|
+
suffix: "",
|
|
149
|
+
}).success,
|
|
150
|
+
).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("rejects an unknown version on either variant", () => {
|
|
154
|
+
// `v` is the evolution hatch: a NEW shape is a new `v`, so a client must
|
|
155
|
+
// never accept a `v` it does not know and guess at the fields it
|
|
156
|
+
// half-recognises.
|
|
157
|
+
expect(
|
|
158
|
+
CommentAnchorSchema.safeParse({ type: "document", v: 2 }).success,
|
|
159
|
+
).toBe(false);
|
|
160
|
+
expect(
|
|
161
|
+
CommentAnchorSchema.safeParse({
|
|
162
|
+
type: "text",
|
|
163
|
+
v: 2,
|
|
164
|
+
quote: "q",
|
|
165
|
+
prefix: "",
|
|
166
|
+
suffix: "",
|
|
167
|
+
}).success,
|
|
168
|
+
).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("rejects a relative position that is not base64", () => {
|
|
172
|
+
// The backend never decodes these, so the charset bound is the ONLY thing
|
|
173
|
+
// standing between a malformed position and a client that cannot decode it
|
|
174
|
+
// either. This is the case a looser `z.string().max(4096)` would let
|
|
175
|
+
// through.
|
|
176
|
+
expect(
|
|
177
|
+
CommentAnchorSchema.safeParse({
|
|
178
|
+
type: "text",
|
|
179
|
+
v: 1,
|
|
180
|
+
relStart: "not base64!!",
|
|
181
|
+
quote: "q",
|
|
182
|
+
prefix: "",
|
|
183
|
+
suffix: "",
|
|
184
|
+
}).success,
|
|
185
|
+
).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("accepts a text anchor with only the quote path", () => {
|
|
189
|
+
// The positive twin of the rule above: a comment authored from a card has
|
|
190
|
+
// no attached collab session and can produce no relative position at all.
|
|
191
|
+
// If this ever stops parsing, commenting outside the editor breaks.
|
|
192
|
+
expect(
|
|
193
|
+
CommentAnchorSchema.safeParse({
|
|
194
|
+
type: "text",
|
|
195
|
+
v: 1,
|
|
196
|
+
quote: "ship the quarterly goals",
|
|
197
|
+
prefix: "",
|
|
198
|
+
suffix: "",
|
|
199
|
+
}).success,
|
|
200
|
+
).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
});
|