diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f7614e53..eb28caf79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,7 +90,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ${{ github.event_name == 'pull_request' && fromJSON('["3.14"]') || fromJSON('["3.11", "3.12", "3.13", "3.14"]') }} + python-version: ["3.11", "3.12", "3.13", "3.14"] defaults: run: working-directory: office diff --git a/README.md b/README.md index dfc5713cf..ea8d26f36 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,8 @@ runtime. | Envelope identity | `@contextualwisdomlab/cwl-editor/envelope-identity` | Framework-independent bounded schema identity for host-owned migration routing | | Revision evidence | `@contextualwisdomlab/cwl-editor/revision-evidence` | Framework-independent canonical envelope, strong revision, and transition evidence | | Text-position selector | `@contextualwisdomlab/cwl-editor/text-position-selector` | React-free deterministic W3C `TextPositionSelector` projection core | +| Review target core | `@contextualwisdomlab/cwl-editor/review` | `implemented_on_active_pr` — React-free deterministic exact-revision review targets; durable review records and policy remain host-owned | +| Review React adapter | `@contextualwisdomlab/cwl-editor/review-react` | `implemented_on_active_pr` — controlled accessible thread and suggestion-decision presentation over the React-free review contract; hosts own actions, authorization, bodies, and persistence | | Autosave | `@contextualwisdomlab/cwl-editor/autosave` | Provider-neutral bounded single-flight persistence coordination | | Headless Markdown | `@contextualwisdomlab/cwl-editor/markdown` | React-free deterministic Markdown/HTML/email/plain-text conversion | | Styles | `@contextualwisdomlab/cwl-editor/styles.css` | Editor layout and theming | @@ -179,6 +181,12 @@ editorRef.current?.focus(); `insertValue` is mode-aware, inserts at the current selection, and triggers the normal `onChange` path without wiping the document. +The Active PR / Proposed review surface also exposes +`applyReviewSuggestionDecision()`. After host authorization, `accept` applies +one exact-revision insert/delete transaction that participates in editor +undo/redo; `reject` preserves the document. A concurrent edit or repeated +accept fails stale instead of re-anchoring the proposal. + ### Atomic revision-envelope capture Autosave, AI, template, and review operations should capture their document and @@ -746,4 +754,4 @@ capabilities they require. - **Fonts:** Noto Sans families are SIL Open Font License 1.1. See [`LICENSE`](LICENSE), [`src/fonts/OFL.txt`](src/fonts/OFL.txt), and -[`src/fonts/NOTICE`](src/fonts/NOTICE). \ No newline at end of file +[`src/fonts/NOTICE`](src/fonts/NOTICE). diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 5a62716e5..f2788fd61 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -47,6 +47,16 @@ Local evidence records describe narrowly scoped facts such as revision identity, No single status collapses those authorities. Ordinary evidence must avoid embedding complete document bodies, selected quote text, credentials, tenant identifiers, prompts, model outputs, or private exception causes unless a separate authorized contract explicitly requires them. +## Review contract (Active PR / Proposed) + +The proposed `./review` subpath accepts only versioned, bounded, provider-neutral review targets, thread presentation records, and insert/delete suggestions. Targets use `inkspan-prosemirror-text` projection offsets and one exact canonical document revision. The surface validates and detaches untrusted metadata, rejects stale direct reuse, and can classify host-supplied before/after envelopes as accepted, rejected, or stale without retaining proposal text or document bodies in the result. + +Classification is evidence, not authorization. After host authorization, `CwlEditorHandle.applyReviewSuggestionDecision()` may apply an insert/delete acceptance as one exact-revision editor transaction; rejection preserves the document and creates no history entry. The adapter captures one `EditorState`, verifies its canonical revision asynchronously, and refuses the operation if the live state changed before dispatch. Acceptance must produce a changed revision; rejection must preserve the revision; a stale target paired with any document change fails closed. The host owns reviewer/thread identity, permissions, transport, durable persistence, resolution, notifications, audit, retention, and cross-revision re-anchoring. + +The proposed `./review-react` subpath renders controlled native-button target markers, thread lists, and one-suggestion decision controls from the validated review contract. It exposes selection, reply, resolve, accept, and reject intents only. Host-controlled selection remains authoritative, unavailable actions remain disabled, keyboard focus traversal does not commit selection, and host callbacks cannot turn presentation state into authorization or durable success. Review presentation defaults to `printMode="exclude"`; explicit `include` prints bounded labels and status/comment summaries while suppressing interactive controls. + +The print option is not a general export switch. Review metadata is excluded from non-print exports, and deterministic Markdown, HTML, email, plain-text, and Office conversion serializes canonical document content only. A host that includes review records in another artifact owns that separately governed export and its authorization, disclosure, provenance, retention, accessibility, and publication policy. + ## W3C text-position selector evidence contract Protected `main` exposes `getTextPositionSelectorEvidence()` through the root package as a revision-scoped annotation-interoperability primitive. It does **not** reinterpret `CwlEditorSelectionSnapshot` or ProseMirror structural positions as W3C positions. It derives a separate W3C `TextPositionSelector` from the same captured immutable editor state that is used for revision derivation. @@ -149,6 +159,7 @@ Rollback must preserve readable canonical documents and must not require silentl | Markdown/HTML editing | deterministic editor state and supported import/export semantics | application workflow, document ownership, authorization | | document envelope/revision | schema validation, identity routing, canonical bytes, local equality evidence | migration orchestration, durable storage, signatures, tenant binding | | selection / W3C annotation evidence | exact-revision structural coordinates and versioned text-position projection | annotation identity/body, source IRI, authorization, persistence, audit, publication, re-anchoring | +| review targets and suggestions (Active PR / Proposed) | bounded exact-revision metadata, accessible controlled presentation, revision-only operation classification | editor mutation, reviewer/thread identity, authorization, persistence, resolution, notifications, audit, re-anchoring | | autosave | local ordering/state, callback contract, validator validation | transport, durable CAS, retry/offline policy, persistence | | collaboration | provider-neutral editor/Yjs binding | provider lifecycle, rooms, identity, authorization, persistence, awareness privacy | | Office rendering | deterministic bounded JSON→artifact conversion | file destination policy, downstream distribution, tenant authorization | diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 28b86bc56..a1a8869d2 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -86,6 +86,8 @@ These values may remain ephemeral or release-artifact metadata. Their presence i | `document_transition` | none required; host may store | change evidence | no | content-lineage evidence only | | `selection_evidence` | none required | review/selection capture | no | exact-revision ProseMirror coordinates only | | `text_position_selector_evidence` | none required; `implemented_on_protected_main` | interoperable review/annotation capture | no | exact-revision W3C text positions satisfying `0 <= start <= end <= projectedCodePointLength` under one versioned projection only | +| `review_suggestion` | host if persisted; Active PR / Proposed | one insert/delete proposal | insert text only for insertion proposals | exact-revision untrusted proposal, not authorization or durable state | +| `review_operation` | none required; host may persist its own decision | one accept/reject attempt | no | revision-only local result; acceptance is one undoable editor transaction and rejection is document-preserving | | `autosave_revision` | none required | queued local save evidence | envelope-bearing evidence may be retained boundedly by queue | local save ordering only | | `autosave_snapshot` | none required | lifecycle observation/coordination | no | local machine state only; validator fields remain confidential metadata | | `durable_validator` | host | durable version | no | host concurrency evidence, not authorization | diff --git a/docs/PRD.md b/docs/PRD.md index 3ccec07c5..8e7510708 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -31,6 +31,7 @@ The product promise is: **author, convert, collaborate, and prove document chang 11. Produce reviewable package, security, compatibility, accessibility, SBOM/provenance, and release evidence tied to one exact protected source head. 12. Give security researchers a discoverable private vulnerability-reporting and coordinated-disclosure path without promising unsupported SLAs, bounties, certification, or legal safe harbor. 13. Identify a complete unsupported document-envelope generation safely enough for the host to select its own migration without accepting that generation as current Inkspan document semantics. +14. Review a revision-scoped comment or insert/delete proposal through an accessible controlled surface without moving reviewer identity, authorization, durable thread state, or document mutation authority into Inkspan. ## Required outcomes @@ -61,6 +62,7 @@ The product promise is: **author, convert, collaborate, and prove document chang - Autosave remains single-flight with bounded active/pending work and explicit conflict/failure recovery. - Durable saves use a host/server-selected strong validator; conflict or ambiguous failure never silently advances it. - Lifecycle observation emits only distinct externally visible document-free state transitions; construction and no-op operations do not manufacture notifications. +- Proposed review targets and insert/delete suggestions bind to one exact document revision and the named text projection. Stale targets fail closed; accepting must change the revision, rejecting must preserve it, and neither result may claim authorization or durable review state. ### SSR and native forms @@ -94,6 +96,7 @@ The product promise is: **author, convert, collaborate, and prove document chang - Toolbar shortcut metadata must reflect repository-level shipped behavior, including host/editor bindings such as link editing, rather than only extension-local defaults. - Application-visible saving/conflict/recovery messages must be derivable from programmatic state without Inkspan prescribing untranslated user-facing copy. - Export/print surfaces must not rely on color alone or inaccessible interaction-only state where the corresponding product surface exists. +- By default, review metadata is excluded from non-print exports: deterministic Markdown, HTML, email, plain-text, and Office conversion receives canonical document content only. A host that needs comments or review status in an artifact must build a separately governed export with its own authorization, disclosure, retention, and accessibility policy. ### Security disclosure and vulnerability handling @@ -148,3 +151,5 @@ Protected `main` is the sole implemented baseline. Open PRs may describe Propose SafeClipboard, real Chromium/Firefox/WebKit release assurance, lifecycle observation, the root security disclosure lifecycle, toolbar shortcut accessibility metadata, SSR/native-form serialization, revision-scoped selection evidence, W3C text-position selector evidence, document-transition evidence, and envelope identity migration routing are implemented on protected `main`. A named editor-chrome theme-token catalog, DTCG 2025.10 interchange snapshot, and Storybook inventory for repeating toolbar/editor objects are Active PR / Proposed and are not shipped claims until protected integration. Hosts must check inventoried active-chrome contrast (`--cwl-accent` on `--cwl-accent-soft`) in addition to body text. + +The provider-neutral review contract and controlled accessible review-thread/target-marker surfaces are also Active PR / Proposed. They validate bounded host data, emit only detached intent snapshots, and apply an authorized insert/delete acceptance as one exact-revision undoable editor transaction while rejection preserves the document. They do not own reviewer identity, authorization, thread persistence, audit, notifications, or cross-revision re-anchoring. diff --git a/docs/TEST_STRATEGY.md b/docs/TEST_STRATEGY.md index d9cde2003..f9ee8efb7 100644 --- a/docs/TEST_STRATEGY.md +++ b/docs/TEST_STRATEGY.md @@ -55,12 +55,13 @@ At minimum, maintain regressions for: - SSR client-controlled form values, escaping, hydration continuity, reset behavior, and absence of server editor construction; - autosave stale validators, conflict/failure recovery, ambiguous transport outcomes, duplicate/no-op lifecycle transitions, callback exceptions, queue bounds, flush/close behavior, and durable-validator coherence; - selection/revision races and document movement during asynchronous hashing; +- review suggestion decisions covering insert, delete, reject, repeated accept, Unicode projection boundaries, out-of-range selectors, undo/redo, and document movement during asynchronous hashing; - Office formula prefixes, invalid XML characters, malicious strings, path/publication races, invalid worksheet names, invalid freeze panes, cyclic input, pathological nesting, excessive container size, and partial write failure; - package/release stale draft assets, unexpected or non-regular local entries, exact four-file inventory violations, incomplete remote uploads, GitHub-vs-local digest mismatch, stale exact-head evidence, mutable provenance inputs, and isolated packed-consumer behavior. ## Concurrency and failure testing -Use deterministic barriers/fakes for local concurrency and real process/file boundaries where required. Prove that an observer exception cannot alter queue ordering; a stale digest cannot bind to a later editor state; an ambiguous durable save does not advance a validator; close/recovery does not leak waiters; and file publication either completes under the documented contract or fails without silently replacing unrelated content. +Use deterministic barriers/fakes for local concurrency and real process/file boundaries where required. Prove that an observer exception cannot alter queue ordering; a stale digest cannot bind to a later editor state or review transaction; an ambiguous durable save does not advance a validator; close/recovery does not leak waiters; and file publication either completes under the documented contract or fails without silently replacing unrelated content. Host persistence transactions, tenant isolation, distributed collaboration authorization, durable audit storage, and production network retry policy are host-owned and must be tested by the embedding product. Inkspan tests verify only the explicit adapter contract at those boundaries. diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index e1337651d..e746c65e1 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -53,6 +53,10 @@ Local SHA-256 revisions identify deterministic content equality only. Selection Concurrent editors, delayed digests, stale selections, ambiguous transport failure, or stale durable validators can cause lost updates or false success. Inkspan must bind asynchronous evidence to one immutable local state, keep autosave single-flight with bounded pending work, fail closed on ambiguous durable outcomes, and require explicit recovery from blocked conflict/failure states. Hosts own atomic persistence transactions and durable conflict resolution. +### Review suggestions and transaction admission + +An untrusted review suggestion can target a stale revision, an unsupported projection boundary, or a range that no longer denotes the intended content. The Active PR / Proposed editor adapter validates the bounded proposal, hashes the captured document, verifies that the live `EditorState` did not change during hashing, and maps only an exact version-1 text boundary before dispatching one insert/delete transaction. Any mismatch fails closed without mutation. Hosts still authorize the actor and own durable exact-once decisions, audit, persistence, and re-anchoring; undoing a local transaction does not erase or rewrite host review history. + ### Collaboration and Yjs Inkspan may bind to Yjs-compatible document/awareness surfaces but does not own provider creation, room authorization, tenant identity, persistence, retention, or durable audit. Yjs updates and awareness metadata can contain sensitive tenant information. Host providers must authenticate rooms, authorize membership, bound awareness disclosure, and apply retention/encryption policy. Inkspan must not silently create a network provider or elevate an awareness update into authorization. diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index ffe5d86d4..fc8809c7f 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -15,6 +15,7 @@ This record maps durable Inkspan product decisions to authoritative standards, p | Envelope version routing | A bounded identity-only inspector identifies `schemaId`/`schemaVersion` for dispatch while the current parser stays strict and the host owns migration execution | RFC 8259; RFC 7493; RFC 8785 for canonical current-schema bytes | ADR 0015, protected-main `documentEnvelopeIdentity` implementation/tests, envelope guide/doctoring and framework-independent packed consumers | Protected-main evidence proves only bounded routing metadata; identifying a schema generation does not validate that generation's document semantics, authorize migration, or prove durable persistence | | Canonical document bytes | Deterministic revision evidence is derived from canonicalized validated document content | RFC 8785, JSON Canonicalization Scheme | revision-evidence, transition-evidence, restore tests | A content digest proves equality only, not actor/time/authorization/durable write | | W3C text-position selector | Revision-scoped annotation interoperability uses a distinct versioned logical-text projection satisfying `0 <= start <= end <= projectedCodePointLength`, with inclusive `start`, exclusive `end`, Unicode-code-point offsets, grapheme-boundary validation, and same-state revision binding instead of relabeling ProseMirror coordinates | W3C Web Annotation Data Model; ProseMirror reference manual; ECMA-402 13th edition | ADR 0018, protected-main text-position selector implementation/tests, packed consumer verifier, selection lifecycle and doctoring | Protected-main evidence proves positions only for the named projection and exact revision; it does not prove actor, authorization, durable annotation acceptance, source IRI policy, or cross-revision re-anchoring | +| Exact-revision review mutation | Authorized insert/delete acceptance maps the versioned selector into the same captured editor state and dispatches one undoable transaction; rejection preserves the document | W3C Web Annotation Data Model; ProseMirror state, transaction, and history contracts | Active PR ADR 0005, review handle integration/concurrency/undo tests, packed root consumer | Local mutation evidence is not actor authorization, durable exact-once decision state, audit, persistence, or cross-revision re-anchoring | | Headless deterministic Markdown conversion | One serializer implementation and one framework-neutral safe-link/inline-raster policy are exposed through a self-contained ESM/CommonJS/TypeScript `./markdown` subpath | CommonMark 0.31.2; Node.js package `exports` documentation | protected-main #114 implementation, packed Node consumers, package-distribution contract, `docs/doctoring/headless-markdown-package.md` | `implemented_on_protected_main`; deterministic conversion does not grant MIME delivery, recipient, auth, tenant, persistence, network, credential, or model authority | | Provenance semantics | Local transition/release evidence keeps content lineage separate from actor/authorization/durable claims | W3C PROV family | transition evidence, release evidence, canonical data model | Inkspan does not claim complete PROV conformance or host audit provenance | | Accessibility | Native controls, keyboard semantics, shortcut metadata, semantic placeholder guidance, and host-facing status state support accessible embedding | W3C WCAG 2.2; WAI-ARIA 1.2 where used | protected toolbar/accessibility tests, SSR tests, autosave lifecycle data, protected #131 placeholder tests/packed consumer and `docs/doctoring/editor-placeholder-accessibility.md` | Component evidence alone is not a full host WCAG conformance claim; `aria-placeholder` supplements but never replaces the accessible name | diff --git a/docs/TRD.md b/docs/TRD.md index 80cec07ac..373457a07 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -54,6 +54,14 @@ Selection, transition, and W3C text-position evidence are implemented on protect The React-free text-position-selector package surface is also protected-main behavior. It reuses the deterministic projection helper without claiming editor state capture, annotation identity, authorization, durable persistence, or cross-revision re-anchoring. Hosts retain those authorities. +## Review boundary (Active PR / Proposed) + +The proposed React-free review surface validates bounded thread presentations and insert/delete proposals against the versioned text-position projection and exact canonical document revision. It returns detached frozen metadata and revision-only operation evidence. A stale proposal may be classified only when the document stayed unchanged; an accepted operation that changed nothing, a rejected operation that changed content, or any stale operation paired with a mutation fails closed. + +The proposed React surface is controlled and intent-only. Native buttons, one roving thread-selection tab stop, Arrow Up/Down and Home/End focus movement, host-supplied localized labels, and semantic status/comment summaries provide the bounded interaction layer. Review presentation is excluded from print by default; explicit inclusion retains bounded labels and summaries while suppressing reply/resolve controls. The shared editor handle applies an authorized insert/delete acceptance as one undoable transaction only while the captured `EditorState` and exact canonical revision still match; rejection creates no transaction. Inkspan does not authorize the actor, persist or resolve a thread, send notifications, create durable audit, or re-anchor a target across revisions. + +Outside browser print, review metadata is excluded from non-print exports. Markdown, HTML, email, plain-text, and Office converters continue to serialize canonical document content only; `printMode` does not alter those inputs. A host may compose review records into a separately governed export, but Inkspan does not acquire its authorization, disclosure, provenance, retention, or artifact-publication authority. + ## Autosave state machine and durable concurrency States are `idle`, `saving`, `blocked`, `closing`, and `closed`, with explicit blocked reasons. The local queue remains single-flight and retains bounded active/pending work and bounded flush waiters. Evidence supplied to a callback is immutable and validated before scheduling. @@ -141,4 +149,4 @@ Queued, cancelled, skipped-required, absent, stale-head, predecessor-head, statu Protected `main` is the sole shipped implementation baseline. SafeClipboard, cross-engine browser assurance, the security disclosure lifecycle, autosave lifecycle observation, toolbar shortcut accessibility metadata, accessible editor placeholder semantics, SSR/native-form serialization, revision-scoped selection evidence, W3C text-position selector evidence, the React-free text-position-selector subpath, document-transition evidence, envelope identity routing, framework-neutral deterministic Markdown conversion, CSS paged-media print output, DOCX informative PNG figures, bounded rich-text runs, bounded paragraph alignment, bounded heading alignment, and the OIDC-backed unified stable registry release train are `implemented_on_protected_main`. -The bounded DOCX rich-run external hyperlink contract in #137 is `implemented_on_active_pr` under Proposed ADR 0026. Open branches may extend the protected boundary, but no active-PR capability becomes shipped merely because its design, tests, or documentation are complete. +The bounded DOCX rich-run external hyperlink contract in #137 is `implemented_on_active_pr` under Proposed ADR 0026. The provider-neutral review contract, exact-revision insert/delete transaction adapter, and controlled React review presentation surfaces are likewise `implemented_on_active_pr`; physical screen-reader acceptance, visual-regression acceptance, and durable host workflows remain unaccepted. Open branches may extend the protected boundary, but no active-PR capability becomes shipped merely because its design, tests, or documentation are complete. diff --git a/docs/UML.md b/docs/UML.md index de4e20d31..9a5a29f81 100644 --- a/docs/UML.md +++ b/docs/UML.md @@ -133,6 +133,28 @@ sequenceDiagram Model output never bypasses deterministic validation, host authorization, user review, or durable save concurrency. +## Review suggestion decision (Active PR / Proposed) + +```mermaid +sequenceDiagram + participant Host + participant Handle as CwlEditorHandle + participant State as Captured EditorState + participant History as ProseMirror history + Host->>Handle: authorized suggestion + accept/reject + Handle->>State: validate proposal and canonical revision + alt live state changed or selector unsupported + Handle-->>Host: stale/invalid result; no mutation + else reject + Handle-->>Host: revision-only rejected result; no history entry + else accept + Handle->>History: dispatch one insert/delete transaction + Handle-->>Host: before/after revision transition + end +``` + +The host remains identity, authorization, persistence, durable exact-once decision, audit, and cross-revision re-anchoring authority. Local undo/redo reverses or reapplies the editor transaction only; it does not rewrite host review records. + ## Import and export flow ```mermaid diff --git a/docs/adr/0005-revision-scoped-review-evidence.md b/docs/adr/0005-revision-scoped-review-evidence.md index 8dfa121df..aa4b9702c 100644 --- a/docs/adr/0005-revision-scoped-review-evidence.md +++ b/docs/adr/0005-revision-scoped-review-evidence.md @@ -11,14 +11,17 @@ Delayed review, annotation, AI assistance, and audit-like workflows need to refe - Store selected text or full documents in ordinary review metadata. Rejected because it duplicates sensitive content and still does not guarantee stable anchoring after edits. - Capture coordinates first and reread the document later for hashing. Rejected because asynchronous work can bind coordinates and revision to different editor states. - Capture structural coordinates and canonical content from one immutable editor snapshot, then derive minimum revision-scoped evidence. Selected because it preserves temporal consistency while minimizing disclosure. +- Let each host translate review selectors and dispatch its own editor steps. Rejected because it duplicates the projection boundary, produces inconsistent undo behavior, and moves deterministic editor mutation out of Inkspan. ## Decision Capture selection coordinates and the canonical document envelope from the same immutable editor state before asynchronous revision derivation. Transition evidence validates previous and resulting envelopes before deriving both revisions. Evidence contains only the minimum revision/coordinate/change metadata required by the versioned contract and excludes document bodies, actor, tenant, time, authorization, model identity, transport result, signature, and durable-write claims. +For an authorized insert/delete proposal, `CwlEditorHandle.applyReviewSuggestionDecision()` captures one immutable `EditorState`, validates the proposal and exact canonical revision, and confirms that the live state is still the captured state before dispatch. Acceptance maps the versioned text projection back to validated text positions and dispatches one ProseMirror transaction so normal undo/redo history remains authoritative. Rejection preserves the document and creates no transaction. The adapter never silently re-anchors a stale or unsupported selector. + ## Consequences -Hosts can detect stale review coordinates and content transitions without duplicating full document content in routine evidence. Hosts remain responsible for durable annotation IDs, cross-revision re-anchoring, actor/time attribution, authorization, audit storage, and model-use policy. +Hosts can detect stale review coordinates and content transitions without duplicating full document content in routine evidence. Authorized insert/delete decisions share one deterministic transaction and history boundary. Hosts remain responsible for durable annotation IDs, exact-once decision persistence, cross-revision re-anchoring, actor/time attribution, authorization, audit storage, and model-use policy. ## Failure and recovery @@ -34,7 +37,7 @@ Evidence shape and coordinate semantics are versioned contracts. Existing eviden ## Verification -Use concurrency tests, range/caret cases, transition ordering tests, recursive document-content absence checks, frozen-output checks, packed ESM/CommonJS/strict-TypeScript consumers, and exact-head coverage/security gates. +Use concurrency tests, insert/delete/reject and undo/redo cases, Unicode projection boundaries, range/caret cases, transition ordering tests, recursive document-content absence checks, frozen-output checks, packed ESM/CommonJS/strict-TypeScript consumers, and exact-head coverage/security gates. ## Rollback or supersession diff --git a/docs/package-distribution.md b/docs/package-distribution.md index ddb4df0ee..544bbc4a3 100644 --- a/docs/package-distribution.md +++ b/docs/package-distribution.md @@ -17,6 +17,8 @@ integrations. | `@contextualwisdomlab/cwl-editor/envelope-identity` | Framework-independent identity-only envelope routing for bounded schema identity inspection; migration remains host-owned | | `@contextualwisdomlab/cwl-editor/revision-evidence` | Framework-independent revision evidence and document-transition evidence for local content equality/lineage claims | | `@contextualwisdomlab/cwl-editor/text-position-selector` | `implemented_on_protected_main` — React-free text-position projection core implementing W3C `TextPositionSelector`; interactive capture, revision binding, authorization, persistence, and re-anchoring remain outside this subpath | +| `@contextualwisdomlab/cwl-editor/review` | `implemented_on_active_pr` — React-free deterministic review-target validation bound to exact local SHA-256 revision evidence plus Inkspan's W3C text-position projection; durable review records and policy remain host-owned | +| `@contextualwisdomlab/cwl-editor/review-react` | `implemented_on_active_pr` — controlled accessible React thread presentation over the bounded review contract; hosts supply labels and own actions, authorization, bodies, and persistence | | `@contextualwisdomlab/cwl-editor/markdown` | `implemented_on_active_pr` — headless deterministic Markdown/HTML/email/plain-text conversion with the same safe-link and strict inline-raster policies as the editor, without importing the React/TipTap editor graph | | `@contextualwisdomlab/cwl-editor/styles.css` | Editor layout and theming | | `@contextualwisdomlab/cwl-editor/fonts.css` | Full offline KR/EN/JP/SC/TC/VI font bundle | @@ -58,11 +60,18 @@ embedded in the npm tarball. dependencies so the consumer's package manager installs and resolves it; it is not merely a type-only dependency. - The framework-independent autosave, converter, envelope-identity, - revision-evidence, text-position-selector, and Markdown entrypoints do not - require React UI, a mounted editor, naruon, contextual-orchestrator, a + revision-evidence, text-position-selector, review, and Markdown entrypoints do + not require React UI, a mounted editor, naruon, contextual-orchestrator, a database, provider credentials, or host transport. Their individual package-consumer gates additionally prevent framework dependencies from leaking into subpaths whose public contracts exclude them. +- The review-react subpath is an optional controlled React presentation adapter. + It validates every host-supplied presentation through the React-free review + contract before rendering, receives visible and accessible copy from the host, + and emits selection, reply, resolve, accept, and reject intent callbacks only. Presentation + capability flags never grant authority on their own; missing host callbacks + keep actions disabled. It owns no comment body, actor lookup, authorization, + persistence, notification, or transport. - The Markdown subpath exposes `markdownToHtml`, `htmlToMarkdown`, `normalizeMarkdown`, `markdownToEmailHtml`, `markdownToPlainText`, and `htmlToPlainText` plus their option types. It bundles deterministic conversion @@ -81,6 +90,15 @@ embedded in the npm tarball. state or bind a selector to a document revision. Hosts remain responsible for annotation identifiers/bodies, source-resource identity, authorization, tenancy, persistence, audit, and cross-revision re-anchoring. +- The review subpath composes only validated local SHA-256 revision evidence, + Inkspan's canonical text-position selector/projection identity, and a detached + immutable v1 target. It rejects malformed, accessor-backed, symbolic, or + unknown target fields without retaining caller-owned objects. It cannot prove + that an arbitrary caller-supplied offset is inside a document without the + referenced document, and it creates no durable comment/thread identifier, + actor identity, authorization, tenant boundary, persistence, retention, + notification, audit occurrence, collaboration-provider admission, or + cross-revision re-anchoring authority. Hosts retain all of those duties. - Envelope identity output is routing metadata only. It does not accept an unsupported document generation as current semantics and does not move schema registry, migration, persistence, rollback, or authorization authority into @@ -109,9 +127,9 @@ production library build. The verification chain: 4. rejects internal source, tests, demos, Office files, coverage output, and workflow files from the npm tarball; 5. imports the root, collaboration, converter, autosave, envelope-identity, - revision-evidence, text-position-selector, and Markdown surfaces through their - dedicated packed-consumer checks, including framework-free isolation where - that is part of the public contract; + revision-evidence, text-position-selector, review, review-react, and Markdown + surfaces through their dedicated packed-consumer checks, including + framework-free isolation where that is part of the public contract; 6. exercises supported ESM/CommonJS entrypoints and compiles strict TypeScript consumers against the published declaration surfaces; 7. resolves public CSS and font subpaths; and @@ -129,6 +147,20 @@ rejects **ambient network and credential authority** such as `fetch`, of the selector's structural contract and introduce no interactive runtime authority. +The active review-package check likewise builds and extracts a real npm tarball, +executes `@contextualwisdomlab/cwl-editor/review` through ESM and CommonJS, +compiles a strict TypeScript consumer, exercises valid and malformed target +metadata, and rejects external runtime imports, dynamic module loaders, and +ambient network or credential authority. This is active-PR evidence only until +the review subpath is integrated into protected main. + +The active review-react package check is configured to build and extract a real +npm tarball, exercise ESM and CommonJS server-render consumers plus strict +TypeScript declarations, permit only host-supplied React peer runtime imports, +and reject dynamic module loaders plus ambient network or credential authority. +Its result is active-PR evidence only until the review-react subpath is integrated +into protected main. + The Markdown package check likewise builds and extracts a real npm tarball, executes its ESM and CommonJS entrypoints outside the source tree, compiles a strict TypeScript consumer, and verifies representative safe-link, plain-text, diff --git a/docs/print-output.md b/docs/print-output.md index 3bf67d1c3..4e0a1efc0 100644 --- a/docs/print-output.md +++ b/docs/print-output.md @@ -27,6 +27,10 @@ The print stylesheet: These are paged-media hints, not a promise that every browser/printer combination will produce byte-identical pagination. Browser engines and printer/PDF drivers remain separate rendering authorities. +The Active PR / Proposed review surface defaults `printMode` to `exclude`, so target markers and thread panels do not enter print output accidentally. A host may choose `include` to print the validated thread labels plus status/comment summaries; reply and resolve controls remain hidden because they are interactive chrome. The option does not add comment bodies, actor identity, timestamps, authorization, durable audit, or a governed export receipt. + +`printMode` affects browser print presentation only. Review metadata is excluded from non-print exports: Markdown, HTML, email, plain-text, and Office conversion continues to receive canonical document content only. A host that needs review records in another artifact must create a separately governed export with its own authorization, disclosure, provenance, retention, accessibility, and publication policy. + ## Accessibility and fidelity Printing must not depend on the visual color theme to distinguish authored links, and it must not leak collaborative presence or placeholder UI into the document. Author-provided semantic structure remains the source: headings remain headings, tables remain tables, links remain links, images retain their DOM alternative-text semantics, and code/preformatted text remains text rather than rasterized screen content. diff --git a/package.json b/package.json index 7e861123e..286aea09d 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,16 @@ "import": "./dist/cwl-text-position-selector.js", "require": "./dist/cwl-text-position-selector.cjs" }, + "./review": { + "types": "./dist/review/index.d.ts", + "import": "./dist/cwl-review.js", + "require": "./dist/cwl-review.cjs" + }, + "./review-react": { + "types": "./dist/review-react/index.d.ts", + "import": "./dist/cwl-review-react.js", + "require": "./dist/cwl-review-react.cjs" + }, "./markdown": { "types": "./dist/markdown/index.d.ts", "import": "./dist/cwl-markdown.js", @@ -99,7 +109,7 @@ ], "scripts": { "dev": "vite", - "build": "tsc --noEmit && vite build && vite build --config vite.collaboration.config.ts && vite build --config vite.converter.config.ts && vite build --config vite.envelope-identity.config.ts && vite build --config vite.revision-evidence.config.ts && vite build --config vite.autosave.config.ts && vite build --config vite.text-position-selector.config.ts && vite build --config vite.markdown.config.ts && node ./scripts/copy-styles.mjs", + "build": "tsc --noEmit && vite build && vite build --config vite.collaboration.config.ts && vite build --config vite.converter.config.ts && vite build --config vite.envelope-identity.config.ts && vite build --config vite.revision-evidence.config.ts && vite build --config vite.autosave.config.ts && vite build --config vite.text-position-selector.config.ts && vite build --config vite.review.config.ts && vite build --config vite.review-react.config.ts && vite build --config vite.markdown.config.ts && node ./scripts/copy-styles.mjs", "build:demo": "vite build --config vite.demo.config.ts", "fonts": "node ./scripts/fetch-fonts.mjs", "preview": "vite preview", @@ -110,7 +120,7 @@ "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", "test:package-config": "node --test ./scripts/revision-evidence-consumer-config.test.mjs ./scripts/release-metadata.test.mjs ./scripts/javascript-runtime-authority.test.mjs", - "verify:package": "pnpm run test:package-config && node ./tests/package/verify-package.mjs && node ./tests/package/verify-editor-placeholder-package.mjs && node ./scripts/verify-canonical-envelope-package.mjs && node ./scripts/verify-revision-evidence-package.mjs && node ./scripts/verify-framework-free-revision-evidence-package.mjs && node ./scripts/verify-framework-free-envelope-identity-package.mjs && node ./tests/package/verify-framework-free-autosave-package.mjs && node ./scripts/verify-text-position-selector-package.mjs && node ./scripts/verify-text-position-selector-subpath-package.mjs && node ./scripts/verify-markdown-subpath-package.mjs" + "verify:package": "pnpm run test:package-config && node ./tests/package/verify-package.mjs && node ./tests/package/verify-editor-placeholder-package.mjs && node ./scripts/verify-canonical-envelope-package.mjs && node ./scripts/verify-revision-evidence-package.mjs && node ./scripts/verify-framework-free-revision-evidence-package.mjs && node ./scripts/verify-framework-free-envelope-identity-package.mjs && node ./tests/package/verify-framework-free-autosave-package.mjs && node ./scripts/verify-text-position-selector-package.mjs && node ./scripts/verify-text-position-selector-subpath-package.mjs && node ./scripts/verify-review-package.mjs && node ./scripts/verify-review-react-package.mjs && node ./scripts/verify-markdown-subpath-package.mjs" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", diff --git a/scripts/verify-review-package.mjs b/scripts/verify-review-package.mjs new file mode 100644 index 000000000..97b585fe3 --- /dev/null +++ b/scripts/verify-review-package.mjs @@ -0,0 +1,312 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const packageJson = JSON.parse( + readFileSync(join(repositoryRoot, 'package.json'), 'utf8'), +); +const verificationRoot = mkdtempSync(join(tmpdir(), 'inkspan-review-')); +const extractionDirectory = join(verificationRoot, 'extracted'); +const consumerDirectory = join(verificationRoot, 'consumer'); +const packageDirectory = join( + consumerDirectory, + 'node_modules', + ...packageJson.name.split('/'), +); + +const dynamicLoaderPattern = /(?:\bimport\s*\(|\brequire\s*\()/u; +const externalRuntimeImportPattern = + /(?:\bimport\s+(?:[^'";]*?\sfrom\s*)?['"][^'"]+['"]|\bexport\s+[^'";]*?\sfrom\s*['"][^'"]+['"])/u; +const ambientAuthorityPattern = + /(?:\bfetch\s*\(|\bXMLHttpRequest\b|\bWebSocket\b|\bEventSource\b|\bprocess\.env\b|\bimport\.meta\.env\b|\bDeno\.env\b|\bBun\.env\b)/u; + +function run(command, argumentsList, cwd = repositoryRoot) { + return execFileSync(command, argumentsList, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + }); +} + +function preparePackage() { + mkdirSync(extractionDirectory, { recursive: true }); + mkdirSync(dirname(packageDirectory), { recursive: true }); + const packOutput = run('npm', [ + 'pack', + '--json', + '--ignore-scripts', + '--pack-destination', + verificationRoot, + ]); + const packResult = JSON.parse(packOutput)[0]; + assert.equal(packResult.name, packageJson.name); + assert.equal(packResult.version, packageJson.version); + const tarballPath = join(verificationRoot, packResult.filename); + assert.ok(existsSync(tarballPath)); + run('tar', ['-xzf', tarballPath, '-C', extractionDirectory]); + renameSync(join(extractionDirectory, 'package'), packageDirectory); + writeFileSync( + join(consumerDirectory, 'package.json'), + '{"name":"inkspan-review-consumer","private":true,"type":"module"}\n', + 'utf8', + ); + + const repositoryTiptap = join(repositoryRoot, 'node_modules', '@tiptap'); + const consumerTiptap = join(consumerDirectory, 'node_modules', '@tiptap'); + assert.ok(existsSync(repositoryTiptap)); + symlinkSync(repositoryTiptap, consumerTiptap, 'dir'); +} + +function verifyAuthorityFreeBundles() { + for (const filename of ['cwl-review.js', 'cwl-review.cjs']) { + const source = readFileSync(join(packageDirectory, 'dist', filename), 'utf8'); + assert.equal( + dynamicLoaderPattern.test(source), + false, + `${filename} must not invoke dynamic module loaders`, + ); + assert.doesNotMatch( + source, + externalRuntimeImportPattern, + `${filename} must not import external runtime authority`, + ); + assert.doesNotMatch( + source, + ambientAuthorityPattern, + `${filename} must not reference ambient network or credential authority`, + ); + } +} + +function verifyRuntimeConsumers() { + const esmPath = join(consumerDirectory, 'consumer.mjs'); + writeFileSync( + esmPath, + `import assert from 'node:assert/strict'; +import { + INKSPAN_REVIEW_CONTRACT_VERSION, + TEXT_POSITION_PROJECTION_ID, + TEXT_POSITION_PROJECTION_VERSION, + CwlReviewOperationError, + CwlReviewSuggestionError, + CwlReviewTargetError, + DocumentEnvelopeRevisionError, + TextPositionSelectorEvidenceError, + createDocumentEnvelopeRevision, + createReviewOperationResult, + createReviewSuggestion, + createReviewTarget, + createTextPositionSelector, +} from '${packageJson.name}/review'; +assert.equal(INKSPAN_REVIEW_CONTRACT_VERSION, 1); +assert.equal(TEXT_POSITION_PROJECTION_ID, 'inkspan-prosemirror-text'); +assert.equal(TEXT_POSITION_PROJECTION_VERSION, 1); +assert.equal(typeof CwlReviewOperationError, 'function'); +assert.equal(typeof CwlReviewSuggestionError, 'function'); +assert.equal(typeof CwlReviewTargetError, 'function'); +assert.equal(typeof DocumentEnvelopeRevisionError, 'function'); +assert.equal(typeof TextPositionSelectorEvidenceError, 'function'); +assert.equal(typeof createDocumentEnvelopeRevision, 'function'); +assert.equal(typeof createReviewOperationResult, 'function'); +assert.equal(typeof createReviewSuggestion, 'function'); +assert.equal(typeof createReviewTarget, 'function'); +assert.equal(typeof createTextPositionSelector, 'function'); +const digestHex = 'a'.repeat(64); +const target = createReviewTarget({ + contractVersion: 1, + revision: { + algorithm: 'SHA-256', + digestHex, + strongEntityTag: '\"sha256-' + digestHex + '\"', + }, + selector: { type: 'TextPositionSelector', start: 1, end: 2 }, + projection: { id: 'inkspan-prosemirror-text', version: 1 }, +}); +assert.equal(target.revision.digestHex, digestHex); +assert.equal(target.selector.start, 1); +assert.equal(Object.isFrozen(target), true); +assert.throws( + () => createReviewTarget({ ...target, contractVersion: 2 }), + CwlReviewTargetError, +); +const insertionTarget = createReviewTarget({ + ...target, + selector: { type: 'TextPositionSelector', start: 1, end: 1 }, +}); +const suggestion = createReviewSuggestion({ + contractVersion: 1, + kind: 'insert', + target: insertionTarget, + text: 'proposal', +}); +assert.equal(suggestion.kind, 'insert'); +assert.equal(Object.isFrozen(suggestion), true); +assert.throws( + () => createReviewSuggestion({ ...suggestion, text: '' }), + CwlReviewSuggestionError, +); +`, + 'utf8', + ); + const cjsPath = join(consumerDirectory, 'consumer.cjs'); + writeFileSync( + cjsPath, + `const assert = require('node:assert/strict'); +const review = require('${packageJson.name}/review'); +assert.equal(review.INKSPAN_REVIEW_CONTRACT_VERSION, 1); +assert.equal(review.TEXT_POSITION_PROJECTION_ID, 'inkspan-prosemirror-text'); +assert.equal(review.TEXT_POSITION_PROJECTION_VERSION, 1); +assert.equal(typeof review.CwlReviewOperationError, 'function'); +assert.equal(typeof review.CwlReviewSuggestionError, 'function'); +assert.equal(typeof review.CwlReviewTargetError, 'function'); +assert.equal(typeof review.createDocumentEnvelopeRevision, 'function'); +assert.equal(typeof review.createReviewOperationResult, 'function'); +assert.equal(typeof review.createReviewSuggestion, 'function'); +assert.equal(typeof review.createReviewTarget, 'function'); +assert.equal(typeof review.createTextPositionSelector, 'function'); +const digestHex = 'b'.repeat(64); +const target = review.createReviewTarget({ + contractVersion: 1, + revision: { + algorithm: 'SHA-256', + digestHex, + strongEntityTag: '\"sha256-' + digestHex + '\"', + }, + selector: { type: 'TextPositionSelector', start: 0, end: 0 }, + projection: { id: 'inkspan-prosemirror-text', version: 1 }, +}); +assert.equal(target.revision.digestHex, digestHex); +assert.equal(Object.isFrozen(target.projection), true); +const suggestion = review.createReviewSuggestion({ + contractVersion: 1, + kind: 'insert', + target, + text: 'proposal', +}); +assert.equal(suggestion.text, 'proposal'); +assert.equal(Object.isFrozen(suggestion), true); +`, + 'utf8', + ); + run(process.execPath, [esmPath], consumerDirectory); + run(process.execPath, [cjsPath], consumerDirectory); +} + +function verifyDeclarationConsumer() { + const sourcePath = join(consumerDirectory, 'consumer.ts'); + const configurationPath = join(consumerDirectory, 'tsconfig.json'); + writeFileSync( + sourcePath, + `import { + INKSPAN_REVIEW_CONTRACT_VERSION, + CwlReviewOperationError, + CwlReviewSuggestionError, + CwlReviewTargetError, + createReviewOperationResult, + createReviewSuggestion, + createReviewTarget, + type CwlReviewOperationErrorCode, + type CwlReviewOperationResult, + type CwlReviewSuggestion, + type CwlReviewSuggestionErrorCode, + type CwlReviewTarget, + type CwlReviewTargetErrorCode, + type CwlEditorDocumentRevision, + type CwlEditorTextPositionSelector, + type CwlEditorTextProjectionIdentity, +} from '${packageJson.name}/review'; +declare const revision: CwlEditorDocumentRevision; +declare const selector: CwlEditorTextPositionSelector; +declare const projection: CwlEditorTextProjectionIdentity; +const target: CwlReviewTarget = { + contractVersion: INKSPAN_REVIEW_CONTRACT_VERSION, + revision, + selector, + projection, +}; +const detachedTarget: CwlReviewTarget = createReviewTarget(target); +const suggestion: CwlReviewSuggestion = createReviewSuggestion({ + contractVersion: INKSPAN_REVIEW_CONTRACT_VERSION, + kind: 'delete', + target: { + ...target, + selector: { type: 'TextPositionSelector', start: 0, end: 1 }, + }, +}); +const operationPromise: Promise = + createReviewOperationResult(suggestion, 'reject', {}, {}); +const targetCode: CwlReviewTargetErrorCode = new CwlReviewTargetError().code; +const suggestionCode: CwlReviewSuggestionErrorCode = + new CwlReviewSuggestionError().code; +const operationCode: CwlReviewOperationErrorCode = + new CwlReviewOperationError('invalid_operation').code; +void [ + detachedTarget.revision, + detachedTarget.selector.start, + detachedTarget.selector.end, + detachedTarget.projection.id, + suggestion.kind, + operationPromise, + targetCode, + suggestionCode, + operationCode, +]; +`, + 'utf8', + ); + writeFileSync( + configurationPath, + `${JSON.stringify( + { + compilerOptions: { + noEmit: true, + strict: true, + skipLibCheck: false, + module: 'NodeNext', + moduleResolution: 'NodeNext', + target: 'ES2022', + lib: ['ES2022', 'DOM', 'DOM.Iterable'], + types: [], + }, + files: ['./consumer.ts'], + }, + null, + 2, + )}\n`, + 'utf8', + ); + const compilerPath = join( + repositoryRoot, + 'node_modules', + 'typescript', + 'bin', + 'tsc', + ); + assert.ok(existsSync(compilerPath)); + run(process.execPath, [compilerPath, '--project', configurationPath], consumerDirectory); +} + +try { + preparePackage(); + verifyAuthorityFreeBundles(); + verifyRuntimeConsumers(); + verifyDeclarationConsumer(); + console.log( + `Verified packed ${packageJson.name}/review through authority-bounded ESM, CommonJS, and strict TypeScript consumers.`, + ); +} finally { + rmSync(verificationRoot, { recursive: true, force: true }); +} diff --git a/scripts/verify-review-react-package.mjs b/scripts/verify-review-react-package.mjs new file mode 100644 index 000000000..c116bcde3 --- /dev/null +++ b/scripts/verify-review-react-package.mjs @@ -0,0 +1,278 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const packageJson = JSON.parse( + readFileSync(join(repositoryRoot, 'package.json'), 'utf8'), +); +const verificationRoot = mkdtempSync(join(tmpdir(), 'inkspan-review-react-')); +const extractionDirectory = join(verificationRoot, 'extracted'); +const consumerDirectory = join(verificationRoot, 'consumer'); +const packageDirectory = join( + consumerDirectory, + 'node_modules', + ...packageJson.name.split('/'), +); + +const ambientAuthorityPattern = + /(?:\bfetch\s*\(|\bXMLHttpRequest\b|\bWebSocket\b|\bEventSource\b|\bprocess\.env\b|\bimport\.meta\.env\b|\bDeno\.env\b|\bBun\.env\b)/u; +const dynamicImportPattern = /\bimport\s*\(/u; +const esmSpecifierPattern = + /\b(?:import|export)\s+(?:[^'";]*?\sfrom\s*)?['"]([^'"]+)['"]/gu; +const requireSpecifierPattern = /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/gu; +const allowedRuntimeSpecifiers = new Set(['react', 'react/jsx-runtime']); + +function run(command, argumentsList, cwd = repositoryRoot) { + return execFileSync(command, argumentsList, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + }); +} + +function linkDependency(name) { + const source = join(repositoryRoot, 'node_modules', ...name.split('/')); + const target = join(consumerDirectory, 'node_modules', ...name.split('/')); + assert.ok(existsSync(source), `repository dependency missing: ${name}`); + mkdirSync(dirname(target), { recursive: true }); + symlinkSync(source, target, 'dir'); +} + +function preparePackage() { + mkdirSync(extractionDirectory, { recursive: true }); + mkdirSync(dirname(packageDirectory), { recursive: true }); + const packOutput = run('npm', [ + 'pack', + '--json', + '--ignore-scripts', + '--pack-destination', + verificationRoot, + ]); + const packResult = JSON.parse(packOutput)[0]; + assert.equal(packResult.name, packageJson.name); + assert.equal(packResult.version, packageJson.version); + const tarballPath = join(verificationRoot, packResult.filename); + assert.ok(existsSync(tarballPath)); + run('tar', ['-xzf', tarballPath, '-C', extractionDirectory]); + renameSync(join(extractionDirectory, 'package'), packageDirectory); + writeFileSync( + join(consumerDirectory, 'package.json'), + '{"name":"inkspan-review-react-consumer","private":true,"type":"module"}\n', + 'utf8', + ); + for (const dependency of [ + 'react', + 'react-dom', + '@types/react', + '@types/react-dom', + '@tiptap/core', + '@tiptap/pm', + ]) { + linkDependency(dependency); + } +} + +function verifyBoundedRuntimeImports() { + const files = [ + ['cwl-review-react.js', esmSpecifierPattern], + ['cwl-review-react.cjs', requireSpecifierPattern], + ]; + for (const [filename, specifierPattern] of files) { + const source = readFileSync(join(packageDirectory, 'dist', filename), 'utf8'); + assert.equal( + ambientAuthorityPattern.test(source), + false, + `${filename} must not reference ambient network or credential authority`, + ); + assert.equal( + dynamicImportPattern.test(source), + false, + `${filename} must not dynamically import runtime authority`, + ); + specifierPattern.lastIndex = 0; + for (const match of source.matchAll(specifierPattern)) { + assert.ok( + allowedRuntimeSpecifiers.has(match[1]), + `${filename} imports unexpected runtime authority: ${match[1]}`, + ); + } + } +} + +function presentationFixture(digestCharacter) { + const digestHex = digestCharacter.repeat(64); + return { + contractVersion: 1, + threadKey: 'thread_1', + target: { + contractVersion: 1, + revision: { + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }, + selector: { type: 'TextPositionSelector', start: 3, end: 8 }, + projection: { id: 'inkspan-prosemirror-text', version: 1 }, + }, + state: 'unresolved', + commentCount: 2, + selected: true, + canReply: true, + canResolve: true, + }; +} + +function verifyRuntimeConsumers() { + const labelsSource = `{ + region: 'Document review', + thread: (_thread, index) => 'Thread ' + (index + 1), + reply: 'Reply', + resolve: 'Resolve', +}`; + const fixture = JSON.stringify(presentationFixture('a')); + const esmPath = join(consumerDirectory, 'consumer.mjs'); + writeFileSync( + esmPath, + `import assert from 'node:assert/strict'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { CwlReviewSuggestionDecision, CwlReviewThreadList } from '${packageJson.name}/review-react'; +const html = renderToStaticMarkup(React.createElement(CwlReviewThreadList, { + presentations: [${fixture}], + labels: ${labelsSource}, + onSelectThread() {}, +})); +assert.match(html, /aria-label="Document review"/u); +assert.match(html, /aria-pressed="true"/u); +assert.match(html, /data-cwl-review-print="exclude"/u); +assert.match(html, />Reply]*disabled=""[^>]*>Reply', 'u')); +const suggestionHtml = renderToStaticMarkup(React.createElement(CwlReviewSuggestionDecision, { + suggestion: { contractVersion: 1, kind: 'delete', target: ${fixture}.target }, + label: 'Delete suggested wording', + acceptLabel: 'Accept', + rejectLabel: 'Reject', + onAccept() {}, +})); +assert.match(suggestionHtml, /aria-label="Accept — Delete suggested wording"/u); +assert.match(suggestionHtml, /data-cwl-review-print="exclude"/u); +`, + 'utf8', + ); + const cjsPath = join(consumerDirectory, 'consumer.cjs'); + writeFileSync( + cjsPath, + `const assert = require('node:assert/strict'); +const React = require('react'); +const { renderToStaticMarkup } = require('react-dom/server'); +const { CwlReviewSuggestionDecision, CwlReviewThreadList } = require('${packageJson.name}/review-react'); +const html = renderToStaticMarkup(React.createElement(CwlReviewThreadList, { + presentations: [${JSON.stringify(presentationFixture('b'))}], + labels: ${labelsSource}, + printMode: 'include', + onSelectThread() {}, +})); +assert.match(html, /aria-label="Document review"/u); +assert.match(html, /aria-pressed="true"/u); +assert.match(html, /data-cwl-review-print="include"/u); +assert.match(html, />Resolve]*disabled=""[^>]*>Resolve', 'u')); +assert.equal(typeof CwlReviewSuggestionDecision, 'function'); +`, + 'utf8', + ); + run(process.execPath, [esmPath], consumerDirectory); + run(process.execPath, [cjsPath], consumerDirectory); +} + +function verifyDeclarationConsumer() { + const sourcePath = join(consumerDirectory, 'consumer.ts'); + const configurationPath = join(consumerDirectory, 'tsconfig.json'); + writeFileSync( + sourcePath, + `import { + CwlReviewThreadList, + CwlReviewSuggestionDecision, + type CwlReviewPrintMode, + type CwlReviewSuggestionDecisionProps, + type CwlReviewThreadListLabels, + type CwlReviewThreadListProps, +} from '${packageJson.name}/review-react'; +const printMode: CwlReviewPrintMode = 'include'; +const labels: CwlReviewThreadListLabels = { + region: 'Document review', + thread: (thread, index) => String(index) + thread.state, + reply: 'Reply', + resolve: 'Resolve', +}; +const props: CwlReviewThreadListProps = { + presentations: [], + labels, + printMode, + onSelectThread(thread) { + void thread.threadKey; + }, +}; +const component: typeof CwlReviewThreadList = CwlReviewThreadList; +const suggestionProps: CwlReviewSuggestionDecisionProps = { + suggestion: {}, + label: 'Suggestion', + acceptLabel: 'Accept', + rejectLabel: 'Reject', +}; +const suggestionComponent: typeof CwlReviewSuggestionDecision = CwlReviewSuggestionDecision; +void [props, component, suggestionProps, suggestionComponent, printMode]; +`, + 'utf8', + ); + writeFileSync( + configurationPath, + `${JSON.stringify( + { + compilerOptions: { + noEmit: true, + strict: true, + skipLibCheck: false, + module: 'NodeNext', + moduleResolution: 'NodeNext', + target: 'ES2022', + lib: ['ES2022', 'DOM', 'DOM.Iterable'], + jsx: 'react-jsx', + types: ['react'], + }, + files: ['./consumer.ts'], + }, + null, + 2, + )}\n`, + 'utf8', + ); + const compilerPath = join(repositoryRoot, 'node_modules', 'typescript', 'bin', 'tsc'); + assert.ok(existsSync(compilerPath)); + run(process.execPath, [compilerPath, '--project', configurationPath], consumerDirectory); +} + +try { + preparePackage(); + verifyBoundedRuntimeImports(); + verifyRuntimeConsumers(); + verifyDeclarationConsumer(); + console.log( + `Verified packed ${packageJson.name}/review-react through bounded ESM, CommonJS, and strict TypeScript consumers.`, + ); +} finally { + rmSync(verificationRoot, { recursive: true, force: true }); +} diff --git a/src/components/CwlEditor.reviewSuggestion.test.tsx b/src/components/CwlEditor.reviewSuggestion.test.tsx new file mode 100644 index 000000000..d2e69f121 --- /dev/null +++ b/src/components/CwlEditor.reviewSuggestion.test.tsx @@ -0,0 +1,237 @@ +import { act, cleanup, render, waitFor } from '@testing-library/react'; +import { createRef } from 'react'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { DocumentEnvelopeDigestProvider } from '../documentEnvelopeRevision.js'; +import type { CwlReviewSuggestion } from '../review/index.js'; +import type { CwlEditorHandle } from '../types.js'; +import { CwlEditor } from './CwlEditor.js'; + +afterEach(cleanup); + +async function reviewFixture(defaultValue = 'Alpha beta') { + const editorRef = createRef(); + render(); + await waitFor(() => expect(editorRef.current?.getEditor()).not.toBeNull()); + const handle = editorRef.current!; + const revision = await handle.getDocumentEnvelopeRevision(); + return { handle, revision: revision! }; +} + +function suggestion( + revision: Awaited>, + kind: 'insert' | 'delete', +): CwlReviewSuggestion { + const target = { + contractVersion: 1 as const, + revision: revision!, + selector: { + type: 'TextPositionSelector' as const, + start: 6, + end: kind === 'insert' ? 6 : 10, + }, + projection: { id: 'inkspan-prosemirror-text' as const, version: 1 as const }, + }; + return kind === 'insert' + ? { contractVersion: 1, kind, target, text: 'new ' } + : { contractVersion: 1, kind, target }; +} + +describe('CwlEditor review suggestion decisions', () => { + it.each([ + ['insert', '

Alpha new beta

'], + ['delete', '

Alpha

'], + ] as const)('accepts %s once and keeps it in undo/redo history', async (kind, html) => { + const { handle, revision } = await reviewFixture(); + + await act(async () => { + await handle.applyReviewSuggestionDecision(suggestion(revision, kind), 'accept'); + }); + expect(handle.getHTML()).toBe(html); + + act(() => handle.getEditor()!.commands.undo()); + expect(handle.getHTML()).toBe('

Alpha beta

'); + act(() => handle.getEditor()!.commands.redo()); + expect(handle.getHTML()).toBe(html); + + await expect( + handle.applyReviewSuggestionDecision(suggestion(revision, kind), 'accept'), + ).rejects.toMatchObject({ code: 'stale_operation' }); + }); + + it('rejects deterministically without creating document history', async () => { + const { handle, revision } = await reviewFixture(); + const proposal = suggestion(revision, 'delete'); + + const first = await handle.applyReviewSuggestionDecision(proposal, 'reject'); + const retry = await handle.applyReviewSuggestionDecision(proposal, 'reject'); + + expect(first).toEqual(retry); + expect(first).toMatchObject({ action: 'reject', status: 'rejected' }); + expect(handle.getHTML()).toBe('

Alpha beta

'); + let undone = true; + act(() => { + undone = handle.getEditor()!.commands.undo(); + }); + expect(undone).toBe(false); + }); + + it('fails stale when the editor changes while revision hashing is pending', async () => { + const { handle, revision } = await reviewFixture(); + let release!: () => void; + const blocked = new Promise((resolve) => { + release = resolve; + }); + let started!: () => void; + const hashing = new Promise((resolve) => { + started = resolve; + }); + const provider: DocumentEnvelopeDigestProvider = { + async digest(_algorithm, source) { + started(); + await blocked; + return crypto.subtle.digest('SHA-256', source); + }, + }; + + const decision = handle.applyReviewSuggestionDecision( + suggestion(revision, 'insert'), + 'accept', + undefined, + provider, + ); + await hashing; + act(() => handle.setValue('Newer document')); + release(); + + await expect(decision).rejects.toMatchObject({ code: 'stale_operation' }); + expect(handle.getHTML()).toBe('

Newer document

'); + }); + + it('does not mutate when accepted-operation evidence cannot be created', async () => { + const { handle, revision } = await reviewFixture(); + const provider: DocumentEnvelopeDigestProvider = { + async digest() { + throw new Error('private digest failure'); + }, + }; + + await expect( + handle.applyReviewSuggestionDecision( + suggestion(revision, 'insert'), + 'accept', + undefined, + provider, + ), + ).rejects.toThrow('Document envelope SHA-256 digest could not be created'); + expect(handle.getHTML()).toBe('

Alpha beta

'); + }); + + it('fails stale when the editor changes while accepted evidence is created', async () => { + const { handle, revision } = await reviewFixture(); + let digestCount = 0; + const provider: DocumentEnvelopeDigestProvider = { + async digest(_algorithm, source) { + digestCount += 1; + const digest = await crypto.subtle.digest('SHA-256', source); + if (digestCount === 3) { + act(() => handle.setValue('Newer document')); + } + return digest; + }, + }; + + await expect( + handle.applyReviewSuggestionDecision( + suggestion(revision, 'insert'), + 'accept', + undefined, + provider, + ), + ).rejects.toMatchObject({ code: 'stale_operation' }); + expect(handle.getHTML()).toBe('

Newer document

'); + }); + + it('maps Unicode code-point offsets without splitting graphemes', async () => { + const { handle, revision } = await reviewFixture('A😀B'); + const proposal = suggestion(revision, 'insert'); + const unicodeProposal = { + ...proposal, + target: { + ...proposal.target, + selector: { type: 'TextPositionSelector' as const, start: 2, end: 2 }, + }, + }; + + await act(async () => { + await handle.applyReviewSuggestionDecision(unicodeProposal, 'accept'); + }); + expect(handle.getHTML()).toBe('

A😀new B

'); + }); + + it('maps the start of the document into its first text block', async () => { + const { handle, revision } = await reviewFixture(); + const proposal = suggestion(revision, 'insert'); + + await act(async () => { + await handle.applyReviewSuggestionDecision( + { + ...proposal, + target: { + ...proposal.target, + selector: { type: 'TextPositionSelector', start: 0, end: 0 }, + }, + }, + 'accept', + ); + }); + expect(handle.getHTML()).toBe('

new Alpha beta

'); + }); + + it('maps offsets after a projected block separator', async () => { + const { handle, revision } = await reviewFixture('Alpha\n\nBeta'); + const proposal = suggestion(revision, 'insert'); + + await act(async () => { + await handle.applyReviewSuggestionDecision( + { + ...proposal, + target: { + ...proposal.target, + selector: { type: 'TextPositionSelector', start: 6, end: 6 }, + }, + }, + 'accept', + ); + }); + expect(handle.getHTML()).toBe('

Alpha

new Beta

'); + }); + + it('rejects unsupported decisions and out-of-range projection offsets', async () => { + const { handle, revision } = await reviewFixture(); + await expect( + handle.applyReviewSuggestionDecision( + suggestion(revision, 'insert'), + 'cancel' as 'accept', + ), + ).rejects.toMatchObject({ code: 'invalid_operation' }); + + const proposal = suggestion(revision, 'insert'); + await expect( + handle.applyReviewSuggestionDecision( + { + ...proposal, + target: { + ...proposal.target, + selector: { + type: 'TextPositionSelector', + start: 100, + end: 100, + }, + }, + }, + 'accept', + ), + ).rejects.toMatchObject({ code: 'invalid_operation' }); + expect(handle.getHTML()).toBe('

Alpha beta

'); + }); +}); diff --git a/src/components/useEditorHandle.test.tsx b/src/components/useEditorHandle.test.tsx index 912ce499f..48e909928 100644 --- a/src/components/useEditorHandle.test.tsx +++ b/src/components/useEditorHandle.test.tsx @@ -50,6 +50,9 @@ describe('useEditorHandle', () => { ).resolves.toBeNull(); await expect(handle.getSelectionRevisionEvidence()).resolves.toBeNull(); await expect(handle.getTextPositionSelectorEvidence()).resolves.toBeNull(); + await expect( + handle.applyReviewSuggestionDecision({}, 'accept'), + ).resolves.toBeNull(); expect(handle.validateDocumentEnvelope({})).toBe(false); expect(handle.validateDocumentEnvelopeBytes(new Uint8Array())).toBe(false); expect(handle.restoreDocumentEnvelope({})).toBeNull(); diff --git a/src/components/useEditorHandle.ts b/src/components/useEditorHandle.ts index 710fe883f..aeadebc0c 100644 --- a/src/components/useEditorHandle.ts +++ b/src/components/useEditorHandle.ts @@ -31,6 +31,7 @@ import { } from '../documentSchema.js'; import { createTextPositionSelector } from '../textPositionSelectorEvidence.js'; import type { CwlEditorHandle, EditorMode } from '../types.js'; +import { applyReviewSuggestionDecision } from '../review/editorMutation.js'; import { createEditorDocumentSnapshot } from './editorDocumentSnapshot.js'; import { editorHtmlToValue, editorValueToHtml } from './editorSerialization.js'; @@ -128,6 +129,21 @@ export function useEditorHandle( ); return Object.freeze({ revision, selector, textProjection }); }, + applyReviewSuggestionDecision: ( + suggestion, + action, + limits, + digestProvider, + ) => + editor + ? applyReviewSuggestionDecision( + editor, + suggestion, + action, + limits, + digestProvider, + ) + : Promise.resolve(null), setValue: (next: string) => { if (!editor) return; editor.commands.setContent( diff --git a/src/printStyles.test.ts b/src/printStyles.test.ts index 632a033b1..cd009ed65 100644 --- a/src/printStyles.test.ts +++ b/src/printStyles.test.ts @@ -29,6 +29,16 @@ describe('print stylesheet contract', () => { ); }); + it('excludes review chrome by default and prints only opted-in review summaries', () => { + const printStyles = styles.slice(styles.indexOf('@media print')); + expect(printStyles).toMatch( + /\.cwl-review\[data-cwl-review-print='exclude'\][\s\S]*display:\s*none\s*!important\s*;/u, + ); + expect(printStyles).toMatch( + /\.cwl-review\[data-cwl-review-print='include'\] \.cwl-review__action\s*\{[^}]*display:\s*none\s*!important\s*;/u, + ); + }); + it('does not print placeholder UI and keeps document blocks page-safe', () => { const printIndex = styles.indexOf('@media print'); expect(printIndex).toBeGreaterThan(-1); @@ -64,4 +74,4 @@ describe('print stylesheet contract', () => { expect(browserSpecification).not.toContain('/src/styles.css'); expect(browserConfiguration).toContain('pnpm --dir ../.. build'); }); -}); \ No newline at end of file +}); diff --git a/src/review-react/index.test.tsx b/src/review-react/index.test.tsx new file mode 100644 index 000000000..28900449d --- /dev/null +++ b/src/review-react/index.test.tsx @@ -0,0 +1,311 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createReviewThreadPresentation } from '../review/index.js'; +import { CwlReviewThreadList } from './index.js'; + +afterEach(cleanup); + +function target(digest = 'a') { + const digestHex = digest.repeat(64); + return { + contractVersion: 1, + revision: { + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }, + selector: { + type: 'TextPositionSelector', + start: 3, + end: 8, + }, + projection: { + id: 'inkspan-prosemirror-text', + version: 1, + }, + }; +} + +function presentation( + threadKey: string, + overrides: Record = {}, +) { + return { + contractVersion: 1, + threadKey, + target: target(threadKey === 'thread_1' ? 'a' : 'b'), + state: 'unresolved', + commentCount: 2, + selected: false, + canReply: true, + canResolve: true, + ...overrides, + }; +} + +const labels = { + region: 'Document review', + thread: (thread: ReturnType, index: number) => + `Thread ${index + 1}: ${thread.state}, ${thread.commentCount} comments`, + reply: 'Reply', + resolve: 'Resolve', +}; + +function expectInvalidLabels(candidate: unknown) { + expect(() => + render( + , + ), + ).toThrow('Review presentation metadata is invalid.'); +} + +describe('CwlReviewThreadList', () => { + it('defaults review print output to excluded and accepts explicit inclusion', () => { + const { rerender } = render( + , + ); + expect(screen.getByRole('region')).toHaveAttribute( + 'data-cwl-review-print', + 'exclude', + ); + + rerender( + , + ); + expect(screen.getByRole('region')).toHaveAttribute( + 'data-cwl-review-print', + 'include', + ); + }); + + it('renders a controlled accessible thread list and emits detached presentation intents', () => { + const onSelectThread = vi.fn(); + const onReplyThread = vi.fn(); + const onResolveThread = vi.fn(); + const first = presentation('thread_1', { selected: true }); + const second = presentation('thread_2', { + state: 'resolved', + canReply: false, + canResolve: true, + }); + + render( + , + ); + + const region = screen.getByRole('region', { name: 'Document review' }); + expect(region).toBeInTheDocument(); + + const firstThread = screen.getByRole('button', { + name: 'Thread 1: unresolved, 2 comments', + }); + const secondThread = screen.getByRole('button', { + name: 'Thread 2: resolved, 2 comments', + }); + expect(firstThread).toHaveAttribute('aria-pressed', 'true'); + expect(secondThread).toHaveAttribute('aria-pressed', 'false'); + + fireEvent.click(secondThread); + expect(onSelectThread).toHaveBeenCalledTimes(1); + const selected = onSelectThread.mock.calls[0]?.[0] as ReturnType< + typeof createReviewThreadPresentation + >; + expect(selected.threadKey).toBe('thread_2'); + expect(selected).not.toBe(second); + expect(Object.isFrozen(selected)).toBe(true); + + const replyButtons = [ + screen.getByRole('button', { + name: 'Reply — Thread 1: unresolved, 2 comments', + }), + screen.getByRole('button', { + name: 'Reply — Thread 2: resolved, 2 comments', + }), + ]; + const resolveButtons = [ + screen.getByRole('button', { + name: 'Resolve — Thread 1: unresolved, 2 comments', + }), + screen.getByRole('button', { + name: 'Resolve — Thread 2: resolved, 2 comments', + }), + ]; + expect(replyButtons[0]).toHaveTextContent('Reply'); + expect(resolveButtons[0]).toHaveTextContent('Resolve'); + expect(replyButtons[0]).toBeEnabled(); + expect(replyButtons[1]).toBeDisabled(); + expect(resolveButtons[0]).toBeEnabled(); + expect(resolveButtons[1]).toBeDisabled(); + + fireEvent.click(replyButtons[0]!); + fireEvent.click(resolveButtons[0]!); + expect(onReplyThread).toHaveBeenCalledWith(expect.objectContaining({ threadKey: 'thread_1' })); + expect(onResolveThread).toHaveBeenCalledWith( + expect.objectContaining({ threadKey: 'thread_1' }), + ); + }); + + it('keeps capability booleans presentation-only when host action callbacks are absent', () => { + render( + , + ); + + expect( + screen.getByRole('button', { + name: 'Reply — Thread 1: unresolved, 2 comments', + }), + ).toBeDisabled(); + expect( + screen.getByRole('button', { + name: 'Resolve — Thread 1: unresolved, 2 comments', + }), + ).toBeDisabled(); + }); + + it('fails closed before rendering duplicate host thread keys', () => { + const first = presentation('thread_1'); + const duplicate = presentation('thread_1', { selected: true }); + + expect(() => + render( + , + ), + ).toThrow(/Review presentation metadata is invalid/u); + expect(screen.queryByRole('region', { name: 'Document review' })).not.toBeInTheDocument(); + }); + + it('fails closed before invoking malformed presentation collection behavior', () => { + const privateSentinel = 'private-presentation-collection-must-not-leak'; + const hostileCollection = Object.defineProperty({}, 'map', { + enumerable: true, + get() { + throw new Error(privateSentinel); + }, + }) as unknown as readonly unknown[]; + + expect(() => + render( + , + ), + ).toThrow('Review presentation metadata is invalid.'); + expect(screen.queryByRole('region')).not.toBeInTheDocument(); + }); + + it('fails closed through the review contract before rendering hostile thread metadata', () => { + const hostile = presentation('thread_1', { + commentBody: 'private-body-must-not-render', + }); + + expect(() => + render( + , + ), + ).toThrow(/Review presentation metadata is invalid/u); + expect(screen.queryByText('private-body-must-not-render')).not.toBeInTheDocument(); + }); + + it('fails closed before invoking accessor-backed or inaccessible host labels', () => { + const privateSentinel = 'private-review-label-must-not-leak'; + let regionGetterCalls = 0; + const hostileLabels = { + thread: labels.thread, + reply: labels.reply, + resolve: labels.resolve, + } as Record; + Object.defineProperty(hostileLabels, 'region', { + enumerable: true, + get() { + regionGetterCalls += 1; + throw new Error(privateSentinel); + }, + }); + + expectInvalidLabels(hostileLabels); + expect(regionGetterCalls).toBe(0); + expect(screen.queryByRole('region')).not.toBeInTheDocument(); + + expectInvalidLabels({ ...labels, region: '' }); + expectInvalidLabels({ ...labels, region: 'x'.repeat(513) }); + }); + + it('rejects malformed host label containers and descriptor shapes', () => { + expectInvalidLabels(null); + expectInvalidLabels({ + region: labels.region, + thread: labels.thread, + reply: labels.reply, + unexpected: labels.resolve, + }); + expectInvalidLabels({ + region: labels.region, + thread: labels.thread, + reply: labels.reply, + [Symbol('resolve')]: labels.resolve, + }); + expectInvalidLabels({ ...labels, thread: 'not-a-function' }); + + const hiddenRegion = { ...labels }; + Object.defineProperty(hiddenRegion, 'region', { + configurable: true, + enumerable: false, + value: labels.region, + }); + expectInvalidLabels(hiddenRegion); + + const missingDescriptor = new Proxy( + {}, + { + ownKeys: () => ['region', 'thread', 'reply', 'resolve'], + getOwnPropertyDescriptor: () => undefined, + }, + ); + expectInvalidLabels(missingDescriptor); + }); + + it('fails closed on invalid or throwing per-thread accessible labels', () => { + expectInvalidLabels({ ...labels, thread: () => '' }); + + const privateSentinel = 'private-thread-label-must-not-leak'; + expectInvalidLabels({ + ...labels, + thread() { + throw new Error(privateSentinel); + }, + }); + }); +}); diff --git a/src/review-react/index.tsx b/src/review-react/index.tsx new file mode 100644 index 000000000..ca53b3a62 --- /dev/null +++ b/src/review-react/index.tsx @@ -0,0 +1,643 @@ +import { useId, useRef, useState } from 'react'; +import { + createReviewSuggestion, + createReviewThreadPresentation, + CwlReviewPresentationError, + type CwlReviewSuggestion, + type CwlReviewThreadPresentation, +} from '../review/index.js'; + +/** Host-owned visible copy for Inkspan's bounded review-thread list. */ +export interface CwlReviewThreadListLabels { + /** Accessible name for the review region. */ + readonly region: string; + /** Visible and accessible label for one validated thread. */ + readonly thread: ( + thread: CwlReviewThreadPresentation, + index: number, + ) => string; + /** Optional visible status summary. Must be paired with `comments`. */ + readonly status?: ( + thread: CwlReviewThreadPresentation, + index: number, + ) => string; + /** Optional visible comment-count summary. Must be paired with `status`. */ + readonly comments?: ( + thread: CwlReviewThreadPresentation, + index: number, + ) => string; + /** Visible label for the host-owned reply intent. */ + readonly reply: string; + /** Visible label for the host-owned resolve intent. */ + readonly resolve: string; +} + +/** Whether validated review presentation participates in browser print output. */ +export type CwlReviewPrintMode = 'exclude' | 'include'; + +/** Controlled inputs and intent callbacks for the review-thread list. */ +export interface CwlReviewThreadListProps { + /** Untrusted host presentation records validated before rendering. */ + readonly presentations: readonly unknown[]; + /** Host-supplied localized visible and accessible copy. */ + readonly labels: CwlReviewThreadListLabels; + /** Defaults to `exclude` so review chrome never enters print accidentally. */ + readonly printMode?: CwlReviewPrintMode; + /** Selection intent; the host remains the controlled-state authority. */ + readonly onSelectThread: (thread: CwlReviewThreadPresentation) => void; + /** Optional reply intent; absence keeps reply controls disabled. */ + readonly onReplyThread?: (thread: CwlReviewThreadPresentation) => void; + /** Optional resolve intent; absence keeps resolve controls disabled. */ + readonly onResolveThread?: (thread: CwlReviewThreadPresentation) => void; +} + +/** Controlled inputs for one accessible inline review-target marker. */ +export interface CwlReviewTargetMarkerProps { + /** Untrusted host presentation record validated before rendering. */ + readonly presentation: unknown; + /** Host-supplied localized visible and accessible marker label. */ + readonly label: string; + /** Defaults to `exclude` so review chrome never enters print accidentally. */ + readonly printMode?: CwlReviewPrintMode; + /** Selection intent; the host remains editor-selection authority. */ + readonly onSelectThread: (thread: CwlReviewThreadPresentation) => void; +} + +/** Controlled accessible accept/reject intents for one validated suggestion. */ +export interface CwlReviewSuggestionDecisionProps { + /** Untrusted insert/delete suggestion validated before rendering. */ + readonly suggestion: unknown; + /** Visible and accessible host-owned summary of the suggestion. */ + readonly label: string; + /** Visible host-owned label for accepting the suggestion. */ + readonly acceptLabel: string; + /** Visible host-owned label for rejecting the suggestion. */ + readonly rejectLabel: string; + /** Defaults to `exclude` so interactive decisions never print accidentally. */ + readonly printMode?: CwlReviewPrintMode; + /** Optional accept intent; absence keeps the action disabled. */ + readonly onAccept?: (suggestion: CwlReviewSuggestion) => void; + /** Optional reject intent; absence keeps the action disabled. */ + readonly onReject?: (suggestion: CwlReviewSuggestion) => void; +} + +const REVIEW_LABEL_KEYS = ['region', 'thread', 'reply', 'resolve'] as const; +const REVIEW_SUMMARY_LABEL_KEYS = ['status', 'comments'] as const; +const MAX_REVIEW_LABEL_CODE_UNITS = 512; +const MAX_REVIEW_THREAD_PRESENTATIONS = 1_024; + +type ReviewThreadLabelFactory = CwlReviewThreadListLabels['thread']; +type ReviewThreadStatusLabelFactory = NonNullable< + CwlReviewThreadListLabels['status'] +>; +type ReviewThreadCommentsLabelFactory = NonNullable< + CwlReviewThreadListLabels['comments'] +>; +type ReviewIntentCallback = CwlReviewThreadListProps['onSelectThread']; + +interface ValidatedReviewThreadListLabels { + readonly region: string; + readonly thread: ReviewThreadLabelFactory; + readonly status: ReviewThreadStatusLabelFactory | undefined; + readonly comments: ReviewThreadCommentsLabelFactory | undefined; + readonly reply: string; + readonly resolve: string; +} + +interface ValidatedReviewIntentCallbacks { + readonly onSelectThread: ReviewIntentCallback; + readonly onReplyThread: ReviewIntentCallback | undefined; + readonly onResolveThread: ReviewIntentCallback | undefined; +} + +function rejectReviewPresentation(): never { + throw new CwlReviewPresentationError(); +} + +function requireVisibleLabel(value: unknown): string { + if ( + typeof value !== 'string' || + value.trim().length === 0 || + value.length > MAX_REVIEW_LABEL_CODE_UNITS + ) { + rejectReviewPresentation(); + } + return value; +} + +function validateReviewPrintMode(value: unknown): CwlReviewPrintMode { + if (value === undefined || value === 'exclude') return 'exclude'; + if (value === 'include') return value; + rejectReviewPresentation(); +} + +function validateReviewThreadListLabels( + source: unknown, +): ValidatedReviewThreadListLabels { + try { + if (typeof source !== 'object' || source === null) { + rejectReviewPresentation(); + } + const ownKeys = Reflect.ownKeys(source); + const allowedKeys = [...REVIEW_LABEL_KEYS, ...REVIEW_SUMMARY_LABEL_KEYS]; + const hasSummaryLabels = + ownKeys.length === allowedKeys.length && + REVIEW_SUMMARY_LABEL_KEYS.every((key) => ownKeys.includes(key)); + if ( + (ownKeys.length !== REVIEW_LABEL_KEYS.length && !hasSummaryLabels) || + ownKeys.some( + (key) => + typeof key !== 'string' || + !allowedKeys.some((candidate) => candidate === key), + ) + ) { + rejectReviewPresentation(); + } + + const expectedKeys = hasSummaryLabels ? allowedKeys : REVIEW_LABEL_KEYS; + const values: Record = {}; + for (const key of expectedKeys) { + const descriptor = Object.getOwnPropertyDescriptor(source, key); + if ( + descriptor === undefined || + descriptor.enumerable !== true || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + rejectReviewPresentation(); + } + values[key] = descriptor.value; + } + + if ( + typeof values.thread !== 'function' || + (hasSummaryLabels && + (typeof values.status !== 'function' || + typeof values.comments !== 'function')) + ) { + rejectReviewPresentation(); + } + return Object.freeze({ + region: requireVisibleLabel(values.region), + thread: values.thread as ReviewThreadLabelFactory, + status: hasSummaryLabels + ? (values.status as ReviewThreadStatusLabelFactory) + : undefined, + comments: hasSummaryLabels + ? (values.comments as ReviewThreadCommentsLabelFactory) + : undefined, + reply: requireVisibleLabel(values.reply), + resolve: requireVisibleLabel(values.resolve), + }); + } catch { + rejectReviewPresentation(); + } +} + +function validateReviewIntentCallbacks( + onSelectThread: unknown, + onReplyThread: unknown, + onResolveThread: unknown, +): ValidatedReviewIntentCallbacks { + if (typeof onSelectThread !== 'function') { + rejectReviewPresentation(); + } + if (onReplyThread !== undefined && typeof onReplyThread !== 'function') { + rejectReviewPresentation(); + } + if (onResolveThread !== undefined && typeof onResolveThread !== 'function') { + rejectReviewPresentation(); + } + return Object.freeze({ + onSelectThread: onSelectThread as ReviewIntentCallback, + onReplyThread: onReplyThread as ReviewIntentCallback | undefined, + onResolveThread: onResolveThread as ReviewIntentCallback | undefined, + }); +} + +function createThreadLabel( + labelFactory: ReviewThreadLabelFactory, + presentation: CwlReviewThreadPresentation, + index: number, +): string { + try { + return requireVisibleLabel(labelFactory(presentation, index)); + } catch { + rejectReviewPresentation(); + } +} + +function createThreadSummaryLabel( + labelFactory: + | ReviewThreadStatusLabelFactory + | ReviewThreadCommentsLabelFactory, + presentation: CwlReviewThreadPresentation, + index: number, +): string { + try { + return requireVisibleLabel(labelFactory(presentation, index)); + } catch { + rejectReviewPresentation(); + } +} + +function validateReviewThreadPresentations( + presentations: readonly unknown[], +): readonly CwlReviewThreadPresentation[] { + try { + if (!Array.isArray(presentations)) { + throw new CwlReviewPresentationError(); + } + + const lengthDescriptor = Object.getOwnPropertyDescriptor( + presentations, + 'length', + ); + if ( + lengthDescriptor === undefined || + !Object.prototype.hasOwnProperty.call(lengthDescriptor, 'value') || + typeof lengthDescriptor.value !== 'number' || + !Number.isSafeInteger(lengthDescriptor.value) || + lengthDescriptor.value < 0 || + lengthDescriptor.value > MAX_REVIEW_THREAD_PRESENTATIONS + ) { + throw new CwlReviewPresentationError(); + } + const presentationCount = lengthDescriptor.value; + + const validatedPresentations: CwlReviewThreadPresentation[] = []; + for (let index = 0; index < presentationCount; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor( + presentations, + String(index), + ); + if ( + descriptor === undefined || + descriptor.enumerable !== true || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + throw new CwlReviewPresentationError(); + } + validatedPresentations.push( + createReviewThreadPresentation(descriptor.value), + ); + } + + const threadKeys = new Set(); + let selectedThreadCount = 0; + for (const presentation of validatedPresentations) { + if (threadKeys.has(presentation.threadKey)) { + throw new CwlReviewPresentationError(); + } + threadKeys.add(presentation.threadKey); + if (presentation.selected) { + selectedThreadCount += 1; + if (selectedThreadCount > 1) { + throw new CwlReviewPresentationError(); + } + } + } + return validatedPresentations; + } catch { + throw new CwlReviewPresentationError(); + } +} + +function reviewThreadFocusIndex( + key: string, + index: number, + lastIndex: number, +): number | undefined { + switch (key) { + case 'ArrowDown': + return Math.min(index + 1, lastIndex); + case 'ArrowUp': + return Math.max(index - 1, 0); + case 'Home': + return 0; + case 'End': + return lastIndex; + default: + return undefined; + } +} + +function initialReviewThreadKey( + presentations: readonly CwlReviewThreadPresentation[], +): string | null { + let firstThreadKey: string | null = null; + for (const presentation of presentations) { + firstThreadKey ??= presentation.threadKey; + if (presentation.selected) { + return presentation.threadKey; + } + } + return firstThreadKey; +} + +function resolveReviewThreadKey( + presentations: readonly CwlReviewThreadPresentation[], + focusedThreadKey: string | null, +): string | null { + if (focusedThreadKey !== null) { + for (const presentation of presentations) { + if (presentation.threadKey === focusedThreadKey) { + return focusedThreadKey; + } + } + } + return initialReviewThreadKey(presentations); +} + +function invokeReviewIntent( + callback: (value: Value) => unknown, + value: Value, +): void { + try { + void Promise.resolve(callback(value)).catch(() => undefined); + } catch { + rejectReviewPresentation(); + } +} + +/** Render host-controlled decision intents for one bounded insert/delete suggestion. */ +export function CwlReviewSuggestionDecision({ + suggestion, + label, + acceptLabel, + rejectLabel, + printMode, + onAccept, + onReject, +}: CwlReviewSuggestionDecisionProps) { + const validatedSuggestion = createReviewSuggestion(suggestion); + const validatedLabel = requireVisibleLabel(label); + const validatedAcceptLabel = requireVisibleLabel(acceptLabel); + const validatedRejectLabel = requireVisibleLabel(rejectLabel); + const validatedPrintMode = validateReviewPrintMode(printMode); + if (onAccept !== undefined && typeof onAccept !== 'function') { + rejectReviewPresentation(); + } + if (onReject !== undefined && typeof onReject !== 'function') { + rejectReviewPresentation(); + } + + return ( +
+ {validatedLabel} + + +
+ ); +} + +/** + * Render one controlled accessible inline marker for a validated review target. + * + * The marker validates and detaches the host presentation before rendering, + * bounds visible copy through the same review presentation contract as the + * thread list, and preflights the selection callback. Its pressed state mirrors + * host-controlled selection and its data state gives hosts a non-authoritative + * presentation hook without copying comment bodies or actor data. Activation + * emits only the frozen presentation snapshot; the embedding host remains the + * authority that maps the revision-bound target to editor focus/selection and + * performs authorization, persistence, transport, or collaboration updates. + */ +export function CwlReviewTargetMarker({ + presentation, + label, + printMode, + onSelectThread, +}: CwlReviewTargetMarkerProps) { + const validatedPresentation = createReviewThreadPresentation(presentation); + const validatedLabel = requireVisibleLabel(label); + const validatedPrintMode = validateReviewPrintMode(printMode); + const validatedCallback = validateReviewIntentCallbacks( + onSelectThread, + undefined, + undefined, + ).onSelectThread; + + return ( + + ); +} + +/** + * Render a controlled accessible list of bounded review-thread presentations. + * + * The collection count is snapshotted from its own data descriptor before any + * item inspection, without invoking a caller-controlled `length` getter. Every + * array slot must be a dense enumerable data property, so accessor-backed or + * sparse host entries fail closed without invoking host accessors before the + * React-free review validator inspects each value. Collections with multiple + * host-selected threads likewise fail closed so controlled selection remains + * unambiguous. Host labels must be exact enumerable data fields and bounded + * non-empty visible strings. The required thread-label factory may be + * accompanied by paired status/comment-summary factories; supplying only one + * summary factory fails closed. Accessor-backed labels and thrown/private label + * failures are normalized to the same redacted presentation error before React + * commits inaccessible content. When summary factories are present their + * localized visible output also describes the thread-selection, reply, and + * resolve controls without changing action names. Required and optional host + * intent callbacks are preflighted and snapshotted before rendering so malformed + * runtime values fail closed at the same public presentation boundary rather + * than surfacing a native invocation TypeError. Private failures thrown by + * validated intent callbacks are likewise normalized to the public presentation + * error instead of leaking host details. Exactly one thread-selection target + * participates in the tab order: the host-selected thread is the initial rover + * when present, otherwise the first thread is. Once focus enters the list the + * rover is retained by stable validated `threadKey`. Arrow Up/Down and Home/End + * move that roving DOM focus only; keyboard traversal never commits + * host-controlled thread selection. Repeated reply/resolve controls include the + * already validated thread label in their accessible name so action lists remain + * disambiguated without changing visible host copy. The component emits only + * intent callbacks with the detached, frozen presentation snapshot; it does not + * authorize, persist, transport, mutate, resolve, or reply to host-owned review + * records. + */ +export function CwlReviewThreadList({ + presentations, + labels, + printMode, + onSelectThread, + onReplyThread, + onResolveThread, +}: CwlReviewThreadListProps) { + const listId = useId(); + const threadButtons = useRef>([]); + const [focusedThreadKey, setFocusedThreadKey] = useState(null); + const validatedPresentations = + validateReviewThreadPresentations(presentations); + const validatedLabels = validateReviewThreadListLabels(labels); + const validatedPrintMode = validateReviewPrintMode(printMode); + const validatedCallbacks = validateReviewIntentCallbacks( + onSelectThread, + onReplyThread, + onResolveThread, + ); + const rovingThreadKey = resolveReviewThreadKey( + validatedPresentations, + focusedThreadKey, + ); + + return ( +
+
    + {validatedPresentations.map((presentation, index) => { + const threadLabel = createThreadLabel( + validatedLabels.thread, + presentation, + index, + ); + const statusFactory = validatedLabels.status; + const commentsFactory = validatedLabels.comments; + const semanticSummary = + statusFactory !== undefined && commentsFactory !== undefined + ? { + status: createThreadSummaryLabel( + statusFactory, + presentation, + index, + ), + comments: createThreadSummaryLabel( + commentsFactory, + presentation, + index, + ), + } + : undefined; + const summaryId = + semanticSummary === undefined + ? undefined + : `${listId}-thread-${index}-summary`; + const replyHandler = + presentation.canReply && + validatedCallbacks.onReplyThread !== undefined + ? () => + invokeReviewIntent( + validatedCallbacks.onReplyThread!, + presentation, + ) + : undefined; + const resolveHandler = + presentation.state === 'unresolved' && + presentation.canResolve && + validatedCallbacks.onResolveThread !== undefined + ? () => + invokeReviewIntent( + validatedCallbacks.onResolveThread!, + presentation, + ) + : undefined; + + return ( +
  • + + {semanticSummary === undefined ? null : ( + + {semanticSummary.status}{' '} + {semanticSummary.comments} + + )} + + +
  • + ); + })} +
+
+ ); +} diff --git a/src/review-react/intentCallbackValidation.test.tsx b/src/review-react/intentCallbackValidation.test.tsx new file mode 100644 index 000000000..aaa74e4ca --- /dev/null +++ b/src/review-react/intentCallbackValidation.test.tsx @@ -0,0 +1,188 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CwlReviewPresentationError } from '../review/index.js'; +import { + CwlReviewThreadList, + type CwlReviewThreadListProps, +} from './index.js'; + +afterEach(cleanup); + +function presentation() { + const digestHex = 'a'.repeat(64); + return { + contractVersion: 1, + threadKey: 'thread_1', + target: { + contractVersion: 1, + revision: { + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }, + selector: { + type: 'TextPositionSelector', + start: 3, + end: 8, + }, + projection: { + id: 'inkspan-prosemirror-text', + version: 1, + }, + }, + state: 'unresolved', + commentCount: 1, + selected: false, + canReply: true, + canResolve: true, + }; +} + +const labels = { + region: 'Document review', + thread: () => 'Thread 1', + reply: 'Reply', + resolve: 'Resolve', +}; + +function expectInvalidIntentCallbacks( + overrides: Record, +): void { + const props = { + presentations: [presentation()], + labels, + onSelectThread: vi.fn(), + ...overrides, + } as unknown as CwlReviewThreadListProps; + + expect(() => render()).toThrow( + 'Review presentation metadata is invalid.', + ); +} + +function renderWithCallbacks( + overrides: Partial, +): void { + render( + , + ); +} + +function expectRedactedIntentFailure( + action: () => void, + privateSentinel: string, +): void { + const observedErrors: unknown[] = []; + const handleWindowError = (event: ErrorEvent): void => { + observedErrors.push(event.error); + event.preventDefault(); + }; + + window.addEventListener('error', handleWindowError); + try { + action(); + } finally { + window.removeEventListener('error', handleWindowError); + } + + // React's development event guard can report the same thrown handler error + // more than once through JSDOM's window boundary. Cardinality is a test + // harness detail; the contract is that every externally observed error is + // the stable, redacted Inkspan presentation error. + expect(observedErrors.length).toBeGreaterThan(0); + for (const error of observedErrors) { + expect(error).toBeInstanceOf(CwlReviewPresentationError); + expect(error).toMatchObject({ + code: 'invalid_presentation', + message: 'Review presentation metadata is invalid.', + }); + expect(String(error)).not.toContain(privateSentinel); + } +} + +async function expectRedactedAsyncIntentFailure( + action: () => void, +): Promise { + const unhandledReasons: unknown[] = []; + const handleUnhandledRejection = (event: PromiseRejectionEvent): void => { + unhandledReasons.push(event.reason); + event.preventDefault(); + }; + + window.addEventListener('unhandledrejection', handleUnhandledRejection); + try { + action(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } finally { + window.removeEventListener('unhandledrejection', handleUnhandledRejection); + } + + expect(unhandledReasons).toEqual([]); +} + +describe('CwlReviewThreadList intent callback validation', () => { + it('fails closed before rendering when the required selection callback is malformed', () => { + expectInvalidIntentCallbacks({ onSelectThread: null }); + }); + + it('fails closed before rendering when an optional reply callback is malformed', () => { + expectInvalidIntentCallbacks({ onReplyThread: 'not-a-function' }); + }); + + it('fails closed before rendering when an optional resolve callback is malformed', () => { + expectInvalidIntentCallbacks({ onResolveThread: 42 }); + }); + + it('redacts a private host selection callback failure at the presentation boundary', () => { + const callback = vi.fn(() => { + throw new Error('private selection sentinel'); + }); + renderWithCallbacks({ onSelectThread: callback }); + + expectRedactedIntentFailure(() => { + fireEvent.click(screen.getByRole('button', { name: 'Thread 1' })); + }, 'private selection sentinel'); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('redacts a private host reply callback failure at the presentation boundary', () => { + const callback = vi.fn(() => { + throw new Error('private reply sentinel'); + }); + renderWithCallbacks({ onReplyThread: callback }); + + expectRedactedIntentFailure(() => { + fireEvent.click(screen.getByRole('button', { name: 'Reply — Thread 1' })); + }, 'private reply sentinel'); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('redacts a private host resolve callback failure at the presentation boundary', () => { + const callback = vi.fn(() => { + throw new Error('private resolve sentinel'); + }); + renderWithCallbacks({ onResolveThread: callback }); + + expectRedactedIntentFailure(() => { + fireEvent.click(screen.getByRole('button', { name: 'Resolve — Thread 1' })); + }, 'private resolve sentinel'); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('redacts a rejected async host callback at the presentation boundary', async () => { + const callback = vi.fn(async () => { + throw new Error('private async selection sentinel'); + }); + renderWithCallbacks({ onSelectThread: callback }); + + await expectRedactedAsyncIntentFailure(() => { + fireEvent.click(screen.getByRole('button', { name: 'Thread 1' })); + }); + expect(callback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/review-react/keyboardTraversal.test.tsx b/src/review-react/keyboardTraversal.test.tsx new file mode 100644 index 000000000..98e883065 --- /dev/null +++ b/src/review-react/keyboardTraversal.test.tsx @@ -0,0 +1,185 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CwlReviewThreadList } from './index.js'; + +afterEach(cleanup); + +function target(digest: string) { + const digestHex = digest.repeat(64); + return { + contractVersion: 1, + revision: { + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }, + selector: { + type: 'TextPositionSelector', + start: 3, + end: 8, + }, + projection: { + id: 'inkspan-prosemirror-text', + version: 1, + }, + }; +} + +function presentation( + threadKey: string, + digest: string, + selected = false, +) { + return { + contractVersion: 1, + threadKey, + target: target(digest), + state: 'unresolved', + commentCount: 1, + selected, + canReply: false, + canResolve: false, + }; +} + +const labels = { + region: 'Document review', + thread: (_thread: unknown, index: number) => `Thread ${index + 1}`, + reply: 'Reply', + resolve: 'Resolve', +}; + +describe('CwlReviewThreadList keyboard traversal', () => { + it('moves focus between thread targets without committing host selection', () => { + const onSelectThread = vi.fn(); + render( + , + ); + + const first = screen.getByRole('button', { name: 'Thread 1' }); + const second = screen.getByRole('button', { name: 'Thread 2' }); + + first.focus(); + fireEvent.keyDown(first, { key: 'Tab' }); + expect(first).toHaveFocus(); + + fireEvent.keyDown(first, { key: 'ArrowDown' }); + expect(second).toHaveFocus(); + expect(onSelectThread).not.toHaveBeenCalled(); + + fireEvent.keyDown(second, { key: 'ArrowUp' }); + expect(first).toHaveFocus(); + + fireEvent.keyDown(first, { key: 'End' }); + expect(second).toHaveFocus(); + + fireEvent.keyDown(second, { key: 'Home' }); + expect(first).toHaveFocus(); + }); + + it('clamps arrow traversal at the first and last thread without selecting either thread', () => { + const onSelectThread = vi.fn(); + render( + , + ); + + const first = screen.getByRole('button', { name: 'Thread 1' }); + const last = screen.getByRole('button', { name: 'Thread 3' }); + + first.focus(); + fireEvent.keyDown(first, { key: 'ArrowUp' }); + expect(first).toHaveFocus(); + + last.focus(); + fireEvent.keyDown(last, { key: 'ArrowDown' }); + expect(last).toHaveFocus(); + + expect(onSelectThread).not.toHaveBeenCalled(); + }); + + it('keeps exactly one thread-selection target in the tab order while arrow focus roves', () => { + const onSelectThread = vi.fn(); + render( + , + ); + + const first = screen.getByRole('button', { name: 'Thread 1' }); + const second = screen.getByRole('button', { name: 'Thread 2' }); + const third = screen.getByRole('button', { name: 'Thread 3' }); + + expect(first).toHaveAttribute('tabindex', '-1'); + expect(second).toHaveAttribute('tabindex', '0'); + expect(third).toHaveAttribute('tabindex', '-1'); + + second.focus(); + fireEvent.keyDown(second, { key: 'ArrowDown' }); + expect(third).toHaveFocus(); + expect(second).toHaveAttribute('tabindex', '-1'); + expect(third).toHaveAttribute('tabindex', '0'); + + fireEvent.keyDown(third, { key: 'Home' }); + expect(first).toHaveFocus(); + expect(first).toHaveAttribute('tabindex', '0'); + expect(third).toHaveAttribute('tabindex', '-1'); + expect(onSelectThread).not.toHaveBeenCalled(); + }); + + it('re-homes the rover when the focused thread disappears from host presentation state', () => { + const onSelectThread = vi.fn(); + const { rerender } = render( + , + ); + + const second = screen.getByRole('button', { name: 'Thread 2' }); + fireEvent.focus(second); + expect(second).toHaveAttribute('tabindex', '0'); + + rerender( + , + ); + + const first = screen.getByRole('button', { name: 'Thread 1' }); + const selected = screen.getByRole('button', { name: 'Thread 2' }); + expect(first).toHaveAttribute('tabindex', '-1'); + expect(selected).toHaveAttribute('tabindex', '0'); + expect(onSelectThread).not.toHaveBeenCalled(); + }); +}); diff --git a/src/review-react/presentationCollectionBoundary.test.tsx b/src/review-react/presentationCollectionBoundary.test.tsx new file mode 100644 index 000000000..842a8c415 --- /dev/null +++ b/src/review-react/presentationCollectionBoundary.test.tsx @@ -0,0 +1,136 @@ +import { cleanup, render } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CwlReviewThreadList } from './index.js'; + +afterEach(cleanup); + +function presentation(threadKey = 'thread_1') { + const digestHex = 'a'.repeat(64); + return { + contractVersion: 1, + threadKey, + target: { + contractVersion: 1, + revision: { + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }, + selector: { + type: 'TextPositionSelector', + start: 3, + end: 8, + }, + projection: { + id: 'inkspan-prosemirror-text', + version: 1, + }, + }, + state: 'unresolved', + commentCount: 1, + selected: false, + canReply: true, + canResolve: true, + }; +} + +const labels = { + region: 'Document review', + thread: () => 'Thread', + reply: 'Reply', + resolve: 'Resolve', +}; + +function renderPresentations(presentations: readonly unknown[]) { + return () => + render( + , + ); +} + +describe('CwlReviewThreadList presentation collection boundary', () => { + it('rejects accessor-backed array entries without invoking the accessor', () => { + let getterCalls = 0; + const presentations: unknown[] = []; + Object.defineProperty(presentations, '0', { + enumerable: true, + configurable: true, + get() { + getterCalls += 1; + return presentation(); + }, + }); + + expect(renderPresentations(presentations)).toThrow( + 'Review presentation metadata is invalid.', + ); + expect(getterCalls).toBe(0); + }); + + it('rejects oversized collections before inspecting any presentation entry', () => { + let getterCalls = 0; + const presentations = new Array(1_025); + Object.defineProperty(presentations, '0', { + enumerable: true, + configurable: true, + get() { + getterCalls += 1; + return presentation(); + }, + }); + + expect(renderPresentations(presentations)).toThrow( + 'Review presentation metadata is invalid.', + ); + expect(getterCalls).toBe(0); + }); + + it('does not trust caller-controlled array length reads', () => { + let lengthReads = 0; + const presentations = new Proxy([presentation()], { + get(target, property, receiver) { + if (property === 'length') { + lengthReads += 1; + return 0; + } + return Reflect.get(target, property, receiver); + }, + }); + + expect(renderPresentations(presentations)).not.toThrow(); + expect(lengthReads).toBe(0); + }); + + it('rejects sparse presentation arrays instead of silently skipping holes', () => { + expect(renderPresentations(new Array(1))).toThrow( + 'Review presentation metadata is invalid.', + ); + }); + + it('rejects non-enumerable presentation entries before value validation', () => { + const presentations: unknown[] = []; + Object.defineProperty(presentations, '0', { + enumerable: false, + configurable: true, + writable: true, + value: presentation(), + }); + + expect(renderPresentations(presentations)).toThrow( + 'Review presentation metadata is invalid.', + ); + }); + + it('rejects collections with more than one host-selected thread', () => { + expect( + renderPresentations([ + { ...presentation('thread_1'), selected: true }, + { ...presentation('thread_2'), selected: true }, + ]), + ).toThrow('Review presentation metadata is invalid.'); + }); +}); \ No newline at end of file diff --git a/src/review-react/semanticSummary.test.tsx b/src/review-react/semanticSummary.test.tsx new file mode 100644 index 000000000..596572f7b --- /dev/null +++ b/src/review-react/semanticSummary.test.tsx @@ -0,0 +1,126 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CwlReviewPresentationError } from '../review/index.js'; +import { + CwlReviewThreadList, + type CwlReviewThreadListLabels, +} from './index.js'; + +afterEach(cleanup); + +function presentation( + threadKey: string, + state: 'unresolved' | 'resolved', + commentCount: number, +) { + const digestHex = (threadKey === 'thread_1' ? 'a' : 'b').repeat(64); + return { + contractVersion: 1, + threadKey, + target: { + contractVersion: 1, + revision: { + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }, + selector: { + type: 'TextPositionSelector', + start: 3, + end: 8, + }, + projection: { + id: 'inkspan-prosemirror-text', + version: 1, + }, + }, + state, + commentCount, + selected: false, + canReply: true, + canResolve: state === 'unresolved', + }; +} + +const labels = { + region: 'Document review', + thread: (_thread: unknown, index: number) => `Thread ${index + 1}`, + status: (thread: { readonly state: 'unresolved' | 'resolved' }) => + thread.state === 'resolved' ? 'Resolved' : 'Unresolved', + comments: (thread: { readonly commentCount: number }) => + `${thread.commentCount} comments`, + reply: 'Reply', + resolve: 'Resolve', +} as unknown as CwlReviewThreadListLabels; + +describe('CwlReviewThreadList semantic summaries', () => { + it('renders host-localized status and count text as the accessible description for each thread and action', () => { + render( + , + ); + + const firstThread = screen.getByRole('button', { name: 'Thread 1' }); + const secondThread = screen.getByRole('button', { name: 'Thread 2' }); + expect(firstThread).toHaveAccessibleDescription('Unresolved 2 comments'); + expect(secondThread).toHaveAccessibleDescription('Resolved 5 comments'); + expect(screen.getByText('Unresolved')).toBeVisible(); + expect(screen.getByText('Resolved')).toBeVisible(); + expect(screen.getByText('2 comments')).toBeVisible(); + expect(screen.getByText('5 comments')).toBeVisible(); + + expect( + screen.getByRole('button', { name: 'Reply — Thread 1' }), + ).toHaveAccessibleDescription('Unresolved 2 comments'); + expect( + screen.getByRole('button', { name: 'Resolve — Thread 1' }), + ).toHaveAccessibleDescription('Unresolved 2 comments'); + }); + + it('fails closed when only one semantic-summary label factory is supplied', () => { + const incompleteLabels = { + region: 'Document review', + thread: () => 'Thread 1', + status: () => 'Unresolved', + reply: 'Reply', + resolve: 'Resolve', + } as unknown as CwlReviewThreadListLabels; + + expect(() => + render( + , + ), + ).toThrow('Review presentation metadata is invalid.'); + }); + + it('normalizes a thrown semantic-summary label failure to the public presentation error', () => { + const privateFailureLabels = { + ...labels, + status: () => { + throw new Error('private host localization failure'); + }, + } as unknown as CwlReviewThreadListLabels; + + expect(() => + render( + , + ), + ).toThrowError(CwlReviewPresentationError); + }); +}); diff --git a/src/review-react/suggestionDecision.test.tsx b/src/review-react/suggestionDecision.test.tsx new file mode 100644 index 000000000..ffacb1ac0 --- /dev/null +++ b/src/review-react/suggestionDecision.test.tsx @@ -0,0 +1,130 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CwlReviewSuggestionDecision } from './index.js'; + +afterEach(cleanup); + +function suggestion() { + const digestHex = 'a'.repeat(64); + return { + contractVersion: 1, + kind: 'insert', + target: { + contractVersion: 1, + revision: { + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }, + selector: { type: 'TextPositionSelector', start: 3, end: 3 }, + projection: { id: 'inkspan-prosemirror-text', version: 1 }, + }, + text: 'Suggested text', + }; +} + +describe('CwlReviewSuggestionDecision', () => { + it('emits detached accept and reject intents with disambiguated names', () => { + const onAccept = vi.fn(); + const onReject = vi.fn(); + const source = suggestion(); + render( + , + ); + + fireEvent.click( + screen.getByRole('button', { name: 'Accept — Insert suggested wording' }), + ); + fireEvent.click( + screen.getByRole('button', { name: 'Reject — Insert suggested wording' }), + ); + expect(onAccept).toHaveBeenCalledTimes(1); + expect(onReject).toHaveBeenCalledTimes(1); + expect(onAccept.mock.calls[0]?.[0]).not.toBe(source); + expect(Object.isFrozen(onAccept.mock.calls[0]?.[0])).toBe(true); + }); + + it('defaults to print exclusion and disables unavailable decisions', () => { + const { rerender } = render( + , + ); + + expect(screen.getByRole('group')).toHaveAttribute( + 'data-cwl-review-print', + 'exclude', + ); + expect(screen.getByRole('button', { name: /Accept/u })).toBeDisabled(); + expect(screen.getByRole('button', { name: /Reject/u })).toBeDisabled(); + + rerender( + , + ); + expect(screen.getByRole('group')).toHaveAttribute( + 'data-cwl-review-print', + 'include', + ); + }); + + it('fails closed on malformed suggestion decision inputs', () => { + expect(() => + render( + , + ), + ).toThrow(); + expect(() => + render( + , + ), + ).toThrow('Review presentation metadata is invalid.'); + expect(() => + render( + , + ), + ).toThrow('Review presentation metadata is invalid.'); + expect(() => + render( + , + ), + ).toThrow('Review presentation metadata is invalid.'); + }); +}); diff --git a/src/review-react/targetMarker.test.tsx b/src/review-react/targetMarker.test.tsx new file mode 100644 index 000000000..5c5c63509 --- /dev/null +++ b/src/review-react/targetMarker.test.tsx @@ -0,0 +1,115 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createReviewThreadPresentation } from '../review/index.js'; +import { CwlReviewTargetMarker } from './index.js'; + +afterEach(cleanup); + +function presentation(overrides: Record = {}) { + const digestHex = 'a'.repeat(64); + return { + contractVersion: 1, + threadKey: 'thread_1', + target: { + contractVersion: 1, + revision: { + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }, + selector: { + type: 'TextPositionSelector', + start: 3, + end: 8, + }, + projection: { + id: 'inkspan-prosemirror-text', + version: 1, + }, + }, + state: 'unresolved', + commentCount: 2, + selected: true, + canReply: true, + canResolve: true, + ...overrides, + }; +} + +describe('CwlReviewTargetMarker', () => { + it('uses the fail-safe print default and rejects unsupported print modes', () => { + render( + , + ); + expect(screen.getByRole('button')).toHaveAttribute( + 'data-cwl-review-print', + 'exclude', + ); + cleanup(); + + expect(() => + render( + , + ), + ).toThrow('Review presentation metadata is invalid.'); + }); + + it('renders one accessible controlled inline target marker and emits only a detached host intent', () => { + const source = presentation(); + const onSelectThread = vi.fn(); + + render( + , + ); + + const marker = screen.getByRole('button', { + name: 'Review target: unresolved, 2 comments', + }); + expect(marker).toHaveAttribute('aria-pressed', 'true'); + expect(marker).toHaveAttribute('data-cwl-review-state', 'unresolved'); + + fireEvent.click(marker); + expect(onSelectThread).toHaveBeenCalledTimes(1); + const selected = onSelectThread.mock.calls[0]?.[0] as ReturnType< + typeof createReviewThreadPresentation + >; + expect(selected.threadKey).toBe('thread_1'); + expect(selected).not.toBe(source); + expect(Object.isFrozen(selected)).toBe(true); + }); + + it('fails closed before rendering invalid labels or callbacks', () => { + expect(() => + render( + , + ), + ).toThrow('Review presentation metadata is invalid.'); + + expect(() => + render( + void} + />, + ), + ).toThrow('Review presentation metadata is invalid.'); + }); +}); diff --git a/src/review/editorMutation.ts b/src/review/editorMutation.ts new file mode 100644 index 000000000..83e6d0051 --- /dev/null +++ b/src/review/editorMutation.ts @@ -0,0 +1,121 @@ +import type { Editor } from '@tiptap/react'; +import { TextSelection } from '@tiptap/pm/state'; +import { + createDocumentEnvelope, + type DocumentEnvelopeLimits, +} from '../documentEnvelope.js'; +import type { DocumentEnvelopeDigestProvider } from '../documentEnvelopeRevision.js'; +import { createTextPositionSelector } from '../textPositionSelectorEvidence.js'; +import { + assertReviewSuggestionCurrentRevision, + createReviewOperationResult, + CwlReviewOperationError, + type CwlReviewOperationResult, +} from './index.js'; + +const BLOCK_SEPARATOR = '\n'; +const LEAF_TEXT = '\uFFFC'; + +function projectedLength(editor: Editor, to: number): number { + return Array.from( + editor.state.doc.textBetween(0, to, BLOCK_SEPARATOR, LEAF_TEXT), + ).length; +} + +function firstPosition( + editor: Editor, + offset: number, + strictlyGreater: boolean, +): number { + let low = 0; + let high = editor.state.doc.content.size + 1; + while (low < high) { + const middle = Math.floor((low + high) / 2); + const length = projectedLength( + editor, + Math.min(middle, editor.state.doc.content.size), + ); + if (length > offset || (!strictlyGreater && length === offset)) { + high = middle; + } else { + low = middle + 1; + } + } + return low; +} + +function resolvePosition(editor: Editor, offset: number): number { + const first = firstPosition(editor, offset, false); + const after = firstPosition(editor, offset, true); + + for (let position = first; position < after; position += 1) { + try { + if (!editor.state.doc.resolve(position).parent.inlineContent) continue; + const selection = TextSelection.create(editor.state.doc, position); + const projected = createTextPositionSelector(editor.state.doc, selection); + /* v8 ignore next -- binary search and selector use the same v1 projection. */ + if (projected.selector.start !== offset) continue; + return position; + } catch {} + } + + throw new CwlReviewOperationError('invalid_operation'); +} + +/** Apply one exact-revision review decision to an active editor. */ +export async function applyReviewSuggestionDecision( + editor: Editor, + suggestionSource: unknown, + action: 'accept' | 'reject', + limits?: DocumentEnvelopeLimits, + digestProvider?: DocumentEnvelopeDigestProvider | null, +): Promise { + if (action !== 'accept' && action !== 'reject') { + throw new CwlReviewOperationError('invalid_operation'); + } + + const state = editor.state; + const previousEnvelope = createDocumentEnvelope(state.doc.toJSON(), limits); + const suggestion = await assertReviewSuggestionCurrentRevision( + suggestionSource, + previousEnvelope, + limits, + digestProvider, + ); + if (editor.state !== state) throw new CwlReviewOperationError('stale_operation'); + + if (action === 'accept') { + const from = resolvePosition(editor, suggestion.target.selector.start); + const to = resolvePosition(editor, suggestion.target.selector.end); + const transaction = + suggestion.kind === 'insert' + ? state.tr.insertText(suggestion.text, from) + : state.tr.delete(from, to); + const resultingEnvelope = createDocumentEnvelope( + transaction.doc.toJSON(), + limits, + ); + const result = await createReviewOperationResult( + suggestion, + action, + previousEnvelope, + resultingEnvelope, + limits, + digestProvider, + ); + if (editor.state !== state) { + throw new CwlReviewOperationError('stale_operation'); + } + editor.view.dispatch(transaction); + return result; + } + + return createReviewOperationResult( + suggestion, + action, + previousEnvelope, + previousEnvelope, + limits, + digestProvider, + ); +} diff --git a/src/review/index.test.ts b/src/review/index.test.ts new file mode 100644 index 000000000..fa86dc3e1 --- /dev/null +++ b/src/review/index.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from 'vitest'; +import * as reviewModule from './index.js'; + +interface ReviewTargetFactorySurface { + readonly CwlReviewTargetError: new () => Error & { readonly code: 'invalid_target' }; + readonly createReviewTarget: (source: unknown) => unknown; +} + +function reviewSurface(): ReviewTargetFactorySurface { + return reviewModule as unknown as ReviewTargetFactorySurface; +} + +const digestHex = 'a'.repeat(64); +const revision = Object.freeze({ + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, +}); +const selector = Object.freeze({ + type: 'TextPositionSelector', + start: 2, + end: 5, +}); +const projection = Object.freeze({ + id: 'inkspan-prosemirror-text', + version: 1, +}); + +function validTarget(): Record { + return { + contractVersion: 1, + revision, + selector, + projection, + }; +} + +function expectInvalid(source: unknown): void { + const { createReviewTarget, CwlReviewTargetError } = reviewSurface(); + let failure: unknown; + try { + createReviewTarget(source); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(CwlReviewTargetError); + expect(failure).toMatchObject({ code: 'invalid_target' }); + expect(String(failure)).not.toContain(digestHex); +} + +describe('provider-neutral review target contract', () => { + it('creates one detached deeply frozen exact-revision target', () => { + const { createReviewTarget } = reviewSurface(); + const source = validTarget(); + const target = createReviewTarget(source) as { + readonly contractVersion: 1; + readonly revision: typeof revision; + readonly selector: typeof selector; + readonly projection: typeof projection; + }; + + expect(target).toEqual(source); + expect(target).not.toBe(source); + expect(target.revision).not.toBe(revision); + expect(target.selector).not.toBe(selector); + expect(target.projection).not.toBe(projection); + expect(Object.isFrozen(target)).toBe(true); + expect(Object.isFrozen(target.revision)).toBe(true); + expect(Object.isFrozen(target.selector)).toBe(true); + expect(Object.isFrozen(target.projection)).toBe(true); + }); + + it('rejects malformed contract versions and target shapes', () => { + expectInvalid(null); + expectInvalid({ ...validTarget(), contractVersion: 2 }); + expectInvalid({ ...validTarget(), unexpected: true }); + expectInvalid({ + ...validTarget(), + [Symbol('hidden authority')]: true, + }); + + const sameWidthUnknownKey = validTarget(); + delete sameWidthUnknownKey.contractVersion; + sameWidthUnknownKey.unexpected = 1; + expectInvalid(sameWidthUnknownKey); + + const sameWidthSymbolKey = validTarget(); + delete sameWidthSymbolKey.contractVersion; + Object.defineProperty(sameWidthSymbolKey, Symbol('hidden authority'), { + value: 1, + enumerable: true, + }); + expectInvalid(sameWidthSymbolKey); + + const accessorTarget = validTarget(); + let getterCalls = 0; + Object.defineProperty(accessorTarget, 'revision', { + enumerable: true, + get() { + getterCalls += 1; + return revision; + }, + }); + expectInvalid(accessorTarget); + expect(getterCalls).toBe(0); + + const nonEnumerableTarget = validTarget(); + Object.defineProperty(nonEnumerableTarget, 'projection', { + value: projection, + enumerable: false, + }); + expectInvalid(nonEnumerableTarget); + }); + + it('rejects malformed revision, selector, and projection metadata', () => { + expectInvalid({ + ...validTarget(), + revision: { ...revision, algorithm: 'MD5' }, + }); + expectInvalid({ + ...validTarget(), + revision: { ...revision, digestHex: 'A'.repeat(64) }, + }); + expectInvalid({ + ...validTarget(), + revision: { ...revision, digestHex: 'a'.repeat(63) }, + }); + expectInvalid({ + ...validTarget(), + revision: { ...revision, strongEntityTag: '"sha256-wrong"' }, + }); + expectInvalid({ + ...validTarget(), + selector: { ...selector, type: 'CssSelector' }, + }); + expectInvalid({ + ...validTarget(), + selector: { ...selector, start: -1 }, + }); + expectInvalid({ + ...validTarget(), + selector: { ...selector, start: 1.5 }, + }); + expectInvalid({ + ...validTarget(), + selector: { ...selector, end: Number.MAX_SAFE_INTEGER + 1 }, + }); + expectInvalid({ + ...validTarget(), + selector: { ...selector, start: 6, end: 5 }, + }); + expectInvalid({ + ...validTarget(), + projection: { ...projection, id: 'dom-text' }, + }); + expectInvalid({ + ...validTarget(), + projection: { ...projection, version: 2 }, + }); + }); + + it('rejects malformed nested property shapes without invoking accessors', () => { + for (const key of ['revision', 'selector', 'projection'] as const) { + const source = validTarget(); + const nested = { ...(source[key] as Record) }; + let getterCalls = 0; + const firstKey = Object.keys(nested)[0]!; + Object.defineProperty(nested, firstKey, { + enumerable: true, + get() { + getterCalls += 1; + return undefined; + }, + }); + source[key] = nested; + expectInvalid(source); + expect(getterCalls).toBe(0); + } + }); + + it('normalizes hostile reflection failures without leaking private causes', () => { + const secret = 'private-review-reflection-value'; + const target = new Proxy(validTarget(), { + ownKeys() { + throw new Error(secret); + }, + }); + const { createReviewTarget, CwlReviewTargetError } = reviewSurface(); + let failure: unknown; + try { + createReviewTarget(target); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(CwlReviewTargetError); + expect(String(failure)).not.toContain(secret); + }); +}); diff --git a/src/review/index.ts b/src/review/index.ts new file mode 100644 index 000000000..9791d3e24 --- /dev/null +++ b/src/review/index.ts @@ -0,0 +1,599 @@ +/** + * React-free review contract surface. + * + * Inkspan owns deterministic review targets and bounded proposal metadata bound + * to an exact canonical document revision and text projection. Hosts own durable + * review records, identity, authorization, tenancy, persistence, retention, + * notifications, audit, and cross-revision re-anchoring policy. + */ + +import type { DocumentEnvelopeLimits } from '../documentEnvelope.js'; +import { + createDocumentEnvelopeRevision as computeDocumentEnvelopeRevision, + type CwlEditorDocumentRevision, + type DocumentEnvelopeDigestProvider, +} from '../documentEnvelopeRevision.js'; +import { + createDocumentEnvelopeTransitionEvidence, + type CwlEditorDocumentTransitionEvidence, +} from '../documentTransitionEvidence.js'; +import { + TEXT_POSITION_PROJECTION_ID, + TEXT_POSITION_PROJECTION_VERSION, + type CwlEditorTextPositionSelector, + type CwlEditorTextProjectionIdentity, +} from '../textPositionSelectorEvidence.js'; + +/** Version of Inkspan's deterministic review-target contract. */ +export const INKSPAN_REVIEW_CONTRACT_VERSION = 1 as const; + +/** Stable redacted failure code for malformed review-target metadata. */ +export type CwlReviewTargetErrorCode = 'invalid_target'; + +/** Raised when untrusted review-target metadata violates the public contract. */ +export class CwlReviewTargetError extends Error { + /** Stable machine-readable failure category. */ + readonly code: CwlReviewTargetErrorCode; + + /** Create one redacted review-target validation error. */ + constructor() { + super('Review target metadata is invalid.'); + this.name = 'CwlReviewTargetError'; + this.code = 'invalid_target'; + } +} + +/** Stable redacted failure code for malformed suggestion proposal metadata. */ +export type CwlReviewSuggestionErrorCode = 'invalid_suggestion'; + +/** Raised when untrusted suggestion proposal metadata violates the contract. */ +export class CwlReviewSuggestionError extends Error { + /** Stable machine-readable failure category. */ + readonly code: CwlReviewSuggestionErrorCode; + + /** Create one redacted suggestion validation error. */ + constructor() { + super('Review suggestion is invalid.'); + this.name = 'CwlReviewSuggestionError'; + this.code = 'invalid_suggestion'; + } +} + +/** Stable redacted failure code for malformed review presentation metadata. */ +export type CwlReviewPresentationErrorCode = 'invalid_presentation'; + +/** Raised when host-supplied thread presentation metadata violates the contract. */ +export class CwlReviewPresentationError extends Error { + /** Stable machine-readable failure category. */ + readonly code: CwlReviewPresentationErrorCode; + + /** Create one payload-redacted presentation validation error. */ + constructor() { + super('Review presentation metadata is invalid.'); + this.name = 'CwlReviewPresentationError'; + this.code = 'invalid_presentation'; + } +} + +/** Stable redacted failure codes for review-operation evidence. */ +export type CwlReviewOperationErrorCode = + | 'invalid_operation' + | 'stale_operation' + | 'stale_operation_changed' + | 'accepted_operation_unchanged' + | 'rejected_operation_changed'; + +const REVIEW_OPERATION_ERROR_MESSAGES: Record< + CwlReviewOperationErrorCode, + string +> = { + invalid_operation: 'Review operation is invalid.', + stale_operation: 'Review operation targets a stale document revision.', + stale_operation_changed: 'Stale review operations must not change the document.', + accepted_operation_unchanged: + 'Accepted review operation must change the document revision.', + rejected_operation_changed: + 'Rejected review operation must preserve the document revision.', +}; + +/** Raised when before/after review-operation evidence violates the contract. */ +export class CwlReviewOperationError extends Error { + /** Stable machine-readable failure category. */ + readonly code: CwlReviewOperationErrorCode; + + /** Create one payload-redacted review-operation error. */ + constructor(code: CwlReviewOperationErrorCode) { + super(REVIEW_OPERATION_ERROR_MESSAGES[code]); + this.name = 'CwlReviewOperationError'; + this.code = code; + } +} + +/** + * Immutable target for a host-owned comment or suggestion. + * + * Positions are W3C TextPositionSelector offsets in Inkspan's canonical text + * projection, never DOM offsets. The revision validator prevents a host from + * silently applying a target to a different document revision. This metadata + * contract validates shape and coordinate ordering only; without the source + * document it cannot prove that an arbitrary caller-supplied `end` offset is + * within the referenced projection. Consumers should create selectors through + * Inkspan's selector APIs and reject revision mismatches before applying them. + */ +export interface CwlReviewTarget { + readonly contractVersion: typeof INKSPAN_REVIEW_CONTRACT_VERSION; + readonly revision: CwlEditorDocumentRevision; + readonly selector: CwlEditorTextPositionSelector; + readonly projection: CwlEditorTextProjectionIdentity; +} + +/** Bounded host-supplied metadata used to render one comment-thread target. */ +export interface CwlReviewThreadPresentation { + readonly contractVersion: typeof INKSPAN_REVIEW_CONTRACT_VERSION; + /** Opaque host-owned key carried for callback correlation, never generated here. */ + readonly threadKey: string; + readonly target: CwlReviewTarget; + readonly state: 'unresolved' | 'resolved'; + readonly commentCount: number; + readonly selected: boolean; + readonly canReply: boolean; + readonly canResolve: boolean; +} + +/** Detached insertion proposal with no host identity or persistence authority. */ +export interface CwlReviewInsertSuggestion { + readonly contractVersion: typeof INKSPAN_REVIEW_CONTRACT_VERSION; + readonly kind: 'insert'; + readonly target: CwlReviewTarget; + readonly text: string; +} + +/** Detached deletion proposal with no copied source text or durable authority. */ +export interface CwlReviewDeleteSuggestion { + readonly contractVersion: typeof INKSPAN_REVIEW_CONTRACT_VERSION; + readonly kind: 'delete'; + readonly target: CwlReviewTarget; +} + +/** Provider-neutral proposal data accepted by Inkspan's review contract. */ +export type CwlReviewSuggestion = + | CwlReviewInsertSuggestion + | CwlReviewDeleteSuggestion; + +/** Review decision whose effect is proven only through exact revision evidence. */ +export interface CwlReviewOperationResult { + readonly contractVersion: typeof INKSPAN_REVIEW_CONTRACT_VERSION; + readonly action: 'accept' | 'reject'; + readonly status: 'accepted' | 'rejected' | 'stale'; + readonly beforeRevision: CwlEditorDocumentRevision; + readonly resultingRevision?: CwlEditorDocumentRevision; + readonly transitionEvidence?: CwlEditorDocumentTransitionEvidence; +} + +const REVIEW_TARGET_KEYS = [ + 'contractVersion', + 'revision', + 'selector', + 'projection', +] as const; +const REVIEW_PRESENTATION_KEYS = [ + 'contractVersion', + 'threadKey', + 'target', + 'state', + 'commentCount', + 'selected', + 'canReply', + 'canResolve', +] as const; +const INSERT_SUGGESTION_KEYS = [ + 'contractVersion', + 'kind', + 'target', + 'text', +] as const; +const DELETE_SUGGESTION_KEYS = ['contractVersion', 'kind', 'target'] as const; +const REVISION_KEYS = ['algorithm', 'digestHex', 'strongEntityTag'] as const; +const SELECTOR_KEYS = ['type', 'start', 'end'] as const; +const PROJECTION_KEYS = ['id', 'version'] as const; +const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/u; +const REVIEW_THREAD_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; +const MAX_REVIEW_INSERT_TEXT_CODE_UNITS = 65_536; +const MAX_REVIEW_COMMENT_COUNT = 10_000; + +/** Throw one fresh redacted public validation error. */ +function rejectReviewTarget(): never { + throw new CwlReviewTargetError(); +} + +/** Throw one fresh redacted public suggestion validation error. */ +function rejectReviewSuggestion(): never { + throw new CwlReviewSuggestionError(); +} + +/** Throw one fresh redacted public presentation validation error. */ +function rejectReviewPresentation(): never { + throw new CwlReviewPresentationError(); +} + +/** + * Snapshot exactly named enumerable data properties without invoking accessors. + * + * Unknown keys, symbols, accessors, non-enumerable fields, and reflection + * failures are rejected before Inkspan retains any caller-owned object. + */ +function readExactDataRecord( + source: unknown, + expectedKeys: readonly string[], +): Readonly> { + try { + if (typeof source !== 'object' || source === null) rejectReviewTarget(); + const ownKeys = Reflect.ownKeys(source); + if (ownKeys.length !== expectedKeys.length) rejectReviewTarget(); + for (const key of ownKeys) { + if (typeof key !== 'string' || !expectedKeys.includes(key)) { + rejectReviewTarget(); + } + } + + const values: Record = {}; + for (const key of expectedKeys) { + const descriptor = Object.getOwnPropertyDescriptor(source, key); + if ( + descriptor === undefined || + descriptor.enumerable !== true || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + rejectReviewTarget(); + } + values[key] = descriptor.value; + } + return values; + } catch { + rejectReviewTarget(); + } +} + +/** + * Read only the discriminant needed to choose the exact suggestion shape. + * Accessors and reflection failures are rejected without invoking caller code. + */ +function readSuggestionKind(source: unknown): 'insert' | 'delete' { + try { + if (typeof source !== 'object' || source === null) rejectReviewSuggestion(); + const descriptor = Object.getOwnPropertyDescriptor(source, 'kind'); + if ( + descriptor === undefined || + descriptor.enumerable !== true || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + rejectReviewSuggestion(); + } + if (descriptor.value !== 'insert' && descriptor.value !== 'delete') { + rejectReviewSuggestion(); + } + return descriptor.value; + } catch { + rejectReviewSuggestion(); + } +} + +/** + * Validate and detach untrusted host review-target metadata. + * + * The returned value is deeply frozen across the complete v1 target shape and + * retains no caller-owned nested objects. Reflection/accessor failures and all + * malformed fields collapse to one redacted stable error so private caller data + * is never copied into generic diagnostics. The revision is local SHA-256 + * equality evidence only; this operation grants no identity, authorization, + * tenancy, persistence, timestamp, signature, or durable-write authority. + * + * @param source - Untrusted candidate review-target metadata. + * @returns A detached, deeply frozen v1 review target. + * @throws {CwlReviewTargetError} When any target field or shape is invalid. + */ +export function createReviewTarget(source: unknown): CwlReviewTarget { + const target = readExactDataRecord(source, REVIEW_TARGET_KEYS); + if (target.contractVersion !== INKSPAN_REVIEW_CONTRACT_VERSION) { + rejectReviewTarget(); + } + + const revision = readExactDataRecord(target.revision, REVISION_KEYS); + const digestHex = revision.digestHex; + if ( + revision.algorithm !== 'SHA-256' || + typeof digestHex !== 'string' || + digestHex.length !== 64 || + !SHA256_HEX_PATTERN.test(digestHex) || + revision.strongEntityTag !== `"sha256-${digestHex}"` + ) { + rejectReviewTarget(); + } + + const selector = readExactDataRecord(target.selector, SELECTOR_KEYS); + const start = selector.start; + const end = selector.end; + if ( + selector.type !== 'TextPositionSelector' || + typeof start !== 'number' || + typeof end !== 'number' || + !Number.isSafeInteger(start) || + !Number.isSafeInteger(end) || + start < 0 || + end < start + ) { + rejectReviewTarget(); + } + + const projection = readExactDataRecord(target.projection, PROJECTION_KEYS); + if ( + projection.id !== TEXT_POSITION_PROJECTION_ID || + projection.version !== TEXT_POSITION_PROJECTION_VERSION + ) { + rejectReviewTarget(); + } + + const detachedRevision: CwlEditorDocumentRevision = Object.freeze({ + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }); + const detachedSelector: CwlEditorTextPositionSelector = Object.freeze({ + type: 'TextPositionSelector', + start, + end, + }); + const detachedProjection: CwlEditorTextProjectionIdentity = Object.freeze({ + id: TEXT_POSITION_PROJECTION_ID, + version: TEXT_POSITION_PROJECTION_VERSION, + }); + return Object.freeze({ + contractVersion: INKSPAN_REVIEW_CONTRACT_VERSION, + revision: detachedRevision, + selector: detachedSelector, + projection: detachedProjection, + }); +} + +/** + * Validate and detach host-supplied comment-thread presentation metadata. + * + * The contract deliberately carries no comment body, actor identity, + * authorization assertion, timestamp, persistence state, or durable audit data. + * `threadKey` is an opaque bounded host-owned correlation key only; Inkspan does + * not generate, persist, authenticate, or interpret it. `commentCount`, status, + * selection, and capability booleans are presentation inputs for later + * controlled UI surfaces and grant no host authority by themselves. + * + * @param source - Untrusted host presentation metadata. + * @returns A detached, deeply frozen bounded presentation snapshot. + * @throws {CwlReviewPresentationError} When any field or shape is invalid. + */ +export function createReviewThreadPresentation( + source: unknown, +): CwlReviewThreadPresentation { + try { + const presentation = readExactDataRecord(source, REVIEW_PRESENTATION_KEYS); + if ( + presentation.contractVersion !== INKSPAN_REVIEW_CONTRACT_VERSION || + typeof presentation.threadKey !== 'string' || + !REVIEW_THREAD_KEY_PATTERN.test(presentation.threadKey) || + (presentation.state !== 'unresolved' && presentation.state !== 'resolved') || + typeof presentation.commentCount !== 'number' || + !Number.isSafeInteger(presentation.commentCount) || + presentation.commentCount < 1 || + presentation.commentCount > MAX_REVIEW_COMMENT_COUNT || + typeof presentation.selected !== 'boolean' || + typeof presentation.canReply !== 'boolean' || + typeof presentation.canResolve !== 'boolean' + ) { + rejectReviewPresentation(); + } + + const target = createReviewTarget(presentation.target); + return Object.freeze({ + contractVersion: INKSPAN_REVIEW_CONTRACT_VERSION, + threadKey: presentation.threadKey, + target, + state: presentation.state, + commentCount: presentation.commentCount, + selected: presentation.selected, + canReply: presentation.canReply, + canResolve: presentation.canResolve, + }); + } catch { + rejectReviewPresentation(); + } +} + +/** + * Validate and detach an untrusted insert/delete suggestion proposal. + * + * Insertions must target an insertion point and carry 1..65,536 UTF-16 code + * units of proposal text. Deletions must target a non-empty projected range and + * deliberately carry no copied source body. The returned proposal and target + * are frozen snapshots. This validator does not apply edits, persist records, + * assign identities, authorize actors, or grant model/provider output any + * authority; hosts must still perform admission and Inkspan revision checks at + * the operation boundary. + * + * @param source - Untrusted candidate suggestion proposal metadata. + * @returns A detached, deeply frozen v1 suggestion proposal. + * @throws {CwlReviewSuggestionError} When any proposal field or shape is invalid. + */ +export function createReviewSuggestion(source: unknown): CwlReviewSuggestion { + try { + const kind = readSuggestionKind(source); + const expectedKeys = + kind === 'insert' ? INSERT_SUGGESTION_KEYS : DELETE_SUGGESTION_KEYS; + const suggestion = readExactDataRecord(source, expectedKeys); + if ( + suggestion.contractVersion !== INKSPAN_REVIEW_CONTRACT_VERSION || + suggestion.kind !== kind + ) { + rejectReviewSuggestion(); + } + + const target = createReviewTarget(suggestion.target); + if (kind === 'insert') { + const text = suggestion.text; + if ( + target.selector.start !== target.selector.end || + typeof text !== 'string' || + text.length === 0 || + text.length > MAX_REVIEW_INSERT_TEXT_CODE_UNITS + ) { + rejectReviewSuggestion(); + } + return Object.freeze({ + contractVersion: INKSPAN_REVIEW_CONTRACT_VERSION, + kind, + target, + text, + }); + } + + if (target.selector.start === target.selector.end) { + rejectReviewSuggestion(); + } + return Object.freeze({ + contractVersion: INKSPAN_REVIEW_CONTRACT_VERSION, + kind, + target, + }); + } catch { + rejectReviewSuggestion(); + } +} + +/** + * Validate that a suggestion still targets the exact current document revision. + * + * This is the fail-closed admission guard callers invoke immediately before an + * editor mutation. It returns only the detached/frozen validated suggestion and + * never copies the current document body into review evidence. Hosts retain + * authorization, persistence, conflict policy, and durable audit authority. + * + * @param suggestionSource - Untrusted provider-neutral insert/delete proposal. + * @param currentSource - Exact current document envelope before mutation. + * @param limits - Optional strict document-envelope resource limits. + * @param digestProvider - Optional SHA-256 provider for deterministic testing. + * @returns The detached validated suggestion when the exact revision matches. + * @throws {CwlReviewOperationError} When the suggestion targets another revision. + */ +export async function assertReviewSuggestionCurrentRevision( + suggestionSource: unknown, + currentSource: unknown, + limits?: DocumentEnvelopeLimits, + digestProvider?: DocumentEnvelopeDigestProvider | null, +): Promise { + const suggestion = createReviewSuggestion(suggestionSource); + const currentRevision = await computeDocumentEnvelopeRevision( + currentSource, + limits, + digestProvider, + ); + + if (currentRevision.digestHex !== suggestion.target.revision.digestHex) { + throw new CwlReviewOperationError('stale_operation'); + } + + return suggestion; +} + +/** + * Bind a host/editor review decision to exact before/after document revisions. + * + * This function does not apply an editor transaction and does not persist a + * review decision. The caller supplies the actual previous and resulting + * document envelopes after its authorized operation. Inkspan validates the + * proposal, derives canonical transition evidence, and refuses to classify an + * accepted operation that changed nothing or a rejected operation that changed + * the document. A stale proposal returns a compact `stale` result only when the + * actual document remained unchanged; stale evidence paired with a mutation is + * rejected fail-closed rather than hiding an out-of-contract document change. + * + * The result contains revisions and transition metadata only; proposal text and + * document bodies are not retained. Host-owned identity, authorization, + * persistence, exact-once durable state, audit, and conflict policy remain out + * of scope. + * + * @param suggestionSource - Untrusted provider-neutral insert/delete proposal. + * @param action - Host-authorized review decision to classify. + * @param previousSource - Exact document envelope observed before the operation. + * @param resultingSource - Exact document envelope observed after the operation. + * @param limits - Optional strict document-envelope resource limits. + * @param digestProvider - Optional SHA-256 provider for deterministic testing. + * @returns Frozen revision-only review-operation evidence. + * @throws {CwlReviewOperationError} When action/change semantics conflict. + */ +export async function createReviewOperationResult( + suggestionSource: unknown, + action: 'accept' | 'reject', + previousSource: unknown, + resultingSource: unknown, + limits?: DocumentEnvelopeLimits, + digestProvider?: DocumentEnvelopeDigestProvider | null, +): Promise { + if (action !== 'accept' && action !== 'reject') { + throw new CwlReviewOperationError('invalid_operation'); + } + const suggestion = createReviewSuggestion(suggestionSource); + const transition = await createDocumentEnvelopeTransitionEvidence( + previousSource, + resultingSource, + limits, + digestProvider, + ); + + if ( + transition.previousRevision.digestHex !== suggestion.target.revision.digestHex + ) { + if (transition.changed) { + throw new CwlReviewOperationError('stale_operation_changed'); + } + return Object.freeze({ + contractVersion: INKSPAN_REVIEW_CONTRACT_VERSION, + action, + status: 'stale', + beforeRevision: transition.previousRevision, + }); + } + if (action === 'accept' && !transition.changed) { + throw new CwlReviewOperationError('accepted_operation_unchanged'); + } + if (action === 'reject' && transition.changed) { + throw new CwlReviewOperationError('rejected_operation_changed'); + } + + return Object.freeze({ + contractVersion: INKSPAN_REVIEW_CONTRACT_VERSION, + action, + status: action === 'accept' ? 'accepted' : 'rejected', + beforeRevision: transition.previousRevision, + resultingRevision: transition.resultingRevision, + transitionEvidence: transition, + }); +} + +export { + TEXT_POSITION_PROJECTION_ID, + TEXT_POSITION_PROJECTION_VERSION, + TextPositionSelectorEvidenceError, + createTextPositionSelector, +} from '../textPositionSelectorEvidence.js'; +export type { + CwlEditorTextPositionSelector, + CwlEditorTextProjectionIdentity, + TextPositionSelectorEvidenceErrorCode, +} from '../textPositionSelectorEvidence.js'; +export { + DocumentEnvelopeRevisionError, + createDocumentEnvelopeRevision, + createDocumentEnvelopeRevisionBytes, +} from '../documentEnvelopeRevision.js'; +export type { + CwlEditorDocumentRevision, + DocumentEnvelopeDigestProvider, +} from '../documentEnvelopeRevision.js'; +export type { CwlEditorDocumentTransitionEvidence } from '../documentTransitionEvidence.js'; \ No newline at end of file diff --git a/src/review/operation.test.ts b/src/review/operation.test.ts new file mode 100644 index 000000000..60552fdf1 --- /dev/null +++ b/src/review/operation.test.ts @@ -0,0 +1,292 @@ +import { createHash } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { createDocumentEnvelope } from '../documentEnvelope.js'; +import { + createDocumentEnvelopeRevision, + type CwlEditorDocumentRevision, + type DocumentEnvelopeDigestProvider, +} from '../documentEnvelopeRevision.js'; +import * as reviewModule from './index.js'; + +interface ReviewOperationSurface { + readonly CwlReviewOperationError: new ( + code: + | 'invalid_operation' + | 'stale_operation_changed' + | 'accepted_operation_unchanged' + | 'rejected_operation_changed', + ) => Error & { + readonly code: + | 'invalid_operation' + | 'stale_operation_changed' + | 'accepted_operation_unchanged' + | 'rejected_operation_changed'; + }; + readonly createReviewOperationResult: ( + suggestion: unknown, + action: 'accept' | 'reject', + previousSource: unknown, + resultingSource: unknown, + limits?: unknown, + digestProvider?: DocumentEnvelopeDigestProvider | null, + ) => Promise; +} + +function reviewOperationSurface(): ReviewOperationSurface { + return reviewModule as unknown as ReviewOperationSurface; +} + +function toBytes(source: BufferSource): Uint8Array { + return ArrayBuffer.isView(source) + ? new Uint8Array(source.buffer, source.byteOffset, source.byteLength) + : new Uint8Array(source); +} + +function sha256(source: BufferSource): ArrayBuffer { + const digest = createHash('sha256').update(toBytes(source)).digest(); + const result = new Uint8Array(32); + result.set(digest); + return result.buffer; +} + +function digestProvider(): DocumentEnvelopeDigestProvider { + return { + async digest(algorithm, source) { + expect(algorithm).toBe('SHA-256'); + return sha256(source); + }, + }; +} + +function target(revision: CwlEditorDocumentRevision) { + return { + contractVersion: 1, + revision, + selector: { + type: 'TextPositionSelector', + start: 0, + end: 0, + }, + projection: { + id: 'inkspan-prosemirror-text', + version: 1, + }, + }; +} + +function insertSuggestion(revision: CwlEditorDocumentRevision) { + return { + contractVersion: 1, + kind: 'insert', + target: target(revision), + text: '검토 제안', + }; +} + +const BEFORE_DOCUMENT = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'private before body' }], + }, + ], +}; +const AFTER_DOCUMENT = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'private after body' }], + }, + ], +}; + +describe('provider-neutral review operation evidence', () => { + it('binds an accepted proposal to exact before/after revisions without retaining document bodies', async () => { + const provider = digestProvider(); + const previousEnvelope = createDocumentEnvelope(BEFORE_DOCUMENT); + const resultingEnvelope = createDocumentEnvelope(AFTER_DOCUMENT); + const expectedRevision = await createDocumentEnvelopeRevision( + previousEnvelope, + undefined, + provider, + ); + + const result = (await reviewOperationSurface().createReviewOperationResult( + insertSuggestion(expectedRevision), + 'accept', + previousEnvelope, + resultingEnvelope, + undefined, + provider, + )) as { + readonly contractVersion: 1; + readonly action: 'accept'; + readonly status: 'accepted'; + readonly beforeRevision: CwlEditorDocumentRevision; + readonly resultingRevision: CwlEditorDocumentRevision; + readonly transitionEvidence: { + readonly previousRevision: CwlEditorDocumentRevision; + readonly resultingRevision: CwlEditorDocumentRevision; + readonly changed: boolean; + }; + }; + + expect(result.contractVersion).toBe(1); + expect(result.action).toBe('accept'); + expect(result.status).toBe('accepted'); + expect(result.beforeRevision).toEqual(expectedRevision); + expect(result.resultingRevision).not.toEqual(expectedRevision); + expect(result.transitionEvidence).toMatchObject({ + previousRevision: expectedRevision, + changed: true, + }); + expect(Object.isFrozen(result)).toBe(true); + expect(JSON.stringify(result)).not.toContain('private before body'); + expect(JSON.stringify(result)).not.toContain('private after body'); + expect(JSON.stringify(result)).not.toContain('검토 제안'); + }); + + it('returns a stable stale result only when a mismatched target leaves the document unchanged', async () => { + const provider = digestProvider(); + const previousEnvelope = createDocumentEnvelope(BEFORE_DOCUMENT); + const staleDigest = 'f'.repeat(64); + const staleRevision = Object.freeze({ + algorithm: 'SHA-256' as const, + digestHex: staleDigest, + strongEntityTag: `"sha256-${staleDigest}"`, + }); + + const result = (await reviewOperationSurface().createReviewOperationResult( + insertSuggestion(staleRevision), + 'accept', + previousEnvelope, + previousEnvelope, + undefined, + provider, + )) as Record; + + expect(result.status).toBe('stale'); + expect(result.action).toBe('accept'); + expect(result.beforeRevision).not.toEqual(staleRevision); + expect(result).not.toHaveProperty('resultingRevision'); + expect(result).not.toHaveProperty('transitionEvidence'); + expect(Object.isFrozen(result)).toBe(true); + }); + + it('fails closed when a stale proposal is reported with a changed resulting document', async () => { + const provider = digestProvider(); + const previousEnvelope = createDocumentEnvelope(BEFORE_DOCUMENT); + const resultingEnvelope = createDocumentEnvelope(AFTER_DOCUMENT); + const staleDigest = 'f'.repeat(64); + const staleRevision = Object.freeze({ + algorithm: 'SHA-256' as const, + digestHex: staleDigest, + strongEntityTag: `"sha256-${staleDigest}"`, + }); + + await expect( + reviewOperationSurface().createReviewOperationResult( + insertSuggestion(staleRevision), + 'accept', + previousEnvelope, + resultingEnvelope, + undefined, + provider, + ), + ).rejects.toMatchObject({ + name: 'CwlReviewOperationError', + code: 'stale_operation_changed', + message: 'Stale review operations must not change the document.', + }); + }); + + it('requires accepted operations to change the document and rejected operations to preserve it', async () => { + const provider = digestProvider(); + const previousEnvelope = createDocumentEnvelope(BEFORE_DOCUMENT); + const resultingEnvelope = createDocumentEnvelope(AFTER_DOCUMENT); + const expectedRevision = await createDocumentEnvelopeRevision( + previousEnvelope, + undefined, + provider, + ); + const suggestion = insertSuggestion(expectedRevision); + const { createReviewOperationResult, CwlReviewOperationError } = + reviewOperationSurface(); + + await expect( + createReviewOperationResult( + suggestion, + 'accept', + previousEnvelope, + previousEnvelope, + undefined, + provider, + ), + ).rejects.toMatchObject({ + code: 'accepted_operation_unchanged', + }); + await expect( + createReviewOperationResult( + suggestion, + 'reject', + previousEnvelope, + resultingEnvelope, + undefined, + provider, + ), + ).rejects.toMatchObject({ + code: 'rejected_operation_changed', + }); + + const dynamicOperation = createReviewOperationResult as unknown as ( + suggestion: unknown, + action: unknown, + previousSource: unknown, + resultingSource: unknown, + limits?: unknown, + digestProvider?: DocumentEnvelopeDigestProvider | null, + ) => Promise; + await expect( + dynamicOperation( + suggestion, + 'approve', + previousEnvelope, + resultingEnvelope, + undefined, + provider, + ), + ).rejects.toMatchObject({ code: 'invalid_operation' }); + + const failure = new CwlReviewOperationError('invalid_operation'); + expect(failure.message).not.toContain('private before body'); + expect(failure.message).not.toContain('검토 제안'); + }); + + it('returns a frozen rejected result for an unchanged exact-revision operation', async () => { + const provider = digestProvider(); + const previousEnvelope = createDocumentEnvelope(BEFORE_DOCUMENT); + const expectedRevision = await createDocumentEnvelopeRevision( + previousEnvelope, + undefined, + provider, + ); + + const result = (await reviewOperationSurface().createReviewOperationResult( + insertSuggestion(expectedRevision), + 'reject', + previousEnvelope, + previousEnvelope, + undefined, + provider, + )) as { + readonly status: 'rejected'; + readonly transitionEvidence: { readonly changed: boolean }; + }; + + expect(result.status).toBe('rejected'); + expect(result.transitionEvidence.changed).toBe(false); + expect(Object.isFrozen(result)).toBe(true); + }); +}); diff --git a/src/review/presentation.test.ts b/src/review/presentation.test.ts new file mode 100644 index 000000000..7a5c8af9d --- /dev/null +++ b/src/review/presentation.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest'; +import * as reviewModule from './index.js'; + +interface ReviewPresentationSurface { + readonly CwlReviewPresentationError: new () => Error & { + readonly code: 'invalid_presentation'; + }; + readonly createReviewThreadPresentation: (source: unknown) => unknown; +} + +function reviewPresentationSurface(): ReviewPresentationSurface { + return reviewModule as unknown as ReviewPresentationSurface; +} + +function target() { + const digestHex = 'a'.repeat(64); + return { + contractVersion: 1, + revision: { + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }, + selector: { + type: 'TextPositionSelector', + start: 3, + end: 8, + }, + projection: { + id: 'inkspan-prosemirror-text', + version: 1, + }, + }; +} + +function presentation(overrides: Record = {}) { + return { + contractVersion: 1, + threadKey: 'thread_123', + target: target(), + state: 'unresolved', + commentCount: 2, + selected: true, + canReply: true, + canResolve: true, + ...overrides, + }; +} + +describe('review thread presentation contract', () => { + it('detaches and freezes bounded host presentation metadata without comment bodies', () => { + const { createReviewThreadPresentation } = reviewPresentationSurface(); + const source = presentation(); + const result = createReviewThreadPresentation(source) as { + readonly threadKey: string; + readonly target: { + readonly selector: { readonly start: number; readonly end: number }; + }; + readonly state: string; + readonly commentCount: number; + readonly selected: boolean; + readonly canReply: boolean; + readonly canResolve: boolean; + }; + + expect(result).toEqual(source); + expect(result).not.toBe(source); + expect(result.target).not.toBe(source.target); + expect(Object.isFrozen(result)).toBe(true); + expect(Object.isFrozen(result.target)).toBe(true); + expect(Object.isFrozen(result.target.selector)).toBe(true); + expect(JSON.stringify(result)).not.toContain('commentBody'); + }); + + it('supports resolved and permission-disabled presentation without inventing actor authority', () => { + const { createReviewThreadPresentation } = reviewPresentationSurface(); + const result = createReviewThreadPresentation( + presentation({ + state: 'resolved', + selected: false, + canReply: false, + canResolve: false, + }), + ) as Record; + + expect(result).toMatchObject({ + state: 'resolved', + selected: false, + canReply: false, + canResolve: false, + }); + expect(result).not.toHaveProperty('actorId'); + expect(result).not.toHaveProperty('authorized'); + }); + + it('fails closed on body-like or otherwise unsupported presentation fields', () => { + const { + createReviewThreadPresentation, + CwlReviewPresentationError, + } = reviewPresentationSurface(); + const privateBody = 'private-review-body-must-not-leak'; + + expect(() => + createReviewThreadPresentation( + presentation({ commentBody: privateBody }), + ), + ).toThrow(CwlReviewPresentationError); + try { + createReviewThreadPresentation(presentation({ commentBody: privateBody })); + } catch (error) { + expect(error).toMatchObject({ code: 'invalid_presentation' }); + expect(String(error)).not.toContain(privateBody); + } + }); + + it('bounds opaque host thread keys and comment counts', () => { + const { createReviewThreadPresentation } = reviewPresentationSurface(); + + expect(() => + createReviewThreadPresentation(presentation({ threadKey: '' })), + ).toThrow(); + expect(() => + createReviewThreadPresentation( + presentation({ threadKey: `thread_${'x'.repeat(122)}` }), + ), + ).toThrow(); + expect(() => + createReviewThreadPresentation(presentation({ commentCount: 0 })), + ).toThrow(); + expect(() => + createReviewThreadPresentation(presentation({ commentCount: 10_001 })), + ).toThrow(); + }); + + it('rejects hostile accessors without invoking them', () => { + const { createReviewThreadPresentation } = reviewPresentationSurface(); + let reads = 0; + const source = presentation(); + Object.defineProperty(source, 'threadKey', { + enumerable: true, + configurable: true, + get() { + reads += 1; + throw new Error('private accessor payload'); + }, + }); + + expect(() => createReviewThreadPresentation(source)).toThrow(); + expect(reads).toBe(0); + }); +}); diff --git a/src/review/revisionGuard.test.ts b/src/review/revisionGuard.test.ts new file mode 100644 index 000000000..a84eb3c14 --- /dev/null +++ b/src/review/revisionGuard.test.ts @@ -0,0 +1,129 @@ +import { createHash } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { createDocumentEnvelope } from '../documentEnvelope.js'; +import { + createDocumentEnvelopeRevision, + type CwlEditorDocumentRevision, + type DocumentEnvelopeDigestProvider, +} from '../documentEnvelopeRevision.js'; +import * as reviewModule from './index.js'; + +interface ReviewRevisionGuardSurface { + readonly assertReviewSuggestionCurrentRevision: ( + suggestion: unknown, + currentSource: unknown, + limits?: unknown, + digestProvider?: DocumentEnvelopeDigestProvider | null, + ) => Promise; +} + +function revisionGuardSurface(): ReviewRevisionGuardSurface { + return reviewModule as unknown as ReviewRevisionGuardSurface; +} + +function toBytes(source: BufferSource): Uint8Array { + return ArrayBuffer.isView(source) + ? new Uint8Array(source.buffer, source.byteOffset, source.byteLength) + : new Uint8Array(source); +} + +function sha256(source: BufferSource): ArrayBuffer { + const digest = createHash('sha256').update(toBytes(source)).digest(); + const result = new Uint8Array(32); + result.set(digest); + return result.buffer; +} + +function digestProvider(): DocumentEnvelopeDigestProvider { + return { + async digest(algorithm, source) { + expect(algorithm).toBe('SHA-256'); + return sha256(source); + }, + }; +} + +function insertSuggestion(revision: CwlEditorDocumentRevision) { + return { + contractVersion: 1, + kind: 'insert', + target: { + contractVersion: 1, + revision, + selector: { + type: 'TextPositionSelector', + start: 0, + end: 0, + }, + projection: { + id: 'inkspan-prosemirror-text', + version: 1, + }, + }, + text: 'private proposal text', + }; +} + +const CURRENT_DOCUMENT = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'private current body' }], + }, + ], +}; + +describe('review suggestion mutation-boundary revision guard', () => { + it('returns only a detached validated suggestion when its target matches the exact current revision', async () => { + const provider = digestProvider(); + const currentEnvelope = createDocumentEnvelope(CURRENT_DOCUMENT); + const currentRevision = await createDocumentEnvelopeRevision( + currentEnvelope, + undefined, + provider, + ); + + const admitted = (await revisionGuardSurface().assertReviewSuggestionCurrentRevision( + insertSuggestion(currentRevision), + currentEnvelope, + undefined, + provider, + )) as { + readonly kind: 'insert'; + readonly text: string; + readonly target: { readonly revision: CwlEditorDocumentRevision }; + }; + + expect(admitted.kind).toBe('insert'); + expect(admitted.text).toBe('private proposal text'); + expect(admitted.target.revision).toEqual(currentRevision); + expect(Object.isFrozen(admitted)).toBe(true); + expect(Object.isFrozen(admitted.target)).toBe(true); + expect(JSON.stringify(admitted)).not.toContain('private current body'); + }); + + it('fails closed before mutation when the proposal targets any other document revision', async () => { + const provider = digestProvider(); + const currentEnvelope = createDocumentEnvelope(CURRENT_DOCUMENT); + const staleDigest = 'f'.repeat(64); + const staleRevision = Object.freeze({ + algorithm: 'SHA-256' as const, + digestHex: staleDigest, + strongEntityTag: `"sha256-${staleDigest}"`, + }); + + await expect( + revisionGuardSurface().assertReviewSuggestionCurrentRevision( + insertSuggestion(staleRevision), + currentEnvelope, + undefined, + provider, + ), + ).rejects.toMatchObject({ + name: 'CwlReviewOperationError', + code: 'stale_operation', + message: 'Review operation targets a stale document revision.', + }); + }); +}); diff --git a/src/review/suggestion.test.ts b/src/review/suggestion.test.ts new file mode 100644 index 000000000..a5c2daab7 --- /dev/null +++ b/src/review/suggestion.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from 'vitest'; +import * as reviewModule from './index.js'; + +interface ReviewSuggestionFactorySurface { + readonly CwlReviewSuggestionError: new () => Error & { + readonly code: 'invalid_suggestion'; + }; + readonly createReviewSuggestion: (source: unknown) => unknown; +} + +function reviewSuggestionSurface(): ReviewSuggestionFactorySurface { + return reviewModule as unknown as ReviewSuggestionFactorySurface; +} + +const digestHex = 'b'.repeat(64); +const revision = Object.freeze({ + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, +}); +const projection = Object.freeze({ + id: 'inkspan-prosemirror-text', + version: 1, +}); + +function target(start: number, end: number): Record { + return { + contractVersion: 1, + revision, + selector: { + type: 'TextPositionSelector', + start, + end, + }, + projection, + }; +} + +function insertSuggestion(): Record { + return { + contractVersion: 1, + kind: 'insert', + target: target(2, 2), + text: '제안 👩🏽‍💻', + }; +} + +function deleteSuggestion(): Record { + return { + contractVersion: 1, + kind: 'delete', + target: target(2, 5), + }; +} + +function expectInvalidSuggestion(source: unknown): void { + const { createReviewSuggestion, CwlReviewSuggestionError } = + reviewSuggestionSurface(); + let failure: unknown; + try { + createReviewSuggestion(source); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(CwlReviewSuggestionError); + expect(failure).toMatchObject({ code: 'invalid_suggestion' }); + expect(String(failure)).not.toContain(digestHex); +} + +describe('provider-neutral review suggestion contract', () => { + it('creates detached deeply frozen insertion and deletion proposals', () => { + const { createReviewSuggestion } = reviewSuggestionSurface(); + const insertSource = insertSuggestion(); + const deleteSource = deleteSuggestion(); + + const insert = createReviewSuggestion(insertSource) as { + readonly contractVersion: 1; + readonly kind: 'insert'; + readonly target: { + readonly selector: { readonly start: number; readonly end: number }; + }; + readonly text: string; + }; + const deletion = createReviewSuggestion(deleteSource) as { + readonly contractVersion: 1; + readonly kind: 'delete'; + readonly target: { + readonly selector: { readonly start: number; readonly end: number }; + }; + }; + + expect(insert).toEqual(insertSource); + expect(insert).not.toBe(insertSource); + expect(insert.target).not.toBe(insertSource.target); + expect(insert.text).toBe('제안 👩🏽‍💻'); + expect(Object.isFrozen(insert)).toBe(true); + expect(Object.isFrozen(insert.target)).toBe(true); + expect(Object.isFrozen(insert.target.selector)).toBe(true); + + expect(deletion).toEqual(deleteSource); + expect(deletion).not.toBe(deleteSource); + expect(deletion.target).not.toBe(deleteSource.target); + expect(Object.isFrozen(deletion)).toBe(true); + expect(Object.isFrozen(deletion.target)).toBe(true); + }); + + it('requires exact bounded shapes before retaining suggestion proposal data', () => { + expectInvalidSuggestion(null); + expectInvalidSuggestion('not-a-suggestion'); + expectInvalidSuggestion({ contractVersion: 1, target: target(1, 1) }); + expectInvalidSuggestion({ ...insertSuggestion(), kind: 'replace' }); + expectInvalidSuggestion({ ...insertSuggestion(), contractVersion: 2 }); + expectInvalidSuggestion({ ...insertSuggestion(), unexpected: true }); + expectInvalidSuggestion({ + ...insertSuggestion(), + [Symbol('hidden authority')]: true, + }); + + const accessorKind = insertSuggestion(); + let getterCalls = 0; + Object.defineProperty(accessorKind, 'kind', { + enumerable: true, + get() { + getterCalls += 1; + return 'insert'; + }, + }); + expectInvalidSuggestion(accessorKind); + expect(getterCalls).toBe(0); + + const hiddenKind = insertSuggestion(); + Object.defineProperty(hiddenKind, 'kind', { + value: 'insert', + enumerable: false, + }); + expectInvalidSuggestion(hiddenKind); + }); + + it('requires insertion points and bounded non-empty insertion text', () => { + expectInvalidSuggestion({ + ...insertSuggestion(), + target: target(2, 3), + }); + expectInvalidSuggestion({ ...insertSuggestion(), text: '' }); + expectInvalidSuggestion({ ...insertSuggestion(), text: 1 }); + expectInvalidSuggestion({ + ...insertSuggestion(), + text: 'a'.repeat(65_537), + }); + + const exactLimit = { + ...insertSuggestion(), + text: 'a'.repeat(65_536), + }; + const accepted = reviewSuggestionSurface().createReviewSuggestion( + exactLimit, + ) as { readonly text: string }; + expect(accepted.text).toHaveLength(65_536); + }); + + it('requires deletion suggestions to select existing projected text only', () => { + expectInvalidSuggestion({ + ...deleteSuggestion(), + target: target(2, 2), + }); + expectInvalidSuggestion({ ...deleteSuggestion(), text: 'copied source text' }); + expectInvalidSuggestion({ + ...deleteSuggestion(), + target: { + ...target(2, 5), + revision: { ...revision, algorithm: 'MD5' }, + }, + }); + }); + + it('fails closed when hostile reflection changes or rejects kind evidence', () => { + const privateValue = 'private-suggestion-reflection-value'; + let kindReads = 0; + const changingKind = new Proxy(insertSuggestion(), { + getOwnPropertyDescriptor(source, property) { + const descriptor = Reflect.getOwnPropertyDescriptor(source, property); + if (property !== 'kind' || descriptor === undefined) return descriptor; + kindReads += 1; + return { + ...descriptor, + value: kindReads === 1 ? 'insert' : 'delete', + }; + }, + }); + expectInvalidSuggestion(changingKind); + + const hostileKeys = new Proxy(insertSuggestion(), { + ownKeys() { + throw new Error(privateValue); + }, + }); + const { createReviewSuggestion, CwlReviewSuggestionError } = + reviewSuggestionSurface(); + let failure: unknown; + try { + createReviewSuggestion(hostileKeys); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(CwlReviewSuggestionError); + expect(String(failure)).not.toContain(privateValue); + }); +}); diff --git a/src/reviewExportDocumentation.test.ts b/src/reviewExportDocumentation.test.ts new file mode 100644 index 000000000..a85bd9356 --- /dev/null +++ b/src/reviewExportDocumentation.test.ts @@ -0,0 +1,23 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const repositoryFile = (file: string): string => + readFileSync(resolve(process.cwd(), file), 'utf8'); + +describe('review non-print export authority', () => { + it('keeps review presentation outside deterministic document exports', () => { + for (const file of [ + 'docs/PRD.md', + 'docs/TRD.md', + 'docs/CONTRACTS.md', + 'docs/print-output.md', + ]) { + const document = repositoryFile(file).toLowerCase(); + expect(document).toContain('review metadata is excluded from non-print exports'); + expect(document).toContain('canonical document content'); + expect(document).toContain('separately governed export'); + } + }); +}); diff --git a/src/reviewPackageContract.test.ts b/src/reviewPackageContract.test.ts new file mode 100644 index 000000000..831b5dcc2 --- /dev/null +++ b/src/reviewPackageContract.test.ts @@ -0,0 +1,49 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +interface PackageManifest { + exports?: Record< + string, + { + types?: string; + import?: string; + require?: string; + } + >; + scripts?: Record; +} + +const manifest = JSON.parse( + readFileSync(resolve(process.cwd(), 'package.json'), 'utf8'), +) as PackageManifest; + +describe('review package contract', () => { + it('publishes a dedicated React-free review subpath', () => { + expect(manifest.exports?.['./review']).toEqual({ + types: './dist/review/index.d.ts', + import: './dist/cwl-review.js', + require: './dist/cwl-review.cjs', + }); + expect(manifest.scripts?.build).toContain( + 'vite build --config vite.review.config.ts', + ); + expect(manifest.scripts?.['verify:package']).toContain( + 'scripts/verify-review-package.mjs', + ); + }); + + it('publishes the controlled React review adapter as a separate subpath', () => { + expect(manifest.exports?.['./review-react']).toEqual({ + types: './dist/review-react/index.d.ts', + import: './dist/cwl-review-react.js', + require: './dist/cwl-review-react.cjs', + }); + expect(manifest.scripts?.build).toContain( + 'vite build --config vite.review-react.config.ts', + ); + expect(manifest.scripts?.['verify:package']).toContain( + 'scripts/verify-review-react-package.mjs', + ); + }); +}); diff --git a/src/reviewTransactionDocumentation.test.ts b/src/reviewTransactionDocumentation.test.ts new file mode 100644 index 000000000..2e65afdf1 --- /dev/null +++ b/src/reviewTransactionDocumentation.test.ts @@ -0,0 +1,26 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const repositoryFile = (file: string): string => + readFileSync(resolve(process.cwd(), file), 'utf8'); + +describe('review suggestion transaction documentation', () => { + it('keeps architecture and evidence records connected to the active contract', () => { + expect(repositoryFile('docs/adr/0005-revision-scoped-review-evidence.md')).toContain( + 'applyReviewSuggestionDecision', + ); + expect(repositoryFile('docs/UML.md')).toContain('Review suggestion decision'); + expect(repositoryFile('docs/DATA_MODEL.md')).toContain('`review_operation`'); + expect(repositoryFile('docs/THREAT_MODEL.md')).toContain( + 'Review suggestions and transaction admission', + ); + expect(repositoryFile('docs/TRACEABILITY.md')).toContain( + 'Exact-revision review mutation', + ); + expect(repositoryFile('docs/TEST_STRATEGY.md')).toContain( + 'review suggestion decisions', + ); + }); +}); diff --git a/src/styles.css b/src/styles.css index 3ba488008..c999f1a4c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -262,6 +262,103 @@ padding-top: calc(16px + 1.6em); } +.cwl-review { + color: var(--cwl-fg, #1f2937); + font-family: var(--cwl-font, system-ui, sans-serif); +} + +.cwl-review__threads { + width: min(100%, 22rem); + border-inline-start: 1px solid var(--cwl-border, #d1d5db); + padding-inline-start: 8px; +} + +.cwl-review__suggestion { + display: flex; + align-items: center; + gap: var(--cwl-space-2); + min-inline-size: 0; + margin: 0; + border: 0; + padding: 0; +} + +.cwl-review__threads ul { + margin: 0; + padding: 0; + list-style: none; +} + +.cwl-review__item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 4px; + padding: 8px 0; + border-bottom: 1px solid var(--cwl-border, #d1d5db); +} + +.cwl-review__thread, +.cwl-review__action, +.cwl-review__target { + min-height: 32px; + border: 1px solid var(--cwl-border, #d1d5db); + background: var(--cwl-surface, #ffffff); + color: inherit; + font: inherit; +} + +.cwl-review__thread { + min-width: 0; + padding: 4px 8px; + overflow-wrap: anywhere; + text-align: start; +} + +.cwl-review__thread[aria-pressed='true'] { + border-inline-start: 3px solid var(--cwl-accent, #0969da); + background: var(--cwl-accent-soft, #ddf4ff); +} + +.cwl-review__action { + padding: 4px 8px; +} + +.cwl-review__summary { + grid-column: 1 / -1; + color: var(--cwl-muted, #57606a); + font-size: 12px; +} + +.cwl-review__thread:focus-visible, +.cwl-review__action:focus-visible, +.cwl-review__target:focus-visible { + outline: 2px solid var(--cwl-accent, #0969da); + outline-offset: 2px; +} + +@media (max-width: 30rem) { + .cwl-review__item { + grid-template-columns: 1fr 1fr; + } + + .cwl-review__thread, + .cwl-review__summary { + grid-column: 1 / -1; + } +} + +@media (forced-colors: active) { + .cwl-review__thread[aria-pressed='true'] { + border-inline-start-color: Highlight; + } + + .cwl-review__thread:focus-visible, + .cwl-review__action:focus-visible, + .cwl-review__target:focus-visible { + outline-color: CanvasText; + } +} + @media print { .cwl-editor { --cwl-fg: #000000; @@ -282,10 +379,25 @@ .cwl-toolbar, .cwl-collaboration-status, .collaboration-cursor__caret, - .collaboration-cursor__label { + .collaboration-cursor__label, + .cwl-review[data-cwl-review-print='exclude'] { + display: none !important; + } + + .cwl-review[data-cwl-review-print='include'] .cwl-review__action { display: none !important; } + .cwl-review[data-cwl-review-print='include'].cwl-review__target, + .cwl-review[data-cwl-review-print='include'] .cwl-review__thread { + appearance: none; + border: 0; + padding: 0; + background: transparent; + color: inherit; + font: inherit; + } + .cwl-editor__surface { overflow: visible; max-height: none; diff --git a/src/types.ts b/src/types.ts index 0292d4a22..e3992bf1c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,8 @@ import type { JSONContent } from '@tiptap/core'; import type { Editor } from '@tiptap/react'; +import type { + CwlReviewOperationResult, +} from './review/index.js'; import type { CwlEditorDocumentEnvelope, DocumentEnvelopeLimits, @@ -174,6 +177,18 @@ export interface CwlEditorHandle { limits?: DocumentEnvelopeLimits, digestProvider?: DocumentEnvelopeDigestProvider | null, ): Promise; + /** + * Accept or reject one exact-revision insert/delete suggestion. + * + * Acceptance dispatches one history-aware editor transaction. Rejection + * preserves the document. Returns `null` before editor creation. + */ + applyReviewSuggestionDecision( + suggestion: unknown, + action: 'accept' | 'reject', + limits?: DocumentEnvelopeLimits, + digestProvider?: DocumentEnvelopeDigestProvider | null, + ): Promise; /** Replace the whole document from a string in the active `mode`. */ setValue(value: string): void; /** diff --git a/src/workflowExactHead.test.ts b/src/workflowExactHead.test.ts index 828c28287..615f75644 100644 --- a/src/workflowExactHead.test.ts +++ b/src/workflowExactHead.test.ts @@ -74,7 +74,7 @@ describe('exact-head CI workflow contract', () => { ); expect(workflow).toContain('cancel-in-progress: true'); expect(officeJob).toContain( - "python-version: ${{ github.event_name == 'pull_request' && fromJSON('[\"3.14\"]') || fromJSON('[\"3.11\", \"3.12\", \"3.13\", \"3.14\"]') }}", + 'python-version: ["3.11", "3.12", "3.13", "3.14"]', ); expect(releaseWorkflow).toContain( 'group: ${{ github.workflow }}-${{ github.repository }}-${{ github.ref_name }}', diff --git a/stories/ReviewWorkflow.stories.tsx b/stories/ReviewWorkflow.stories.tsx new file mode 100644 index 000000000..852b80675 --- /dev/null +++ b/stories/ReviewWorkflow.stories.tsx @@ -0,0 +1,141 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { + CwlReviewSuggestionDecision, + CwlReviewThreadList, + type CwlReviewThreadListLabels, +} from '../src/review-react/index.js'; +import '../src/styles.css'; + +const digestHex = 'a'.repeat(64); +const target = { + contractVersion: 1, + revision: { + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }, + selector: { type: 'TextPositionSelector', start: 4, end: 12 }, + projection: { id: 'inkspan-prosemirror-text', version: 1 }, +}; + +const presentation = ( + threadKey: string, + state: 'unresolved' | 'resolved', + selected: boolean, + canReply = true, + canResolve = state === 'unresolved', +) => ({ + contractVersion: 1, + threadKey, + target, + state, + commentCount: state === 'resolved' ? 3 : 1, + selected, + canReply, + canResolve, +}); + +const labels: CwlReviewThreadListLabels = { + region: 'Document review', + thread: (thread) => `Review ${thread.threadKey}`, + status: (thread) => + thread.state === 'resolved' ? 'Resolved' : 'Needs review', + comments: (thread) => `${thread.commentCount} comments`, + reply: 'Reply', + resolve: 'Resolve', +}; + +const disabledLabels: CwlReviewThreadListLabels = { + ...labels, + status: () => 'Target is out of date. Refresh the document before acting.', +}; + +const noOp = () => undefined; + +const meta = { + title: 'Review Workflow', + component: CwlReviewThreadList, + parameters: { layout: 'centered' }, + args: { + labels, + onSelectThread: noOp, + onReplyThread: noOp, + onResolveThread: noOp, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Normal: Story = { + args: { presentations: [presentation('A-104', 'unresolved', false)] }, +}; + +export const SelectedUnresolved: Story = { + args: { presentations: [presentation('A-104', 'unresolved', true)] }, +}; + +export const Resolved: Story = { + args: { presentations: [presentation('A-104', 'resolved', false)] }, +}; + +export const Stale: Story = { + args: { + presentations: [presentation('A-104', 'unresolved', true, false, false)], + labels: disabledLabels, + }, +}; + +export const PermissionDisabled: Story = { + args: { + presentations: [presentation('A-104', 'unresolved', false, false, false)], + }, +}; + +export const NarrowScreen: Story = { + decorators: [(Story) =>
], + args: { + presentations: [ + presentation('A-104', 'unresolved', true), + presentation('A-105', 'resolved', false), + ], + }, +}; + +export const ForcedColors: Story = { + name: 'Forced Colors (use OS/browser emulation)', + args: { presentations: [presentation('A-104', 'unresolved', true)] }, +}; + +export const PrintExcluded: Story = { + args: { + presentations: [presentation('A-104', 'unresolved', true)], + printMode: 'exclude', + }, +}; + +export const PrintIncluded: Story = { + args: { + presentations: [presentation('A-104', 'unresolved', true)], + printMode: 'include', + }, +}; + +export const SuggestionDecision: StoryObj = { + render: () => ( + + ), +}; diff --git a/tests/browser/harness.ts b/tests/browser/harness.ts index c00b47a7d..e414b017f 100644 --- a/tests/browser/harness.ts +++ b/tests/browser/harness.ts @@ -1,4 +1,6 @@ import { Editor } from '@tiptap/core'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; import { ClipboardSanitizationError, buildExtensions, @@ -6,6 +8,10 @@ import { type ClipboardConfig, type ClipboardSanitizationErrorCode, } from 'inkspan-browser-under-test'; +import { + CwlReviewSuggestionDecision, + CwlReviewThreadList, +} from 'inkspan-review-react-under-test'; interface BrowserClipboardProbeRequest { readonly sourceHtml: string; @@ -23,6 +29,11 @@ interface BrowserHostileDocumentProbeResult { readonly message: string; } +interface BrowserReviewIntent { + readonly action: 'select' | 'reply' | 'resolve' | 'accept' | 'reject'; + readonly threadKey: string; +} + declare global { interface Window { runInkspanClipboardProbe( @@ -31,9 +42,104 @@ declare global { runInkspanHostileDocumentProbe( sourceHtml: string, ): BrowserHostileDocumentProbeResult; + mountInkspanReviewProbe(printMode?: 'exclude' | 'include'): void; + mountInkspanSuggestionProbe(printMode?: 'exclude' | 'include'): void; + readInkspanReviewIntents(): readonly BrowserReviewIntent[]; } } +const reviewIntents: BrowserReviewIntent[] = []; +let reviewRoot: Root | null = null; + +const reviewRevision = Object.freeze({ + algorithm: 'SHA-256' as const, + digestHex: 'a'.repeat(64), + strongEntityTag: `"sha256-${'a'.repeat(64)}"`, +}); + +const reviewPresentation = ( + threadKey: string, + selected: boolean, + state: 'unresolved' | 'resolved', + canReply: boolean, + canResolve: boolean, +) => ({ + contractVersion: 1, + threadKey, + target: { + contractVersion: 1, + revision: reviewRevision, + selector: { type: 'TextPositionSelector', start: 0, end: 1 }, + projection: { id: 'inkspan-prosemirror-text', version: 1 }, + }, + state, + commentCount: state === 'resolved' ? 3 : 1, + selected, + canReply, + canResolve, +}); + +window.mountInkspanReviewProbe = (printMode = 'exclude'): void => { + const container = document.querySelector('#harness'); + if (!container) throw new Error('Review harness container is missing.'); + reviewIntents.length = 0; + reviewRoot?.unmount(); + reviewRoot = createRoot(container); + reviewRoot.render( + createElement(CwlReviewThreadList, { + presentations: [ + reviewPresentation('alpha', false, 'unresolved', true, true), + reviewPresentation('beta', true, 'unresolved', false, true), + reviewPresentation('gamma', false, 'resolved', true, false), + ], + labels: { + region: 'Document review', + thread: (thread) => `Thread ${thread.threadKey}`, + status: (thread) => + thread.state === 'resolved' ? 'Resolved' : 'Unresolved', + comments: (thread) => `${thread.commentCount} comments`, + reply: 'Reply', + resolve: 'Resolve', + }, + printMode, + onSelectThread: (thread) => + reviewIntents.push({ action: 'select', threadKey: thread.threadKey }), + onReplyThread: (thread) => + reviewIntents.push({ action: 'reply', threadKey: thread.threadKey }), + onResolveThread: (thread) => + reviewIntents.push({ action: 'resolve', threadKey: thread.threadKey }), + }), + ); +}; + +window.mountInkspanSuggestionProbe = (printMode = 'exclude'): void => { + const container = document.querySelector('#harness'); + if (!container) throw new Error('Review harness container is missing.'); + reviewIntents.length = 0; + reviewRoot?.unmount(); + reviewRoot = createRoot(container); + reviewRoot.render( + createElement(CwlReviewSuggestionDecision, { + suggestion: { + contractVersion: 1, + kind: 'delete', + target: reviewPresentation('suggestion', false, 'unresolved', false, false) + .target, + }, + label: 'Delete suggested wording', + acceptLabel: 'Accept', + rejectLabel: 'Reject', + printMode, + onAccept: () => + reviewIntents.push({ action: 'accept', threadKey: 'suggestion' }), + onReject: () => + reviewIntents.push({ action: 'reject', threadKey: 'suggestion' }), + }), + ); +}; + +window.readInkspanReviewIntents = () => structuredClone(reviewIntents); + window.runInkspanClipboardProbe = ( request: BrowserClipboardProbeRequest, ): BrowserClipboardProbeResult => { diff --git a/tests/browser/playwright.config.ts b/tests/browser/playwright.config.ts index d7d4c2a20..50014e58b 100644 --- a/tests/browser/playwright.config.ts +++ b/tests/browser/playwright.config.ts @@ -2,7 +2,7 @@ import { defineConfig, devices } from '@playwright/test'; const HARNESS_ORIGIN = 'http://127.0.0.1:4173'; const HARNESS_URL = `${HARNESS_ORIGIN}/tests/browser/harness.html`; -const ENGINE_BROWSER_SPECS = /(?:clipboard|focus|print)\.browser\.spec\.ts/u; +const ENGINE_BROWSER_SPECS = /(?:clipboard|focus|print|review)\.browser\.spec\.ts/u; export default defineConfig({ testDir: './specs', diff --git a/tests/browser/specs/review.browser.spec.ts b/tests/browser/specs/review.browser.spec.ts new file mode 100644 index 000000000..9bbdbcb62 --- /dev/null +++ b/tests/browser/specs/review.browser.spec.ts @@ -0,0 +1,137 @@ +import { expect, test } from '@playwright/test'; + +test.beforeEach(async ({ page }) => { + await page.goto('/tests/browser/harness.html'); + await page.addStyleTag({ url: '/dist/cwl-editor.css' }); + await page.evaluate(() => window.mountInkspanReviewProbe()); +}); + +test('keeps review focus and host intent deterministic across real engines', async ({ + page, +}) => { + const region = page.getByRole('region', { name: 'Document review' }); + const alpha = region.getByRole('button', { + name: 'Thread alpha', + exact: true, + }); + const beta = region.getByRole('button', { + name: 'Thread beta', + exact: true, + }); + const gamma = region.getByRole('button', { + name: 'Thread gamma', + exact: true, + }); + + await beta.focus(); + await expect(beta).toBeFocused(); + await expect(beta).toHaveAttribute('aria-pressed', 'true'); + await expect(beta).toHaveAccessibleDescription('Unresolved 1 comments'); + + await page.keyboard.press('End'); + await expect(gamma).toBeFocused(); + await expect(gamma).toHaveAttribute('aria-pressed', 'false'); + await expect(gamma).toHaveAccessibleDescription('Resolved 3 comments'); + expect(await page.evaluate(() => window.readInkspanReviewIntents())).toEqual([]); + + await page.keyboard.press('Enter'); + expect(await page.evaluate(() => window.readInkspanReviewIntents())).toEqual([ + { action: 'select', threadKey: 'gamma' }, + ]); + + await page.keyboard.press('Home'); + await expect(alpha).toBeFocused(); + await page.keyboard.press('ArrowDown'); + await expect(beta).toBeFocused(); + await expect( + region.getByRole('button', { name: 'Reply — Thread beta' }), + ).toBeDisabled(); + await expect( + region.getByRole('button', { name: 'Resolve — Thread beta' }), + ).toBeEnabled(); + await expect( + region.getByRole('button', { name: 'Resolve — Thread gamma' }), + ).toBeDisabled(); +}); + +test('prints review summaries only after explicit opt-in', async ({ page }) => { + const region = page.getByRole('region', { name: 'Document review' }); + await page.emulateMedia({ media: 'print' }); + await expect(region).toBeHidden(); + + await page.evaluate(() => window.mountInkspanReviewProbe('include')); + await expect(region).toBeVisible(); + await expect( + region.getByRole('button', { name: 'Thread beta', exact: true }), + ).toBeVisible(); + await expect( + region.getByRole('button', { name: 'Resolve — Thread beta' }), + ).toBeHidden(); + + await page.evaluate(() => window.mountInkspanSuggestionProbe('include')); + const suggestion = page.getByRole('group', { + name: 'Delete suggested wording', + }); + await expect(suggestion).toBeVisible(); + await expect(suggestion.getByText('Delete suggested wording')).toBeVisible(); + await expect(suggestion.getByRole('button', { name: /Accept/u })).toBeHidden(); + await expect(suggestion.getByRole('button', { name: /Reject/u })).toBeHidden(); +}); + +test('reflows review actions without hiding content on narrow screens', async ({ + page, +}) => { + await page.setViewportSize({ width: 360, height: 640 }); + + const item = page.locator('.cwl-review__item').first(); + const thread = item.locator('.cwl-review__thread'); + const reply = item.getByRole('button', { name: 'Reply — Thread alpha' }); + + await expect(item).toBeVisible(); + await expect(thread).toBeVisible(); + await expect(reply).toBeVisible(); + expect(await item.evaluate((element) => getComputedStyle(element).gridTemplateColumns)).not.toBe( + 'none', + ); + await expect(thread).toHaveCSS('grid-column', '1 / -1'); +}); + +test('preserves selected and keyboard focus cues in forced colors', async ({ + page, +}) => { + await page.emulateMedia({ forcedColors: 'active' }); + + const selected = page.getByRole('button', { + name: 'Thread beta', + exact: true, + }); + await selected.focus(); + + expect(await page.evaluate(() => matchMedia('(forced-colors: active)').matches)).toBe( + true, + ); + await expect(selected).toBeFocused(); + await expect(selected).toHaveCSS('outline-style', 'solid'); + await expect(selected).toHaveCSS('outline-width', '2px'); +}); + +test('exposes keyboard-operable suggestion decision intents', async ({ page }) => { + await page.evaluate(() => window.mountInkspanSuggestionProbe()); + const group = page.getByRole('group', { name: 'Delete suggested wording' }); + const accept = group.getByRole('button', { + name: 'Accept — Delete suggested wording', + }); + const reject = group.getByRole('button', { + name: 'Reject — Delete suggested wording', + }); + + await accept.focus(); + await expect(accept).toBeFocused(); + await page.keyboard.press('Enter'); + await reject.focus(); + await page.keyboard.press('Space'); + expect(await page.evaluate(() => window.readInkspanReviewIntents())).toEqual([ + { action: 'accept', threadKey: 'suggestion' }, + { action: 'reject', threadKey: 'suggestion' }, + ]); +}); diff --git a/tests/browser/vite.config.ts b/tests/browser/vite.config.ts index 50c49dd19..b761eec27 100644 --- a/tests/browser/vite.config.ts +++ b/tests/browser/vite.config.ts @@ -8,11 +8,15 @@ const configuredPackageEntry = process.env.INKSPAN_BROWSER_PACKAGE_ENTRY?.trim() const packageEntry = configuredPackageEntry ? resolve(configuredPackageEntry) : resolve(repositoryRoot, 'src/index.ts'); +const reviewReactEntry = configuredPackageEntry + ? resolve(dirname(configuredPackageEntry), 'cwl-review-react.js') + : resolve(repositoryRoot, 'src/review-react/index.tsx'); export default defineConfig({ resolve: { alias: { 'inkspan-browser-under-test': packageEntry, + 'inkspan-review-react-under-test': reviewReactEntry, }, }, }); diff --git a/tests/package/verify-package.mjs b/tests/package/verify-package.mjs index 82a03e312..0978c0c7d 100644 --- a/tests/package/verify-package.mjs +++ b/tests/package/verify-package.mjs @@ -172,6 +172,9 @@ import { import type { CwlEditorDocumentRevisionEvidence, } from '${packageName}/revision-evidence'; +import type { + CwlReviewOperationResult, +} from '${packageName}/review'; const renderMarkdown: (markdown: string) => string = markdownToHtml; const safeHref: string = validateSafeLinkHref('/documents/current'); @@ -200,6 +203,8 @@ const expectedStrongEntityTag = '"sha256-' + '0'.repeat(64) + '"'; const currentSnapshot: CwlEditorDocumentSnapshot = editorHandle.getSnapshot(); const currentRevision: Promise = editorHandle.getDocumentEnvelopeRevision(undefined, digestProvider); +const reviewDecision: Promise = + editorHandle.applyReviewSuggestionDecision({}, 'accept', undefined, digestProvider); const conditionalRestore: Promise = editorHandle.restoreDocumentEnvelopeIfMatch( expectedStrongEntityTag, @@ -272,6 +277,7 @@ void [ documentSnapshot, currentSnapshot, currentRevision, + reviewDecision, conditionalRestore, conditionalEvidence, conditionalByteRestoreResult, diff --git a/vite.review-react.config.ts b/vite.review-react.config.ts new file mode 100644 index 000000000..ac606bf0c --- /dev/null +++ b/vite.review-react.config.ts @@ -0,0 +1,40 @@ +import { resolve } from 'node:path'; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; + +/** + * Build the controlled React presentation adapter separately from the + * framework-free review contract. React remains host-supplied peer authority. + */ +export default defineConfig({ + plugins: [ + react(), + dts({ + include: [ + 'src/review-react', + 'src/review', + 'src/documentEnvelopeRevision.ts', + 'src/textPositionSelectorEvidence.ts', + ], + exclude: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.spec.ts'], + rollupTypes: false, + entryRoot: 'src', + }), + ], + build: { + emptyOutDir: false, + lib: { + entry: resolve(__dirname, 'src/review-react/index.tsx'), + name: 'InkspanReviewReact', + fileName: (format) => + format === 'es' ? 'cwl-review-react.js' : 'cwl-review-react.cjs', + formats: ['es', 'cjs'], + }, + sourcemap: true, + rollupOptions: { + external: ['react', 'react-dom', 'react/jsx-runtime'], + output: { interop: 'auto' }, + }, + }, +}); diff --git a/vite.review.config.ts b/vite.review.config.ts new file mode 100644 index 000000000..420c5ff78 --- /dev/null +++ b/vite.review.config.ts @@ -0,0 +1,31 @@ +import { resolve } from 'node:path'; +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; + +// React-free review contract build: deterministic revision/selector evidence only. +// No React UI, network, credential, persistence, tenancy, model, or provider authority. +export default defineConfig({ + plugins: [ + dts({ + include: [ + 'src/review', + 'src/documentEnvelopeRevision.ts', + 'src/textPositionSelectorEvidence.ts', + ], + exclude: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.spec.ts'], + rollupTypes: false, + entryRoot: 'src', + }), + ], + build: { + emptyOutDir: false, + lib: { + entry: resolve(__dirname, 'src/review/index.ts'), + name: 'InkspanReview', + fileName: (format) => + format === 'es' ? 'cwl-review.js' : 'cwl-review.cjs', + formats: ['es', 'cjs'], + }, + sourcemap: true, + }, +});