@company-semantics/contracts 48.0.0 → 50.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.
- package/package.json +1 -1
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +293 -4
- package/src/comments/README.md +24 -2
- package/src/comments/__tests__/anchor-corpus.test.ts +86 -0
- package/src/comments/__tests__/anchor-insertion.test.ts +136 -0
- package/src/comments/__tests__/fixtures/README.md +17 -0
- package/src/comments/__tests__/fixtures/comment-anchors.json +46 -1
- package/src/comments/__tests__/fixtures/comment-anchors.provenance.json +3 -3
- package/src/comments/__tests__/schemas.test.ts +79 -0
- package/src/comments/__tests__/suggestion.test.ts +130 -0
- package/src/comments/anchor.ts +62 -2
- package/src/comments/index.ts +14 -0
- package/src/comments/schemas.ts +20 -0
- package/src/comments/suggestion.ts +73 -0
- package/src/generated/openapi-routes.ts +4 -0
- package/src/index.ts +8 -0
package/package.json
CHANGED
|
@@ -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 = '7ed2b7218b4b' as const;
|
|
3
|
+
export const SPEC_HASH_FULL = '7ed2b7218b4b4a160ba6e9d8854f2e9277d540c33658448d9a0452e96f1cd581' as const;
|
package/src/api/generated.ts
CHANGED
|
@@ -3565,6 +3565,74 @@ export interface paths {
|
|
|
3565
3565
|
patch?: never;
|
|
3566
3566
|
trace?: never;
|
|
3567
3567
|
};
|
|
3568
|
+
"/api/comments/threads/{threadId}/accept-intent": {
|
|
3569
|
+
parameters: {
|
|
3570
|
+
query?: never;
|
|
3571
|
+
header?: never;
|
|
3572
|
+
path?: never;
|
|
3573
|
+
cookie?: never;
|
|
3574
|
+
};
|
|
3575
|
+
get?: never;
|
|
3576
|
+
put?: never;
|
|
3577
|
+
/** Claim the acceptance lease on an open suggestion */
|
|
3578
|
+
post: operations["acceptSuggestionIntent"];
|
|
3579
|
+
delete?: never;
|
|
3580
|
+
options?: never;
|
|
3581
|
+
head?: never;
|
|
3582
|
+
patch?: never;
|
|
3583
|
+
trace?: never;
|
|
3584
|
+
};
|
|
3585
|
+
"/api/comments/threads/{threadId}/accept-complete": {
|
|
3586
|
+
parameters: {
|
|
3587
|
+
query?: never;
|
|
3588
|
+
header?: never;
|
|
3589
|
+
path?: never;
|
|
3590
|
+
cookie?: never;
|
|
3591
|
+
};
|
|
3592
|
+
get?: never;
|
|
3593
|
+
put?: never;
|
|
3594
|
+
/** Record the accept decision by claim token */
|
|
3595
|
+
post: operations["acceptSuggestionComplete"];
|
|
3596
|
+
delete?: never;
|
|
3597
|
+
options?: never;
|
|
3598
|
+
head?: never;
|
|
3599
|
+
patch?: never;
|
|
3600
|
+
trace?: never;
|
|
3601
|
+
};
|
|
3602
|
+
"/api/comments/threads/{threadId}/accept-release": {
|
|
3603
|
+
parameters: {
|
|
3604
|
+
query?: never;
|
|
3605
|
+
header?: never;
|
|
3606
|
+
path?: never;
|
|
3607
|
+
cookie?: never;
|
|
3608
|
+
};
|
|
3609
|
+
get?: never;
|
|
3610
|
+
put?: never;
|
|
3611
|
+
/** Release a held acceptance lease without deciding */
|
|
3612
|
+
post: operations["releaseSuggestionClaim"];
|
|
3613
|
+
delete?: never;
|
|
3614
|
+
options?: never;
|
|
3615
|
+
head?: never;
|
|
3616
|
+
patch?: never;
|
|
3617
|
+
trace?: never;
|
|
3618
|
+
};
|
|
3619
|
+
"/api/comments/threads/{threadId}/reject": {
|
|
3620
|
+
parameters: {
|
|
3621
|
+
query?: never;
|
|
3622
|
+
header?: never;
|
|
3623
|
+
path?: never;
|
|
3624
|
+
cookie?: never;
|
|
3625
|
+
};
|
|
3626
|
+
get?: never;
|
|
3627
|
+
put?: never;
|
|
3628
|
+
/** Reject an open suggestion (or withdraw as its author) */
|
|
3629
|
+
post: operations["rejectSuggestion"];
|
|
3630
|
+
delete?: never;
|
|
3631
|
+
options?: never;
|
|
3632
|
+
head?: never;
|
|
3633
|
+
patch?: never;
|
|
3634
|
+
trace?: never;
|
|
3635
|
+
};
|
|
3568
3636
|
}
|
|
3569
3637
|
export type webhooks = Record<string, never>;
|
|
3570
3638
|
export interface components {
|
|
@@ -6397,7 +6465,16 @@ export interface components {
|
|
|
6397
6465
|
/** Format: uuid */
|
|
6398
6466
|
subjectId: string;
|
|
6399
6467
|
anchor: components["schemas"]["CommentAnchor"];
|
|
6400
|
-
|
|
6468
|
+
/** @enum {string} */
|
|
6469
|
+
kind?: "comment" | "suggestion";
|
|
6470
|
+
suggestion?: {
|
|
6471
|
+
/** @constant */
|
|
6472
|
+
v: 1;
|
|
6473
|
+
/** @enum {string} */
|
|
6474
|
+
op: "insert" | "delete" | "replace";
|
|
6475
|
+
insertedText?: string;
|
|
6476
|
+
};
|
|
6477
|
+
body?: string;
|
|
6401
6478
|
mentions?: {
|
|
6402
6479
|
/** Format: uuid */
|
|
6403
6480
|
userId: string;
|
|
@@ -6436,6 +6513,14 @@ export interface components {
|
|
|
6436
6513
|
CommentThreadSummaryResponse: {
|
|
6437
6514
|
thread: components["schemas"]["CommentThreadSummary"];
|
|
6438
6515
|
};
|
|
6516
|
+
/** @description The acceptance lease: the opaque claim token that alone can complete the accept, and how long the lease lives. */
|
|
6517
|
+
SuggestionClaimResponse: {
|
|
6518
|
+
claimToken: string;
|
|
6519
|
+
leaseDurationMs: number;
|
|
6520
|
+
};
|
|
6521
|
+
SuggestionClaimRequest: {
|
|
6522
|
+
claimToken: string;
|
|
6523
|
+
};
|
|
6439
6524
|
/** @description Server-enforced collaboration size limits, published so clients can pre-check. */
|
|
6440
6525
|
CollabSyncLimits: {
|
|
6441
6526
|
maxUpdateBytes: number;
|
|
@@ -6473,10 +6558,13 @@ export interface components {
|
|
|
6473
6558
|
subjectType: "company_md" | "strategy_doc" | "work_item" | "meeting_recording";
|
|
6474
6559
|
subjectId: string;
|
|
6475
6560
|
/** @enum {string} */
|
|
6476
|
-
anchorType: "document" | "text";
|
|
6561
|
+
anchorType: "document" | "text" | "text-insertion";
|
|
6477
6562
|
anchor: components["schemas"]["CommentAnchor"];
|
|
6478
6563
|
/** @enum {string} */
|
|
6479
6564
|
status: "open" | "resolved";
|
|
6565
|
+
/** @enum {string} */
|
|
6566
|
+
kind: "comment" | "suggestion";
|
|
6567
|
+
suggestion: components["schemas"]["Suggestion"] | null;
|
|
6480
6568
|
createdByUserId: string | null;
|
|
6481
6569
|
resolvedByUserId: string | null;
|
|
6482
6570
|
resolvedAt: string | null;
|
|
@@ -6484,7 +6572,7 @@ export interface components {
|
|
|
6484
6572
|
updatedAt: string;
|
|
6485
6573
|
comments: components["schemas"]["Comment"][];
|
|
6486
6574
|
};
|
|
6487
|
-
/** @description Opaque, versioned comment anchor: document-level,
|
|
6575
|
+
/** @description Opaque, versioned comment anchor: document-level, a text anchor with a required quote and optional relative positions, or a zero-width text-insertion point for insert suggestions. */
|
|
6488
6576
|
CommentAnchor: {
|
|
6489
6577
|
/** @constant */
|
|
6490
6578
|
type: "document";
|
|
@@ -6500,6 +6588,24 @@ export interface components {
|
|
|
6500
6588
|
quote: string;
|
|
6501
6589
|
prefix: string;
|
|
6502
6590
|
suffix: string;
|
|
6591
|
+
} | {
|
|
6592
|
+
/** @constant */
|
|
6593
|
+
type: "text-insertion";
|
|
6594
|
+
/** @constant */
|
|
6595
|
+
v: 1;
|
|
6596
|
+
relPos?: string;
|
|
6597
|
+
leftContext: string;
|
|
6598
|
+
rightContext: string;
|
|
6599
|
+
};
|
|
6600
|
+
/** @description A suggested edit as proposed: the operation, its insertedText (insert/replace only), and the decision status composed from the thread row. */
|
|
6601
|
+
Suggestion: {
|
|
6602
|
+
/** @constant */
|
|
6603
|
+
v: 1;
|
|
6604
|
+
/** @enum {string} */
|
|
6605
|
+
op: "insert" | "delete" | "replace";
|
|
6606
|
+
insertedText?: string;
|
|
6607
|
+
/** @enum {string} */
|
|
6608
|
+
status: "open" | "accepted" | "rejected";
|
|
6503
6609
|
};
|
|
6504
6610
|
/** @description One comment; body is null iff soft-deleted (redacted projection with empty mentions). */
|
|
6505
6611
|
Comment: {
|
|
@@ -6526,10 +6632,13 @@ export interface components {
|
|
|
6526
6632
|
subjectType: "company_md" | "strategy_doc" | "work_item" | "meeting_recording";
|
|
6527
6633
|
subjectId: string;
|
|
6528
6634
|
/** @enum {string} */
|
|
6529
|
-
anchorType: "document" | "text";
|
|
6635
|
+
anchorType: "document" | "text" | "text-insertion";
|
|
6530
6636
|
anchor: components["schemas"]["CommentAnchor"];
|
|
6531
6637
|
/** @enum {string} */
|
|
6532
6638
|
status: "open" | "resolved";
|
|
6639
|
+
/** @enum {string} */
|
|
6640
|
+
kind: "comment" | "suggestion";
|
|
6641
|
+
suggestion: components["schemas"]["Suggestion"] | null;
|
|
6533
6642
|
createdByUserId: string | null;
|
|
6534
6643
|
resolvedByUserId: string | null;
|
|
6535
6644
|
resolvedAt: string | null;
|
|
@@ -12635,6 +12744,13 @@ export interface operations {
|
|
|
12635
12744
|
};
|
|
12636
12745
|
content?: never;
|
|
12637
12746
|
};
|
|
12747
|
+
/** @description Not a comment thread: a suggestion is decided via accept or reject, never resolved */
|
|
12748
|
+
409: {
|
|
12749
|
+
headers: {
|
|
12750
|
+
[name: string]: unknown;
|
|
12751
|
+
};
|
|
12752
|
+
content?: never;
|
|
12753
|
+
};
|
|
12638
12754
|
};
|
|
12639
12755
|
};
|
|
12640
12756
|
reopenCommentThread: {
|
|
@@ -12664,6 +12780,179 @@ export interface operations {
|
|
|
12664
12780
|
};
|
|
12665
12781
|
content?: never;
|
|
12666
12782
|
};
|
|
12783
|
+
/** @description Not a comment thread: a decided suggestion is terminal and never reopens */
|
|
12784
|
+
409: {
|
|
12785
|
+
headers: {
|
|
12786
|
+
[name: string]: unknown;
|
|
12787
|
+
};
|
|
12788
|
+
content?: never;
|
|
12789
|
+
};
|
|
12790
|
+
};
|
|
12791
|
+
};
|
|
12792
|
+
acceptSuggestionIntent: {
|
|
12793
|
+
parameters: {
|
|
12794
|
+
query?: never;
|
|
12795
|
+
header?: never;
|
|
12796
|
+
path: {
|
|
12797
|
+
threadId: string;
|
|
12798
|
+
};
|
|
12799
|
+
cookie?: never;
|
|
12800
|
+
};
|
|
12801
|
+
requestBody?: never;
|
|
12802
|
+
responses: {
|
|
12803
|
+
/** @description Lease claimed: the opaque claim token (the only artifact that can complete the accept) and its duration */
|
|
12804
|
+
200: {
|
|
12805
|
+
headers: {
|
|
12806
|
+
[name: string]: unknown;
|
|
12807
|
+
};
|
|
12808
|
+
content: {
|
|
12809
|
+
"application/json": components["schemas"]["SuggestionClaimResponse"];
|
|
12810
|
+
};
|
|
12811
|
+
};
|
|
12812
|
+
/** @description Thread not found, or the caller may not decide suggestions on it */
|
|
12813
|
+
404: {
|
|
12814
|
+
headers: {
|
|
12815
|
+
[name: string]: unknown;
|
|
12816
|
+
};
|
|
12817
|
+
content?: never;
|
|
12818
|
+
};
|
|
12819
|
+
/** @description Not a suggestion thread, already claimed by a live lease, or already decided */
|
|
12820
|
+
409: {
|
|
12821
|
+
headers: {
|
|
12822
|
+
[name: string]: unknown;
|
|
12823
|
+
};
|
|
12824
|
+
content?: never;
|
|
12825
|
+
};
|
|
12826
|
+
};
|
|
12827
|
+
};
|
|
12828
|
+
acceptSuggestionComplete: {
|
|
12829
|
+
parameters: {
|
|
12830
|
+
query?: never;
|
|
12831
|
+
header?: never;
|
|
12832
|
+
path: {
|
|
12833
|
+
threadId: string;
|
|
12834
|
+
};
|
|
12835
|
+
cookie?: never;
|
|
12836
|
+
};
|
|
12837
|
+
requestBody: {
|
|
12838
|
+
content: {
|
|
12839
|
+
"application/json": components["schemas"]["SuggestionClaimRequest"];
|
|
12840
|
+
};
|
|
12841
|
+
};
|
|
12842
|
+
responses: {
|
|
12843
|
+
/** @description Suggestion accepted (thread resolved); valid after lease expiry and retry-safe for the same actor */
|
|
12844
|
+
200: {
|
|
12845
|
+
headers: {
|
|
12846
|
+
[name: string]: unknown;
|
|
12847
|
+
};
|
|
12848
|
+
content: {
|
|
12849
|
+
"application/json": components["schemas"]["CommentThreadSummaryResponse"];
|
|
12850
|
+
};
|
|
12851
|
+
};
|
|
12852
|
+
/** @description Malformed claim token */
|
|
12853
|
+
400: {
|
|
12854
|
+
headers: {
|
|
12855
|
+
[name: string]: unknown;
|
|
12856
|
+
};
|
|
12857
|
+
content?: never;
|
|
12858
|
+
};
|
|
12859
|
+
/** @description Thread not found, or the caller may not read its subject */
|
|
12860
|
+
404: {
|
|
12861
|
+
headers: {
|
|
12862
|
+
[name: string]: unknown;
|
|
12863
|
+
};
|
|
12864
|
+
content?: never;
|
|
12865
|
+
};
|
|
12866
|
+
/** @description Not a suggestion thread, or the token does not match a live claim on an open suggestion */
|
|
12867
|
+
409: {
|
|
12868
|
+
headers: {
|
|
12869
|
+
[name: string]: unknown;
|
|
12870
|
+
};
|
|
12871
|
+
content?: never;
|
|
12872
|
+
};
|
|
12873
|
+
};
|
|
12874
|
+
};
|
|
12875
|
+
releaseSuggestionClaim: {
|
|
12876
|
+
parameters: {
|
|
12877
|
+
query?: never;
|
|
12878
|
+
header?: never;
|
|
12879
|
+
path: {
|
|
12880
|
+
threadId: string;
|
|
12881
|
+
};
|
|
12882
|
+
cookie?: never;
|
|
12883
|
+
};
|
|
12884
|
+
requestBody: {
|
|
12885
|
+
content: {
|
|
12886
|
+
"application/json": components["schemas"]["SuggestionClaimRequest"];
|
|
12887
|
+
};
|
|
12888
|
+
};
|
|
12889
|
+
responses: {
|
|
12890
|
+
/** @description Lease released; the suggestion stays open and claimable */
|
|
12891
|
+
200: {
|
|
12892
|
+
headers: {
|
|
12893
|
+
[name: string]: unknown;
|
|
12894
|
+
};
|
|
12895
|
+
content: {
|
|
12896
|
+
"application/json": components["schemas"]["CommentThreadSummaryResponse"];
|
|
12897
|
+
};
|
|
12898
|
+
};
|
|
12899
|
+
/** @description Malformed claim token */
|
|
12900
|
+
400: {
|
|
12901
|
+
headers: {
|
|
12902
|
+
[name: string]: unknown;
|
|
12903
|
+
};
|
|
12904
|
+
content?: never;
|
|
12905
|
+
};
|
|
12906
|
+
/** @description Thread not found, or the caller may not read its subject */
|
|
12907
|
+
404: {
|
|
12908
|
+
headers: {
|
|
12909
|
+
[name: string]: unknown;
|
|
12910
|
+
};
|
|
12911
|
+
content?: never;
|
|
12912
|
+
};
|
|
12913
|
+
/** @description Not a suggestion thread, or the token does not match a live claim on an open suggestion */
|
|
12914
|
+
409: {
|
|
12915
|
+
headers: {
|
|
12916
|
+
[name: string]: unknown;
|
|
12917
|
+
};
|
|
12918
|
+
content?: never;
|
|
12919
|
+
};
|
|
12920
|
+
};
|
|
12921
|
+
};
|
|
12922
|
+
rejectSuggestion: {
|
|
12923
|
+
parameters: {
|
|
12924
|
+
query?: never;
|
|
12925
|
+
header?: never;
|
|
12926
|
+
path: {
|
|
12927
|
+
threadId: string;
|
|
12928
|
+
};
|
|
12929
|
+
cookie?: never;
|
|
12930
|
+
};
|
|
12931
|
+
requestBody?: never;
|
|
12932
|
+
responses: {
|
|
12933
|
+
/** @description Suggestion rejected (thread resolved) */
|
|
12934
|
+
200: {
|
|
12935
|
+
headers: {
|
|
12936
|
+
[name: string]: unknown;
|
|
12937
|
+
};
|
|
12938
|
+
content: {
|
|
12939
|
+
"application/json": components["schemas"]["CommentThreadSummaryResponse"];
|
|
12940
|
+
};
|
|
12941
|
+
};
|
|
12942
|
+
/** @description Thread not found, or the caller may not decide suggestions on it */
|
|
12943
|
+
404: {
|
|
12944
|
+
headers: {
|
|
12945
|
+
[name: string]: unknown;
|
|
12946
|
+
};
|
|
12947
|
+
content?: never;
|
|
12948
|
+
};
|
|
12949
|
+
/** @description Not a suggestion thread, a live acceptance lease is held, or already decided */
|
|
12950
|
+
409: {
|
|
12951
|
+
headers: {
|
|
12952
|
+
[name: string]: unknown;
|
|
12953
|
+
};
|
|
12954
|
+
content?: never;
|
|
12955
|
+
};
|
|
12667
12956
|
};
|
|
12668
12957
|
};
|
|
12669
12958
|
}
|
package/src/comments/README.md
CHANGED
|
@@ -36,6 +36,22 @@ arbitrate — see the first invariant.
|
|
|
36
36
|
`relStart` is refused rather than accepted with the extras dropped: a client
|
|
37
37
|
that sent them meant to anchor to text and got the discriminator wrong, and
|
|
38
38
|
storing the mistake yields a thread that can never be placed.
|
|
39
|
+
- **A `text-insertion` anchor is a REAL zero-width point, and resolution never
|
|
40
|
+
guesses.** An insert suggestion is not anchored to an adjacent quoted run of
|
|
41
|
+
existing text — a persisted suggestion outlives concurrent edits, and an
|
|
42
|
+
adjacency convention resolves against whichever run survives, the wrong one,
|
|
43
|
+
silently. Instead the point carries an optional `relPos` (one
|
|
44
|
+
`Y.RelativePosition` at the caret) plus bilateral `leftContext`/`rightContext`
|
|
45
|
+
(each max 64 UTF-16 code units, required-but-may-be-empty at document edges).
|
|
46
|
+
Resolution, implemented client-side: rung 1 decodes `relPos` and verifies
|
|
47
|
+
BOTH contexts around the decoded point (stale-not-correct, like the text
|
|
48
|
+
anchor's quote check); rung 2 searches for the UNIQUE
|
|
49
|
+
`leftContext`+`rightContext` seam — any ambiguity resolves orphaned, never a
|
|
50
|
+
guess; rung 3 orphaned. This variant ships contracts-first: its corpus cases
|
|
51
|
+
were added HERE with the deliberate same-diff digest re-pin ADR-CONTRACTS-116
|
|
52
|
+
allows (PRD-00933), and the backend boundary adopts both in PRD-00934 — until
|
|
53
|
+
then the upstream diff shows expected forward drift, and `anchor-corpus:sync`
|
|
54
|
+
must not be run (it would erase the new cases).
|
|
39
55
|
- **A comment's `body` is null exactly when `deletedAt` is set, and such a
|
|
40
56
|
comment carries no mentions.** A soft-deleted comment is a REDACTED
|
|
41
57
|
PROJECTION, not a removed row — the tombstone keeps its position in the
|
|
@@ -79,7 +95,7 @@ arbitrate — see the first invariant.
|
|
|
79
95
|
|
|
80
96
|
| Export | Description |
|
|
81
97
|
| --------------------------------- | --------------------------------------------------------------------------- |
|
|
82
|
-
| `CommentAnchorSchema` | `{document,v:1}` \| `{text,v:1,…}`, strict
|
|
98
|
+
| `CommentAnchorSchema` | `{document,v:1}` \| `{text,v:1,…}` \| `{text-insertion,v:1,…}`, strict |
|
|
83
99
|
| `COMMENT_ANCHOR_TYPES` | The anchor discriminants, as a vocabulary tuple |
|
|
84
100
|
| `CommentAnchorTypeSchema` | Zod mirror of `COMMENT_ANCHOR_TYPES` — what `anchorType` is checked against |
|
|
85
101
|
| `COMMENT_THREAD_STATUSES` | `open` \| `resolved` — there is no third state |
|
|
@@ -93,11 +109,17 @@ arbitrate — see the first invariant.
|
|
|
93
109
|
| `CommentThreadListResponseSchema` | `GET /api/comments` |
|
|
94
110
|
| `MentionableCandidateSchema` | One offerable mention target — no email, no access level |
|
|
95
111
|
| `MentionableResponseSchema` | `GET /api/company-md/docs/{id}/mentionable` |
|
|
112
|
+
| `COMMENT_THREAD_KINDS` | `comment` \| `suggestion` — a suggestion IS a comment thread |
|
|
113
|
+
| `SUGGESTION_OPS` | `insert` \| `delete` \| `replace` — what a suggestion proposes |
|
|
114
|
+
| `SUGGESTION_STATUSES` | `open` \| `accepted` \| `rejected` — the SECOND status level, terminal |
|
|
115
|
+
| `SuggestionSchema` | The versioned suggestion payload (`v:1`), strict, `insertedText` optional |
|
|
96
116
|
|
|
97
117
|
## Dependencies
|
|
98
118
|
|
|
99
119
|
- `zod` — schemas are canonical, types are inferred.
|
|
100
|
-
- `./anchor` ← `./schemas
|
|
120
|
+
- `./anchor` ← `./schemas`; `./suggestion` is a leaf (zod only) so `./schemas`
|
|
121
|
+
can import the thread-kind projection from it without a cycle.
|
|
122
|
+
- Nothing outside this directory is imported: the
|
|
101
123
|
comment vocabulary binds to the existing `commenter` band of
|
|
102
124
|
`../permissions`'s `AccessLevel` and introduces no access level of its own, so
|
|
103
125
|
it needs no import to say so.
|
|
@@ -23,6 +23,14 @@
|
|
|
23
23
|
* re-copies, and ADR-CONTRACTS-116 records that the upstream half is still a
|
|
24
24
|
* documented promise rather than a gate.
|
|
25
25
|
*
|
|
26
|
+
* ONE deliberate exception is live (PRD-00933, ADR slug
|
|
27
|
+
* suggested-edits-wire-vocabulary): the text-insertion cases were added HERE
|
|
28
|
+
* first, with the same-diff re-pin ADR-CONTRACTS-116 allows, because that
|
|
29
|
+
* variant is published before the backend boundary adopts it. The backend
|
|
30
|
+
* copy catches up in PRD-00934 — until then `--check` reports expected
|
|
31
|
+
* forward drift where an upstream tree is reachable, and `sync` must NOT be
|
|
32
|
+
* run (it would overwrite the new cases with the older upstream file).
|
|
33
|
+
*
|
|
26
34
|
* The named rejection tests below are additional rather than a substitute: the
|
|
27
35
|
* corpus covers the same ground by fixture name, and these say in prose what
|
|
28
36
|
* the strict variants are FOR.
|
|
@@ -41,6 +49,24 @@ interface AnchorCase {
|
|
|
41
49
|
anchor: unknown;
|
|
42
50
|
}
|
|
43
51
|
|
|
52
|
+
/**
|
|
53
|
+
* The additive fields the text-insertion rows carry beside `anchor`
|
|
54
|
+
* (PRD-00933): the document the anchor was authored against and the outcome
|
|
55
|
+
* the resolution ladder must produce for it. Inert descriptive data as far as
|
|
56
|
+
* the parse loops are concerned — the suite below is what keeps it honest.
|
|
57
|
+
*/
|
|
58
|
+
interface InsertionFixture extends AnchorCase {
|
|
59
|
+
anchor: { type: "text-insertion"; leftContext: string; rightContext: string };
|
|
60
|
+
document: string;
|
|
61
|
+
expectedResolution:
|
|
62
|
+
{ outcome: "resolved"; offset: number } | { outcome: "orphaned" };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const isInsertionFixture = (c: AnchorCase): c is InsertionFixture => {
|
|
66
|
+
const anchor = c.anchor as { type?: unknown };
|
|
67
|
+
return anchor !== null && anchor?.type === "text-insertion";
|
|
68
|
+
};
|
|
69
|
+
|
|
44
70
|
/** The subset of `comment-anchors.provenance.json` this suite enforces. */
|
|
45
71
|
interface CorpusPin {
|
|
46
72
|
source: { repo: string; path: string };
|
|
@@ -118,6 +144,66 @@ describe("CommentAnchorSchema against the mirrored corpus", () => {
|
|
|
118
144
|
}
|
|
119
145
|
});
|
|
120
146
|
|
|
147
|
+
describe("the text-insertion fixtures resolve as they claim", () => {
|
|
148
|
+
// The corpus's text-insertion rows record a document and an expected
|
|
149
|
+
// resolution outcome (PRD-00933) so the app's resolver (PRD-00935+) has
|
|
150
|
+
// pinned ground truth to test against. Nothing parses those fields, so
|
|
151
|
+
// without this suite a fixture whose document does not actually contain the
|
|
152
|
+
// seam it claims — or contains it twice where it claims once — would sit
|
|
153
|
+
// green here and mislead the resolver's suite downstream. This block
|
|
154
|
+
// re-derives each outcome from the document under the rung-2 semantics
|
|
155
|
+
// documented on `TextInsertionAnchorSchema`: a seam is an offset where
|
|
156
|
+
// `leftContext` ends and `rightContext` begins, only a UNIQUE seam
|
|
157
|
+
// resolves, and any ambiguity is orphaned — never a guess.
|
|
158
|
+
const seamOffsets = (
|
|
159
|
+
document: string,
|
|
160
|
+
left: string,
|
|
161
|
+
right: string,
|
|
162
|
+
): number[] => {
|
|
163
|
+
const offsets: number[] = [];
|
|
164
|
+
// <= length: the seam after the final character is a real insertion
|
|
165
|
+
// point (document end). All arithmetic is UTF-16 code units — plain
|
|
166
|
+
// JS string coordinates, no Unicode normalization.
|
|
167
|
+
for (let i = 0; i <= document.length; i++) {
|
|
168
|
+
if (
|
|
169
|
+
i >= left.length &&
|
|
170
|
+
document.slice(i - left.length, i) === left &&
|
|
171
|
+
document.startsWith(right, i)
|
|
172
|
+
) {
|
|
173
|
+
offsets.push(i);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return offsets;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const insertionCases = corpus.accept.filter(isInsertionFixture);
|
|
180
|
+
|
|
181
|
+
it("has the four required insertion fixtures, so none went missing", () => {
|
|
182
|
+
// Position 0, document end, ambiguous repeated seam, surrogate-pair
|
|
183
|
+
// flanked — the user-specified minimum. A corpus edit that drops one
|
|
184
|
+
// must fail loudly, not shrink the loop below.
|
|
185
|
+
expect(insertionCases.length).toBeGreaterThanOrEqual(4);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
for (const { name, anchor, document, expectedResolution } of insertionCases) {
|
|
189
|
+
it(`resolves ${name} to its recorded outcome`, () => {
|
|
190
|
+
const offsets = seamOffsets(
|
|
191
|
+
document,
|
|
192
|
+
anchor.leftContext,
|
|
193
|
+
anchor.rightContext,
|
|
194
|
+
);
|
|
195
|
+
if (expectedResolution.outcome === "resolved") {
|
|
196
|
+
expect(offsets).toEqual([expectedResolution.offset]);
|
|
197
|
+
} else {
|
|
198
|
+
// Orphaned-by-ambiguity: the seam occurs MORE than once. (A seam
|
|
199
|
+
// occurring zero times is also orphaned, but these fixtures pin the
|
|
200
|
+
// ambiguity rule specifically — the case where guessing is tempting.)
|
|
201
|
+
expect(offsets.length).toBeGreaterThan(1);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
121
207
|
describe("the anchor's load-bearing refusals, named", () => {
|
|
122
208
|
it("rejects a text anchor with no quote", () => {
|
|
123
209
|
// `quote` is the DURABLE FALLBACK — what a resolving client re-finds the
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The text-insertion anchor variant, held to its published bounds
|
|
3
|
+
* (ADR slug suggested-edits-wire-vocabulary).
|
|
4
|
+
*
|
|
5
|
+
* HAND-WRITTEN RATHER THAN CORPUS-DRIVEN, unlike the sibling suite: each
|
|
6
|
+
* case here states in prose what one published bound is FOR. The mirrored
|
|
7
|
+
* corpus in `./fixtures/comment-anchors.json` ALSO carries text-insertion
|
|
8
|
+
* cases — added contracts-first under PRD-00933 with the deliberate same-diff
|
|
9
|
+
* digest re-pin ADR-CONTRACTS-116 allows, since this variant ships before
|
|
10
|
+
* the backend boundary adopts it (PRD-00934). Those corpus rows additionally
|
|
11
|
+
* pin document text and expected resolution outcomes, which this suite does
|
|
12
|
+
* not duplicate.
|
|
13
|
+
*
|
|
14
|
+
* Negative tests mutate ONE field of a well-formed factory object, so each
|
|
15
|
+
* fails on the field under test and nothing incidental.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, expect, it } from "vitest";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
COMMENT_ANCHOR_TYPES,
|
|
21
|
+
CommentAnchorSchema,
|
|
22
|
+
CommentAnchorTypeSchema,
|
|
23
|
+
} from "../anchor.js";
|
|
24
|
+
|
|
25
|
+
/** A well-formed insertion anchor; override one field per negative test. */
|
|
26
|
+
const makeInsertionAnchor = (over: Record<string, unknown> = {}) => ({
|
|
27
|
+
type: "text-insertion",
|
|
28
|
+
v: 1,
|
|
29
|
+
relPos: "AQLmzsvNAgA=",
|
|
30
|
+
leftContext: "the quarterly ",
|
|
31
|
+
rightContext: " goals",
|
|
32
|
+
...over,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("the text-insertion anchor variant", () => {
|
|
36
|
+
it("accepts a full anchor: relPos plus bilateral context", () => {
|
|
37
|
+
expect(CommentAnchorSchema.safeParse(makeInsertionAnchor()).success).toBe(
|
|
38
|
+
true,
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("accepts an anchor with no relPos — the quote-path twin", () => {
|
|
43
|
+
// A client without an attached collab session cannot encode a
|
|
44
|
+
// Y.RelativePosition; the context seam is then the only resolution path.
|
|
45
|
+
// If this stops parsing, suggesting an insertion from outside the editor
|
|
46
|
+
// breaks.
|
|
47
|
+
const { relPos: _relPos, ...rest } = makeInsertionAnchor();
|
|
48
|
+
expect(CommentAnchorSchema.safeParse(rest).success).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("accepts an empty leftContext — insertion at position 0", () => {
|
|
52
|
+
expect(
|
|
53
|
+
CommentAnchorSchema.safeParse(makeInsertionAnchor({ leftContext: "" }))
|
|
54
|
+
.success,
|
|
55
|
+
).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("accepts an empty rightContext — insertion at document end", () => {
|
|
59
|
+
expect(
|
|
60
|
+
CommentAnchorSchema.safeParse(makeInsertionAnchor({ rightContext: "" }))
|
|
61
|
+
.success,
|
|
62
|
+
).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("accepts contexts measured in UTF-16 code units, emoji included", () => {
|
|
66
|
+
// 32 astral emoji = 64 UTF-16 code units: exactly at the bound. The
|
|
67
|
+
// published bound is code units, not code points — the same coordinate
|
|
68
|
+
// system as mention offsets, with no Unicode normalization anywhere.
|
|
69
|
+
const emoji64 = "🎯".repeat(32);
|
|
70
|
+
expect(emoji64.length).toBe(64);
|
|
71
|
+
expect(
|
|
72
|
+
CommentAnchorSchema.safeParse(
|
|
73
|
+
makeInsertionAnchor({ leftContext: emoji64, rightContext: emoji64 }),
|
|
74
|
+
).success,
|
|
75
|
+
).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("rejects a context over 64 code units", () => {
|
|
79
|
+
// Bounded like prefix/suffix: context disambiguates a seam, it does not
|
|
80
|
+
// carry the document.
|
|
81
|
+
expect(
|
|
82
|
+
CommentAnchorSchema.safeParse(
|
|
83
|
+
makeInsertionAnchor({ leftContext: "x".repeat(65) }),
|
|
84
|
+
).success,
|
|
85
|
+
).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("rejects a missing context field", () => {
|
|
89
|
+
// leftContext/rightContext are REQUIRED-but-may-be-empty, so "nothing on
|
|
90
|
+
// that side" and "this client forgot to send it" stay distinguishable.
|
|
91
|
+
const { rightContext: _rightContext, ...rest } = makeInsertionAnchor();
|
|
92
|
+
expect(CommentAnchorSchema.safeParse(rest).success).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("rejects an unknown version", () => {
|
|
96
|
+
// `v` is the evolution hatch: a new shape is a new `v`, and a resolving
|
|
97
|
+
// client must never guess at fields it half-recognises.
|
|
98
|
+
expect(
|
|
99
|
+
CommentAnchorSchema.safeParse(makeInsertionAnchor({ v: 2 })).success,
|
|
100
|
+
).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("rejects a relPos that is not base64", () => {
|
|
104
|
+
expect(
|
|
105
|
+
CommentAnchorSchema.safeParse(
|
|
106
|
+
makeInsertionAnchor({ relPos: "not base64!!" }),
|
|
107
|
+
).success,
|
|
108
|
+
).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("rejects text-anchor fields on an insertion anchor", () => {
|
|
112
|
+
// STRICT like the other variants: a client that sent `quote` meant to
|
|
113
|
+
// anchor to a range and got the discriminator wrong; storing the mistake
|
|
114
|
+
// yields a suggestion that can never be placed.
|
|
115
|
+
expect(
|
|
116
|
+
CommentAnchorSchema.safeParse(makeInsertionAnchor({ quote: "stray" }))
|
|
117
|
+
.success,
|
|
118
|
+
).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("the widened anchor-type vocabulary", () => {
|
|
123
|
+
it("carries text-insertion in the tuple and the enum", () => {
|
|
124
|
+
// What CommentThreadSummarySchema's denormalised `anchorType` projection
|
|
125
|
+
// is checked against — a suggestion thread's summary row must not fail to
|
|
126
|
+
// parse on its own discriminant.
|
|
127
|
+
expect(COMMENT_ANCHOR_TYPES).toContain("text-insertion");
|
|
128
|
+
expect(CommentAnchorTypeSchema.safeParse("text-insertion").success).toBe(
|
|
129
|
+
true,
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("still refuses discriminants outside the vocabulary", () => {
|
|
134
|
+
expect(CommentAnchorTypeSchema.safeParse("region").success).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -35,6 +35,23 @@ obvious to the next reader.
|
|
|
35
35
|
rewrites the pin in the same step. Editing this side to make a test pass fails
|
|
36
36
|
the pin; editing it and re-pinning by hand defeats the check, and shows up as
|
|
37
37
|
exactly that in the diff.
|
|
38
|
+
- **ONE deliberate exception is live (PRD-00933, ADR slug
|
|
39
|
+
suggested-edits-wire-vocabulary).** The `text-insertion` cases were added
|
|
40
|
+
HERE first, with the same-diff re-pin ADR-CONTRACTS-116 explicitly allows,
|
|
41
|
+
because that anchor variant is published contracts-first — the backend
|
|
42
|
+
boundary adopts it in PRD-00934, against the npm-published version of this
|
|
43
|
+
package, and only then mirrors these cases into its own fixture file. Until
|
|
44
|
+
that lands, `pnpm anchor-corpus:check` reports expected FORWARD drift
|
|
45
|
+
wherever a backend tree is reachable, and `pnpm anchor-corpus:sync` must
|
|
46
|
+
NOT be run: it would overwrite the new cases with the older upstream file.
|
|
47
|
+
- **Text-insertion rows carry two additive fields beside `anchor`:**
|
|
48
|
+
`document` (the text the anchor was authored against) and
|
|
49
|
+
`expectedResolution` (`{ outcome: "resolved", offset }` in zero-based UTF-16
|
|
50
|
+
code units, or `{ outcome: "orphaned" }` when the context seam is ambiguous
|
|
51
|
+
and resolution must never guess). The parse loops ignore them; the
|
|
52
|
+
fixture-consistency suite in `../anchor-corpus.test.ts` re-derives each
|
|
53
|
+
outcome from the document, so the recorded ground truth cannot rot silently
|
|
54
|
+
before the app's resolver (PRD-00935+) tests against it.
|
|
38
55
|
- **It is listed in `/.prettierignore`.** Prettier reflows the long anchor
|
|
39
56
|
objects, which would break the byte identity (and fail the pin). The file
|
|
40
57
|
carries the backend's formatting, not this repo's. The pin beside it is NOT
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$comment": "STABLE CONTRACT CORPUS for the comment-anchor route boundary (PRD-00920 / ADR-BE-512 §4). Named accept/reject cases for CommentAnchorSchema (src/api/http/routes/comments/comments.schemas.ts). PRD-00922 copies this file into @company-semantics/contracts and mechanically diffs the two copies, so schema drift between the backend boundary and the published contract is caught by a file comparison. Change cases here only together with the schema; never hand-tune one side.",
|
|
2
|
+
"$comment": "STABLE CONTRACT CORPUS for the comment-anchor route boundary (PRD-00920 / ADR-BE-512 §4). Named accept/reject cases for CommentAnchorSchema (src/api/http/routes/comments/comments.schemas.ts). PRD-00922 copies this file into @company-semantics/contracts and mechanically diffs the two copies, so schema drift between the backend boundary and the published contract is caught by a file comparison. Change cases here only together with the schema; never hand-tune one side. EXCEPTION (PRD-00933, ADR slug suggested-edits-wire-vocabulary): the text-insertion cases below were added CONTRACTS-FIRST, with a deliberate digest re-pin in the same diff, because that anchor variant is published here before the backend boundary adopts it. The backend copy catches up in PRD-00934 — until then the upstream diff shows expected forward drift; do NOT 'fix' it with anchor-corpus:sync, which would erase these cases. Text-insertion rows additively carry 'document' and 'expectedResolution' beside 'anchor': inert descriptive data pinning the resolution-ladder outcome (offsets are zero-based UTF-16 code units; 'orphaned' means the context seam is ambiguous and resolution must never guess). The parse loops ignore the extra keys.",
|
|
3
3
|
"accept": [
|
|
4
4
|
{
|
|
5
5
|
"name": "document-anchor",
|
|
@@ -26,6 +26,51 @@
|
|
|
26
26
|
"prefix": "",
|
|
27
27
|
"suffix": ""
|
|
28
28
|
}
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"name": "text-insertion-at-position-0",
|
|
32
|
+
"anchor": {
|
|
33
|
+
"type": "text-insertion",
|
|
34
|
+
"v": 1,
|
|
35
|
+
"leftContext": "",
|
|
36
|
+
"rightContext": "Welcome to"
|
|
37
|
+
},
|
|
38
|
+
"document": "Welcome to the quarterly plan.",
|
|
39
|
+
"expectedResolution": { "outcome": "resolved", "offset": 0 }
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"name": "text-insertion-at-document-end",
|
|
43
|
+
"anchor": {
|
|
44
|
+
"type": "text-insertion",
|
|
45
|
+
"v": 1,
|
|
46
|
+
"leftContext": "quarterly goals.",
|
|
47
|
+
"rightContext": ""
|
|
48
|
+
},
|
|
49
|
+
"document": "We agreed to ship the quarterly goals.",
|
|
50
|
+
"expectedResolution": { "outcome": "resolved", "offset": 38 }
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"name": "text-insertion-ambiguous-repeated-seam",
|
|
54
|
+
"anchor": {
|
|
55
|
+
"type": "text-insertion",
|
|
56
|
+
"v": 1,
|
|
57
|
+
"leftContext": "item ",
|
|
58
|
+
"rightContext": "done"
|
|
59
|
+
},
|
|
60
|
+
"document": "item done, item done, item done",
|
|
61
|
+
"expectedResolution": { "outcome": "orphaned" }
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"name": "text-insertion-flanked-by-surrogate-pairs",
|
|
65
|
+
"anchor": {
|
|
66
|
+
"type": "text-insertion",
|
|
67
|
+
"v": 1,
|
|
68
|
+
"relPos": "AQLmzsvNAgA=",
|
|
69
|
+
"leftContext": "goals 🎯",
|
|
70
|
+
"rightContext": "🚀 shipped"
|
|
71
|
+
},
|
|
72
|
+
"document": "Q3 goals 🎯🚀 shipped early",
|
|
73
|
+
"expectedResolution": { "outcome": "resolved", "offset": 11 }
|
|
29
74
|
}
|
|
30
75
|
],
|
|
31
76
|
"reject": [
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$comment": "Provenance pin for ./comment-anchors.json, which is a byte-for-byte copy of company-semantics-backend/tests/fixtures/comment-anchors.json (ADR-CONTRACTS-116). anchor-corpus.test.ts re-derives this digest from the file's bytes on every run, so an edit made on THIS side fails CI instead of quietly making the corpus this repo's own. Drift introduced on the BACKEND side is not observable from contracts CI and is not gated here. Regenerate with `pnpm anchor-corpus:sync`; never hand-edit this file or the corpus.",
|
|
2
|
+
"$comment": "Provenance pin for ./comment-anchors.json, which is a byte-for-byte copy of company-semantics-backend/tests/fixtures/comment-anchors.json (ADR-CONTRACTS-116). anchor-corpus.test.ts re-derives this digest from the file's bytes on every run, so an edit made on THIS side fails CI instead of quietly making the corpus this repo's own. Drift introduced on the BACKEND side is not observable from contracts CI and is not gated here. Regenerate with `pnpm anchor-corpus:sync`; never hand-edit this file or the corpus — with ONE reviewed exception, the deliberate same-diff re-pin ADR-CONTRACTS-116 allows: PRD-00933 (ADR slug suggested-edits-wire-vocabulary) added the text-insertion cases contracts-first, because that variant is published here before the backend boundary adopts it. The backend copy catches up in PRD-00934; until then `anchor-corpus:check` reports expected forward drift wherever a backend tree is reachable, and `anchor-corpus:sync` must NOT be run (it would overwrite the new cases with the older upstream file).",
|
|
3
3
|
"source": {
|
|
4
4
|
"repo": "company-semantics-backend",
|
|
5
5
|
"path": "tests/fixtures/comment-anchors.json"
|
|
6
6
|
},
|
|
7
|
-
"bytes":
|
|
8
|
-
"sha256": "
|
|
7
|
+
"bytes": 4749,
|
|
8
|
+
"sha256": "d4bba06575774f204effe4b2256a77208918a769cb82d73fdff8d16cdbf76a53"
|
|
9
9
|
}
|
|
@@ -54,6 +54,8 @@ function makeThread(over: Record<string, unknown> = {}) {
|
|
|
54
54
|
id: THREAD_ID,
|
|
55
55
|
subjectType: "company_md",
|
|
56
56
|
subjectId: DOC_ID,
|
|
57
|
+
kind: "comment",
|
|
58
|
+
suggestion: null,
|
|
57
59
|
anchorType: "text",
|
|
58
60
|
anchor: {
|
|
59
61
|
type: "text",
|
|
@@ -240,6 +242,83 @@ describe("CommentThreadSchema", () => {
|
|
|
240
242
|
});
|
|
241
243
|
});
|
|
242
244
|
|
|
245
|
+
describe("CommentThreadSummarySchema kind and suggestion", () => {
|
|
246
|
+
it("a zero-comment suggestion thread parses through both response shapes", () => {
|
|
247
|
+
// The full suggestion projection: kind `suggestion`, a v1 payload, a
|
|
248
|
+
// zero-width text-insertion anchor, and NO comments. The backend creates a
|
|
249
|
+
// suggestion thread without a first comment (the payload IS the content),
|
|
250
|
+
// so `comments: []` must parse — a min-length on the comments array would
|
|
251
|
+
// refuse every freshly-created suggestion.
|
|
252
|
+
const suggestionThread = makeThread({
|
|
253
|
+
kind: "suggestion",
|
|
254
|
+
suggestion: {
|
|
255
|
+
v: 1,
|
|
256
|
+
op: "insert",
|
|
257
|
+
insertedText: "and measure adoption weekly",
|
|
258
|
+
status: "open",
|
|
259
|
+
},
|
|
260
|
+
anchorType: "text-insertion",
|
|
261
|
+
anchor: {
|
|
262
|
+
type: "text-insertion",
|
|
263
|
+
v: 1,
|
|
264
|
+
leftContext: "quarterly goals",
|
|
265
|
+
rightContext: " by March",
|
|
266
|
+
},
|
|
267
|
+
comments: [],
|
|
268
|
+
});
|
|
269
|
+
expect(CommentThreadSchema.safeParse(suggestionThread).success).toBe(true);
|
|
270
|
+
expect(
|
|
271
|
+
CommentThreadListResponseSchema.safeParse({ threads: [suggestionThread] })
|
|
272
|
+
.success,
|
|
273
|
+
).toBe(true);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("parses a legacy-shaped comment thread — kind comment, suggestion null", () => {
|
|
277
|
+
// Every thread written before suggestions existed arrives exactly like
|
|
278
|
+
// this: the backend projects kind `comment` and suggestion null onto old
|
|
279
|
+
// rows, so the pre-suggestion shape plus those two fields must keep
|
|
280
|
+
// parsing untouched.
|
|
281
|
+
expect(CommentThreadSchema.safeParse(makeThread()).success).toBe(true);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("requires kind — the backend always projects it, so absence is malformed", () => {
|
|
285
|
+
const { kind: _dropped, ...withoutKind } = makeThread();
|
|
286
|
+
expect(CommentThreadSchema.safeParse(withoutKind).success).toBe(false);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("parses a decided suggestion via the two-level status model", () => {
|
|
290
|
+
// The thread enum is NOT widened: a decided suggestion is thread status
|
|
291
|
+
// `resolved` with suggestion.status carrying which way it went, and
|
|
292
|
+
// resolvedByUserId/resolvedAt doubling as the decision actor/time.
|
|
293
|
+
expect(
|
|
294
|
+
CommentThreadSchema.safeParse(
|
|
295
|
+
makeThread({
|
|
296
|
+
kind: "suggestion",
|
|
297
|
+
suggestion: { v: 1, op: "delete", status: "accepted" },
|
|
298
|
+
anchorType: "text",
|
|
299
|
+
status: "resolved",
|
|
300
|
+
resolvedByUserId: USER_ID,
|
|
301
|
+
resolvedAt: "2026-08-02T09:00:00.000Z",
|
|
302
|
+
comments: [],
|
|
303
|
+
}),
|
|
304
|
+
).success,
|
|
305
|
+
).toBe(true);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("rejects a suggestion payload outside the published shape", () => {
|
|
309
|
+
// SuggestionSchema is strict — an unknown field means a newer dialect,
|
|
310
|
+
// which must arrive as a new `v`, not as loose extras.
|
|
311
|
+
expect(
|
|
312
|
+
CommentThreadSchema.safeParse(
|
|
313
|
+
makeThread({
|
|
314
|
+
kind: "suggestion",
|
|
315
|
+
suggestion: { v: 1, op: "delete", status: "open", extra: true },
|
|
316
|
+
}),
|
|
317
|
+
).success,
|
|
318
|
+
).toBe(false);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
243
322
|
describe("CommentThreadListResponseSchema", () => {
|
|
244
323
|
it("parses an empty subject", () => {
|
|
245
324
|
expect(
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The suggestion payload vocabulary, held to the invariants its JSDoc claims
|
|
3
|
+
* (ADR slug suggested-edits-wire-vocabulary).
|
|
4
|
+
*
|
|
5
|
+
* Every negative test mutates ONE field of a well-formed factory result, for
|
|
6
|
+
* the reason `./schemas.test.ts` states: a hand-built broken object can pass
|
|
7
|
+
* for the wrong reason — it fails on the field nobody was testing.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
COMMENT_THREAD_KINDS,
|
|
13
|
+
SUGGESTION_OPS,
|
|
14
|
+
SUGGESTION_STATUSES,
|
|
15
|
+
SuggestionSchema,
|
|
16
|
+
} from "../suggestion.js";
|
|
17
|
+
|
|
18
|
+
function makeSuggestion(over: Record<string, unknown> = {}) {
|
|
19
|
+
return {
|
|
20
|
+
v: 1,
|
|
21
|
+
op: "replace",
|
|
22
|
+
insertedText: "ship the quarterly goals by April",
|
|
23
|
+
status: "open",
|
|
24
|
+
...over,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("the vocabulary tuples", () => {
|
|
29
|
+
it("COMMENT_THREAD_KINDS is exactly comment and suggestion", () => {
|
|
30
|
+
expect([...COMMENT_THREAD_KINDS]).toEqual(["comment", "suggestion"]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("SUGGESTION_OPS is exactly insert, delete and replace", () => {
|
|
34
|
+
expect([...SUGGESTION_OPS]).toEqual(["insert", "delete", "replace"]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("SUGGESTION_STATUSES is exactly open, accepted and rejected", () => {
|
|
38
|
+
// The SECOND status level. The thread-level enum stays open|resolved —
|
|
39
|
+
// `./schemas.test.ts` pins that separately — and the decision lives here.
|
|
40
|
+
expect([...SUGGESTION_STATUSES]).toEqual(["open", "accepted", "rejected"]);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("SuggestionSchema", () => {
|
|
45
|
+
it("parses an insert payload", () => {
|
|
46
|
+
expect(
|
|
47
|
+
SuggestionSchema.safeParse(
|
|
48
|
+
makeSuggestion({ op: "insert", insertedText: "new sentence" }),
|
|
49
|
+
).success,
|
|
50
|
+
).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("parses a replace payload", () => {
|
|
54
|
+
expect(SuggestionSchema.safeParse(makeSuggestion()).success).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("parses a delete payload WITHOUT insertedText", () => {
|
|
58
|
+
// The reason insertedText is optional in the READ schema: a delete has
|
|
59
|
+
// nothing to insert. "Required for insert/replace" is the backend request
|
|
60
|
+
// schema's superRefine, deliberately not re-stated here.
|
|
61
|
+
const { insertedText: _dropped, ...deletePayload } = makeSuggestion({
|
|
62
|
+
op: "delete",
|
|
63
|
+
});
|
|
64
|
+
expect(SuggestionSchema.safeParse(deletePayload).success).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("parses a decided payload", () => {
|
|
68
|
+
expect(
|
|
69
|
+
SuggestionSchema.safeParse(makeSuggestion({ status: "accepted" }))
|
|
70
|
+
.success,
|
|
71
|
+
).toBe(true);
|
|
72
|
+
expect(
|
|
73
|
+
SuggestionSchema.safeParse(makeSuggestion({ status: "rejected" }))
|
|
74
|
+
.success,
|
|
75
|
+
).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("rejects an op outside the vocabulary", () => {
|
|
79
|
+
expect(
|
|
80
|
+
SuggestionSchema.safeParse(makeSuggestion({ op: "move" })).success,
|
|
81
|
+
).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("rejects a status outside the vocabulary", () => {
|
|
85
|
+
// Notably `resolved`: that is the THREAD's terminal state, not the
|
|
86
|
+
// suggestion's. A payload claiming it means the writer confused the two
|
|
87
|
+
// status levels.
|
|
88
|
+
expect(
|
|
89
|
+
SuggestionSchema.safeParse(makeSuggestion({ status: "resolved" }))
|
|
90
|
+
.success,
|
|
91
|
+
).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("rejects an empty insertedText — absent and empty stay distinguishable", () => {
|
|
95
|
+
expect(
|
|
96
|
+
SuggestionSchema.safeParse(makeSuggestion({ insertedText: "" })).success,
|
|
97
|
+
).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("rejects insertedText over 10000 code units", () => {
|
|
101
|
+
expect(
|
|
102
|
+
SuggestionSchema.safeParse(
|
|
103
|
+
makeSuggestion({ insertedText: "x".repeat(10001) }),
|
|
104
|
+
).success,
|
|
105
|
+
).toBe(false);
|
|
106
|
+
expect(
|
|
107
|
+
SuggestionSchema.safeParse(
|
|
108
|
+
makeSuggestion({ insertedText: "x".repeat(10000) }),
|
|
109
|
+
).success,
|
|
110
|
+
).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("rejects unknown keys — a newer dialect must arrive as a new v", () => {
|
|
114
|
+
expect(
|
|
115
|
+
SuggestionSchema.safeParse(makeSuggestion({ decidedBy: "someone" }))
|
|
116
|
+
.success,
|
|
117
|
+
).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("rejects any v other than 1", () => {
|
|
121
|
+
expect(SuggestionSchema.safeParse(makeSuggestion({ v: 2 })).success).toBe(
|
|
122
|
+
false,
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("requires status — the projection always composes it", () => {
|
|
127
|
+
const { status: _dropped, ...withoutStatus } = makeSuggestion();
|
|
128
|
+
expect(SuggestionSchema.safeParse(withoutStatus).success).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
});
|
package/src/comments/anchor.ts
CHANGED
|
@@ -96,14 +96,73 @@ const TextAnchorSchema = z.strictObject({
|
|
|
96
96
|
});
|
|
97
97
|
|
|
98
98
|
/**
|
|
99
|
-
*
|
|
99
|
+
* A ZERO-WIDTH insertion point — where an `insert` suggestion proposes to add
|
|
100
|
+
* text (ADR slug suggested-edits-wire-vocabulary). This is a REAL
|
|
101
|
+
* representation of a point between two characters, deliberately NOT an anchor
|
|
102
|
+
* to an adjacent quoted run of existing text: a persisted suggestion outlives
|
|
103
|
+
* concurrent edits, and an adjacency convention ("insert after this quote")
|
|
104
|
+
* resolves against whichever run happens to survive — the wrong one, silently.
|
|
105
|
+
*
|
|
106
|
+
* `relPos` is a single `Y.RelativePosition` at the caret, OPTIONAL for the
|
|
107
|
+
* same reason `relStart`/`relEnd` are on the text anchor: a client without an
|
|
108
|
+
* attached collab session cannot produce one. Optional means "this client
|
|
109
|
+
* could not", never "this client chose not to".
|
|
110
|
+
*
|
|
111
|
+
* `leftContext`/`rightContext` are the durable fallback — the text
|
|
112
|
+
* immediately before and after the point. They are required-but-may-be-empty
|
|
113
|
+
* (max 64 UTF-16 code units each, like `prefix`/`suffix`): either may be
|
|
114
|
+
* empty or short at a document edge, and an empty string means "there is
|
|
115
|
+
* nothing on that side", which must stay distinguishable from "this client
|
|
116
|
+
* forgot to send it". No Unicode normalization happens anywhere on this path.
|
|
117
|
+
*
|
|
118
|
+
* RESOLUTION SEMANTICS (implemented client-side in the app; the backend is a
|
|
119
|
+
* passthrough that interprets nothing):
|
|
120
|
+
* 1. Decode `relPos` and verify BOTH contexts around the decoded point. A
|
|
121
|
+
* point whose surroundings no longer match is STALE, not correct — the
|
|
122
|
+
* same rule as the text anchor's quote check.
|
|
123
|
+
* 2. Search the document for the `leftContext`+`rightContext` seam. Only a
|
|
124
|
+
* UNIQUE match resolves; any ambiguity (the seam occurs more than once)
|
|
125
|
+
* resolves ORPHANED, never a guess.
|
|
126
|
+
* 3. Orphaned.
|
|
127
|
+
*
|
|
128
|
+
* CONTRACTS-FIRST ORDERING NOTE: unlike the two variants above, this variant
|
|
129
|
+
* is published here BEFORE the backend route boundary adopts it (PRD-00934
|
|
130
|
+
* lands the backend half against the npm-published version of this package).
|
|
131
|
+
* The mirrored corpus gained its text-insertion cases HERE, contracts-first,
|
|
132
|
+
* via the deliberate same-diff digest re-pin ADR-CONTRACTS-116 allows; the
|
|
133
|
+
* backend copy catches up in PRD-00934, and until it does the upstream diff
|
|
134
|
+
* shows expected forward drift (do not "fix" it with anchor-corpus:sync).
|
|
135
|
+
*
|
|
136
|
+
* SPELLING DEVIATION FROM THE AUTHORITATIVE REFERENCE (PRD-00933, must_log).
|
|
137
|
+
* The reference writes these declarations with single-quoted strings; what
|
|
138
|
+
* ships below is double-quoted because that is this repo's Prettier default
|
|
139
|
+
* (no `.prettierrc`, so `singleQuote` is false). Same precedent as the
|
|
140
|
+
* PRD-00922 note above: a different spelling of the same schema, nothing
|
|
141
|
+
* about what is accepted or refused changed.
|
|
142
|
+
*/
|
|
143
|
+
const TextInsertionAnchorSchema = z.strictObject({
|
|
144
|
+
type: z.literal("text-insertion"),
|
|
145
|
+
v: z.literal(1),
|
|
146
|
+
relPos: RelativePositionSchema.optional(),
|
|
147
|
+
leftContext: z.string().max(64),
|
|
148
|
+
rightContext: z.string().max(64),
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The anchor types, as a vocabulary tuple:
|
|
153
|
+
* `["document", "text", "text-insertion"]` (widened by PRD-00933; Prettier
|
|
154
|
+
* wraps the declaration below because it exceeds the print width).
|
|
100
155
|
*
|
|
101
156
|
* Mirrors the union's discriminants, and is what `CommentThreadSummarySchema`'s
|
|
102
157
|
* denormalised `anchorType` column is checked against — the server projects the
|
|
103
158
|
* discriminant out of the jsonb so a client can filter threads without parsing
|
|
104
159
|
* every anchor.
|
|
105
160
|
*/
|
|
106
|
-
export const COMMENT_ANCHOR_TYPES = [
|
|
161
|
+
export const COMMENT_ANCHOR_TYPES = [
|
|
162
|
+
"document",
|
|
163
|
+
"text",
|
|
164
|
+
"text-insertion",
|
|
165
|
+
] as const;
|
|
107
166
|
export const CommentAnchorTypeSchema = z.enum(COMMENT_ANCHOR_TYPES);
|
|
108
167
|
export type CommentAnchorType = z.infer<typeof CommentAnchorTypeSchema>;
|
|
109
168
|
|
|
@@ -116,5 +175,6 @@ export type CommentAnchorType = z.infer<typeof CommentAnchorTypeSchema>;
|
|
|
116
175
|
export const CommentAnchorSchema = z.discriminatedUnion("type", [
|
|
117
176
|
DocumentAnchorSchema,
|
|
118
177
|
TextAnchorSchema,
|
|
178
|
+
TextInsertionAnchorSchema,
|
|
119
179
|
]);
|
|
120
180
|
export type CommentAnchor = z.infer<typeof CommentAnchorSchema>;
|
package/src/comments/index.ts
CHANGED
|
@@ -41,3 +41,17 @@ export type {
|
|
|
41
41
|
MentionableCandidate,
|
|
42
42
|
MentionableResponse,
|
|
43
43
|
} from "./schemas";
|
|
44
|
+
|
|
45
|
+
export {
|
|
46
|
+
COMMENT_THREAD_KINDS,
|
|
47
|
+
SUGGESTION_OPS,
|
|
48
|
+
SUGGESTION_STATUSES,
|
|
49
|
+
SuggestionSchema,
|
|
50
|
+
} from "./suggestion";
|
|
51
|
+
|
|
52
|
+
export type {
|
|
53
|
+
CommentThreadKind,
|
|
54
|
+
Suggestion,
|
|
55
|
+
SuggestionOp,
|
|
56
|
+
SuggestionStatus,
|
|
57
|
+
} from "./suggestion";
|
package/src/comments/schemas.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { z } from "zod";
|
|
21
21
|
import { CommentAnchorSchema, CommentAnchorTypeSchema } from "./anchor";
|
|
22
|
+
import { COMMENT_THREAD_KINDS, SuggestionSchema } from "./suggestion";
|
|
22
23
|
|
|
23
24
|
// =============================================================================
|
|
24
25
|
// Vocabulary
|
|
@@ -154,11 +155,30 @@ export type CommentProjection = z.infer<typeof CommentSchema>;
|
|
|
154
155
|
* the server. It is redundant with `anchor.type` and that is the point: a client
|
|
155
156
|
* can bucket threads into document-level and text-level without parsing every
|
|
156
157
|
* anchor, and the two are written together so they cannot disagree.
|
|
158
|
+
*
|
|
159
|
+
* `kind` is REQUIRED, never optional: the backend always projects it, and
|
|
160
|
+
* `comment` is every thread written before suggestions existed — so absence is
|
|
161
|
+
* a malformed response, not a legacy row (see `COMMENT_THREAD_KINDS`).
|
|
162
|
+
*
|
|
163
|
+
* `suggestion` is null EXACTLY WHEN `kind` is `comment`. Like the redaction
|
|
164
|
+
* invariant on `CommentSchema`, this null-iff rule is DOCUMENTED here and
|
|
165
|
+
* enforced by backend CHECK constraints, not by a Zod refinement — a refinement
|
|
166
|
+
* would make this package refuse a response the server is willing to emit and
|
|
167
|
+
* would be a third wire description no parity guard can check.
|
|
168
|
+
*
|
|
169
|
+
* THE TWO-LEVEL STATUS MODEL: `status` stays exactly `open | resolved`
|
|
170
|
+
* (`COMMENT_THREAD_STATUSES` is NOT widened — widening a closed published enum
|
|
171
|
+
* is a parse outage for every deployed client). A decided suggestion arrives as
|
|
172
|
+
* thread status `resolved` with `suggestion.status` carrying which way it went
|
|
173
|
+
* (`accepted` | `rejected`), and `resolvedByUserId`/`resolvedAt` double as the
|
|
174
|
+
* decision actor and time — there is no second actor/timestamp pair.
|
|
157
175
|
*/
|
|
158
176
|
export const CommentThreadSummarySchema = z.object({
|
|
159
177
|
id: z.string(),
|
|
160
178
|
subjectType: CommentSubjectTypeSchema,
|
|
161
179
|
subjectId: z.string(),
|
|
180
|
+
kind: z.enum(COMMENT_THREAD_KINDS),
|
|
181
|
+
suggestion: SuggestionSchema.nullable(),
|
|
162
182
|
anchorType: CommentAnchorTypeSchema,
|
|
163
183
|
anchor: CommentAnchorSchema,
|
|
164
184
|
status: CommentThreadStatusSchema,
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The suggestion payload — the proposed document edit a suggestion thread
|
|
3
|
+
* carries (ADR slug suggested-edits-wire-vocabulary).
|
|
4
|
+
*
|
|
5
|
+
* READ VOCABULARY ONLY, like the rest of this directory: the create/accept/
|
|
6
|
+
* reject request bodies stay backend-side per ADR-CONT-029. This module is
|
|
7
|
+
* deliberately a LEAF — it imports nothing but `zod`, because `./schemas` will
|
|
8
|
+
* import from it (the thread summary's `kind`/`suggestion` projection) and an
|
|
9
|
+
* import back the other way would be a cycle.
|
|
10
|
+
*
|
|
11
|
+
* Zod-canonical: the schema is the source of truth, the type is inferred.
|
|
12
|
+
*
|
|
13
|
+
* SPELLING DEVIATION FROM THE AUTHORITATIVE REFERENCE (PRD-00933, must_log).
|
|
14
|
+
* The reference block writes these declarations with single-quoted strings;
|
|
15
|
+
* what ships below is double-quoted because that is this repo's Prettier
|
|
16
|
+
* default (there is no `.prettierrc`, so `singleQuote` is false and
|
|
17
|
+
* `pnpm format:check` rewrites any single-quoted string here). Same precedent
|
|
18
|
+
* as `./anchor`'s PRD-00922 note: a different spelling of the same schema,
|
|
19
|
+
* nothing about what is accepted or refused changed.
|
|
20
|
+
*/
|
|
21
|
+
import { z } from "zod";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Thread kinds. A suggestion IS a comment thread — same table, same replies,
|
|
25
|
+
* mentions and notifications — carrying a proposed document edit. `comment`
|
|
26
|
+
* is every thread written before suggestions existed, which is why the
|
|
27
|
+
* backend always projects this field rather than leaving it optional.
|
|
28
|
+
*/
|
|
29
|
+
export const COMMENT_THREAD_KINDS = ["comment", "suggestion"] as const;
|
|
30
|
+
export type CommentThreadKind = (typeof COMMENT_THREAD_KINDS)[number];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What a suggestion proposes doing to the anchored range: `insert` at a
|
|
34
|
+
* zero-width point, `delete` the anchored quote, or `replace` it.
|
|
35
|
+
*/
|
|
36
|
+
export const SUGGESTION_OPS = ["insert", "delete", "replace"] as const;
|
|
37
|
+
export type SuggestionOp = (typeof SUGGESTION_OPS)[number];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* open -> accepted | rejected. Terminal states are terminal: there is no
|
|
41
|
+
* reopen for a decided suggestion (unlike comment resolve/reopen). This is
|
|
42
|
+
* the SECOND status level — the thread-level `status` enum stays exactly
|
|
43
|
+
* `open | resolved`, and a decided suggestion arrives as thread status
|
|
44
|
+
* `resolved` with this field carrying which way it went. Widening the closed
|
|
45
|
+
* thread enum instead would be a parse outage for every deployed client.
|
|
46
|
+
*/
|
|
47
|
+
export const SUGGESTION_STATUSES = ["open", "accepted", "rejected"] as const;
|
|
48
|
+
export type SuggestionStatus = (typeof SUGGESTION_STATUSES)[number];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Versioned suggestion payload. Evolution = new `v` variants, never mutation
|
|
52
|
+
* (same rule as CommentAnchor). `status` is composed by the backend read
|
|
53
|
+
* projection from its dedicated status column — the stored jsonb does NOT
|
|
54
|
+
* carry it; one truth per field.
|
|
55
|
+
*
|
|
56
|
+
* `insertedText`: required for insert/replace, absent for delete — enforced
|
|
57
|
+
* by the backend request schema's superRefine, not here. This READ schema
|
|
58
|
+
* keeps it optional so a delete payload parses without it; refining here
|
|
59
|
+
* would make this package refuse a response the server is willing to emit
|
|
60
|
+
* (the same reasoning as `./schemas`' redaction invariant).
|
|
61
|
+
*
|
|
62
|
+
* Strict: a payload carrying fields outside this shape is refused rather than
|
|
63
|
+
* accepted with the extras dropped — an unknown field means the writer was
|
|
64
|
+
* speaking a newer dialect, and that must arrive as a new `v`, not as loose
|
|
65
|
+
* extras a resolving client half-recognises.
|
|
66
|
+
*/
|
|
67
|
+
export const SuggestionSchema = z.strictObject({
|
|
68
|
+
v: z.literal(1),
|
|
69
|
+
op: z.enum(SUGGESTION_OPS),
|
|
70
|
+
insertedText: z.string().min(1).max(10000).optional(),
|
|
71
|
+
status: z.enum(SUGGESTION_STATUSES),
|
|
72
|
+
});
|
|
73
|
+
export type Suggestion = z.infer<typeof SuggestionSchema>;
|
|
@@ -23,7 +23,11 @@ export const openApiRoutes = {
|
|
|
23
23
|
'/api/chats/{id}/pin': ['DELETE', 'POST'],
|
|
24
24
|
'/api/comments': ['GET'],
|
|
25
25
|
'/api/comments/threads': ['POST'],
|
|
26
|
+
'/api/comments/threads/{threadId}/accept-complete': ['POST'],
|
|
27
|
+
'/api/comments/threads/{threadId}/accept-intent': ['POST'],
|
|
28
|
+
'/api/comments/threads/{threadId}/accept-release': ['POST'],
|
|
26
29
|
'/api/comments/threads/{threadId}/comments': ['POST'],
|
|
30
|
+
'/api/comments/threads/{threadId}/reject': ['POST'],
|
|
27
31
|
'/api/comments/threads/{threadId}/reopen': ['POST'],
|
|
28
32
|
'/api/comments/threads/{threadId}/resolve': ['POST'],
|
|
29
33
|
'/api/comments/{commentId}': ['DELETE', 'PATCH'],
|
package/src/index.ts
CHANGED
|
@@ -333,7 +333,10 @@ export type {
|
|
|
333
333
|
export {
|
|
334
334
|
COMMENT_ANCHOR_TYPES,
|
|
335
335
|
COMMENT_SUBJECT_TYPES,
|
|
336
|
+
COMMENT_THREAD_KINDS,
|
|
336
337
|
COMMENT_THREAD_STATUSES,
|
|
338
|
+
SUGGESTION_OPS,
|
|
339
|
+
SUGGESTION_STATUSES,
|
|
337
340
|
} from "./comments/index";
|
|
338
341
|
|
|
339
342
|
export {
|
|
@@ -348,6 +351,7 @@ export {
|
|
|
348
351
|
CommentThreadSummarySchema,
|
|
349
352
|
MentionableCandidateSchema,
|
|
350
353
|
MentionableResponseSchema,
|
|
354
|
+
SuggestionSchema,
|
|
351
355
|
} from "./comments/index";
|
|
352
356
|
|
|
353
357
|
export type {
|
|
@@ -357,11 +361,15 @@ export type {
|
|
|
357
361
|
CommentProjection,
|
|
358
362
|
CommentSubjectType,
|
|
359
363
|
CommentThread,
|
|
364
|
+
CommentThreadKind,
|
|
360
365
|
CommentThreadListResponse,
|
|
361
366
|
CommentThreadStatus,
|
|
362
367
|
CommentThreadSummary,
|
|
363
368
|
MentionableCandidate,
|
|
364
369
|
MentionableResponse,
|
|
370
|
+
Suggestion,
|
|
371
|
+
SuggestionOp,
|
|
372
|
+
SuggestionStatus,
|
|
365
373
|
} from "./comments/index";
|
|
366
374
|
|
|
367
375
|
// Chat domain types
|