@company-semantics/contracts 45.2.0 → 46.0.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.
Files changed (31) hide show
  1. package/package.json +9 -2
  2. package/src/__tests__/resource-keys.test.ts +59 -0
  3. package/src/api/generated-spec-hash.ts +2 -2
  4. package/src/api/generated.ts +95 -2
  5. package/src/comments/README.md +115 -0
  6. package/src/comments/__tests__/README.md +49 -0
  7. package/src/comments/__tests__/anchor-corpus.test.ts +202 -0
  8. package/src/comments/__tests__/fixtures/README.md +58 -0
  9. package/src/comments/__tests__/fixtures/comment-anchors.json +94 -0
  10. package/src/comments/__tests__/fixtures/comment-anchors.provenance.json +9 -0
  11. package/src/comments/__tests__/schemas.test.ts +274 -0
  12. package/src/comments/anchor.ts +120 -0
  13. package/src/comments/index.ts +43 -0
  14. package/src/comments/schemas.ts +227 -0
  15. package/src/generated/openapi-routes.ts +1 -0
  16. package/src/index.ts +42 -0
  17. package/src/notifications/__tests__/__snapshots__/registry.test.ts.snap +4 -0
  18. package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +741 -0
  19. package/src/notifications/__tests__/definition.test.ts +4 -3
  20. package/src/notifications/__tests__/fixtures.ts +34 -0
  21. package/src/notifications/__tests__/kinds.test.ts +11 -4
  22. package/src/notifications/__tests__/registry.test.ts +7 -5
  23. package/src/notifications/__tests__/render-snapshot.test.ts +36 -0
  24. package/src/notifications/kinds/comment-mention.ts +82 -0
  25. package/src/notifications/kinds/comment-reply.ts +79 -0
  26. package/src/notifications/kinds/index.ts +2 -0
  27. package/src/notifications/kinds.ts +12 -3
  28. package/src/notifications/payloads.ts +49 -0
  29. package/src/notifications/registry.ts +6 -2
  30. package/src/resource-keys.ts +68 -0
  31. 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.2.0",
3
+ "version": "46.0.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": "795d779110bb8dee6374229e227dc7b7e813197c34a35bbe2066113e0939a988"
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 = '7f497f657ee3' as const;
3
- export const SPEC_HASH_FULL = '7f497f657ee3f1a81b096b02094743f5e0fa6a3d03c0bb92cdb0034762ca3ee7' as const;
2
+ export const SPEC_HASH = '9421bda67789' as const;
3
+ export const SPEC_HASH_FULL = '9421bda67789792a5303137aaeb2f7f886cb490e8db9bee11e03f2de13f94806' as const;
@@ -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;
@@ -3614,6 +3631,7 @@ export interface components {
3614
3631
  };
3615
3632
  candidateKey: string;
3616
3633
  contentSha256: string;
3634
+ sourceSha256: string;
3617
3635
  };
3618
3636
  ResyncSlackAvatarCommitResponse: {
3619
3637
  /** @constant */
@@ -3628,6 +3646,7 @@ export interface components {
3628
3646
  ResyncSlackAvatarCommitRequest: {
3629
3647
  candidateKey: string;
3630
3648
  contentSha256: string;
3649
+ sourceSha256: string;
3631
3650
  };
3632
3651
  SamlCallbackRequest: {
3633
3652
  SAMLResponse: string;
@@ -4891,6 +4910,10 @@ export interface components {
4891
4910
  seq: string;
4892
4911
  update: string;
4893
4912
  };
4913
+ /** @description The document readers the caller may mention, ordered and bounded. */
4914
+ CompanyMdMentionableResponse: {
4915
+ items: components["schemas"]["CompanyMdMentionableCandidate"][];
4916
+ };
4894
4917
  CompanyMdContextBankResponse: {
4895
4918
  items: {
4896
4919
  id: string;
@@ -5737,7 +5760,7 @@ export interface components {
5737
5760
  notification: {
5738
5761
  id: string;
5739
5762
  /** @enum {string} */
5740
- kind: "companyMd.access_requested" | "companyMd.access_request_approved" | "companyMd.access_request_denied";
5763
+ kind: "companyMd.access_requested" | "companyMd.access_request_approved" | "companyMd.access_request_denied" | "comment.mention" | "comment.reply";
5741
5764
  target: {
5742
5765
  /** @enum {string} */
5743
5766
  type: "company_md" | "org_unit" | "org" | "execution";
@@ -6278,6 +6301,12 @@ export interface components {
6278
6301
  subjectId: string;
6279
6302
  anchor: components["schemas"]["CommentAnchor"];
6280
6303
  body: string;
6304
+ mentions?: {
6305
+ /** Format: uuid */
6306
+ userId: string;
6307
+ startOffset: number;
6308
+ endOffset: number;
6309
+ }[];
6281
6310
  };
6282
6311
  /** @description The written reply, and whether it auto-reopened a resolved thread. */
6283
6312
  CommentReplyResponse: {
@@ -6286,6 +6315,12 @@ export interface components {
6286
6315
  };
6287
6316
  CommentReplyRequest: {
6288
6317
  body: string;
6318
+ mentions?: {
6319
+ /** Format: uuid */
6320
+ userId: string;
6321
+ startOffset: number;
6322
+ endOffset: number;
6323
+ }[];
6289
6324
  };
6290
6325
  /** @description The comment after the mutation (redacted projection after delete). */
6291
6326
  CommentResponse: {
@@ -6293,6 +6328,12 @@ export interface components {
6293
6328
  };
6294
6329
  EditCommentRequest: {
6295
6330
  body: string;
6331
+ mentions?: {
6332
+ /** Format: uuid */
6333
+ userId: string;
6334
+ startOffset: number;
6335
+ endOffset: number;
6336
+ }[];
6296
6337
  };
6297
6338
  /** @description The thread after a resolve/reopen transition. */
6298
6339
  CommentThreadSummaryResponse: {
@@ -6303,6 +6344,12 @@ export interface components {
6303
6344
  maxUpdateBytes: number;
6304
6345
  maxDocTextBytes: number;
6305
6346
  };
6347
+ /** @description A principal who can read the document and may therefore be mentioned. */
6348
+ CompanyMdMentionableCandidate: {
6349
+ userId: string;
6350
+ displayName: string;
6351
+ avatarUrl: string | null;
6352
+ };
6306
6353
  /** @description An ACL-admitted context-doc discovery hit. */
6307
6354
  ContextDocDiscoveryHit: {
6308
6355
  id: string;
@@ -6360,7 +6407,14 @@ export interface components {
6360
6407
  editedAt: string | null;
6361
6408
  deletedAt: string | null;
6362
6409
  createdAt: string;
6363
- mentions: string[];
6410
+ mentions: components["schemas"]["CommentMention"][];
6411
+ };
6412
+ /** @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. */
6413
+ CommentMention: {
6414
+ userId: string;
6415
+ startOffset: number | null;
6416
+ endOffset: number | null;
6417
+ displayName: string;
6364
6418
  };
6365
6419
  /** @description A comment thread without its comments. */
6366
6420
  CommentThreadSummary: {
@@ -8774,6 +8828,45 @@ export interface operations {
8774
8828
  };
8775
8829
  };
8776
8830
  };
8831
+ listCompanyMdMentionableUsers: {
8832
+ parameters: {
8833
+ query?: {
8834
+ q?: string;
8835
+ limit?: number;
8836
+ };
8837
+ header?: never;
8838
+ path: {
8839
+ id: string;
8840
+ };
8841
+ cookie?: never;
8842
+ };
8843
+ requestBody?: never;
8844
+ responses: {
8845
+ /** @description Document readers the caller may mention, ordered and bounded */
8846
+ 200: {
8847
+ headers: {
8848
+ [name: string]: unknown;
8849
+ };
8850
+ content: {
8851
+ "application/json": components["schemas"]["CompanyMdMentionableResponse"];
8852
+ };
8853
+ };
8854
+ /** @description Invalid query parameters */
8855
+ 400: {
8856
+ headers: {
8857
+ [name: string]: unknown;
8858
+ };
8859
+ content?: never;
8860
+ };
8861
+ /** @description Document not found, or the caller may not comment on it */
8862
+ 404: {
8863
+ headers: {
8864
+ [name: string]: unknown;
8865
+ };
8866
+ content?: never;
8867
+ };
8868
+ };
8869
+ };
8777
8870
  extractCompanyMd: {
8778
8871
  parameters: {
8779
8872
  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
+ });