diff --git a/.github/workflows/writing-diagnostics-controller-tdd.yml b/.github/workflows/writing-diagnostics-controller-tdd.yml new file mode 100644 index 00000000..b2b839d0 --- /dev/null +++ b/.github/workflows/writing-diagnostics-controller-tdd.yml @@ -0,0 +1,140 @@ +name: Writing Diagnostics Controller TDD + +on: + push: + branches: + - feat/writing-diagnostics-controller + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: writing-diagnostics-controller-tdd-${{ github.ref }} + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + focused-controller: + runs-on: ubuntu-24.04 + timeout-minutes: 25 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Run revision-bound controller contract tests + run: >- + pnpm exec vitest run + src/components/useWritingDiagnosticsController.test.tsx + src/components/useWritingDiagnosticsController.actions.test.tsx + src/components/useWritingDiagnosticsController.boundary.test.tsx + src/components/useWritingDiagnosticsController.coverage.test.tsx + --pool=forks + --maxWorkers=1 + - name: Prove complete owned production coverage + shell: bash + run: | + set +e + pnpm exec vitest run \ + src/components/useWritingDiagnosticsController.test.tsx \ + src/components/useWritingDiagnosticsController.actions.test.tsx \ + src/components/useWritingDiagnosticsController.boundary.test.tsx \ + src/components/useWritingDiagnosticsController.coverage.test.tsx \ + --pool=forks \ + --maxWorkers=1 \ + --coverage \ + --coverage.include=src/components/useWritingDiagnosticsController.ts \ + --coverage.reporter=json + status=$? + node --input-type=module <<'NODE' + import fs from 'node:fs'; + + const sourcePath = 'src/components/useWritingDiagnosticsController.ts'; + if (!fs.existsSync('coverage/coverage-final.json')) { + console.log( + `::error file=${sourcePath},line=1::Controller coverage report was not produced.`, + ); + process.exit(0); + } + const report = JSON.parse( + fs.readFileSync('coverage/coverage-final.json', 'utf8'), + ); + const entry = Object.entries(report).find(([path]) => + path.endsWith(`/${sourcePath}`), + ); + if (!entry) { + console.log( + `::error file=${sourcePath},line=1::Controller coverage entry is missing.`, + ); + process.exit(0); + } + + const [, file] = entry; + const statementEntries = Object.entries(file.s); + const functionEntries = Object.entries(file.f); + const branchEntries = Object.entries(file.b); + const statementCovered = statementEntries.filter(([, count]) => count > 0).length; + const functionCovered = functionEntries.filter(([, count]) => count > 0).length; + const branchCounts = branchEntries.flatMap(([, counts]) => counts); + const branchCovered = branchCounts.filter((count) => count > 0).length; + console.log( + `::notice file=${sourcePath},line=1::Statements ${statementCovered}/${statementEntries.length}; ` + + `functions ${functionCovered}/${functionEntries.length}; branches ${branchCovered}/${branchCounts.length}.`, + ); + + const missingStatements = new Set(); + for (const [id, count] of statementEntries) { + if (count === 0) { + missingStatements.add(file.statementMap[id].start.line); + } + } + for (const line of [...missingStatements].sort((left, right) => left - right)) { + console.log( + `::error file=${sourcePath},line=${line}::Controller statement is not covered.`, + ); + } + + const missingFunctions = new Set(); + for (const [id, count] of functionEntries) { + if (count === 0) { + const definition = file.fnMap[id]; + missingFunctions.add( + definition.decl?.start.line ?? definition.loc.start.line, + ); + } + } + for (const line of [...missingFunctions].sort((left, right) => left - right)) { + console.log( + `::error file=${sourcePath},line=${line}::Controller function is not covered.`, + ); + } + + for (const [id, counts] of branchEntries) { + const branch = file.branchMap[id]; + counts.forEach((count, index) => { + if (count !== 0) return; + const location = branch.locations?.[index] ?? branch.loc; + console.log( + `::error file=${sourcePath},line=${location.start.line}::Controller branch ${index} is not covered.`, + ); + }); + } + NODE + exit "$status" + - name: Typecheck controller and public action contracts + run: pnpm typecheck + - name: Build every package entrypoint + run: pnpm build + - name: Verify packed-package consumers + run: pnpm verify:package + - name: Build the demonstration application + run: pnpm run build:demo diff --git a/src/components/useWritingDiagnosticsController.actions.test.tsx b/src/components/useWritingDiagnosticsController.actions.test.tsx new file mode 100644 index 00000000..75522e43 --- /dev/null +++ b/src/components/useWritingDiagnosticsController.actions.test.tsx @@ -0,0 +1,191 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { Editor } from '@tiptap/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { DocumentEnvelopeDigestProvider } from '../documentEnvelopeRevision.js'; +import { writingDiagnosticsPluginKey } from '../extensions/WritingDiagnostics.js'; +import { buildExtensions } from '../extensions/kit.js'; +import { + TEXT_POSITION_PROJECTION_ID, + TEXT_POSITION_PROJECTION_VERSION, +} from '../textPositionSelectorEvidence.js'; +import type { CwlWritingDiagnostic } from '../writingDiagnostics.js'; +import { useWritingDiagnosticsController } from './useWritingDiagnosticsController.js'; + +const DIGEST = '11'.repeat(32); +const openEditors: Editor[] = []; + +function createEditor(): Editor { + const editor = new Editor({ + extensions: buildExtensions(), + content: '

Alpha beta gamma

', + }); + openEditors.push(editor); + return editor; +} + +function diagnostic(id = 'diag-1'): CwlWritingDiagnostic { + return { + diagnosticId: id, + documentRevision: { + algorithm: 'SHA-256', + digestHex: DIGEST, + strongEntityTag: `"sha256-${DIGEST}"`, + }, + textProjection: { + id: TEXT_POSITION_PROJECTION_ID, + version: TEXT_POSITION_PROJECTION_VERSION, + }, + selector: { type: 'TextPositionSelector', start: 0, end: 5 }, + categoryCode: 'host.category', + priority: 'important', + title: 'Host title', + explanation: 'Host explanation', + suggestedReplacement: 'Omega', + provenance: { + workflowId: 'workflow', + workflowVersion: '1', + judgePolicyVersion: '1', + }, + }; +} + +function digestProvider(): DocumentEnvelopeDigestProvider { + const bytes = Uint8Array.from( + DIGEST.match(/../gu)!.map((part) => Number.parseInt(part, 16)), + ); + return { digest: vi.fn(async () => bytes.slice().buffer) }; +} + +function installedIds(editor: Editor): string[] { + return ( + writingDiagnosticsPluginKey.getState(editor.state)?.diagnostics ?? [] + ).map((item) => item.diagnosticId); +} + +afterEach(() => { + for (const editor of openEditors.splice(0)) { + if (!editor.isDestroyed) editor.destroy(); + } +}); + +describe('writing diagnostic feedback actions', () => { + it('reports Ignore without changing authored content or dismissing presentation', async () => { + const editor = createEditor(); + const before = editor.getJSON(); + const onAction = vi.fn(); + const provider = digestProvider(); + const diagnostics = [diagnostic()]; + const { result } = renderHook(() => + useWritingDiagnosticsController({ + editor, + diagnostics, + digestProvider: provider, + onAction, + }), + ); + await waitFor(() => expect(result.current.status).toBe('active')); + const generation = result.current.generation; + + let event = null as ReturnType; + act(() => { + event = result.current.ignoreDiagnostic('diag-1'); + }); + + expect(event).toMatchObject({ + action: 'ignored', + reasonCode: 'explicit', + diagnosticId: 'diag-1', + generation, + }); + expect(editor.getJSON()).toEqual(before); + expect(installedIds(editor)).toEqual(['diag-1']); + expect(result.current.diagnostics).toHaveLength(1); + expect(result.current.generation).toBe(generation); + expect(onAction).toHaveBeenCalledTimes(1); + }); + + it('reports Explain without changing authored content or dismissing presentation', async () => { + const editor = createEditor(); + const before = editor.getJSON(); + const onAction = vi.fn(); + const provider = digestProvider(); + const diagnostics = [diagnostic()]; + const { result } = renderHook(() => + useWritingDiagnosticsController({ + editor, + diagnostics, + digestProvider: provider, + onAction, + }), + ); + await waitFor(() => expect(result.current.status).toBe('active')); + const generation = result.current.generation; + + let event = null as ReturnType< + typeof result.current.requestDiagnosticExplanation + >; + act(() => { + event = result.current.requestDiagnosticExplanation('diag-1'); + }); + + expect(event).toMatchObject({ + action: 'requested_explanation', + reasonCode: 'explicit', + diagnosticId: 'diag-1', + generation, + }); + expect(editor.getJSON()).toEqual(before); + expect(installedIds(editor)).toEqual(['diag-1']); + expect(result.current.diagnostics).toHaveLength(1); + expect(result.current.generation).toBe(generation); + expect(onAction).toHaveBeenCalledTimes(1); + }); + + it('dismisses only local presentation and never mutates authored content', async () => { + const editor = createEditor(); + const before = editor.getJSON(); + const provider = digestProvider(); + const diagnostics = [diagnostic()]; + const { result } = renderHook(() => + useWritingDiagnosticsController({ + editor, + diagnostics, + digestProvider: provider, + }), + ); + await waitFor(() => expect(result.current.status).toBe('active')); + const generation = result.current.generation; + + let event = null as ReturnType; + act(() => { + event = result.current.dismissDiagnostic('diag-1'); + }); + + expect(event).toMatchObject({ + action: 'dismissed', + reasonCode: 'explicit', + diagnosticId: 'diag-1', + generation: generation + 1, + }); + expect(editor.getJSON()).toEqual(before); + expect(installedIds(editor)).toEqual([]); + expect(result.current.diagnostics).toEqual([]); + }); + + it('focuses only an installed current diagnostic', async () => { + const editor = createEditor(); + const provider = digestProvider(); + const diagnostics = [diagnostic()]; + const { result } = renderHook(() => + useWritingDiagnosticsController({ + editor, + diagnostics, + digestProvider: provider, + }), + ); + await waitFor(() => expect(result.current.status).toBe('active')); + + expect(result.current.focusDiagnostic('missing')).toBe(false); + expect(result.current.focusDiagnostic('diag-1')).toBe(true); + }); +}); diff --git a/src/components/useWritingDiagnosticsController.boundary.test.tsx b/src/components/useWritingDiagnosticsController.boundary.test.tsx new file mode 100644 index 00000000..071fb89f --- /dev/null +++ b/src/components/useWritingDiagnosticsController.boundary.test.tsx @@ -0,0 +1,216 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { Editor } from '@tiptap/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { DocumentEnvelopeDigestProvider } from '../documentEnvelopeRevision.js'; +import { buildExtensions } from '../extensions/kit.js'; +import { + TEXT_POSITION_PROJECTION_ID, + TEXT_POSITION_PROJECTION_VERSION, +} from '../textPositionSelectorEvidence.js'; +import type { CwlWritingDiagnostic } from '../writingDiagnostics.js'; + +const boundaryState = vi.hoisted(() => ({ + failValidationUnexpectedly: false, + failProjectionIdentity: false, + afterProjection: null as null | (() => void), +})); + +vi.mock('../writingDiagnostics.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + validateWritingDiagnostics: (...args: Parameters) => { + if (boundaryState.failValidationUnexpectedly) { + throw new Error('private validator failure'); + } + return actual.validateWritingDiagnostics(...args); + }, + }; +}); + +vi.mock('../writingDiagnosticProjection.js', async (importOriginal) => { + const actual = await importOriginal< + typeof import('../writingDiagnosticProjection.js') + >(); + return { + ...actual, + resolveTextPositionSelector: ( + ...args: Parameters + ) => { + if (boundaryState.failProjectionIdentity) { + throw Object.freeze({ code: 'projection' }); + } + const range = actual.resolveTextPositionSelector(...args); + boundaryState.afterProjection?.(); + return range; + }, + }; +}); + +import { useWritingDiagnosticsController } from './useWritingDiagnosticsController.js'; + +const DIGEST = '11'.repeat(32); +const openEditors: Editor[] = []; + +function createEditor(): Editor { + const editor = new Editor({ + extensions: buildExtensions(), + content: '

Alpha beta gamma

', + }); + openEditors.push(editor); + return editor; +} + +function diagnostic(): CwlWritingDiagnostic { + return { + diagnosticId: 'diag-1', + documentRevision: { + algorithm: 'SHA-256', + digestHex: DIGEST, + strongEntityTag: `"sha256-${DIGEST}"`, + }, + textProjection: { + id: TEXT_POSITION_PROJECTION_ID, + version: TEXT_POSITION_PROJECTION_VERSION, + }, + selector: { type: 'TextPositionSelector', start: 0, end: 5 }, + categoryCode: 'host.category', + priority: 'important', + title: 'Host title', + explanation: 'Host explanation', + provenance: { + workflowId: 'workflow', + workflowVersion: '1', + judgePolicyVersion: '1', + }, + }; +} + +function digestProvider(): DocumentEnvelopeDigestProvider { + return { + digest: vi.fn(async () => + Uint8Array.from( + DIGEST.match(/../gu)!.map((part) => Number.parseInt(part, 16)), + ).buffer, + ), + }; +} + +afterEach(() => { + boundaryState.failValidationUnexpectedly = false; + boundaryState.failProjectionIdentity = false; + boundaryState.afterProjection = null; + for (const editor of openEditors.splice(0)) { + if (!editor.isDestroyed) editor.destroy(); + } + vi.restoreAllMocks(); +}); + +describe('writing diagnostics controller defensive dependency boundaries', () => { + it('normalizes an unexpected validator exception to a redacted contract error', async () => { + boundaryState.failValidationUnexpectedly = true; + const editor = createEditor(); + const provider = digestProvider(); + const onError = vi.fn(); + const { result } = renderHook(() => + useWritingDiagnosticsController({ + editor, + diagnostics: [diagnostic()], + digestProvider: provider, + onError, + }), + ); + + await waitFor(() => expect(result.current.status).toBe('invalid')); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0]![0]).toMatchObject({ code: 'contract' }); + expect(onError.mock.calls[0]![0].message).not.toContain('private validator'); + }); + + it('preserves the projection classification from the inverse-projection boundary', async () => { + boundaryState.failProjectionIdentity = true; + const editor = createEditor(); + const provider = digestProvider(); + const onError = vi.fn(); + const { result } = renderHook(() => + useWritingDiagnosticsController({ + editor, + diagnostics: [diagnostic()], + digestProvider: provider, + onError, + }), + ); + + await waitFor(() => expect(result.current.status).toBe('invalid')); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'projection' }), + ); + }); + + it('publishes stale when the editor is destroyed after selector resolution', async () => { + const editor = createEditor(); + const provider = digestProvider(); + boundaryState.afterProjection = () => editor.destroy(); + const { result } = renderHook(() => + useWritingDiagnosticsController({ + editor, + diagnostics: [diagnostic()], + digestProvider: provider, + }), + ); + + await waitFor(() => expect(result.current.status).toBe('stale')); + expect(editor.isDestroyed).toBe(true); + expect(result.current.diagnostics).toEqual([]); + }); + + it('discards a resolved selector when a transaction invalidates its generation', async () => { + const editor = createEditor(); + const provider = digestProvider(); + boundaryState.afterProjection = () => { + editor.commands.insertContent('!'); + }; + const { result } = renderHook(() => + useWritingDiagnosticsController({ + editor, + diagnostics: [diagnostic()], + digestProvider: provider, + }), + ); + + await waitFor(() => expect(result.current.status).toBe('stale')); + expect(editor.getText()).toContain('!'); + expect(result.current.diagnostics).toEqual([]); + }); + + it('ignores a stale editor transaction if a retired listener fires after replacement', async () => { + const first = createEditor(); + const second = createEditor(); + const provider = digestProvider(); + vi.spyOn(first, 'off').mockImplementation(() => first); + + const { result, rerender } = renderHook( + ({ editor }: { editor: Editor }) => + useWritingDiagnosticsController({ + editor, + diagnostics: [], + digestProvider: provider, + }), + { initialProps: { editor: first } }, + ); + await waitFor(() => expect(result.current.status).toBe('active')); + + rerender({ editor: second }); + await waitFor(() => { + expect(result.current.status).toBe('active'); + expect(result.current.editor).toBe(second); + }); + + act(() => { + first.commands.insertContent('!'); + }); + + expect(result.current.status).toBe('active'); + expect(result.current.editor).toBe(second); + }); +}); diff --git a/src/components/useWritingDiagnosticsController.coverage.test.tsx b/src/components/useWritingDiagnosticsController.coverage.test.tsx new file mode 100644 index 00000000..ae83620d --- /dev/null +++ b/src/components/useWritingDiagnosticsController.coverage.test.tsx @@ -0,0 +1,390 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { Editor } from '@tiptap/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { DocumentEnvelopeDigestProvider } from '../documentEnvelopeRevision.js'; +import { writingDiagnosticsPluginKey } from '../extensions/WritingDiagnostics.js'; +import { buildExtensions } from '../extensions/kit.js'; +import { + TEXT_POSITION_PROJECTION_ID, + TEXT_POSITION_PROJECTION_VERSION, +} from '../textPositionSelectorEvidence.js'; +import type { CwlWritingDiagnostic } from '../writingDiagnostics.js'; +import { + useWritingDiagnosticsController, + type UseWritingDiagnosticsControllerOptions, +} from './useWritingDiagnosticsController.js'; + +const DIGEST_A = '11'.repeat(32); +const DIGEST_B = '22'.repeat(32); +const openEditors: Editor[] = []; + +function createEditor(): Editor { + const editor = new Editor({ + extensions: buildExtensions(), + content: '

Alpha beta gamma

', + }); + openEditors.push(editor); + return editor; +} + +function revision(digestHex = DIGEST_A) { + return Object.freeze({ + algorithm: 'SHA-256' as const, + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }); +} + +function diagnostic( + diagnosticId = 'diag-1', + overrides: Partial = {}, +): CwlWritingDiagnostic { + return { + diagnosticId, + documentRevision: revision(), + textProjection: { + id: TEXT_POSITION_PROJECTION_ID, + version: TEXT_POSITION_PROJECTION_VERSION, + }, + selector: { type: 'TextPositionSelector', start: 0, end: 5 }, + categoryCode: 'host.category', + priority: 'important', + title: 'Host title', + explanation: 'Host explanation', + suggestedReplacement: 'Omega', + provenance: { + workflowId: 'workflow', + workflowVersion: '1', + judgePolicyVersion: '1', + }, + ...overrides, + }; +} + +function digestBytes(digestHex = DIGEST_A): ArrayBuffer { + return Uint8Array.from( + digestHex.match(/../gu)!.map((part) => Number.parseInt(part, 16)), + ).buffer; +} + +function staticDigestProvider( + digestHex = DIGEST_A, +): DocumentEnvelopeDigestProvider { + return { digest: vi.fn(async () => digestBytes(digestHex)) }; +} + +function renderController(options: UseWritingDiagnosticsControllerOptions) { + return renderHook( + (next: UseWritingDiagnosticsControllerOptions) => + useWritingDiagnosticsController(next), + { initialProps: options }, + ); +} + +function overrideCommands( + editor: Editor, + overrides: Record, +): void { + const commands = editor.commands; + Object.defineProperty(editor, 'commands', { + configurable: true, + get: () => ({ ...commands, ...overrides }) as typeof commands, + }); +} + +function installedIds(editor: Editor): string[] { + return ( + writingDiagnosticsPluginKey.getState(editor.state)?.diagnostics ?? [] + ).map((item) => item.diagnosticId); +} + +afterEach(() => { + for (const editor of openEditors.splice(0)) { + if (!editor.isDestroyed) editor.destroy(); + } + vi.restoreAllMocks(); +}); + +describe('writing diagnostics controller defensive coverage', () => { + it('contains throwing error observers without changing the invalid result', async () => { + const editor = createEditor(); + const onError = vi.fn(() => { + throw new Error('host observer failure'); + }); + const { result } = renderController({ + editor, + diagnostics: [ + diagnostic('mismatch', { documentRevision: revision(DIGEST_B) }), + ], + digestProvider: staticDigestProvider(), + onError, + }); + + await waitFor(() => expect(result.current.status).toBe('invalid')); + expect(onError).toHaveBeenCalledTimes(1); + expect(installedIds(editor)).toEqual([]); + }); + + it('reprocesses a same-identity diagnostic array when its length changes', async () => { + const editor = createEditor(); + const provider = staticDigestProvider(); + const diagnostics = [diagnostic('first')]; + const { result, rerender } = renderController({ + editor, + diagnostics, + digestProvider: provider, + }); + await waitFor(() => expect(result.current.diagnostics).toHaveLength(1)); + + diagnostics.push(diagnostic('second')); + rerender({ editor, diagnostics, digestProvider: provider }); + + await waitFor(() => expect(result.current.diagnostics).toHaveLength(2)); + expect(installedIds(editor)).toEqual(['first', 'second']); + expect(provider.digest).toHaveBeenCalledTimes(2); + }); + + it('contains clear-decoration command failures during input processing', async () => { + const editor = createEditor(); + overrideCommands(editor, { + clearWritingDiagnostics: () => { + throw new Error('view already unavailable'); + }, + }); + const { result } = renderController({ + editor, + diagnostics: [], + digestProvider: staticDigestProvider(), + }); + + await waitFor(() => expect(result.current.status).toBe('active')); + expect(result.current.diagnostics).toEqual([]); + }); + + it('treats a null editor as stale without attempting revision work', async () => { + const provider = staticDigestProvider(); + const { result } = renderController({ + editor: null, + diagnostics: [diagnostic()], + digestProvider: provider, + }); + + await waitFor(() => expect(result.current.status).toBe('stale')); + expect(provider.digest).not.toHaveBeenCalled(); + }); + + it('installs an explicit empty diagnostic generation without hashing', async () => { + const editor = createEditor(); + const provider = staticDigestProvider(); + const { result } = renderController({ + editor, + diagnostics: [], + digestProvider: provider, + }); + + await waitFor(() => expect(result.current.status).toBe('active')); + expect(result.current.diagnostics).toEqual([]); + expect(provider.digest).not.toHaveBeenCalled(); + }); + + it('fails closed when installing an empty diagnostic generation throws', async () => { + const editor = createEditor(); + overrideCommands(editor, { + installWritingDiagnostics: () => { + throw new Error('command failure'); + }, + }); + const onError = vi.fn(); + const { result } = renderController({ + editor, + diagnostics: [], + digestProvider: staticDigestProvider(), + onError, + }); + + await waitFor(() => expect(result.current.status).toBe('invalid')); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'lifecycle' }), + ); + }); + + it('redacts document-envelope construction failures as revision errors', async () => { + const editor = createEditor(); + vi.spyOn(editor.state.doc, 'toJSON').mockImplementationOnce(() => { + throw new Error('private document failure'); + }); + const onError = vi.fn(); + const { result } = renderController({ + editor, + diagnostics: [diagnostic()], + digestProvider: staticDigestProvider(), + onError, + }); + + await waitFor(() => expect(result.current.status).toBe('invalid')); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'revision' }), + ); + }); + + it('silently discards a rejected digest from a superseded generation', async () => { + let rejectFirst: ((reason?: unknown) => void) | undefined; + let calls = 0; + const provider: DocumentEnvelopeDigestProvider = { + digest: vi.fn(() => { + calls += 1; + if (calls === 1) { + return new Promise((_resolve, reject) => { + rejectFirst = reject; + }); + } + return Promise.resolve(digestBytes()); + }), + }; + const editor = createEditor(); + const { result, rerender } = renderController({ + editor, + diagnostics: [diagnostic('first')], + digestProvider: provider, + }); + await waitFor(() => expect(calls).toBe(1)); + + rerender({ + editor, + diagnostics: [diagnostic('second')], + digestProvider: provider, + }); + await waitFor(() => expect(result.current.status).toBe('active')); + expect(installedIds(editor)).toEqual(['second']); + + await act(async () => { + rejectFirst?.(new Error('obsolete provider failure')); + await Promise.resolve(); + }); + expect(result.current.status).toBe('active'); + expect(installedIds(editor)).toEqual(['second']); + }); + + it('marks the captured generation stale when view state changes without a transaction event', async () => { + let resolveDigest: ((value: ArrayBuffer) => void) | undefined; + const provider: DocumentEnvelopeDigestProvider = { + digest: vi.fn( + () => + new Promise((resolve) => { + resolveDigest = resolve; + }), + ), + }; + const editor = createEditor(); + const { result } = renderController({ + editor, + diagnostics: [diagnostic()], + digestProvider: provider, + }); + await waitFor(() => expect(provider.digest).toHaveBeenCalledTimes(1)); + + act(() => { + const state = editor.state; + editor.view.updateState(state.apply(state.tr.insertText('X', 1))); + }); + await act(async () => { + resolveDigest?.(digestBytes()); + await Promise.resolve(); + }); + + await waitFor(() => expect(result.current.status).toBe('stale')); + expect(installedIds(editor)).toEqual([]); + }); + + it('rejects selectors that cannot resolve against the verified snapshot', async () => { + const editor = createEditor(); + const onError = vi.fn(); + const { result } = renderController({ + editor, + diagnostics: [ + diagnostic('out-of-range', { + selector: { + type: 'TextPositionSelector', + start: 10_000, + end: 10_001, + }, + }), + ], + digestProvider: staticDigestProvider(), + onError, + }); + + await waitFor(() => expect(result.current.status).toBe('invalid')); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'selector' }), + ); + expect(installedIds(editor)).toEqual([]); + }); + + it('fails closed when installing a verified non-empty generation throws', async () => { + const editor = createEditor(); + overrideCommands(editor, { + installWritingDiagnostics: () => { + throw new Error('command failure'); + }, + }); + const onError = vi.fn(); + const { result } = renderController({ + editor, + diagnostics: [diagnostic()], + digestProvider: staticDigestProvider(), + onError, + }); + + await waitFor(() => expect(result.current.status).toBe('invalid')); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'lifecycle' }), + ); + }); + + it('contains focus command exceptions and refuses inactive or missing actions', async () => { + const editor = createEditor(); + const { result } = renderController({ + editor, + diagnostics: [diagnostic()], + digestProvider: staticDigestProvider(), + }); + + expect(result.current.ignoreDiagnostic('diag-1')).toBeNull(); + await waitFor(() => expect(result.current.status).toBe('active')); + expect(result.current.ignoreDiagnostic('missing')).toBeNull(); + + overrideCommands(editor, { + focusWritingDiagnostic: () => { + throw new Error('focus failure'); + }, + }); + expect(result.current.focusDiagnostic('diag-1')).toBe(false); + }); + + it('does not dismiss a diagnostic when the replacement decoration install fails', async () => { + const editor = createEditor(); + const { result } = renderController({ + editor, + diagnostics: [diagnostic()], + digestProvider: staticDigestProvider(), + }); + await waitFor(() => expect(result.current.status).toBe('active')); + const generation = result.current.generation; + + overrideCommands(editor, { + installWritingDiagnostics: () => { + throw new Error('dismiss install failure'); + }, + }); + let event: ReturnType = null; + act(() => { + event = result.current.dismissDiagnostic('diag-1'); + }); + + expect(event).toBeNull(); + expect(result.current.generation).toBe(generation); + expect(result.current.diagnostics).toHaveLength(1); + expect(installedIds(editor)).toEqual(['diag-1']); + }); +}); diff --git a/src/components/useWritingDiagnosticsController.test.tsx b/src/components/useWritingDiagnosticsController.test.tsx new file mode 100644 index 00000000..dcae1a72 --- /dev/null +++ b/src/components/useWritingDiagnosticsController.test.tsx @@ -0,0 +1,453 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { Editor } from '@tiptap/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { DocumentEnvelopeDigestProvider } from '../documentEnvelopeRevision.js'; +import { writingDiagnosticsPluginKey } from '../extensions/WritingDiagnostics.js'; +import { buildExtensions } from '../extensions/kit.js'; +import { + TEXT_POSITION_PROJECTION_ID, + TEXT_POSITION_PROJECTION_VERSION, +} from '../textPositionSelectorEvidence.js'; +import type { CwlWritingDiagnostic } from '../writingDiagnostics.js'; +import { + useWritingDiagnosticsController, + type UseWritingDiagnosticsControllerOptions, +} from './useWritingDiagnosticsController.js'; + +const DIGEST_A = '11'.repeat(32); +const DIGEST_B = '22'.repeat(32); +const openEditors: Editor[] = []; + +function track(editor: Editor): Editor { + openEditors.push(editor); + return editor; +} + +function createEditor(content = '

Alpha beta gamma

'): Editor { + return track( + new Editor({ + extensions: buildExtensions(), + content, + }), + ); +} + +function revision(digestHex = DIGEST_A) { + return Object.freeze({ + algorithm: 'SHA-256' as const, + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }); +} + +function diagnostic( + overrides: Partial = {}, +): CwlWritingDiagnostic { + return { + diagnosticId: 'diag-1', + documentRevision: revision(), + textProjection: { + id: TEXT_POSITION_PROJECTION_ID, + version: TEXT_POSITION_PROJECTION_VERSION, + }, + selector: { + type: 'TextPositionSelector', + start: 0, + end: 5, + }, + categoryCode: 'host.category', + priority: 'important', + title: 'Host title', + explanation: 'Host explanation', + suggestedReplacement: 'Omega', + provenance: { + workflowId: 'workflow', + workflowVersion: '1', + judgePolicyVersion: '1', + }, + ...overrides, + }; +} + +function staticDigestProvider( + digestHex = DIGEST_A, +): DocumentEnvelopeDigestProvider { + const bytes = Uint8Array.from( + digestHex.match(/../gu)!.map((part) => Number.parseInt(part, 16)), + ); + return { + digest: vi.fn(async () => bytes.slice().buffer), + }; +} + +function deferredDigestProvider() { + const resolvers: Array<(value: ArrayBuffer) => void> = []; + const provider: DocumentEnvelopeDigestProvider = { + digest: vi.fn( + () => + new Promise((resolve) => { + resolvers.push(resolve); + }), + ), + }; + return { + provider, + resolve(index: number, digestHex = DIGEST_A) { + const bytes = Uint8Array.from( + digestHex.match(/../gu)!.map((part) => Number.parseInt(part, 16)), + ); + const resolve = resolvers[index]; + if (!resolve) throw new Error(`Missing digest resolver ${index}`); + resolve(bytes.buffer); + }, + count: () => resolvers.length, + }; +} + +function pluginDiagnostics(editor: Editor) { + return writingDiagnosticsPluginKey.getState(editor.state)?.diagnostics ?? []; +} + +function renderController( + options: UseWritingDiagnosticsControllerOptions, +) { + return renderHook( + (next: UseWritingDiagnosticsControllerOptions) => + useWritingDiagnosticsController(next), + { initialProps: options }, + ); +} + +afterEach(() => { + for (const editor of openEditors.splice(0)) { + if (!editor.isDestroyed) editor.destroy(); + } + vi.restoreAllMocks(); +}); + +describe('useWritingDiagnosticsController', () => { + it('verifies one exact snapshot before installing structural decorations', async () => { + const editor = createEditor(); + const provider = staticDigestProvider(); + const { result } = renderController({ + editor, + diagnostics: [diagnostic()], + digestProvider: provider, + }); + + await waitFor(() => expect(result.current.status).toBe('active')); + + expect(result.current.generation).toBeGreaterThanOrEqual(0); + expect(result.current.diagnostics).toHaveLength(1); + expect(result.current.diagnostics[0]).toMatchObject({ + diagnostic: { diagnosticId: 'diag-1' }, + from: 1, + to: 6, + }); + expect(pluginDiagnostics(editor)).toEqual([ + expect.objectContaining({ + diagnosticId: 'diag-1', + from: 1, + to: 6, + priority: 'important', + }), + ]); + expect(provider.digest).toHaveBeenCalledTimes(1); + }); + + it('treats omitted diagnostics as absent and performs no semantic fallback', async () => { + const editor = createEditor('

rude incorrect urgent 무례함 오류 긴급

'); + const provider = staticDigestProvider(); + const { result } = renderController({ + editor, + diagnostics: undefined, + digestProvider: provider, + }); + + await waitFor(() => expect(result.current.status).toBe('absent')); + expect(result.current.diagnostics).toEqual([]); + expect(pluginDiagnostics(editor)).toEqual([]); + expect(provider.digest).not.toHaveBeenCalled(); + }); + + it('revalidates a same-identity array when hostile members are mutated', async () => { + const editor = createEditor(); + const onError = vi.fn(); + const diagnostics: unknown[] = [diagnostic()]; + const { result, rerender } = renderController({ + editor, + diagnostics: diagnostics as readonly CwlWritingDiagnostic[], + digestProvider: staticDigestProvider(), + onError, + }); + await waitFor(() => expect(result.current.status).toBe('active')); + + const hostile = {}; + Object.defineProperty(hostile, 'diagnosticId', { + enumerable: true, + get() { + throw new Error('private getter failure'); + }, + }); + diagnostics[0] = hostile; + rerender({ + editor, + diagnostics: diagnostics as readonly CwlWritingDiagnostic[], + digestProvider: result.current.digestProvider, + onError, + }); + + await waitFor(() => expect(result.current.status).toBe('invalid')); + expect(pluginDiagnostics(editor)).toEqual([]); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'contract' }), + ); + expect(String(onError.mock.calls.at(-1)?.[0])).not.toContain( + 'private getter failure', + ); + }); + + it('rejects revision mismatches atomically without installing any range', async () => { + const editor = createEditor(); + const onError = vi.fn(); + const { result } = renderController({ + editor, + diagnostics: [diagnostic({ documentRevision: revision(DIGEST_B) })], + digestProvider: staticDigestProvider(DIGEST_A), + onError, + }); + + await waitFor(() => expect(result.current.status).toBe('invalid')); + expect(result.current.diagnostics).toEqual([]); + expect(pluginDiagnostics(editor)).toEqual([]); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'revision' }), + ); + }); + + it('rejects unsupported projections before attempting editor revision work', async () => { + const editor = createEditor(); + const provider = staticDigestProvider(); + const onError = vi.fn(); + const bad = diagnostic({ + textProjection: { + id: TEXT_POSITION_PROJECTION_ID, + version: 999, + } as unknown as CwlWritingDiagnostic['textProjection'], + }); + const { result } = renderController({ + editor, + diagnostics: [bad], + digestProvider: provider, + onError, + }); + + await waitFor(() => expect(result.current.status).toBe('invalid')); + expect(provider.digest).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'projection' }), + ); + }); + + it('contains digest-provider failures behind the redacted revision boundary', async () => { + const editor = createEditor(); + const onError = vi.fn(); + const provider: DocumentEnvelopeDigestProvider = { + digest: vi.fn(async () => { + throw new Error('private provider failure'); + }), + }; + const { result } = renderController({ + editor, + diagnostics: [diagnostic()], + digestProvider: provider, + onError, + }); + + await waitFor(() => expect(result.current.status).toBe('invalid')); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'revision' }), + ); + expect(String(onError.mock.calls.at(-1)?.[0])).not.toContain( + 'private provider failure', + ); + }); + + it('marks a generation stale when the document changes during hashing', async () => { + const editor = createEditor(); + const deferred = deferredDigestProvider(); + const { result } = renderController({ + editor, + diagnostics: [diagnostic()], + digestProvider: deferred.provider, + }); + + await waitFor(() => expect(deferred.count()).toBe(1)); + act(() => { + editor.commands.insertContent('X'); + }); + await waitFor(() => expect(result.current.status).toBe('stale')); + + await act(async () => { + deferred.resolve(0); + await Promise.resolve(); + }); + expect(result.current.status).toBe('stale'); + expect(pluginDiagnostics(editor)).toEqual([]); + }); + + it('prevents an older overlapping verification from installing after replacement', async () => { + const editor = createEditor(); + const deferred = deferredDigestProvider(); + const first = [diagnostic({ diagnosticId: 'first' })]; + const second = [diagnostic({ diagnosticId: 'second' })]; + const { result, rerender } = renderController({ + editor, + diagnostics: first, + digestProvider: deferred.provider, + }); + await waitFor(() => expect(deferred.count()).toBe(1)); + + rerender({ + editor, + diagnostics: second, + digestProvider: deferred.provider, + }); + await waitFor(() => expect(deferred.count()).toBe(2)); + + await act(async () => { + deferred.resolve(1); + await Promise.resolve(); + }); + await waitFor(() => expect(result.current.status).toBe('active')); + expect(pluginDiagnostics(editor)).toEqual([ + expect.objectContaining({ diagnosticId: 'second' }), + ]); + + await act(async () => { + deferred.resolve(0); + await Promise.resolve(); + }); + expect(pluginDiagnostics(editor)).toEqual([ + expect.objectContaining({ diagnosticId: 'second' }), + ]); + }); + + it('moves verification to a replacement editor and clears the old editor', async () => { + const firstEditor = createEditor(); + const secondEditor = createEditor(); + const provider = staticDigestProvider(); + const diagnostics = [diagnostic()]; + const { result, rerender } = renderController({ + editor: firstEditor, + diagnostics, + digestProvider: provider, + }); + await waitFor(() => expect(result.current.status).toBe('active')); + expect(pluginDiagnostics(firstEditor)).toHaveLength(1); + + rerender({ + editor: secondEditor, + diagnostics, + digestProvider: provider, + }); + await waitFor(() => { + expect(result.current.status).toBe('active'); + expect(result.current.editor).toBe(secondEditor); + }); + expect(pluginDiagnostics(firstEditor)).toEqual([]); + expect(pluginDiagnostics(secondEditor)).toHaveLength(1); + }); + + it('never installs after editor destruction while verification is pending', async () => { + const editor = createEditor(); + const deferred = deferredDigestProvider(); + const { result } = renderController({ + editor, + diagnostics: [diagnostic()], + digestProvider: deferred.provider, + }); + await waitFor(() => expect(deferred.count()).toBe(1)); + + act(() => editor.destroy()); + await act(async () => { + deferred.resolve(0); + await Promise.resolve(); + }); + + await waitFor(() => expect(result.current.status).toBe('stale')); + }); + + it('uses the latest error callback without recreating or rehashing the editor', async () => { + const editor = createEditor(); + const provider = staticDigestProvider(); + const firstError = vi.fn(); + const secondError = vi.fn(); + const diagnostics: unknown[] = [diagnostic()]; + const { result, rerender } = renderController({ + editor, + diagnostics: diagnostics as readonly CwlWritingDiagnostic[], + digestProvider: provider, + onError: firstError, + }); + await waitFor(() => expect(result.current.status).toBe('active')); + expect(provider.digest).toHaveBeenCalledTimes(1); + + rerender({ + editor, + diagnostics: diagnostics as readonly CwlWritingDiagnostic[], + digestProvider: provider, + onError: secondError, + }); + await Promise.resolve(); + expect(provider.digest).toHaveBeenCalledTimes(1); + + diagnostics[0] = diagnostic({ documentRevision: revision(DIGEST_B) }); + rerender({ + editor, + diagnostics: diagnostics as readonly CwlWritingDiagnostic[], + digestProvider: provider, + onError: secondError, + }); + await waitFor(() => expect(result.current.status).toBe('invalid')); + expect(secondError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'revision' }), + ); + expect(firstError).not.toHaveBeenCalled(); + }); + + it('contains action-callback exceptions and emits no authored text', async () => { + const editor = createEditor(); + const onAction = vi.fn(() => { + throw new Error('host callback failure'); + }); + const { result } = renderController({ + editor, + diagnostics: [diagnostic()], + digestProvider: staticDigestProvider(), + onAction, + }); + await waitFor(() => expect(result.current.status).toBe('active')); + + let actionResult: ReturnType = null; + act(() => { + actionResult = result.current.ignoreDiagnostic('diag-1'); + }); + + expect(actionResult).toEqual( + expect.objectContaining({ + action: 'ignored', + diagnosticId: 'diag-1', + categoryCode: 'host.category', + reasonCode: 'explicit', + }), + ); + expect(JSON.stringify(actionResult)).not.toContain('Host title'); + expect(JSON.stringify(actionResult)).not.toContain('Host explanation'); + expect(JSON.stringify(actionResult)).not.toContain('Omega'); + expect(pluginDiagnostics(editor)).toEqual([ + expect.objectContaining({ diagnosticId: 'diag-1' }), + ]); + expect(result.current.status).toBe('active'); + }); +}); diff --git a/src/components/useWritingDiagnosticsController.ts b/src/components/useWritingDiagnosticsController.ts new file mode 100644 index 00000000..c0179383 --- /dev/null +++ b/src/components/useWritingDiagnosticsController.ts @@ -0,0 +1,615 @@ +/** + * Revision-bound controller for host-supplied writing diagnostics. + * + * The controller validates hostile host data before reading editor state, hashes + * one immutable document snapshot, resolves every selector against that same + * snapshot, and installs only a complete verified generation. It performs no + * semantic language judgment and never calls a model, provider, network, or + * persistence service. + */ +import type { Editor } from '@tiptap/react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { createDocumentEnvelope } from '../documentEnvelope.js'; +import { + createValidatedDocumentEnvelopeRevision, + type CwlEditorDocumentRevision, + type DocumentEnvelopeDigestProvider, +} from '../documentEnvelopeRevision.js'; +import type { CwlResolvedWritingDiagnosticDecoration } from '../extensions/WritingDiagnostics.js'; +import { resolveTextPositionSelector } from '../writingDiagnosticProjection.js'; +import { + WritingDiagnosticError, + validateWritingDiagnostics, + type CwlWritingDiagnostic, +} from '../writingDiagnostics.js'; +import { useLatestRef } from './useLatestRef.js'; + +/** Stable lifecycle states exposed to built-in Inkspan diagnostic UI. */ +export type WritingDiagnosticsControllerStatus = + | 'absent' + | 'verifying' + | 'active' + | 'invalid' + | 'stale'; + +/** Privacy-minimized host-visible action classifications. */ +export type CwlWritingDiagnosticAction = + | 'applied' + | 'ignored' + | 'dismissed' + | 'requested_explanation' + | 'stale' + | 'conflict'; + +/** Stable reason codes that never embed authored or model-produced text. */ +export type CwlWritingDiagnosticActionReasonCode = + | 'explicit' + | 'document_changed' + | 'revision_mismatch' + | 'projection_mismatch' + | 'selector_invalid' + | 'verification_failed' + | 'lifecycle_ended' + | 'diagnostic_missing'; + +/** Redacted result/callback payload for one writing-diagnostic action. */ +export interface CwlWritingDiagnosticActionEvent { + readonly action: CwlWritingDiagnosticAction; + readonly reasonCode: CwlWritingDiagnosticActionReasonCode; + readonly diagnosticId: string; + readonly documentRevision: CwlEditorDocumentRevision; + readonly categoryCode: string; + readonly generation: number; +} + +/** One validated diagnostic plus its exact current ProseMirror range. */ +export interface CwlVerifiedWritingDiagnostic { + readonly diagnostic: CwlWritingDiagnostic; + readonly from: number; + readonly to: number; +} + +/** Internal hook inputs shared later by standalone and collaborative surfaces. */ +export interface UseWritingDiagnosticsControllerOptions { + readonly editor: Editor | null; + readonly diagnostics?: unknown; + readonly digestProvider?: DocumentEnvelopeDigestProvider | null; + readonly onAction?: (event: CwlWritingDiagnosticActionEvent) => void; + readonly onError?: (error: WritingDiagnosticError) => void; +} + +/** Stable controller surface consumed by the built-in panel and editor adapters. */ +export interface WritingDiagnosticsController { + readonly status: WritingDiagnosticsControllerStatus; + readonly generation: number; + readonly editor: Editor | null; + readonly diagnostics: readonly CwlVerifiedWritingDiagnostic[]; + /** Exposed only for deterministic integration tests and later adapter plumbing. */ + readonly digestProvider: DocumentEnvelopeDigestProvider | null | undefined; + readonly focusDiagnostic: (diagnosticId: string) => boolean; + readonly ignoreDiagnostic: ( + diagnosticId: string, + ) => CwlWritingDiagnosticActionEvent | null; + readonly dismissDiagnostic: ( + diagnosticId: string, + ) => CwlWritingDiagnosticActionEvent | null; + readonly requestDiagnosticExplanation: ( + diagnosticId: string, + ) => CwlWritingDiagnosticActionEvent | null; +} + +interface ControllerSnapshot { + readonly status: WritingDiagnosticsControllerStatus; + readonly generation: number; + readonly editor: Editor | null; + readonly diagnostics: readonly CwlVerifiedWritingDiagnostic[]; +} + +interface ValidProcessedInput { + readonly kind: 'valid'; + readonly rawInput: unknown; + readonly editor: Editor | null; + readonly digestProvider: DocumentEnvelopeDigestProvider | null | undefined; + readonly diagnostics: readonly CwlWritingDiagnostic[]; +} + +interface InvalidProcessedInput { + readonly kind: 'invalid'; + readonly rawInput: unknown; + readonly editor: Editor | null; + readonly digestProvider: DocumentEnvelopeDigestProvider | null | undefined; + readonly errorCode: WritingDiagnosticError['code']; +} + +interface AbsentProcessedInput { + readonly kind: 'absent'; + readonly rawInput: undefined; + readonly editor: Editor | null; + readonly digestProvider: DocumentEnvelopeDigestProvider | null | undefined; +} + +type ProcessedInput = + | ValidProcessedInput + | InvalidProcessedInput + | AbsentProcessedInput; + +const EMPTY_VERIFIED_DIAGNOSTICS = Object.freeze( + [] as CwlVerifiedWritingDiagnostic[], +); + +function snapshot( + status: WritingDiagnosticsControllerStatus, + generation: number, + editor: Editor | null, + diagnostics: readonly CwlVerifiedWritingDiagnostic[] = EMPTY_VERIFIED_DIAGNOSTICS, +): ControllerSnapshot { + return Object.freeze({ status, generation, editor, diagnostics }); +} + +function notifyError( + callback: ((error: WritingDiagnosticError) => void) | undefined, + error: WritingDiagnosticError, +): void { + try { + callback?.(error); + } catch { + // Host callbacks are advisory observers and never own editor determinism. + } +} + +function notifyAction( + callback: ((event: CwlWritingDiagnosticActionEvent) => void) | undefined, + event: CwlWritingDiagnosticActionEvent, +): void { + try { + callback?.(event); + } catch { + // Host callback failure must not roll back or corrupt local controller state. + } +} + +function revisionsEqual( + left: CwlEditorDocumentRevision, + right: CwlEditorDocumentRevision, +): boolean { + return ( + left.algorithm === right.algorithm && + left.digestHex === right.digestHex && + left.strongEntityTag === right.strongEntityTag + ); +} + +function diagnosticsEqual( + left: readonly CwlWritingDiagnostic[], + right: readonly CwlWritingDiagnostic[], +): boolean { + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index += 1) { + const a = left[index]!; + const b = right[index]!; + if ( + a.diagnosticId !== b.diagnosticId || + !revisionsEqual(a.documentRevision, b.documentRevision) || + a.textProjection.id !== b.textProjection.id || + a.textProjection.version !== b.textProjection.version || + a.selector.type !== b.selector.type || + a.selector.start !== b.selector.start || + a.selector.end !== b.selector.end || + a.categoryCode !== b.categoryCode || + a.priority !== b.priority || + a.title !== b.title || + a.explanation !== b.explanation || + a.suggestedReplacement !== b.suggestedReplacement || + a.confidence !== b.confidence || + a.provenance.workflowId !== b.provenance.workflowId || + a.provenance.workflowVersion !== b.provenance.workflowVersion || + a.provenance.judgePolicyVersion !== b.provenance.judgePolicyVersion || + a.provenance.orchestrationMode !== b.provenance.orchestrationMode + ) { + return false; + } + } + return true; +} + +function sameProcessedInput( + previous: ProcessedInput | null, + next: ProcessedInput, +): boolean { + if ( + previous === null || + previous.kind !== next.kind || + previous.editor !== next.editor || + previous.digestProvider !== next.digestProvider + ) { + return false; + } + if (previous.kind === 'valid' && next.kind === 'valid') { + return diagnosticsEqual(previous.diagnostics, next.diagnostics); + } + if (previous.kind === 'invalid' && next.kind === 'invalid') { + return previous.errorCode === next.errorCode; + } + return true; +} + +function clearEditorDiagnostics(editor: Editor | null): void { + if (editor === null || editor.isDestroyed) return; + try { + editor.commands.clearWritingDiagnostics(); + } catch { + // A destroyed/replaced host view cannot make stale decorations authoritative. + } +} + +function toDecoration( + item: CwlVerifiedWritingDiagnostic, +): CwlResolvedWritingDiagnosticDecoration { + return Object.freeze({ + diagnosticId: item.diagnostic.diagnosticId, + from: item.from, + to: item.to, + priority: item.diagnostic.priority, + }); +} + +function projectionErrorToDiagnosticError(error: unknown): WritingDiagnosticError { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: unknown }).code === 'projection' + ) { + return new WritingDiagnosticError('projection'); + } + return new WritingDiagnosticError('selector'); +} + +/** + * Bind one host diagnostic prop to one exact editor revision and generation. + * + * Every committed render revalidates the hostile input structurally. This is + * intentional: a caller may mutate an array in place, so reference identity is + * not accepted as proof that previously validated members are unchanged. A + * bounded detached comparison suppresses duplicate hash work on ordinary React + * rerenders and callback replacement. + */ +export function useWritingDiagnosticsController( + options: UseWritingDiagnosticsControllerOptions, +): WritingDiagnosticsController { + const { editor, diagnostics, digestProvider, onAction, onError } = options; + const actionRef = useLatestRef(onAction); + const errorRef = useLatestRef(onError); + const generationRef = useRef(0); + const processedRef = useRef(null); + const [current, setCurrent] = useState(() => + snapshot('absent', 0, editor), + ); + const currentRef = useRef(current); + currentRef.current = current; + + const publish = useCallback((next: ControllerSnapshot): void => { + currentRef.current = next; + setCurrent(next); + }, []); + + useEffect(() => { + if (editor === null) return undefined; + const handleTransaction = ({ transaction }: { transaction: { docChanged: boolean } }) => { + if (!transaction.docChanged) return; + const active = currentRef.current; + if (active.editor !== editor) return; + if (active.status === 'active' || active.status === 'verifying') { + const nextGeneration = generationRef.current + 1; + generationRef.current = nextGeneration; + publish(snapshot('stale', nextGeneration, editor)); + } + }; + editor.on('transaction', handleTransaction); + return () => { + editor.off('transaction', handleTransaction); + clearEditorDiagnostics(editor); + }; + }, [editor, publish]); + + useEffect(() => { + let processed: ProcessedInput; + + if (diagnostics === undefined) { + processed = { + kind: 'absent', + rawInput: undefined, + editor, + digestProvider, + }; + } else { + try { + const validated = validateWritingDiagnostics(diagnostics); + processed = { + kind: 'valid', + rawInput: diagnostics, + editor, + digestProvider, + diagnostics: validated, + }; + } catch (error) { + const validationError = + error instanceof WritingDiagnosticError + ? error + : new WritingDiagnosticError('contract'); + processed = { + kind: 'invalid', + rawInput: diagnostics, + editor, + digestProvider, + errorCode: validationError.code, + }; + } + } + + if (sameProcessedInput(processedRef.current, processed)) { + return; + } + + const previous = processedRef.current; + processedRef.current = processed; + const nextGeneration = generationRef.current + 1; + generationRef.current = nextGeneration; + + if (previous?.editor !== editor) { + clearEditorDiagnostics(previous?.editor ?? null); + } + clearEditorDiagnostics(editor); + + if (processed.kind === 'absent') { + publish(snapshot('absent', nextGeneration, editor)); + return; + } + + if (processed.kind === 'invalid') { + publish(snapshot('invalid', nextGeneration, editor)); + notifyError( + errorRef.current, + new WritingDiagnosticError(processed.errorCode), + ); + return; + } + + if (editor === null || editor.isDestroyed) { + publish(snapshot('stale', nextGeneration, editor)); + return; + } + + if (processed.diagnostics.length === 0) { + let installed = false; + try { + installed = editor.commands.installWritingDiagnostics(nextGeneration, []); + } catch { + installed = false; + } + if (!installed) { + const error = new WritingDiagnosticError('lifecycle'); + publish(snapshot('invalid', nextGeneration, editor)); + notifyError(errorRef.current, error); + return; + } + publish(snapshot('active', nextGeneration, editor)); + return; + } + + publish(snapshot('verifying', nextGeneration, editor)); + const capturedState = editor.state; + const capturedDocument = capturedState.doc; + let envelope; + try { + envelope = createDocumentEnvelope(capturedDocument.toJSON()); + } catch { + const error = new WritingDiagnosticError('revision'); + publish(snapshot('invalid', nextGeneration, editor)); + notifyError(errorRef.current, error); + return; + } + + void (async () => { + let actualRevision: CwlEditorDocumentRevision; + try { + actualRevision = await createValidatedDocumentEnvelopeRevision( + envelope, + digestProvider, + ); + } catch { + if (generationRef.current !== nextGeneration) return; + const error = new WritingDiagnosticError('revision'); + publish(snapshot('invalid', nextGeneration, editor)); + notifyError(errorRef.current, error); + return; + } + + if (generationRef.current !== nextGeneration) return; + if (editor.isDestroyed) { + publish(snapshot('stale', nextGeneration, editor)); + return; + } + if (!editor.state.doc.eq(capturedDocument)) { + const staleGeneration = nextGeneration + 1; + generationRef.current = staleGeneration; + publish(snapshot('stale', staleGeneration, editor)); + return; + } + + for (const diagnostic of processed.diagnostics) { + if (!revisionsEqual(diagnostic.documentRevision, actualRevision)) { + const error = new WritingDiagnosticError('revision'); + publish(snapshot('invalid', nextGeneration, editor)); + notifyError(errorRef.current, error); + return; + } + } + + const verified: CwlVerifiedWritingDiagnostic[] = []; + try { + for (const diagnostic of processed.diagnostics) { + const range = resolveTextPositionSelector( + capturedDocument, + diagnostic.selector, + diagnostic.textProjection, + ); + verified.push( + Object.freeze({ + diagnostic, + from: range.from, + to: range.to, + }), + ); + } + } catch (error) { + const publicError = projectionErrorToDiagnosticError(error); + publish(snapshot('invalid', nextGeneration, editor)); + notifyError(errorRef.current, publicError); + return; + } + + if (generationRef.current !== nextGeneration) return; + if (editor.isDestroyed) { + publish(snapshot('stale', nextGeneration, editor)); + return; + } + const frozenVerified = Object.freeze(verified); + let installed = false; + try { + installed = editor.commands.installWritingDiagnostics( + nextGeneration, + frozenVerified.map(toDecoration), + ); + } catch { + installed = false; + } + if (!installed) { + const error = new WritingDiagnosticError('lifecycle'); + publish(snapshot('invalid', nextGeneration, editor)); + notifyError(errorRef.current, error); + return; + } + publish(snapshot('active', nextGeneration, editor, frozenVerified)); + })(); + }); + + const focusDiagnostic = useCallback((diagnosticId: string): boolean => { + const active = currentRef.current; + if ( + active.status !== 'active' || + active.editor === null || + active.editor.isDestroyed || + !active.diagnostics.some( + (candidate) => candidate.diagnostic.diagnosticId === diagnosticId, + ) + ) { + return false; + } + try { + return active.editor.commands.focusWritingDiagnostic( + active.generation, + diagnosticId, + ); + } catch { + return false; + } + }, []); + + const consumeDiagnostic = useCallback( + ( + diagnosticId: string, + action: Extract< + CwlWritingDiagnosticAction, + 'ignored' | 'dismissed' | 'requested_explanation' + >, + ): CwlWritingDiagnosticActionEvent | null => { + const active = currentRef.current; + if ( + active.status !== 'active' || + active.editor === null || + active.editor.isDestroyed + ) { + return null; + } + const target = active.diagnostics.find( + (candidate) => candidate.diagnostic.diagnosticId === diagnosticId, + ); + if (!target) return null; + + if (action !== 'dismissed') { + const event = Object.freeze({ + action, + reasonCode: 'explicit' as const, + diagnosticId: target.diagnostic.diagnosticId, + documentRevision: target.diagnostic.documentRevision, + categoryCode: target.diagnostic.categoryCode, + generation: active.generation, + }); + notifyAction(actionRef.current, event); + return event; + } + + const remaining = active.diagnostics.filter( + (candidate) => candidate !== target, + ); + const nextGeneration = generationRef.current + 1; + let installed = false; + try { + installed = active.editor.commands.installWritingDiagnostics( + nextGeneration, + remaining.map(toDecoration), + ); + } catch { + installed = false; + } + if (!installed) return null; + + generationRef.current = nextGeneration; + const next = snapshot( + 'active', + nextGeneration, + active.editor, + Object.freeze([...remaining]), + ); + publish(next); + const event = Object.freeze({ + action, + reasonCode: 'explicit' as const, + diagnosticId: target.diagnostic.diagnosticId, + documentRevision: target.diagnostic.documentRevision, + categoryCode: target.diagnostic.categoryCode, + generation: nextGeneration, + }); + notifyAction(actionRef.current, event); + return event; + }, + [actionRef, publish], + ); + + const ignoreDiagnostic = useCallback( + (diagnosticId: string) => consumeDiagnostic(diagnosticId, 'ignored'), + [consumeDiagnostic], + ); + const dismissDiagnostic = useCallback( + (diagnosticId: string) => consumeDiagnostic(diagnosticId, 'dismissed'), + [consumeDiagnostic], + ); + const requestDiagnosticExplanation = useCallback( + (diagnosticId: string) => + consumeDiagnostic(diagnosticId, 'requested_explanation'), + [consumeDiagnostic], + ); + + return Object.freeze({ + status: current.status, + generation: current.generation, + editor: current.editor, + diagnostics: current.diagnostics, + digestProvider, + focusDiagnostic, + ignoreDiagnostic, + dismissDiagnostic, + requestDiagnosticExplanation, + }); +}