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,
+ });
+}