From 3d7f51c81e4bd6308a89ebd2b912b394fc37068d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 12:37:34 +0900 Subject: [PATCH 01/26] test(diagnostics): define revision-bound controller contract --- .../writing-diagnostics-controller-tdd.yml | 37 ++ .../useWritingDiagnosticsController.test.tsx | 451 ++++++++++++++++++ 2 files changed, 488 insertions(+) create mode 100644 .github/workflows/writing-diagnostics-controller-tdd.yml create mode 100644 src/components/useWritingDiagnosticsController.test.tsx diff --git a/.github/workflows/writing-diagnostics-controller-tdd.yml b/.github/workflows/writing-diagnostics-controller-tdd.yml new file mode 100644 index 00000000..58aba996 --- /dev/null +++ b/.github/workflows/writing-diagnostics-controller-tdd.yml @@ -0,0 +1,37 @@ +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: 15 + 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 + - name: Typecheck controller and public action contracts + run: pnpm typecheck diff --git a/src/components/useWritingDiagnosticsController.test.tsx b/src/components/useWritingDiagnosticsController.test.tsx new file mode 100644 index 00000000..26f13e84 --- /dev/null +++ b/src/components/useWritingDiagnosticsController.test.tsx @@ -0,0 +1,451 @@ +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 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; + 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(result.current.status).toBe('active'); + }); +}); From 6326860f7b40030ace84f2c178f9eb60b69f7b53 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 12:39:55 +0900 Subject: [PATCH 02/26] feat(diagnostics): bind diagnostics to exact editor revisions --- .../useWritingDiagnosticsController.ts | 598 ++++++++++++++++++ 1 file changed, 598 insertions(+) create mode 100644 src/components/useWritingDiagnosticsController.ts diff --git a/src/components/useWritingDiagnosticsController.ts b/src/components/useWritingDiagnosticsController.ts new file mode 100644 index 00000000..c4c5c2f9 --- /dev/null +++ b/src/components/useWritingDiagnosticsController.ts @@ -0,0 +1,598 @@ +/** + * 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.rawInput !== next.rawInput || + 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; + let validationError: WritingDiagnosticError | null = null; + + 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) { + 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') { + const error = validationError ?? new WritingDiagnosticError(processed.errorCode); + publish(snapshot('invalid', nextGeneration, editor)); + notifyError(errorRef.current, error); + 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 || editor.isDestroyed) 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; + + 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, + }); +} From d617041f749d5b2340967de2528dca7ff923b795 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 12:42:55 +0900 Subject: [PATCH 03/26] test(diagnostics): keep controller fixtures type-safe --- src/components/useWritingDiagnosticsController.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/useWritingDiagnosticsController.test.tsx b/src/components/useWritingDiagnosticsController.test.tsx index 26f13e84..0b7a71d4 100644 --- a/src/components/useWritingDiagnosticsController.test.tsx +++ b/src/components/useWritingDiagnosticsController.test.tsx @@ -233,7 +233,7 @@ describe('useWritingDiagnosticsController', () => { textProjection: { id: TEXT_POSITION_PROJECTION_ID, version: 999, - } as CwlWritingDiagnostic['textProjection'], + } as unknown as CwlWritingDiagnostic['textProjection'], }); const { result } = renderController({ editor, @@ -429,12 +429,12 @@ describe('useWritingDiagnosticsController', () => { }); await waitFor(() => expect(result.current.status).toBe('active')); - let actionResult: ReturnType; + let actionResult: ReturnType = null; act(() => { actionResult = result.current.ignoreDiagnostic('diag-1'); }); - expect(actionResult!).toEqual( + expect(actionResult).toEqual( expect.objectContaining({ action: 'ignored', diagnosticId: 'diag-1', From 6bf9a185f1b2bcc79af1c51254d7125e9b34be9f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 12:45:10 +0900 Subject: [PATCH 04/26] test(diagnostics): enforce controller production coverage --- .github/workflows/writing-diagnostics-controller-tdd.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/writing-diagnostics-controller-tdd.yml b/.github/workflows/writing-diagnostics-controller-tdd.yml index 58aba996..ca8540a9 100644 --- a/.github/workflows/writing-diagnostics-controller-tdd.yml +++ b/.github/workflows/writing-diagnostics-controller-tdd.yml @@ -19,7 +19,7 @@ env: jobs: focused-controller: runs-on: ubuntu-24.04 - timeout-minutes: 15 + timeout-minutes: 20 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -33,5 +33,7 @@ jobs: - run: pnpm install --frozen-lockfile - name: Run revision-bound controller contract tests run: pnpm exec vitest run src/components/useWritingDiagnosticsController.test.tsx + - name: Prove complete owned production coverage + run: pnpm coverage - name: Typecheck controller and public action contracts run: pnpm typecheck From 7d433081668c033ed6f5457228f92779c0294c39 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 12:48:30 +0900 Subject: [PATCH 05/26] test(diagnostics): surface exact controller coverage gaps --- .../writing-diagnostics-controller-tdd.yml | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/.github/workflows/writing-diagnostics-controller-tdd.yml b/.github/workflows/writing-diagnostics-controller-tdd.yml index ca8540a9..e6592d84 100644 --- a/.github/workflows/writing-diagnostics-controller-tdd.yml +++ b/.github/workflows/writing-diagnostics-controller-tdd.yml @@ -34,6 +34,32 @@ jobs: - name: Run revision-bound controller contract tests run: pnpm exec vitest run src/components/useWritingDiagnosticsController.test.tsx - name: Prove complete owned production coverage - run: pnpm coverage + shell: bash + run: | + set +e + pnpm exec vitest run --coverage --coverage.reporter=json + status=$? + node --input-type=module <<'NODE' + import fs from 'node:fs'; + const report = JSON.parse(fs.readFileSync('coverage/coverage-final.json', 'utf8')); + const entry = Object.entries(report).find(([path]) => path.endsWith('/src/components/useWritingDiagnosticsController.ts')); + if (!entry) { + console.log('::error title=Controller coverage gaps::controller coverage entry missing'); + process.exit(0); + } + const [, file] = entry; + const statements = Object.entries(file.s).filter(([, count]) => count === 0).map(([id]) => file.statementMap[id].start.line); + const functions = Object.entries(file.f).filter(([, count]) => count === 0).map(([id]) => file.fnMap[id].loc.start.line); + const branches = []; + for (const [id, counts] of Object.entries(file.b)) { + counts.forEach((count, index) => { + if (count === 0) branches.push(`${file.branchMap[id].loc.start.line}:${index}`); + }); + } + if (statements.length || functions.length || branches.length) { + console.log(`::error title=Controller coverage gaps::statements=${[...new Set(statements)].join(',')}; functions=${[...new Set(functions)].join(',')}; branches=${branches.join(',')}`); + } + NODE + exit "$status" - name: Typecheck controller and public action contracts run: pnpm typecheck From 5999ae7017a1d5c65e4ab1c24848f3058e157889 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 12:52:14 +0900 Subject: [PATCH 06/26] test(diagnostics): preserve feedback presentation semantics --- ...tingDiagnosticsController.actions.test.tsx | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 src/components/useWritingDiagnosticsController.actions.test.tsx diff --git a/src/components/useWritingDiagnosticsController.actions.test.tsx b/src/components/useWritingDiagnosticsController.actions.test.tsx new file mode 100644 index 00000000..1848e899 --- /dev/null +++ b/src/components/useWritingDiagnosticsController.actions.test.tsx @@ -0,0 +1,183 @@ +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 { result } = renderHook(() => + useWritingDiagnosticsController({ + editor, + diagnostics: [diagnostic()], + digestProvider: digestProvider(), + 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 { result } = renderHook(() => + useWritingDiagnosticsController({ + editor, + diagnostics: [diagnostic()], + digestProvider: digestProvider(), + 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 { result } = renderHook(() => + useWritingDiagnosticsController({ + editor, + diagnostics: [diagnostic()], + digestProvider: digestProvider(), + }), + ); + 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 { result } = renderHook(() => + useWritingDiagnosticsController({ + editor, + diagnostics: [diagnostic()], + digestProvider: digestProvider(), + }), + ); + await waitFor(() => expect(result.current.status).toBe('active')); + + expect(result.current.focusDiagnostic('missing')).toBe(false); + expect(result.current.focusDiagnostic('diag-1')).toBe(true); + }); +}); From e378fbac54457b96c648bef6249b5a3dbe7a95b3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 14:07:55 +0900 Subject: [PATCH 07/26] test(diagnostics): execute feedback action contract --- .github/workflows/writing-diagnostics-controller-tdd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/writing-diagnostics-controller-tdd.yml b/.github/workflows/writing-diagnostics-controller-tdd.yml index e6592d84..61fcd547 100644 --- a/.github/workflows/writing-diagnostics-controller-tdd.yml +++ b/.github/workflows/writing-diagnostics-controller-tdd.yml @@ -32,7 +32,7 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - name: Run revision-bound controller contract tests - run: pnpm exec vitest run src/components/useWritingDiagnosticsController.test.tsx + run: pnpm exec vitest run src/components/useWritingDiagnosticsController.test.tsx src/components/useWritingDiagnosticsController.actions.test.tsx - name: Prove complete owned production coverage shell: bash run: | From 497de78a10c2d1d9dea3f7314bf785fa79c9d3a4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 14:08:57 +0900 Subject: [PATCH 08/26] fix(diagnostics): preserve advisory feedback presentation --- src/components/useWritingDiagnosticsController.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/components/useWritingDiagnosticsController.ts b/src/components/useWritingDiagnosticsController.ts index c4c5c2f9..3ca47782 100644 --- a/src/components/useWritingDiagnosticsController.ts +++ b/src/components/useWritingDiagnosticsController.ts @@ -533,6 +533,19 @@ export function useWritingDiagnosticsController( ); 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, ); From 5c315f0153f0fb071b4fa4105b73e46a8548d691 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 14:12:12 +0900 Subject: [PATCH 09/26] test(diagnostics): keep digest fixture identity stable --- .../useWritingDiagnosticsController.actions.test.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/components/useWritingDiagnosticsController.actions.test.tsx b/src/components/useWritingDiagnosticsController.actions.test.tsx index 1848e899..5318faf2 100644 --- a/src/components/useWritingDiagnosticsController.actions.test.tsx +++ b/src/components/useWritingDiagnosticsController.actions.test.tsx @@ -73,11 +73,12 @@ describe('writing diagnostic feedback actions', () => { const editor = createEditor(); const before = editor.getJSON(); const onAction = vi.fn(); + const provider = digestProvider(); const { result } = renderHook(() => useWritingDiagnosticsController({ editor, diagnostics: [diagnostic()], - digestProvider: digestProvider(), + digestProvider: provider, onAction, }), ); @@ -106,11 +107,12 @@ describe('writing diagnostic feedback actions', () => { const editor = createEditor(); const before = editor.getJSON(); const onAction = vi.fn(); + const provider = digestProvider(); const { result } = renderHook(() => useWritingDiagnosticsController({ editor, diagnostics: [diagnostic()], - digestProvider: digestProvider(), + digestProvider: provider, onAction, }), ); @@ -140,11 +142,12 @@ describe('writing diagnostic feedback actions', () => { it('dismisses only local presentation and never mutates authored content', async () => { const editor = createEditor(); const before = editor.getJSON(); + const provider = digestProvider(); const { result } = renderHook(() => useWritingDiagnosticsController({ editor, diagnostics: [diagnostic()], - digestProvider: digestProvider(), + digestProvider: provider, }), ); await waitFor(() => expect(result.current.status).toBe('active')); @@ -168,11 +171,12 @@ describe('writing diagnostic feedback actions', () => { it('focuses only an installed current diagnostic', async () => { const editor = createEditor(); + const provider = digestProvider(); const { result } = renderHook(() => useWritingDiagnosticsController({ editor, diagnostics: [diagnostic()], - digestProvider: digestProvider(), + digestProvider: provider, }), ); await waitFor(() => expect(result.current.status).toBe('active')); From d4481f270680c20317d8338beef770b13d72b230 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 14:15:01 +0900 Subject: [PATCH 10/26] test(diagnostics): stabilize hostile input fixture identity --- .../useWritingDiagnosticsController.actions.test.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/components/useWritingDiagnosticsController.actions.test.tsx b/src/components/useWritingDiagnosticsController.actions.test.tsx index 5318faf2..75522e43 100644 --- a/src/components/useWritingDiagnosticsController.actions.test.tsx +++ b/src/components/useWritingDiagnosticsController.actions.test.tsx @@ -74,10 +74,11 @@ describe('writing diagnostic feedback actions', () => { const before = editor.getJSON(); const onAction = vi.fn(); const provider = digestProvider(); + const diagnostics = [diagnostic()]; const { result } = renderHook(() => useWritingDiagnosticsController({ editor, - diagnostics: [diagnostic()], + diagnostics, digestProvider: provider, onAction, }), @@ -108,10 +109,11 @@ describe('writing diagnostic feedback actions', () => { const before = editor.getJSON(); const onAction = vi.fn(); const provider = digestProvider(); + const diagnostics = [diagnostic()]; const { result } = renderHook(() => useWritingDiagnosticsController({ editor, - diagnostics: [diagnostic()], + diagnostics, digestProvider: provider, onAction, }), @@ -143,10 +145,11 @@ describe('writing diagnostic feedback actions', () => { const editor = createEditor(); const before = editor.getJSON(); const provider = digestProvider(); + const diagnostics = [diagnostic()]; const { result } = renderHook(() => useWritingDiagnosticsController({ editor, - diagnostics: [diagnostic()], + diagnostics, digestProvider: provider, }), ); @@ -172,10 +175,11 @@ describe('writing diagnostic feedback actions', () => { it('focuses only an installed current diagnostic', async () => { const editor = createEditor(); const provider = digestProvider(); + const diagnostics = [diagnostic()]; const { result } = renderHook(() => useWritingDiagnosticsController({ editor, - diagnostics: [diagnostic()], + diagnostics, digestProvider: provider, }), ); From c5361d2c2128fa1324c26edcfd31d99e4a8127ac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 14:23:15 +0900 Subject: [PATCH 11/26] test(diagnostics): align ignore feedback with advisory contract --- src/components/useWritingDiagnosticsController.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/useWritingDiagnosticsController.test.tsx b/src/components/useWritingDiagnosticsController.test.tsx index 0b7a71d4..dcae1a72 100644 --- a/src/components/useWritingDiagnosticsController.test.tsx +++ b/src/components/useWritingDiagnosticsController.test.tsx @@ -445,7 +445,9 @@ describe('useWritingDiagnosticsController', () => { 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(pluginDiagnostics(editor)).toEqual([ + expect.objectContaining({ diagnosticId: 'diag-1' }), + ]); expect(result.current.status).toBe('active'); }); }); From 6470f8bfd51a7689ee1af01061be3000e739cfa5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 16:37:03 +0900 Subject: [PATCH 12/26] test(diagnostics): exercise controller failure boundaries --- ...ingDiagnosticsController.coverage.test.tsx | 372 ++++++++++++++++++ 1 file changed, 372 insertions(+) create mode 100644 src/components/useWritingDiagnosticsController.coverage.test.tsx diff --git a/src/components/useWritingDiagnosticsController.coverage.test.tsx b/src/components/useWritingDiagnosticsController.coverage.test.tsx new file mode 100644 index 00000000..4c1d0c71 --- /dev/null +++ b/src/components/useWritingDiagnosticsController.coverage.test.tsx @@ -0,0 +1,372 @@ +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 the verified document no longer compares equal', async () => { + const editor = createEditor(); + vi.spyOn(editor.state.doc, 'eq').mockReturnValue(false); + const { result } = renderController({ + editor, + diagnostics: [diagnostic()], + digestProvider: staticDigestProvider(), + }); + + 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']); + }); +}); From 0bebc700009a3535d46e212e1eac3444280f133a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 16:40:24 +0900 Subject: [PATCH 13/26] test(diagnostics): stabilize defensive coverage probes --- ...ingDiagnosticsController.coverage.test.tsx | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/components/useWritingDiagnosticsController.coverage.test.tsx b/src/components/useWritingDiagnosticsController.coverage.test.tsx index 4c1d0c71..ae83620d 100644 --- a/src/components/useWritingDiagnosticsController.coverage.test.tsx +++ b/src/components/useWritingDiagnosticsController.coverage.test.tsx @@ -134,12 +134,12 @@ describe('writing diagnostics controller defensive coverage', () => { diagnostics, digestProvider: provider, }); - await waitFor(() => expect(result.current.diagnostics)).toHaveLength(1); + 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); + await waitFor(() => expect(result.current.diagnostics).toHaveLength(2)); expect(installedIds(editor)).toEqual(['first', 'second']); expect(provider.digest).toHaveBeenCalledTimes(2); }); @@ -265,13 +265,31 @@ describe('writing diagnostics controller defensive coverage', () => { expect(installedIds(editor)).toEqual(['second']); }); - it('marks the captured generation stale when the verified document no longer compares equal', async () => { + 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(); - vi.spyOn(editor.state.doc, 'eq').mockReturnValue(false); const { result } = renderController({ editor, diagnostics: [diagnostic()], - digestProvider: staticDigestProvider(), + 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')); From 4fedd0d98ac11f8d9e986cf38f5f873d3f823a58 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 16:45:33 +0900 Subject: [PATCH 14/26] test(diagnostics): cover controller boundary normalization --- ...ingDiagnosticsController.boundary.test.tsx | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 src/components/useWritingDiagnosticsController.boundary.test.tsx diff --git a/src/components/useWritingDiagnosticsController.boundary.test.tsx b/src/components/useWritingDiagnosticsController.boundary.test.tsx new file mode 100644 index 00000000..ff8b25ee --- /dev/null +++ b/src/components/useWritingDiagnosticsController.boundary.test.tsx @@ -0,0 +1,173 @@ +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, +})); + +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' }); + } + return actual.resolveTextPositionSelector(...args); + }, + }; +}); + +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; + 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 onError = vi.fn(); + const { result } = renderHook(() => + useWritingDiagnosticsController({ + editor, + diagnostics: [diagnostic()], + digestProvider: digestProvider(), + 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 onError = vi.fn(); + const { result } = renderHook(() => + useWritingDiagnosticsController({ + editor, + diagnostics: [diagnostic()], + digestProvider: digestProvider(), + onError, + }), + ); + + await waitFor(() => expect(result.current.status).toBe('invalid')); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'projection' }), + ); + }); + + it('ignores a stale editor transaction if a retired listener fires after replacement', async () => { + const first = createEditor(); + const second = createEditor(); + vi.spyOn(first, 'off').mockImplementation(() => first); + + const { result, rerender } = renderHook( + ({ editor }: { editor: Editor }) => + useWritingDiagnosticsController({ + editor, + diagnostics: [], + digestProvider: digestProvider(), + }), + { 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); + }); +}); From c6216e9b886068ac1395a933bded9ed64b6dbd68 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:13:43 +0900 Subject: [PATCH 15/26] ci(diagnostics): annotate controller coverage gaps --- .../writing-diagnostics-controller-tdd.yml | 69 ++++++++++++++++--- 1 file changed, 58 insertions(+), 11 deletions(-) diff --git a/.github/workflows/writing-diagnostics-controller-tdd.yml b/.github/workflows/writing-diagnostics-controller-tdd.yml index 61fcd547..96f405a4 100644 --- a/.github/workflows/writing-diagnostics-controller-tdd.yml +++ b/.github/workflows/writing-diagnostics-controller-tdd.yml @@ -41,24 +41,71 @@ jobs: status=$? node --input-type=module <<'NODE' import fs from 'node:fs'; - const report = JSON.parse(fs.readFileSync('coverage/coverage-final.json', 'utf8')); - const entry = Object.entries(report).find(([path]) => path.endsWith('/src/components/useWritingDiagnosticsController.ts')); + + const sourcePath = 'src/components/useWritingDiagnosticsController.ts'; + 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 title=Controller coverage gaps::controller coverage entry missing'); + console.log( + `::error file=${sourcePath},line=1::Controller coverage entry is missing.`, + ); process.exit(0); } + const [, file] = entry; - const statements = Object.entries(file.s).filter(([, count]) => count === 0).map(([id]) => file.statementMap[id].start.line); - const functions = Object.entries(file.f).filter(([, count]) => count === 0).map(([id]) => file.fnMap[id].loc.start.line); - const branches = []; - for (const [id, counts] of Object.entries(file.b)) { + 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) branches.push(`${file.branchMap[id].loc.start.line}:${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.`, + ); }); } - if (statements.length || functions.length || branches.length) { - console.log(`::error title=Controller coverage gaps::statements=${[...new Set(statements)].join(',')}; functions=${[...new Set(functions)].join(',')}; branches=${branches.join(',')}`); - } NODE exit "$status" - name: Typecheck controller and public action contracts From d997d9b270f517837ba7de9b75b89d8ba445ceaf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:19:38 +0900 Subject: [PATCH 16/26] ci(diagnostics): bound controller coverage workload --- .../writing-diagnostics-controller-tdd.yml | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/workflows/writing-diagnostics-controller-tdd.yml b/.github/workflows/writing-diagnostics-controller-tdd.yml index 96f405a4..3339193e 100644 --- a/.github/workflows/writing-diagnostics-controller-tdd.yml +++ b/.github/workflows/writing-diagnostics-controller-tdd.yml @@ -32,17 +32,35 @@ jobs: 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 + 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 - name: Prove complete owned production coverage shell: bash run: | set +e - pnpm exec vitest run --coverage --coverage.reporter=json + 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 \ + --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'), ); From b5cc856f6ae1abc87d52f2f24d90fad46de2cbe0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:24:24 +0900 Subject: [PATCH 17/26] ci(diagnostics): repair inline controller input stability --- .../controller-inline-input-fix-once.yml | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 .github/workflows/controller-inline-input-fix-once.yml diff --git a/.github/workflows/controller-inline-input-fix-once.yml b/.github/workflows/controller-inline-input-fix-once.yml new file mode 100644 index 00000000..2c96175d --- /dev/null +++ b/.github/workflows/controller-inline-input-fix-once.yml @@ -0,0 +1,75 @@ +name: Controller Inline Input Fix Once + +on: + push: + branches: + - feat/writing-diagnostics-controller + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: controller-inline-input-fix-once-${{ github.ref }} + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + repair-inline-input-stability: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: true + - 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: Remove raw input identity from the detached equality gate + run: | + set -euo pipefail + python <<'PY' + from pathlib import Path + + path = Path('src/components/useWritingDiagnosticsController.ts') + source = path.read_text(encoding='utf-8') + old = """ previous.kind !== next.kind || + previous.rawInput !== next.rawInput || + previous.editor !== next.editor || + """ + new = """ previous.kind !== next.kind || + previous.editor !== next.editor || + """ + if source.count(old) != 1: + raise SystemExit('expected exactly one raw-input identity gate') + path.write_text(source.replace(old, new), encoding='utf-8') + PY + - name: Verify inline values, defensive boundaries, and public types + run: | + set -euo pipefail + 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 + pnpm typecheck + - name: Publish the validated source repair and remove this workflow + env: + TARGET_BRANCH: feat/writing-diagnostics-controller + run: | + set -euo pipefail + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add src/components/useWritingDiagnosticsController.ts + git rm .github/workflows/controller-inline-input-fix-once.yml + git diff --cached --check + git commit -m 'fix(diagnostics): stabilize inline controller inputs' + git push origin "HEAD:${TARGET_BRANCH}" From 670f979bf72e1343c1529c0dad1817fe73a1cebf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:26:22 +0900 Subject: [PATCH 18/26] test(diagnostics): stabilize defensive digest provider --- .../useWritingDiagnosticsController.boundary.test.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/useWritingDiagnosticsController.boundary.test.tsx b/src/components/useWritingDiagnosticsController.boundary.test.tsx index ff8b25ee..404b33ea 100644 --- a/src/components/useWritingDiagnosticsController.boundary.test.tsx +++ b/src/components/useWritingDiagnosticsController.boundary.test.tsx @@ -106,12 +106,13 @@ 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: digestProvider(), + digestProvider: provider, onError, }), ); @@ -125,12 +126,13 @@ describe('writing diagnostics controller defensive dependency boundaries', () => 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: digestProvider(), + digestProvider: provider, onError, }), ); @@ -144,6 +146,7 @@ describe('writing diagnostics controller defensive dependency boundaries', () => 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( @@ -151,7 +154,7 @@ describe('writing diagnostics controller defensive dependency boundaries', () => useWritingDiagnosticsController({ editor, diagnostics: [], - digestProvider: digestProvider(), + digestProvider: provider, }), { initialProps: { editor: first } }, ); From da3c400097fb97fca3732b2bcd6d0e4e36bdb8c9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:27:13 +0000 Subject: [PATCH 19/26] fix(diagnostics): stabilize inline controller inputs --- .../controller-inline-input-fix-once.yml | 75 ------------------- .../useWritingDiagnosticsController.ts | 1 - 2 files changed, 76 deletions(-) delete mode 100644 .github/workflows/controller-inline-input-fix-once.yml diff --git a/.github/workflows/controller-inline-input-fix-once.yml b/.github/workflows/controller-inline-input-fix-once.yml deleted file mode 100644 index 2c96175d..00000000 --- a/.github/workflows/controller-inline-input-fix-once.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: Controller Inline Input Fix Once - -on: - push: - branches: - - feat/writing-diagnostics-controller - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: controller-inline-input-fix-once-${{ github.ref }} - cancel-in-progress: true - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - repair-inline-input-stability: - runs-on: ubuntu-24.04 - timeout-minutes: 15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.sha }} - persist-credentials: true - - 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: Remove raw input identity from the detached equality gate - run: | - set -euo pipefail - python <<'PY' - from pathlib import Path - - path = Path('src/components/useWritingDiagnosticsController.ts') - source = path.read_text(encoding='utf-8') - old = """ previous.kind !== next.kind || - previous.rawInput !== next.rawInput || - previous.editor !== next.editor || - """ - new = """ previous.kind !== next.kind || - previous.editor !== next.editor || - """ - if source.count(old) != 1: - raise SystemExit('expected exactly one raw-input identity gate') - path.write_text(source.replace(old, new), encoding='utf-8') - PY - - name: Verify inline values, defensive boundaries, and public types - run: | - set -euo pipefail - 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 - pnpm typecheck - - name: Publish the validated source repair and remove this workflow - env: - TARGET_BRANCH: feat/writing-diagnostics-controller - run: | - set -euo pipefail - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add src/components/useWritingDiagnosticsController.ts - git rm .github/workflows/controller-inline-input-fix-once.yml - git diff --cached --check - git commit -m 'fix(diagnostics): stabilize inline controller inputs' - git push origin "HEAD:${TARGET_BRANCH}" diff --git a/src/components/useWritingDiagnosticsController.ts b/src/components/useWritingDiagnosticsController.ts index 3ca47782..ccd160e3 100644 --- a/src/components/useWritingDiagnosticsController.ts +++ b/src/components/useWritingDiagnosticsController.ts @@ -219,7 +219,6 @@ function sameProcessedInput( if ( previous === null || previous.kind !== next.kind || - previous.rawInput !== next.rawInput || previous.editor !== next.editor || previous.digestProvider !== next.digestProvider ) { From 0f23ddfb43604cf4f01a40d95bd903a0d9560aa1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:31:53 +0900 Subject: [PATCH 20/26] ci(diagnostics): bound controller test workers --- .github/workflows/writing-diagnostics-controller-tdd.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/writing-diagnostics-controller-tdd.yml b/.github/workflows/writing-diagnostics-controller-tdd.yml index 3339193e..1d8671ba 100644 --- a/.github/workflows/writing-diagnostics-controller-tdd.yml +++ b/.github/workflows/writing-diagnostics-controller-tdd.yml @@ -38,6 +38,8 @@ jobs: 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: | @@ -47,6 +49,8 @@ jobs: 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 From 01673b4497a2e9b51c9d799e6a684bb3c9c40580 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:35:15 +0900 Subject: [PATCH 21/26] test(diagnostics): require stale state after projection teardown --- ...ingDiagnosticsController.boundary.test.tsx | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/components/useWritingDiagnosticsController.boundary.test.tsx b/src/components/useWritingDiagnosticsController.boundary.test.tsx index 404b33ea..d844ba50 100644 --- a/src/components/useWritingDiagnosticsController.boundary.test.tsx +++ b/src/components/useWritingDiagnosticsController.boundary.test.tsx @@ -12,6 +12,7 @@ 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) => { @@ -39,7 +40,9 @@ vi.mock('../writingDiagnosticProjection.js', async (importOriginal) => { if (boundaryState.failProjectionIdentity) { throw Object.freeze({ code: 'projection' }); } - return actual.resolveTextPositionSelector(...args); + const range = actual.resolveTextPositionSelector(...args); + boundaryState.afterProjection?.(); + return range; }, }; }); @@ -96,6 +99,7 @@ function digestProvider(): DocumentEnvelopeDigestProvider { afterEach(() => { boundaryState.failValidationUnexpectedly = false; boundaryState.failProjectionIdentity = false; + boundaryState.afterProjection = null; for (const editor of openEditors.splice(0)) { if (!editor.isDestroyed) editor.destroy(); } @@ -143,6 +147,23 @@ describe('writing diagnostics controller defensive dependency boundaries', () => ); }); + 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('ignores a stale editor transaction if a retired listener fires after replacement', async () => { const first = createEditor(); const second = createEditor(); From 9a93047d858864b6b43d313d4513638651c89ad3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:38:06 +0900 Subject: [PATCH 22/26] ci(diagnostics): repair controller teardown and dead branch --- .../controller-coverage-fix-once.yml | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 .github/workflows/controller-coverage-fix-once.yml diff --git a/.github/workflows/controller-coverage-fix-once.yml b/.github/workflows/controller-coverage-fix-once.yml new file mode 100644 index 00000000..298a86d6 --- /dev/null +++ b/.github/workflows/controller-coverage-fix-once.yml @@ -0,0 +1,167 @@ +name: Controller Coverage Fix Once + +on: + push: + branches: + - feat/writing-diagnostics-controller + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: controller-coverage-fix-once-${{ github.ref }} + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + repair-controller-boundaries: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: true + - 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: Repair stale publication and remove the impossible error fallback + run: | + set -euo pipefail + python <<'PY' + from pathlib import Path + + path = Path('src/components/useWritingDiagnosticsController.ts') + source = path.read_text(encoding='utf-8') + replacements = [ + ( + """interface InvalidProcessedInput { + readonly kind: 'invalid'; + readonly rawInput: unknown; + readonly editor: Editor | null; + readonly digestProvider: DocumentEnvelopeDigestProvider | null | undefined; + readonly errorCode: WritingDiagnosticError['code']; + } + """, + """interface InvalidProcessedInput { + readonly kind: 'invalid'; + readonly rawInput: unknown; + readonly editor: Editor | null; + readonly digestProvider: DocumentEnvelopeDigestProvider | null | undefined; + readonly error: WritingDiagnosticError; + } + """, + ), + ( + """ if (previous.kind === 'invalid' && next.kind === 'invalid') { + return previous.errorCode === next.errorCode; + } + """, + """ if (previous.kind === 'invalid' && next.kind === 'invalid') { + return previous.error.code === next.error.code; + } + """, + ), + ( + """ let processed: ProcessedInput; + let validationError: WritingDiagnosticError | null = null; + """, + """ let processed: ProcessedInput; + """, + ), + ( + """ } catch (error) { + validationError = + error instanceof WritingDiagnosticError + ? error + : new WritingDiagnosticError('contract'); + processed = { + kind: 'invalid', + rawInput: diagnostics, + editor, + digestProvider, + errorCode: validationError.code, + }; + } + """, + """ } catch (error) { + const validationError = + error instanceof WritingDiagnosticError + ? error + : new WritingDiagnosticError('contract'); + processed = { + kind: 'invalid', + rawInput: diagnostics, + editor, + digestProvider, + error: validationError, + }; + } + """, + ), + ( + """ if (processed.kind === 'invalid') { + const error = validationError ?? new WritingDiagnosticError(processed.errorCode); + publish(snapshot('invalid', nextGeneration, editor)); + notifyError(errorRef.current, error); + return; + } + """, + """ if (processed.kind === 'invalid') { + publish(snapshot('invalid', nextGeneration, editor)); + notifyError(errorRef.current, processed.error); + return; + } + """, + ), + ( + """ if (generationRef.current !== nextGeneration || editor.isDestroyed) return; + const frozenVerified = Object.freeze(verified); + """, + """ if (generationRef.current !== nextGeneration) return; + if (editor.isDestroyed) { + publish(snapshot('stale', nextGeneration, editor)); + return; + } + const frozenVerified = Object.freeze(verified); + """, + ), + ] + + for old, new in replacements: + if source.count(old) != 1: + raise SystemExit(f'unexpected replacement count for:\n{old}') + source = source.replace(old, new) + path.write_text(source, encoding='utf-8') + PY + - name: Verify exact controller behavior, coverage, and public types + run: | + set -euo pipefail + 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 + pnpm typecheck + - name: Publish the validated source repair and remove this workflow + env: + TARGET_BRANCH: feat/writing-diagnostics-controller + run: | + set -euo pipefail + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add src/components/useWritingDiagnosticsController.ts + git rm .github/workflows/controller-coverage-fix-once.yml + git diff --cached --check + git commit -m 'fix(diagnostics): close controller teardown gaps' + git push origin "HEAD:${TARGET_BRANCH}" From a2eb1da0491eab88f866872e6ae8a217c0c4dee0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:41:31 +0900 Subject: [PATCH 23/26] ci(diagnostics): make controller repair whitespace-stable --- .../controller-coverage-fix-once.yml | 125 +++++------------- 1 file changed, 36 insertions(+), 89 deletions(-) diff --git a/.github/workflows/controller-coverage-fix-once.yml b/.github/workflows/controller-coverage-fix-once.yml index 298a86d6..8b7b631f 100644 --- a/.github/workflows/controller-coverage-fix-once.yml +++ b/.github/workflows/controller-coverage-fix-once.yml @@ -36,108 +36,55 @@ jobs: set -euo pipefail python <<'PY' from pathlib import Path + import re path = Path('src/components/useWritingDiagnosticsController.ts') source = path.read_text(encoding='utf-8') - replacements = [ - ( - """interface InvalidProcessedInput { - readonly kind: 'invalid'; - readonly rawInput: unknown; - readonly editor: Editor | null; - readonly digestProvider: DocumentEnvelopeDigestProvider | null | undefined; - readonly errorCode: WritingDiagnosticError['code']; - } - """, - """interface InvalidProcessedInput { - readonly kind: 'invalid'; - readonly rawInput: unknown; - readonly editor: Editor | null; - readonly digestProvider: DocumentEnvelopeDigestProvider | null | undefined; - readonly error: WritingDiagnosticError; - } - """, - ), - ( - """ if (previous.kind === 'invalid' && next.kind === 'invalid') { - return previous.errorCode === next.errorCode; - } - """, - """ if (previous.kind === 'invalid' && next.kind === 'invalid') { - return previous.error.code === next.error.code; - } - """, - ), + + patterns = [ ( - """ let processed: ProcessedInput; - let validationError: WritingDiagnosticError | null = null; - """, - """ let processed: ProcessedInput; - """, + r"\n\s*let validationError: WritingDiagnosticError \| null = null;\n", + "\n", ), ( - """ } catch (error) { - validationError = - error instanceof WritingDiagnosticError - ? error - : new WritingDiagnosticError('contract'); - processed = { - kind: 'invalid', - rawInput: diagnostics, - editor, - digestProvider, - errorCode: validationError.code, - }; - } - """, - """ } catch (error) { - const validationError = - error instanceof WritingDiagnosticError - ? error - : new WritingDiagnosticError('contract'); - processed = { - kind: 'invalid', - rawInput: diagnostics, - editor, - digestProvider, - error: validationError, - }; - } - """, + r"(?m)^(\s*)validationError =\n\1 error instanceof WritingDiagnosticError\n\1 \? error\n\1 : new WritingDiagnosticError\('contract'\);$", + lambda match: ( + f"{match.group(1)}const validationError =\n" + f"{match.group(1)} error instanceof WritingDiagnosticError\n" + f"{match.group(1)} ? error\n" + f"{match.group(1)} : new WritingDiagnosticError('contract');" + ), ), ( - """ if (processed.kind === 'invalid') { - const error = validationError ?? new WritingDiagnosticError(processed.errorCode); - publish(snapshot('invalid', nextGeneration, editor)); - notifyError(errorRef.current, error); - return; - } - """, - """ if (processed.kind === 'invalid') { - publish(snapshot('invalid', nextGeneration, editor)); - notifyError(errorRef.current, processed.error); - return; - } - """, + r"(?m)^(\s*)const error = validationError \?\? new WritingDiagnosticError\(processed\.errorCode\);\n" + r"\1publish\(snapshot\('invalid', nextGeneration, editor\)\);\n" + r"\1notifyError\(errorRef\.current, error\);$", + lambda match: ( + f"{match.group(1)}publish(snapshot('invalid', nextGeneration, editor));\n" + f"{match.group(1)}notifyError(\n" + f"{match.group(1)} errorRef.current,\n" + f"{match.group(1)} new WritingDiagnosticError(processed.errorCode),\n" + f"{match.group(1)});" + ), ), ( - """ if (generationRef.current !== nextGeneration || editor.isDestroyed) return; - const frozenVerified = Object.freeze(verified); - """, - """ if (generationRef.current !== nextGeneration) return; - if (editor.isDestroyed) { - publish(snapshot('stale', nextGeneration, editor)); - return; - } - const frozenVerified = Object.freeze(verified); - """, + r"(?m)^(\s*)if \(generationRef\.current !== nextGeneration \|\| editor\.isDestroyed\) return;\n" + r"\1const frozenVerified = Object\.freeze\(verified\);$", + lambda match: ( + f"{match.group(1)}if (generationRef.current !== nextGeneration) return;\n" + f"{match.group(1)}if (editor.isDestroyed) {{\n" + f"{match.group(1)} publish(snapshot('stale', nextGeneration, editor));\n" + f"{match.group(1)} return;\n" + f"{match.group(1)}}}\n" + f"{match.group(1)}const frozenVerified = Object.freeze(verified);" + ), ), ] - for old, new in replacements: - if source.count(old) != 1: - raise SystemExit(f'unexpected replacement count for:\n{old}') - source = source.replace(old, new) + for pattern, replacement in patterns: + source, count = re.subn(pattern, replacement, source, count=1) + if count != 1: + raise SystemExit(f'unexpected replacement count {count} for {pattern}') path.write_text(source, encoding='utf-8') PY - name: Verify exact controller behavior, coverage, and public types From 71d2548dc1ebe9021cb0251a3aa618c4ffe776bd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:44:11 +0900 Subject: [PATCH 24/26] test(diagnostics): cover projection generation invalidation --- ...ingDiagnosticsController.boundary.test.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/components/useWritingDiagnosticsController.boundary.test.tsx b/src/components/useWritingDiagnosticsController.boundary.test.tsx index d844ba50..071fb89f 100644 --- a/src/components/useWritingDiagnosticsController.boundary.test.tsx +++ b/src/components/useWritingDiagnosticsController.boundary.test.tsx @@ -164,6 +164,25 @@ describe('writing diagnostics controller defensive dependency boundaries', () => 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(); From 4609f4be433581832ab0529cb8c7bc36ccad5bf0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:45:00 +0000 Subject: [PATCH 25/26] fix(diagnostics): close controller teardown gaps --- .../controller-coverage-fix-once.yml | 114 ------------------ .../useWritingDiagnosticsController.ts | 15 ++- 2 files changed, 10 insertions(+), 119 deletions(-) delete mode 100644 .github/workflows/controller-coverage-fix-once.yml diff --git a/.github/workflows/controller-coverage-fix-once.yml b/.github/workflows/controller-coverage-fix-once.yml deleted file mode 100644 index 8b7b631f..00000000 --- a/.github/workflows/controller-coverage-fix-once.yml +++ /dev/null @@ -1,114 +0,0 @@ -name: Controller Coverage Fix Once - -on: - push: - branches: - - feat/writing-diagnostics-controller - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: controller-coverage-fix-once-${{ github.ref }} - cancel-in-progress: true - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - repair-controller-boundaries: - runs-on: ubuntu-24.04 - timeout-minutes: 20 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.sha }} - persist-credentials: true - - 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: Repair stale publication and remove the impossible error fallback - run: | - set -euo pipefail - python <<'PY' - from pathlib import Path - import re - - path = Path('src/components/useWritingDiagnosticsController.ts') - source = path.read_text(encoding='utf-8') - - patterns = [ - ( - r"\n\s*let validationError: WritingDiagnosticError \| null = null;\n", - "\n", - ), - ( - r"(?m)^(\s*)validationError =\n\1 error instanceof WritingDiagnosticError\n\1 \? error\n\1 : new WritingDiagnosticError\('contract'\);$", - lambda match: ( - f"{match.group(1)}const validationError =\n" - f"{match.group(1)} error instanceof WritingDiagnosticError\n" - f"{match.group(1)} ? error\n" - f"{match.group(1)} : new WritingDiagnosticError('contract');" - ), - ), - ( - r"(?m)^(\s*)const error = validationError \?\? new WritingDiagnosticError\(processed\.errorCode\);\n" - r"\1publish\(snapshot\('invalid', nextGeneration, editor\)\);\n" - r"\1notifyError\(errorRef\.current, error\);$", - lambda match: ( - f"{match.group(1)}publish(snapshot('invalid', nextGeneration, editor));\n" - f"{match.group(1)}notifyError(\n" - f"{match.group(1)} errorRef.current,\n" - f"{match.group(1)} new WritingDiagnosticError(processed.errorCode),\n" - f"{match.group(1)});" - ), - ), - ( - r"(?m)^(\s*)if \(generationRef\.current !== nextGeneration \|\| editor\.isDestroyed\) return;\n" - r"\1const frozenVerified = Object\.freeze\(verified\);$", - lambda match: ( - f"{match.group(1)}if (generationRef.current !== nextGeneration) return;\n" - f"{match.group(1)}if (editor.isDestroyed) {{\n" - f"{match.group(1)} publish(snapshot('stale', nextGeneration, editor));\n" - f"{match.group(1)} return;\n" - f"{match.group(1)}}}\n" - f"{match.group(1)}const frozenVerified = Object.freeze(verified);" - ), - ), - ] - - for pattern, replacement in patterns: - source, count = re.subn(pattern, replacement, source, count=1) - if count != 1: - raise SystemExit(f'unexpected replacement count {count} for {pattern}') - path.write_text(source, encoding='utf-8') - PY - - name: Verify exact controller behavior, coverage, and public types - run: | - set -euo pipefail - 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 - pnpm typecheck - - name: Publish the validated source repair and remove this workflow - env: - TARGET_BRANCH: feat/writing-diagnostics-controller - run: | - set -euo pipefail - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add src/components/useWritingDiagnosticsController.ts - git rm .github/workflows/controller-coverage-fix-once.yml - git diff --cached --check - git commit -m 'fix(diagnostics): close controller teardown gaps' - git push origin "HEAD:${TARGET_BRANCH}" diff --git a/src/components/useWritingDiagnosticsController.ts b/src/components/useWritingDiagnosticsController.ts index ccd160e3..c0179383 100644 --- a/src/components/useWritingDiagnosticsController.ts +++ b/src/components/useWritingDiagnosticsController.ts @@ -314,7 +314,6 @@ export function useWritingDiagnosticsController( useEffect(() => { let processed: ProcessedInput; - let validationError: WritingDiagnosticError | null = null; if (diagnostics === undefined) { processed = { @@ -334,7 +333,7 @@ export function useWritingDiagnosticsController( diagnostics: validated, }; } catch (error) { - validationError = + const validationError = error instanceof WritingDiagnosticError ? error : new WritingDiagnosticError('contract'); @@ -368,9 +367,11 @@ export function useWritingDiagnosticsController( } if (processed.kind === 'invalid') { - const error = validationError ?? new WritingDiagnosticError(processed.errorCode); publish(snapshot('invalid', nextGeneration, editor)); - notifyError(errorRef.current, error); + notifyError( + errorRef.current, + new WritingDiagnosticError(processed.errorCode), + ); return; } @@ -468,7 +469,11 @@ export function useWritingDiagnosticsController( return; } - if (generationRef.current !== nextGeneration || editor.isDestroyed) return; + if (generationRef.current !== nextGeneration) return; + if (editor.isDestroyed) { + publish(snapshot('stale', nextGeneration, editor)); + return; + } const frozenVerified = Object.freeze(verified); let installed = false; try { From c2f6df89cb193f6e3b9abcbf1fba078c79385784 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:46:21 +0900 Subject: [PATCH 26/26] ci(diagnostics): verify controller package acceptance --- .github/workflows/writing-diagnostics-controller-tdd.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/writing-diagnostics-controller-tdd.yml b/.github/workflows/writing-diagnostics-controller-tdd.yml index 1d8671ba..b2b839d0 100644 --- a/.github/workflows/writing-diagnostics-controller-tdd.yml +++ b/.github/workflows/writing-diagnostics-controller-tdd.yml @@ -19,7 +19,7 @@ env: jobs: focused-controller: runs-on: ubuntu-24.04 - timeout-minutes: 20 + timeout-minutes: 25 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -132,3 +132,9 @@ jobs: 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