From 0c1c5147f32a43bfe229f5bd9744769b0e02b730 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:46:55 +0900 Subject: [PATCH 01/39] test(diagnostics): require standalone editor guidance integration --- .../CwlEditor.writingDiagnostics.test.tsx | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 src/components/CwlEditor.writingDiagnostics.test.tsx diff --git a/src/components/CwlEditor.writingDiagnostics.test.tsx b/src/components/CwlEditor.writingDiagnostics.test.tsx new file mode 100644 index 00000000..343564bb --- /dev/null +++ b/src/components/CwlEditor.writingDiagnostics.test.tsx @@ -0,0 +1,112 @@ +import { + act, + cleanup, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { createRef } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { CwlEditorHandle } from '../types.js'; +import type { CwlWritingDiagnostic } from '../writingDiagnostics.js'; +import { CwlEditor } from './CwlEditor.js'; + +afterEach(cleanup); + +/** Capture exact current revision and text-position evidence for one selection. */ +async function diagnosticForSelection( + handle: CwlEditorHandle, + title = 'Clarify the request', +): Promise { + const evidence = await handle.getTextPositionSelectorEvidence(); + if (evidence === null) throw new Error('Missing selector evidence'); + return { + diagnosticId: 'diagnostic-1', + documentRevision: evidence.revision, + textProjection: evidence.textProjection, + selector: evidence.selector, + categoryCode: 'clarity', + priority: 'important', + title, + explanation: 'State who should do what and by when.', + suggestedReplacement: 'Please confirm the owner and due date.', + provenance: { + workflowId: 'email-writing-review', + workflowVersion: '1', + judgePolicyVersion: 'evaluation-only-1', + }, + }; +} + +describe('CwlEditor host-supplied writing diagnostics', () => { + it('creates no semantic guidance when the host omits diagnostics', async () => { + render( + , + ); + + await waitFor(() => + expect(document.querySelector('.cwl-editor__content')).not.toBeNull(), + ); + expect(screen.queryByRole('region', { name: /writing/i })).toBeNull(); + expect(document.querySelector('.cwl-writing-diagnostic')).toBeNull(); + }); + + it('renders an explicitly supplied empty diagnostic set as advisory UI', async () => { + render( + , + ); + + expect( + await screen.findByRole('region', { name: 'Email writing guidance' }), + ).toBeInTheDocument(); + expect(screen.getByText('0 writing diagnostics')).toBeInTheDocument(); + }); + + it('renders a diagnostic only after exact revision verification', async () => { + const handleRef = createRef(); + const onWritingDiagnosticAction = vi.fn(); + const view = render( + , + ); + await waitFor(() => expect(handleRef.current?.getEditor()).not.toBeNull()); + + act(() => { + handleRef.current!.getEditor()!.commands.setTextSelection({ + from: 1, + to: 6, + }); + }); + const diagnostic = await diagnosticForSelection(handleRef.current!); + + view.rerender( + , + ); + + expect( + await screen.findByRole('region', { name: 'Email writing guidance' }), + ).toBeInTheDocument(); + expect(await screen.findByText('Clarify the request')).toBeInTheDocument(); + expect(document.querySelectorAll('.cwl-writing-diagnostic')).toHaveLength(1); + expect(onWritingDiagnosticAction).not.toHaveBeenCalled(); + }); +}); From 471d91b5c329c638f60ac0734ca7b368ba11f6dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:47:11 +0900 Subject: [PATCH 02/39] ci(diagnostics): expose standalone integration TDD state --- ...writing-diagnostics-editor-actions-tdd.yml | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/writing-diagnostics-editor-actions-tdd.yml diff --git a/.github/workflows/writing-diagnostics-editor-actions-tdd.yml b/.github/workflows/writing-diagnostics-editor-actions-tdd.yml new file mode 100644 index 00000000..90cd0e4e --- /dev/null +++ b/.github/workflows/writing-diagnostics-editor-actions-tdd.yml @@ -0,0 +1,41 @@ +name: Writing Diagnostics Editor Actions TDD + +on: + push: + branches: + - feat/writing-diagnostics-editor-actions + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: writing-diagnostics-editor-actions-tdd-${{ github.ref }} + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + focused-editor-actions: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + 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 standalone writing-diagnostic integration tests + run: >- + pnpm exec vitest run + src/components/CwlEditor.writingDiagnostics.test.tsx + --pool=forks + --maxWorkers=1 + - name: Typecheck standalone writing-diagnostic contracts + run: pnpm typecheck From e6f8ceddd4f6a0cecf73e3f260782a861c16d784 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:49:32 +0900 Subject: [PATCH 03/39] ci(diagnostics): integrate standalone editor once --- .../editor-actions-integration-once.yml | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 .github/workflows/editor-actions-integration-once.yml diff --git a/.github/workflows/editor-actions-integration-once.yml b/.github/workflows/editor-actions-integration-once.yml new file mode 100644 index 00000000..2cb3ef44 --- /dev/null +++ b/.github/workflows/editor-actions-integration-once.yml @@ -0,0 +1,152 @@ +name: Editor Actions Integration Once + +on: + push: + branches: + - feat/writing-diagnostics-editor-actions + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: editor-actions-integration-once-${{ github.ref }} + cancel-in-progress: true + +jobs: + integrate-public-editor: + 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: Integrate diagnostics into the standalone editor + run: | + python <<'PY' + from pathlib import Path + + types_path = Path('src/types.ts') + types = types_path.read_text(encoding='utf-8') + import_anchor = """import type { + ClipboardConfig, + ClipboardSanitizationError, + } from './extensions/SafeClipboard.js'; + """ + import_replacement = import_anchor + """import type { + CwlWritingDiagnostic, + WritingDiagnosticError, + } from './writingDiagnostics.js'; + import type { CwlWritingDiagnosticActionEvent } from './components/useWritingDiagnosticsController.js'; + """ + props_anchor = """ /** Whether the host form requires editor input before submission. */ + ariaRequired?: boolean; + } + """ + props_replacement = """ /** Whether the host form requires editor input before submission. */ + ariaRequired?: boolean; + /** Host-supplied diagnostics already produced by a trusted external reviewer. */ + writingDiagnostics?: readonly CwlWritingDiagnostic[]; + /** Privacy-minimized observer for explicit diagnostic actions. */ + onWritingDiagnosticAction?: ( + event: CwlWritingDiagnosticActionEvent, + ) => void; + /** Redacted observer for structural verification failures. */ + onWritingDiagnosticsError?: (error: WritingDiagnosticError) => void; + /** Accessible name for the built-in writing-guidance region. */ + writingDiagnosticsLabel?: string; + /** Include a compact diagnostic appendix in printed output. */ + printWritingDiagnostics?: boolean; + } + """ + if import_anchor not in types or props_anchor not in types: + raise SystemExit('types.ts integration anchors changed') + types = types.replace(import_anchor, import_replacement, 1) + types = types.replace(props_anchor, props_replacement, 1) + types_path.write_text(types, encoding='utf-8') + + editor_path = Path('src/components/CwlEditor.tsx') + editor = editor_path.read_text(encoding='utf-8') + import_anchor = """import { useEditorHandle } from './useEditorHandle.js'; + import { useLatestRef } from './useLatestRef.js'; + """ + import_replacement = """import { useEditorHandle } from './useEditorHandle.js'; + import { useLatestRef } from './useLatestRef.js'; + import { useWritingDiagnosticsController } from './useWritingDiagnosticsController.js'; + import { WritingDiagnosticsPanel } from './WritingDiagnosticsPanel.js'; + """ + props_anchor = """ ariaInvalid, + ariaRequired, + }, + """ + props_replacement = """ ariaInvalid, + ariaRequired, + writingDiagnostics, + onWritingDiagnosticAction, + onWritingDiagnosticsError, + writingDiagnosticsLabel, + printWritingDiagnostics, + }, + """ + hook_anchor = """ useEditorHandle(ref, editor, modeRef); + + useEffect(() => { + """ + hook_replacement = """ useEditorHandle(ref, editor, modeRef); + + const writingDiagnosticsController = useWritingDiagnosticsController({ + editor, + diagnostics: writingDiagnostics, + onAction: onWritingDiagnosticAction, + onError: onWritingDiagnosticsError, + }); + + useEffect(() => { + """ + frame_anchor = """ onFormReset={editor && observesFormReset ? handleFormReset : undefined} + /> + """ + frame_replacement = """ onFormReset={editor && observesFormReset ? handleFormReset : undefined} + writingDiagnosticsPanel={ + writingDiagnostics === undefined ? undefined : ( + + ) + } + /> + """ + for anchor in (import_anchor, props_anchor, hook_anchor, frame_anchor): + if anchor not in editor: + raise SystemExit('CwlEditor.tsx integration anchor changed') + editor = editor.replace(import_anchor, import_replacement, 1) + editor = editor.replace(props_anchor, props_replacement, 1) + editor = editor.replace(hook_anchor, hook_replacement, 1) + editor = editor.replace(frame_anchor, frame_replacement, 1) + editor_path.write_text(editor, encoding='utf-8') + PY + - name: Verify the focused public integration + run: | + pnpm exec vitest run src/components/CwlEditor.writingDiagnostics.test.tsx --pool=forks --maxWorkers=1 + pnpm typecheck + - name: Publish the validated integration and remove this workflow + env: + TARGET_BRANCH: feat/writing-diagnostics-editor-actions + run: | + set -euo pipefail + rm .github/workflows/editor-actions-integration-once.yml + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add src/types.ts src/components/CwlEditor.tsx .github/workflows/editor-actions-integration-once.yml + git diff --cached --check + git commit -m 'feat(diagnostics): integrate guidance into standalone editor' + git push origin "HEAD:${TARGET_BRANCH}" From bee158ea3a49415c0db593e88f8e3e8b6a0ab750 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:51:46 +0900 Subject: [PATCH 04/39] ci(diagnostics): make standalone patch anchors exact --- .../editor-actions-integration-once.yml | 200 ++++++++++-------- 1 file changed, 107 insertions(+), 93 deletions(-) diff --git a/.github/workflows/editor-actions-integration-once.yml b/.github/workflows/editor-actions-integration-once.yml index 2cb3ef44..cce8f69b 100644 --- a/.github/workflows/editor-actions-integration-once.yml +++ b/.github/workflows/editor-actions-integration-once.yml @@ -33,105 +33,119 @@ jobs: python <<'PY' from pathlib import Path + def replace_once(source: str, old: str, new: str, label: str) -> str: + if source.count(old) != 1: + raise SystemExit(f'{label} anchor count was {source.count(old)}') + return source.replace(old, new, 1) + types_path = Path('src/types.ts') types = types_path.read_text(encoding='utf-8') - import_anchor = """import type { - ClipboardConfig, - ClipboardSanitizationError, - } from './extensions/SafeClipboard.js'; - """ - import_replacement = import_anchor + """import type { - CwlWritingDiagnostic, - WritingDiagnosticError, - } from './writingDiagnostics.js'; - import type { CwlWritingDiagnosticActionEvent } from './components/useWritingDiagnosticsController.js'; - """ - props_anchor = """ /** Whether the host form requires editor input before submission. */ - ariaRequired?: boolean; - } - """ - props_replacement = """ /** Whether the host form requires editor input before submission. */ - ariaRequired?: boolean; - /** Host-supplied diagnostics already produced by a trusted external reviewer. */ - writingDiagnostics?: readonly CwlWritingDiagnostic[]; - /** Privacy-minimized observer for explicit diagnostic actions. */ - onWritingDiagnosticAction?: ( - event: CwlWritingDiagnosticActionEvent, - ) => void; - /** Redacted observer for structural verification failures. */ - onWritingDiagnosticsError?: (error: WritingDiagnosticError) => void; - /** Accessible name for the built-in writing-guidance region. */ - writingDiagnosticsLabel?: string; - /** Include a compact diagnostic appendix in printed output. */ - printWritingDiagnostics?: boolean; - } - """ - if import_anchor not in types or props_anchor not in types: - raise SystemExit('types.ts integration anchors changed') - types = types.replace(import_anchor, import_replacement, 1) - types = types.replace(props_anchor, props_replacement, 1) + old = ( + "import type {\n" + " ClipboardConfig,\n" + " ClipboardSanitizationError,\n" + "} from './extensions/SafeClipboard.js';\n" + ) + new = old + ( + "import type {\n" + " CwlWritingDiagnostic,\n" + " WritingDiagnosticError,\n" + "} from './writingDiagnostics.js';\n" + "import type { CwlWritingDiagnosticActionEvent } from " + "'./components/useWritingDiagnosticsController.js';\n" + ) + types = replace_once(types, old, new, 'types imports') + old = ( + " /** Whether the host form requires editor input before submission. */\n" + " ariaRequired?: boolean;\n" + "}\n" + ) + new = ( + " /** Whether the host form requires editor input before submission. */\n" + " ariaRequired?: boolean;\n" + " /** Host-supplied diagnostics already produced by a trusted external reviewer. */\n" + " writingDiagnostics?: readonly CwlWritingDiagnostic[];\n" + " /** Privacy-minimized observer for explicit diagnostic actions. */\n" + " onWritingDiagnosticAction?: (\n" + " event: CwlWritingDiagnosticActionEvent,\n" + " ) => void;\n" + " /** Redacted observer for structural verification failures. */\n" + " onWritingDiagnosticsError?: (error: WritingDiagnosticError) => void;\n" + " /** Accessible name for the built-in writing-guidance region. */\n" + " writingDiagnosticsLabel?: string;\n" + " /** Include a compact diagnostic appendix in printed output. */\n" + " printWritingDiagnostics?: boolean;\n" + "}\n" + ) + types = replace_once(types, old, new, 'types props') types_path.write_text(types, encoding='utf-8') editor_path = Path('src/components/CwlEditor.tsx') editor = editor_path.read_text(encoding='utf-8') - import_anchor = """import { useEditorHandle } from './useEditorHandle.js'; - import { useLatestRef } from './useLatestRef.js'; - """ - import_replacement = """import { useEditorHandle } from './useEditorHandle.js'; - import { useLatestRef } from './useLatestRef.js'; - import { useWritingDiagnosticsController } from './useWritingDiagnosticsController.js'; - import { WritingDiagnosticsPanel } from './WritingDiagnosticsPanel.js'; - """ - props_anchor = """ ariaInvalid, - ariaRequired, - }, - """ - props_replacement = """ ariaInvalid, - ariaRequired, - writingDiagnostics, - onWritingDiagnosticAction, - onWritingDiagnosticsError, - writingDiagnosticsLabel, - printWritingDiagnostics, - }, - """ - hook_anchor = """ useEditorHandle(ref, editor, modeRef); - - useEffect(() => { - """ - hook_replacement = """ useEditorHandle(ref, editor, modeRef); - - const writingDiagnosticsController = useWritingDiagnosticsController({ - editor, - diagnostics: writingDiagnostics, - onAction: onWritingDiagnosticAction, - onError: onWritingDiagnosticsError, - }); - - useEffect(() => { - """ - frame_anchor = """ onFormReset={editor && observesFormReset ? handleFormReset : undefined} - /> - """ - frame_replacement = """ onFormReset={editor && observesFormReset ? handleFormReset : undefined} - writingDiagnosticsPanel={ - writingDiagnostics === undefined ? undefined : ( - - ) - } - /> - """ - for anchor in (import_anchor, props_anchor, hook_anchor, frame_anchor): - if anchor not in editor: - raise SystemExit('CwlEditor.tsx integration anchor changed') - editor = editor.replace(import_anchor, import_replacement, 1) - editor = editor.replace(props_anchor, props_replacement, 1) - editor = editor.replace(hook_anchor, hook_replacement, 1) - editor = editor.replace(frame_anchor, frame_replacement, 1) + old = ( + "import { useEditorHandle } from './useEditorHandle.js';\n" + "import { useLatestRef } from './useLatestRef.js';\n" + ) + new = ( + "import { useEditorHandle } from './useEditorHandle.js';\n" + "import { useLatestRef } from './useLatestRef.js';\n" + "import { useWritingDiagnosticsController } from " + "'./useWritingDiagnosticsController.js';\n" + "import { WritingDiagnosticsPanel } from './WritingDiagnosticsPanel.js';\n" + ) + editor = replace_once(editor, old, new, 'editor imports') + old = ( + " ariaInvalid,\n" + " ariaRequired,\n" + " },\n" + ) + new = ( + " ariaInvalid,\n" + " ariaRequired,\n" + " writingDiagnostics,\n" + " onWritingDiagnosticAction,\n" + " onWritingDiagnosticsError,\n" + " writingDiagnosticsLabel,\n" + " printWritingDiagnostics,\n" + " },\n" + ) + editor = replace_once(editor, old, new, 'editor props') + old = ( + " useEditorHandle(ref, editor, modeRef);\n" + "\n" + " useEffect(() => {\n" + ) + new = ( + " useEditorHandle(ref, editor, modeRef);\n" + "\n" + " const writingDiagnosticsController = useWritingDiagnosticsController({\n" + " editor,\n" + " diagnostics: writingDiagnostics,\n" + " onAction: onWritingDiagnosticAction,\n" + " onError: onWritingDiagnosticsError,\n" + " });\n" + "\n" + " useEffect(() => {\n" + ) + editor = replace_once(editor, old, new, 'editor controller') + old = ( + " onFormReset={editor && observesFormReset ? handleFormReset : undefined}\n" + " />\n" + ) + new = ( + " onFormReset={editor && observesFormReset ? handleFormReset : undefined}\n" + " writingDiagnosticsPanel={\n" + " writingDiagnostics === undefined ? undefined : (\n" + " \n" + " )\n" + " }\n" + " />\n" + ) + editor = replace_once(editor, old, new, 'editor frame') editor_path.write_text(editor, encoding='utf-8') PY - name: Verify the focused public integration From 965c02f84a38caec9004f30aba655cf0d0d2117d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 11:52:17 +0000 Subject: [PATCH 05/39] feat(diagnostics): integrate guidance into standalone editor --- .../editor-actions-integration-once.yml | 166 ------------------ src/components/CwlEditor.tsx | 23 +++ src/types.ts | 17 ++ 3 files changed, 40 insertions(+), 166 deletions(-) delete mode 100644 .github/workflows/editor-actions-integration-once.yml diff --git a/.github/workflows/editor-actions-integration-once.yml b/.github/workflows/editor-actions-integration-once.yml deleted file mode 100644 index cce8f69b..00000000 --- a/.github/workflows/editor-actions-integration-once.yml +++ /dev/null @@ -1,166 +0,0 @@ -name: Editor Actions Integration Once - -on: - push: - branches: - - feat/writing-diagnostics-editor-actions - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: editor-actions-integration-once-${{ github.ref }} - cancel-in-progress: true - -jobs: - integrate-public-editor: - 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: Integrate diagnostics into the standalone editor - run: | - python <<'PY' - from pathlib import Path - - def replace_once(source: str, old: str, new: str, label: str) -> str: - if source.count(old) != 1: - raise SystemExit(f'{label} anchor count was {source.count(old)}') - return source.replace(old, new, 1) - - types_path = Path('src/types.ts') - types = types_path.read_text(encoding='utf-8') - old = ( - "import type {\n" - " ClipboardConfig,\n" - " ClipboardSanitizationError,\n" - "} from './extensions/SafeClipboard.js';\n" - ) - new = old + ( - "import type {\n" - " CwlWritingDiagnostic,\n" - " WritingDiagnosticError,\n" - "} from './writingDiagnostics.js';\n" - "import type { CwlWritingDiagnosticActionEvent } from " - "'./components/useWritingDiagnosticsController.js';\n" - ) - types = replace_once(types, old, new, 'types imports') - old = ( - " /** Whether the host form requires editor input before submission. */\n" - " ariaRequired?: boolean;\n" - "}\n" - ) - new = ( - " /** Whether the host form requires editor input before submission. */\n" - " ariaRequired?: boolean;\n" - " /** Host-supplied diagnostics already produced by a trusted external reviewer. */\n" - " writingDiagnostics?: readonly CwlWritingDiagnostic[];\n" - " /** Privacy-minimized observer for explicit diagnostic actions. */\n" - " onWritingDiagnosticAction?: (\n" - " event: CwlWritingDiagnosticActionEvent,\n" - " ) => void;\n" - " /** Redacted observer for structural verification failures. */\n" - " onWritingDiagnosticsError?: (error: WritingDiagnosticError) => void;\n" - " /** Accessible name for the built-in writing-guidance region. */\n" - " writingDiagnosticsLabel?: string;\n" - " /** Include a compact diagnostic appendix in printed output. */\n" - " printWritingDiagnostics?: boolean;\n" - "}\n" - ) - types = replace_once(types, old, new, 'types props') - types_path.write_text(types, encoding='utf-8') - - editor_path = Path('src/components/CwlEditor.tsx') - editor = editor_path.read_text(encoding='utf-8') - old = ( - "import { useEditorHandle } from './useEditorHandle.js';\n" - "import { useLatestRef } from './useLatestRef.js';\n" - ) - new = ( - "import { useEditorHandle } from './useEditorHandle.js';\n" - "import { useLatestRef } from './useLatestRef.js';\n" - "import { useWritingDiagnosticsController } from " - "'./useWritingDiagnosticsController.js';\n" - "import { WritingDiagnosticsPanel } from './WritingDiagnosticsPanel.js';\n" - ) - editor = replace_once(editor, old, new, 'editor imports') - old = ( - " ariaInvalid,\n" - " ariaRequired,\n" - " },\n" - ) - new = ( - " ariaInvalid,\n" - " ariaRequired,\n" - " writingDiagnostics,\n" - " onWritingDiagnosticAction,\n" - " onWritingDiagnosticsError,\n" - " writingDiagnosticsLabel,\n" - " printWritingDiagnostics,\n" - " },\n" - ) - editor = replace_once(editor, old, new, 'editor props') - old = ( - " useEditorHandle(ref, editor, modeRef);\n" - "\n" - " useEffect(() => {\n" - ) - new = ( - " useEditorHandle(ref, editor, modeRef);\n" - "\n" - " const writingDiagnosticsController = useWritingDiagnosticsController({\n" - " editor,\n" - " diagnostics: writingDiagnostics,\n" - " onAction: onWritingDiagnosticAction,\n" - " onError: onWritingDiagnosticsError,\n" - " });\n" - "\n" - " useEffect(() => {\n" - ) - editor = replace_once(editor, old, new, 'editor controller') - old = ( - " onFormReset={editor && observesFormReset ? handleFormReset : undefined}\n" - " />\n" - ) - new = ( - " onFormReset={editor && observesFormReset ? handleFormReset : undefined}\n" - " writingDiagnosticsPanel={\n" - " writingDiagnostics === undefined ? undefined : (\n" - " \n" - " )\n" - " }\n" - " />\n" - ) - editor = replace_once(editor, old, new, 'editor frame') - editor_path.write_text(editor, encoding='utf-8') - PY - - name: Verify the focused public integration - run: | - pnpm exec vitest run src/components/CwlEditor.writingDiagnostics.test.tsx --pool=forks --maxWorkers=1 - pnpm typecheck - - name: Publish the validated integration and remove this workflow - env: - TARGET_BRANCH: feat/writing-diagnostics-editor-actions - run: | - set -euo pipefail - rm .github/workflows/editor-actions-integration-once.yml - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add src/types.ts src/components/CwlEditor.tsx .github/workflows/editor-actions-integration-once.yml - git diff --cached --check - git commit -m 'feat(diagnostics): integrate guidance into standalone editor' - git push origin "HEAD:${TARGET_BRANCH}" diff --git a/src/components/CwlEditor.tsx b/src/components/CwlEditor.tsx index 598ac948..1b47bf9f 100644 --- a/src/components/CwlEditor.tsx +++ b/src/components/CwlEditor.tsx @@ -19,6 +19,8 @@ import { applyEditorFormReset } from './editorFormReset.js'; import { editorHtmlToValue, editorValueToHtml } from './editorSerialization.js'; import { useEditorHandle } from './useEditorHandle.js'; import { useLatestRef } from './useLatestRef.js'; +import { useWritingDiagnosticsController } from './useWritingDiagnosticsController.js'; +import { WritingDiagnosticsPanel } from './WritingDiagnosticsPanel.js'; /** * CwlEditor — a commercial-grade rich-text editor with interchangeable @@ -62,6 +64,11 @@ export const CwlEditor = forwardRef( ariaErrorMessage, ariaInvalid, ariaRequired, + writingDiagnostics, + onWritingDiagnosticAction, + onWritingDiagnosticsError, + writingDiagnosticsLabel, + printWritingDiagnostics, }, ref, ) { @@ -192,6 +199,13 @@ export const CwlEditor = forwardRef( useEditorHandle(ref, editor, modeRef); + const writingDiagnosticsController = useWritingDiagnosticsController({ + editor, + diagnostics: writingDiagnostics, + onAction: onWritingDiagnosticAction, + onError: onWritingDiagnosticsError, + }); + useEffect(() => { editor?.setEditable(editable); }, [editor, editable]); @@ -248,6 +262,15 @@ export const CwlEditor = forwardRef( formFieldDisabled={formFieldDisabled} formFieldInitialValue={selectedDocumentValue} onFormReset={editor && observesFormReset ? handleFormReset : undefined} + writingDiagnosticsPanel={ + writingDiagnostics === undefined ? undefined : ( + + ) + } /> ); }, diff --git a/src/types.ts b/src/types.ts index 0292d4a2..a810cfec 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,11 @@ import type { ClipboardConfig, ClipboardSanitizationError, } from './extensions/SafeClipboard.js'; +import type { + CwlWritingDiagnostic, + WritingDiagnosticError, +} from './writingDiagnostics.js'; +import type { CwlWritingDiagnosticActionEvent } from './components/useWritingDiagnosticsController.js'; /** Which document surface the editor reads from and writes to. */ export type EditorMode = 'markdown' | 'html'; @@ -375,4 +380,16 @@ export interface CwlEditorProps { ariaInvalid?: boolean | 'grammar' | 'spelling'; /** Whether the host form requires editor input before submission. */ ariaRequired?: boolean; + /** Host-supplied diagnostics already produced by a trusted external reviewer. */ + writingDiagnostics?: readonly CwlWritingDiagnostic[]; + /** Privacy-minimized observer for explicit diagnostic actions. */ + onWritingDiagnosticAction?: ( + event: CwlWritingDiagnosticActionEvent, + ) => void; + /** Redacted observer for structural verification failures. */ + onWritingDiagnosticsError?: (error: WritingDiagnosticError) => void; + /** Accessible name for the built-in writing-guidance region. */ + writingDiagnosticsLabel?: string; + /** Include a compact diagnostic appendix in printed output. */ + printWritingDiagnostics?: boolean; } From 9d6b688b6a8e895d573569122708ec8814ea16d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:54:52 +0900 Subject: [PATCH 06/39] test(diagnostics): require revision-safe undoable application --- .../CwlEditor.writingDiagnosticApply.test.tsx | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 src/components/CwlEditor.writingDiagnosticApply.test.tsx diff --git a/src/components/CwlEditor.writingDiagnosticApply.test.tsx b/src/components/CwlEditor.writingDiagnosticApply.test.tsx new file mode 100644 index 00000000..df275aaf --- /dev/null +++ b/src/components/CwlEditor.writingDiagnosticApply.test.tsx @@ -0,0 +1,105 @@ +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { createRef } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { CwlEditorHandle } from '../types.js'; +import type { CwlWritingDiagnostic } from '../writingDiagnostics.js'; +import { CwlEditor } from './CwlEditor.js'; + +afterEach(cleanup); + +async function exactDiagnostic( + handle: CwlEditorHandle, + replacement: string, +): Promise { + const evidence = await handle.getTextPositionSelectorEvidence(); + if (evidence === null) throw new Error('Missing selector evidence'); + return { + diagnosticId: 'apply-diagnostic', + documentRevision: evidence.revision, + textProjection: evidence.textProjection, + selector: evidence.selector, + categoryCode: 'clarity', + priority: 'important', + title: 'Clarify the request', + explanation: 'Make the requested action explicit.', + suggestedReplacement: replacement, + provenance: { + workflowId: 'email-writing-review', + workflowVersion: '1', + judgePolicyVersion: 'evaluation-only-1', + }, + }; +} + +describe('CwlEditor writing-diagnostic application', () => { + it('rechecks the exact revision, inserts plain text, invalidates diagnostics, and remains undoable', async () => { + const handleRef = createRef(); + const onAction = vi.fn(); + const onChange = vi.fn(); + const view = render( + , + ); + await waitFor(() => expect(handleRef.current?.getEditor()).not.toBeNull()); + + act(() => { + handleRef.current!.getEditor()!.commands.setTextSelection({ from: 1, to: 6 }); + }); + const replacement = ''; + const diagnostic = await exactDiagnostic(handleRef.current!, replacement); + + view.rerender( + , + ); + + const apply = await screen.findByRole('button', { + name: 'Apply suggestion for Clarify the request', + }); + expect(apply).toBeEnabled(); + fireEvent.click(apply); + + await waitFor(() => + expect(handleRef.current?.getValue()).toBe( + ' beta', + ), + ); + expect(document.querySelector('script')).toBeNull(); + expect(document.querySelector('.cwl-writing-diagnostic')).toBeNull(); + expect(onAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'applied', + reasonCode: 'explicit', + diagnosticId: 'apply-diagnostic', + resultingDocumentRevision: expect.objectContaining({ + algorithm: 'SHA-256', + }), + }), + ); + expect(onChange).toHaveBeenCalled(); + + act(() => { + expect(handleRef.current!.getEditor()!.commands.undo()).toBe(true); + }); + expect(handleRef.current?.getValue()).toBe('Alpha beta'); + }); +}); From 1b046e84af1510e2e9f7028c68c2c953bfe56cf7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:55:16 +0900 Subject: [PATCH 07/39] ci(diagnostics): exercise revision-safe application RED --- .github/workflows/writing-diagnostics-editor-actions-tdd.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/writing-diagnostics-editor-actions-tdd.yml b/.github/workflows/writing-diagnostics-editor-actions-tdd.yml index 90cd0e4e..115c3c7d 100644 --- a/.github/workflows/writing-diagnostics-editor-actions-tdd.yml +++ b/.github/workflows/writing-diagnostics-editor-actions-tdd.yml @@ -35,6 +35,7 @@ jobs: run: >- pnpm exec vitest run src/components/CwlEditor.writingDiagnostics.test.tsx + src/components/CwlEditor.writingDiagnosticApply.test.tsx --pool=forks --maxWorkers=1 - name: Typecheck standalone writing-diagnostic contracts From 22f7af27375969dcf02312edb1748fe65a0a6dd8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:59:07 +0900 Subject: [PATCH 08/39] ci(diagnostics): implement revision-safe apply once --- .../workflows/editor-actions-apply-once.yml | 254 ++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 .github/workflows/editor-actions-apply-once.yml diff --git a/.github/workflows/editor-actions-apply-once.yml b/.github/workflows/editor-actions-apply-once.yml new file mode 100644 index 00000000..7cbce7f0 --- /dev/null +++ b/.github/workflows/editor-actions-apply-once.yml @@ -0,0 +1,254 @@ +name: Editor Actions Apply Once + +on: + push: + branches: + - feat/writing-diagnostics-editor-actions + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: editor-actions-apply-once-${{ github.ref }} + cancel-in-progress: true + +jobs: + implement-revision-safe-apply: + 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: Implement exact-revision plain-text application + run: | + python <<'PY' + from pathlib import Path + + def replace_once(source: str, old: str, new: str, label: str) -> str: + count = source.count(old) + if count != 1: + raise SystemExit(f'{label} anchor count was {count}') + return source.replace(old, new, 1) + + controller_path = Path('src/components/useWritingDiagnosticsController.ts') + controller = controller_path.read_text(encoding='utf-8') + + old = ( + " readonly documentRevision: CwlEditorDocumentRevision;\n" + " readonly categoryCode: string;\n" + ) + new = ( + " readonly documentRevision: CwlEditorDocumentRevision;\n" + " /** Strong revision produced by a successful document mutation. */\n" + " readonly resultingDocumentRevision?: CwlEditorDocumentRevision;\n" + " readonly categoryCode: string;\n" + ) + controller = replace_once(controller, old, new, 'action event revision') + + old = ( + " readonly focusDiagnostic: (diagnosticId: string) => boolean;\n" + " readonly ignoreDiagnostic: (\n" + ) + new = ( + " readonly focusDiagnostic: (diagnosticId: string) => boolean;\n" + " readonly applyDiagnostic: (\n" + " diagnosticId: string,\n" + " ) => Promise;\n" + " readonly ignoreDiagnostic: (\n" + ) + controller = replace_once(controller, old, new, 'controller apply contract') + + old = " const focusDiagnostic = useCallback((diagnosticId: string): boolean => {\n" + apply_implementation = r''' const applyDiagnostic = useCallback( + async ( + diagnosticId: string, + ): Promise => { + 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, + ); + const replacement = target?.diagnostic.suggestedReplacement; + if (target === undefined || replacement === undefined) return null; + + const activeEditor = active.editor; + const capturedDocument = activeEditor.state.doc; + const transaction = activeEditor.state.tr.insertText( + replacement, + target.from, + target.to, + ); + let currentRevision: CwlEditorDocumentRevision; + let resultingRevision: CwlEditorDocumentRevision; + try { + const currentEnvelope = createDocumentEnvelope( + capturedDocument.toJSON(), + ); + const resultingEnvelope = createDocumentEnvelope( + transaction.doc.toJSON(), + ); + [currentRevision, resultingRevision] = await Promise.all([ + createValidatedDocumentEnvelopeRevision( + currentEnvelope, + digestProvider, + ), + createValidatedDocumentEnvelopeRevision( + resultingEnvelope, + digestProvider, + ), + ]); + } catch { + const error = new WritingDiagnosticError('revision'); + notifyError(errorRef.current, error); + return null; + } + + const latest = currentRef.current; + const revisionMatches = revisionsEqual( + currentRevision, + target.diagnostic.documentRevision, + ); + const documentMatches = activeEditor.state.doc.eq(capturedDocument); + if ( + latest.status !== 'active' || + latest.generation !== active.generation || + latest.editor !== activeEditor || + activeEditor.isDestroyed || + !revisionMatches || + !documentMatches + ) { + const conflict = Object.freeze({ + action: 'conflict' as const, + reasonCode: revisionMatches + ? ('document_changed' as const) + : ('revision_mismatch' as const), + diagnosticId: target.diagnostic.diagnosticId, + documentRevision: target.diagnostic.documentRevision, + categoryCode: target.diagnostic.categoryCode, + generation: generationRef.current, + }); + notifyAction(actionRef.current, conflict); + return conflict; + } + + try { + activeEditor.view.dispatch(transaction); + } catch { + const error = new WritingDiagnosticError('lifecycle'); + notifyError(errorRef.current, error); + return null; + } + + const event = Object.freeze({ + action: 'applied' as const, + reasonCode: 'explicit' as const, + diagnosticId: target.diagnostic.diagnosticId, + documentRevision: target.diagnostic.documentRevision, + resultingDocumentRevision: resultingRevision, + categoryCode: target.diagnostic.categoryCode, + generation: generationRef.current, + }); + notifyAction(actionRef.current, event); + return event; + }, + [actionRef, digestProvider, errorRef], + ); + +''' + controller = replace_once( + controller, + old, + apply_implementation + old, + 'controller apply implementation', + ) + + old = ( + " focusDiagnostic,\n" + " ignoreDiagnostic,\n" + ) + new = ( + " focusDiagnostic,\n" + " applyDiagnostic,\n" + " ignoreDiagnostic,\n" + ) + controller = replace_once(controller, old, new, 'controller return') + controller_path.write_text(controller, encoding='utf-8') + + editor_path = Path('src/components/CwlEditor.tsx') + editor = editor_path.read_text(encoding='utf-8') + old = ( + " label={writingDiagnosticsLabel ?? 'Writing guidance'}\n" + " printEnabled={printWritingDiagnostics}\n" + ) + new = ( + " label={writingDiagnosticsLabel ?? 'Writing guidance'}\n" + " onApplyDiagnostic={(diagnosticId) => {\n" + " void writingDiagnosticsController.applyDiagnostic(\n" + " diagnosticId,\n" + " );\n" + " }}\n" + " printEnabled={printWritingDiagnostics}\n" + ) + editor = replace_once(editor, old, new, 'editor apply callback') + editor_path.write_text(editor, encoding='utf-8') + + for test_path in [ + Path('src/components/WritingDiagnosticsPanel.test.tsx'), + Path('src/components/WritingDiagnosticsPanel.keyboard.test.tsx'), + Path('src/components/WritingDiagnosticsPanel.print.test.tsx'), + ]: + source = test_path.read_text(encoding='utf-8') + old = " focusDiagnostic: vi.fn(() => " + index = source.find(old) + if index < 0: + raise SystemExit(f'{test_path} focus mock anchor missing') + line_end = source.find('\n', index) + if line_end < 0: + raise SystemExit(f'{test_path} focus mock line incomplete') + insertion = " applyDiagnostic: vi.fn(async () => null),\n" + source = source[: line_end + 1] + insertion + source[line_end + 1 :] + test_path.write_text(source, encoding='utf-8') + PY + - name: Verify revision-safe application + run: | + pnpm exec vitest run \ + src/components/CwlEditor.writingDiagnostics.test.tsx \ + src/components/CwlEditor.writingDiagnosticApply.test.tsx \ + src/components/WritingDiagnosticsPanel.test.tsx \ + src/components/WritingDiagnosticsPanel.keyboard.test.tsx \ + src/components/WritingDiagnosticsPanel.print.test.tsx \ + --pool=forks --maxWorkers=1 + pnpm typecheck + - name: Publish validated application and remove this workflow + env: + TARGET_BRANCH: feat/writing-diagnostics-editor-actions + run: | + set -euo pipefail + rm .github/workflows/editor-actions-apply-once.yml + 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 \ + src/components/CwlEditor.tsx \ + src/components/WritingDiagnosticsPanel.test.tsx \ + src/components/WritingDiagnosticsPanel.keyboard.test.tsx \ + src/components/WritingDiagnosticsPanel.print.test.tsx \ + .github/workflows/editor-actions-apply-once.yml + git diff --cached --check + git commit -m 'feat(diagnostics): apply revision-safe replacements' + git push origin "HEAD:${TARGET_BRANCH}" From 03fb65d10dcbc6c58391dfae66151adffa7c45ca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 21:01:22 +0900 Subject: [PATCH 09/39] ci(diagnostics): repair apply workflow syntax --- .../workflows/editor-actions-apply-once.yml | 187 ++---------------- 1 file changed, 21 insertions(+), 166 deletions(-) diff --git a/.github/workflows/editor-actions-apply-once.yml b/.github/workflows/editor-actions-apply-once.yml index 7cbce7f0..f2bffeed 100644 --- a/.github/workflows/editor-actions-apply-once.yml +++ b/.github/workflows/editor-actions-apply-once.yml @@ -31,6 +31,7 @@ jobs: - name: Implement exact-revision plain-text application run: | python <<'PY' + import base64 from pathlib import Path def replace_once(source: str, old: str, new: str, label: str) -> str: @@ -41,187 +42,41 @@ jobs: controller_path = Path('src/components/useWritingDiagnosticsController.ts') controller = controller_path.read_text(encoding='utf-8') - - old = ( - " readonly documentRevision: CwlEditorDocumentRevision;\n" - " readonly categoryCode: string;\n" - ) - new = ( - " readonly documentRevision: CwlEditorDocumentRevision;\n" - " /** Strong revision produced by a successful document mutation. */\n" - " readonly resultingDocumentRevision?: CwlEditorDocumentRevision;\n" - " readonly categoryCode: string;\n" - ) + old = " readonly documentRevision: CwlEditorDocumentRevision;\n readonly categoryCode: string;\n" + new = " readonly documentRevision: CwlEditorDocumentRevision;\n /** Strong revision produced by a successful document mutation. */\n readonly resultingDocumentRevision?: CwlEditorDocumentRevision;\n readonly categoryCode: string;\n" controller = replace_once(controller, old, new, 'action event revision') - - old = ( - " readonly focusDiagnostic: (diagnosticId: string) => boolean;\n" - " readonly ignoreDiagnostic: (\n" - ) - new = ( - " readonly focusDiagnostic: (diagnosticId: string) => boolean;\n" - " readonly applyDiagnostic: (\n" - " diagnosticId: string,\n" - " ) => Promise;\n" - " readonly ignoreDiagnostic: (\n" - ) + old = " readonly focusDiagnostic: (diagnosticId: string) => boolean;\n readonly ignoreDiagnostic: (\n" + new = " readonly focusDiagnostic: (diagnosticId: string) => boolean;\n readonly applyDiagnostic: (\n diagnosticId: string,\n ) => Promise;\n readonly ignoreDiagnostic: (\n" controller = replace_once(controller, old, new, 'controller apply contract') - old = " const focusDiagnostic = useCallback((diagnosticId: string): boolean => {\n" - apply_implementation = r''' const applyDiagnostic = useCallback( - async ( - diagnosticId: string, - ): Promise => { - 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, - ); - const replacement = target?.diagnostic.suggestedReplacement; - if (target === undefined || replacement === undefined) return null; - - const activeEditor = active.editor; - const capturedDocument = activeEditor.state.doc; - const transaction = activeEditor.state.tr.insertText( - replacement, - target.from, - target.to, - ); - let currentRevision: CwlEditorDocumentRevision; - let resultingRevision: CwlEditorDocumentRevision; - try { - const currentEnvelope = createDocumentEnvelope( - capturedDocument.toJSON(), - ); - const resultingEnvelope = createDocumentEnvelope( - transaction.doc.toJSON(), - ); - [currentRevision, resultingRevision] = await Promise.all([ - createValidatedDocumentEnvelopeRevision( - currentEnvelope, - digestProvider, - ), - createValidatedDocumentEnvelopeRevision( - resultingEnvelope, - digestProvider, - ), - ]); - } catch { - const error = new WritingDiagnosticError('revision'); - notifyError(errorRef.current, error); - return null; - } - - const latest = currentRef.current; - const revisionMatches = revisionsEqual( - currentRevision, - target.diagnostic.documentRevision, - ); - const documentMatches = activeEditor.state.doc.eq(capturedDocument); - if ( - latest.status !== 'active' || - latest.generation !== active.generation || - latest.editor !== activeEditor || - activeEditor.isDestroyed || - !revisionMatches || - !documentMatches - ) { - const conflict = Object.freeze({ - action: 'conflict' as const, - reasonCode: revisionMatches - ? ('document_changed' as const) - : ('revision_mismatch' as const), - diagnosticId: target.diagnostic.diagnosticId, - documentRevision: target.diagnostic.documentRevision, - categoryCode: target.diagnostic.categoryCode, - generation: generationRef.current, - }); - notifyAction(actionRef.current, conflict); - return conflict; - } - - try { - activeEditor.view.dispatch(transaction); - } catch { - const error = new WritingDiagnosticError('lifecycle'); - notifyError(errorRef.current, error); - return null; - } - - const event = Object.freeze({ - action: 'applied' as const, - reasonCode: 'explicit' as const, - diagnosticId: target.diagnostic.diagnosticId, - documentRevision: target.diagnostic.documentRevision, - resultingDocumentRevision: resultingRevision, - categoryCode: target.diagnostic.categoryCode, - generation: generationRef.current, - }); - notifyAction(actionRef.current, event); - return event; - }, - [actionRef, digestProvider, errorRef], - ); - -''' - controller = replace_once( - controller, - old, - apply_implementation + old, - 'controller apply implementation', - ) - - old = ( - " focusDiagnostic,\n" - " ignoreDiagnostic,\n" - ) - new = ( - " focusDiagnostic,\n" - " applyDiagnostic,\n" - " ignoreDiagnostic,\n" - ) + encoded = "ICBjb25zdCBhcHBseURpYWdub3N0aWMgPSB1c2VDYWxsYmFjaygKICAgIGFzeW5jICgKICAgICAgZGlhZ25vc3RpY0lkOiBzdHJpbmcsCiAgICApOiBQcm9taXNlPEN3bFdyaXRpbmdEaWFnbm9zdGljQWN0aW9uRXZlbnQgfCBudWxsPiA9PiB7CiAgICAgIGNvbnN0IGFjdGl2ZSA9IGN1cnJlbnRSZWYuY3VycmVudDsKICAgICAgaWYgKAogICAgICAgIGFjdGl2ZS5zdGF0dXMgIT09ICdhY3RpdmUnIHx8CiAgICAgICAgYWN0aXZlLmVkaXRvciA9PT0gbnVsbCB8fAogICAgICAgIGFjdGl2ZS5lZGl0b3IuaXNEZXN0cm95ZWQKICAgICAgKSB7CiAgICAgICAgcmV0dXJuIG51bGw7CiAgICAgIH0KICAgICAgY29uc3QgdGFyZ2V0ID0gYWN0aXZlLmRpYWdub3N0aWNzLmZpbmQoCiAgICAgICAgKGNhbmRpZGF0ZSkgPT4gY2FuZGlkYXRlLmRpYWdub3N0aWMuZGlhZ25vc3RpY0lkID09PSBkaWFnbm9zdGljSWQsCiAgICAgICk7CiAgICAgIGNvbnN0IHJlcGxhY2VtZW50ID0gdGFyZ2V0Py5kaWFnbm9zdGljLnN1Z2dlc3RlZFJlcGxhY2VtZW50OwogICAgICBpZiAodGFyZ2V0ID09PSB1bmRlZmluZWQgfHwgcmVwbGFjZW1lbnQgPT09IHVuZGVmaW5lZCkgcmV0dXJuIG51bGw7CgogICAgICBjb25zdCBhY3RpdmVFZGl0b3IgPSBhY3RpdmUuZWRpdG9yOwogICAgICBjb25zdCBjYXB0dXJlZERvY3VtZW50ID0gYWN0aXZlRWRpdG9yLnN0YXRlLmRvYzsKICAgICAgY29uc3QgdHJhbnNhY3Rpb24gPSBhY3RpdmVFZGl0b3Iuc3RhdGUudHIuaW5zZXJ0VGV4dCgKICAgICAgICByZXBsYWNlbWVudCwKICAgICAgICB0YXJnZXQuZnJvbSwKICAgICAgICB0YXJnZXQudG8sCiAgICAgICk7CiAgICAgIGxldCBjdXJyZW50UmV2aXNpb246IEN3bEVkaXRvckRvY3VtZW50UmV2aXNpb247CiAgICAgIGxldCByZXN1bHRpbmdSZXZpc2lvbjogQ3dsRWRpdG9yRG9jdW1lbnRSZXZpc2lvbjsKICAgICAgdHJ5IHsKICAgICAgICBjb25zdCBjdXJyZW50RW52ZWxvcGUgPSBjcmVhdGVEb2N1bWVudEVudmVsb3BlKAogICAgICAgICAgY2FwdHVyZWREb2N1bWVudC50b0pTT04oKSwKICAgICAgICApOwogICAgICAgIGNvbnN0IHJlc3VsdGluZ0VudmVsb3BlID0gY3JlYXRlRG9jdW1lbnRFbnZlbG9wZSgKICAgICAgICAgIHRyYW5zYWN0aW9uLmRvYy50b0pTT04oKSwKICAgICAgICApOwogICAgICAgIFtjdXJyZW50UmV2aXNpb24sIHJlc3VsdGluZ1JldmlzaW9uXSA9IGF3YWl0IFByb21pc2UuYWxsKFsKICAgICAgICAgIGNyZWF0ZVZhbGlkYXRlZERvY3VtZW50RW52ZWxvcGVSZXZpc2lvbigKICAgICAgICAgICAgY3VycmVudEVudmVsb3BlLAogICAgICAgICAgICBkaWdlc3RQcm92aWRlciwKICAgICAgICAgICksCiAgICAgICAgICBjcmVhdGVWYWxpZGF0ZWREb2N1bWVudEVudmVsb3BlUmV2aXNpb24oCiAgICAgICAgICAgIHJlc3VsdGluZ0VudmVsb3BlLAogICAgICAgICAgICBkaWdlc3RQcm92aWRlciwKICAgICAgICAgICksCiAgICAgICAgXSk7CiAgICAgIH0gY2F0Y2ggewogICAgICAgIGNvbnN0IGVycm9yID0gbmV3IFdyaXRpbmdEaWFnbm9zdGljRXJyb3IoJ3JldmlzaW9uJyk7CiAgICAgICAgbm90aWZ5RXJyb3IoZXJyb3JSZWYuY3VycmVudCwgZXJyb3IpOwogICAgICAgIHJldHVybiBudWxsOwogICAgICB9CgogICAgICBjb25zdCBsYXRlc3QgPSBjdXJyZW50UmVmLmN1cnJlbnQ7CiAgICAgIGNvbnN0IHJldmlzaW9uTWF0Y2hlcyA9IHJldmlzaW9uc0VxdWFsKAogICAgICAgIGN1cnJlbnRSZXZpc2lvbiwKICAgICAgICB0YXJnZXQuZGlhZ25vc3RpYy5kb2N1bWVudFJldmlzaW9uLAogICAgICApOwogICAgICBjb25zdCBkb2N1bWVudE1hdGNoZXMgPSBhY3RpdmVFZGl0b3Iuc3RhdGUuZG9jLmVxKGNhcHR1cmVkRG9jdW1lbnQpOwogICAgICBpZiAoCiAgICAgICAgbGF0ZXN0LnN0YXR1cyAhPT0gJ2FjdGl2ZScgfHwKICAgICAgICBsYXRlc3QuZ2VuZXJhdGlvbiAhPT0gYWN0aXZlLmdlbmVyYXRpb24gfHwKICAgICAgICBsYXRlc3QuZWRpdG9yICE9PSBhY3RpdmVFZGl0b3IgfHwKICAgICAgICBhY3RpdmVFZGl0b3IuaXNEZXN0cm95ZWQgfHwKICAgICAgICAhcmV2aXNpb25NYXRjaGVzIHx8CiAgICAgICAgIWRvY3VtZW50TWF0Y2hlcwogICAgICApIHsKICAgICAgICBjb25zdCBjb25mbGljdCA9IE9iamVjdC5mcmVlemUoewogICAgICAgICAgYWN0aW9uOiAnY29uZmxpY3QnIGFzIGNvbnN0LAogICAgICAgICAgcmVhc29uQ29kZTogcmV2aXNpb25NYXRjaGVzCiAgICAgICAgICAgID8gKCdkb2N1bWVudF9jaGFuZ2VkJyBhcyBjb25zdCkKICAgICAgICAgICAgOiAoJ3JldmlzaW9uX21pc21hdGNoJyBhcyBjb25zdCksCiAgICAgICAgICBkaWFnbm9zdGljSWQ6IHRhcmdldC5kaWFnbm9zdGljLmRpYWdub3N0aWNJZCwKICAgICAgICAgIGRvY3VtZW50UmV2aXNpb246IHRhcmdldC5kaWFnbm9zdGljLmRvY3VtZW50UmV2aXNpb24sCiAgICAgICAgICBjYXRlZ29yeUNvZGU6IHRhcmdldC5kaWFnbm9zdGljLmNhdGVnb3J5Q29kZSwKICAgICAgICAgIGdlbmVyYXRpb246IGdlbmVyYXRpb25SZWYuY3VycmVudCwKICAgICAgICB9KTsKICAgICAgICBub3RpZnlBY3Rpb24oYWN0aW9uUmVmLmN1cnJlbnQsIGNvbmZsaWN0KTsKICAgICAgICByZXR1cm4gY29uZmxpY3Q7CiAgICAgIH0KCiAgICAgIHRyeSB7CiAgICAgICAgYWN0aXZlRWRpdG9yLnZpZXcuZGlzcGF0Y2godHJhbnNhY3Rpb24pOwogICAgICB9IGNhdGNoIHsKICAgICAgICBjb25zdCBlcnJvciA9IG5ldyBXcml0aW5nRGlhZ25vc3RpY0Vycm9yKCdsaWZlY3ljbGUnKTsKICAgICAgICBub3RpZnlFcnJvcihlcnJvclJlZi5jdXJyZW50LCBlcnJvcik7CiAgICAgICAgcmV0dXJuIG51bGw7CiAgICAgIH0KCiAgICAgIGNvbnN0IGV2ZW50ID0gT2JqZWN0LmZyZWV6ZSh7CiAgICAgICAgYWN0aW9uOiAnYXBwbGllZCcgYXMgY29uc3QsCiAgICAgICAgcmVhc29uQ29kZTogJ2V4cGxpY2l0JyBhcyBjb25zdCwKICAgICAgICBkaWFnbm9zdGljSWQ6IHRhcmdldC5kaWFnbm9zdGljLmRpYWdub3N0aWNJZCwKICAgICAgICBkb2N1bWVudFJldmlzaW9uOiB0YXJnZXQuZGlhZ25vc3RpYy5kb2N1bWVudFJldmlzaW9uLAogICAgICAgIHJlc3VsdGluZ0RvY3VtZW50UmV2aXNpb246IHJlc3VsdGluZ1JldmlzaW9uLAogICAgICAgIGNhdGVnb3J5Q29kZTogdGFyZ2V0LmRpYWdub3N0aWMuY2F0ZWdvcnlDb2RlLAogICAgICAgIGdlbmVyYXRpb246IGdlbmVyYXRpb25SZWYuY3VycmVudCwKICAgICAgfSk7CiAgICAgIG5vdGlmeUFjdGlvbihhY3Rpb25SZWYuY3VycmVudCwgZXZlbnQpOwogICAgICByZXR1cm4gZXZlbnQ7CiAgICB9LAogICAgW2FjdGlvblJlZiwgZGlnZXN0UHJvdmlkZXIsIGVycm9yUmVmXSwKICApOwoK" + apply_implementation = base64.b64decode(encoded).decode('utf-8') + controller = replace_once(controller, old, apply_implementation + old, 'controller implementation') + old = " focusDiagnostic,\n ignoreDiagnostic,\n" + new = " focusDiagnostic,\n applyDiagnostic,\n ignoreDiagnostic,\n" controller = replace_once(controller, old, new, 'controller return') controller_path.write_text(controller, encoding='utf-8') editor_path = Path('src/components/CwlEditor.tsx') editor = editor_path.read_text(encoding='utf-8') - old = ( - " label={writingDiagnosticsLabel ?? 'Writing guidance'}\n" - " printEnabled={printWritingDiagnostics}\n" - ) - new = ( - " label={writingDiagnosticsLabel ?? 'Writing guidance'}\n" - " onApplyDiagnostic={(diagnosticId) => {\n" - " void writingDiagnosticsController.applyDiagnostic(\n" - " diagnosticId,\n" - " );\n" - " }}\n" - " printEnabled={printWritingDiagnostics}\n" - ) + old = " label={writingDiagnosticsLabel ?? 'Writing guidance'}\n printEnabled={printWritingDiagnostics}\n" + new = " label={writingDiagnosticsLabel ?? 'Writing guidance'}\n onApplyDiagnostic={(diagnosticId) => {\n void writingDiagnosticsController.applyDiagnostic(diagnosticId);\n }}\n printEnabled={printWritingDiagnostics}\n" editor = replace_once(editor, old, new, 'editor apply callback') editor_path.write_text(editor, encoding='utf-8') - for test_path in [ - Path('src/components/WritingDiagnosticsPanel.test.tsx'), - Path('src/components/WritingDiagnosticsPanel.keyboard.test.tsx'), - Path('src/components/WritingDiagnosticsPanel.print.test.tsx'), + for test_name in [ + 'src/components/WritingDiagnosticsPanel.test.tsx', + 'src/components/WritingDiagnosticsPanel.keyboard.test.tsx', + 'src/components/WritingDiagnosticsPanel.print.test.tsx', ]: + test_path = Path(test_name) source = test_path.read_text(encoding='utf-8') - old = " focusDiagnostic: vi.fn(() => " - index = source.find(old) + marker = " focusDiagnostic: vi.fn(() => " + index = source.find(marker) if index < 0: - raise SystemExit(f'{test_path} focus mock anchor missing') + raise SystemExit(f'{test_name} focus mock anchor missing') line_end = source.find('\n', index) - if line_end < 0: - raise SystemExit(f'{test_path} focus mock line incomplete') - insertion = " applyDiagnostic: vi.fn(async () => null),\n" - source = source[: line_end + 1] + insertion + source[line_end + 1 :] + source = source[: line_end + 1] + " applyDiagnostic: vi.fn(async () => null),\n" + source[line_end + 1 :] test_path.write_text(source, encoding='utf-8') PY - name: Verify revision-safe application From ef005917dd4ab81d524047ac1eada5fae535a127 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 21:03:14 +0900 Subject: [PATCH 10/39] ci(diagnostics): normalize panel mock indentation --- .github/workflows/editor-actions-apply-once.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/editor-actions-apply-once.yml b/.github/workflows/editor-actions-apply-once.yml index f2bffeed..bc901da0 100644 --- a/.github/workflows/editor-actions-apply-once.yml +++ b/.github/workflows/editor-actions-apply-once.yml @@ -71,12 +71,15 @@ jobs: ]: test_path = Path(test_name) source = test_path.read_text(encoding='utf-8') - marker = " focusDiagnostic: vi.fn(() => " + marker = 'focusDiagnostic: vi.fn(() =>' index = source.find(marker) if index < 0: raise SystemExit(f'{test_name} focus mock anchor missing') + line_start = source.rfind('\n', 0, index) + 1 + indent = source[line_start:index] line_end = source.find('\n', index) - source = source[: line_end + 1] + " applyDiagnostic: vi.fn(async () => null),\n" + source[line_end + 1 :] + insertion = f'{indent}applyDiagnostic: vi.fn(async () => null),\n' + source = source[: line_end + 1] + insertion + source[line_end + 1 :] test_path.write_text(source, encoding='utf-8') PY - name: Verify revision-safe application From 8a11b400dd0f1876d0ce19e75c1cfbd8d43118bf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:03:50 +0000 Subject: [PATCH 11/39] feat(diagnostics): apply revision-safe replacements --- .../workflows/editor-actions-apply-once.yml | 112 ------------------ src/components/CwlEditor.tsx | 3 + .../WritingDiagnosticsPanel.keyboard.test.tsx | 1 + .../WritingDiagnosticsPanel.print.test.tsx | 1 + .../WritingDiagnosticsPanel.test.tsx | 1 + .../useWritingDiagnosticsController.ts | 107 +++++++++++++++++ 6 files changed, 113 insertions(+), 112 deletions(-) delete mode 100644 .github/workflows/editor-actions-apply-once.yml diff --git a/.github/workflows/editor-actions-apply-once.yml b/.github/workflows/editor-actions-apply-once.yml deleted file mode 100644 index bc901da0..00000000 --- a/.github/workflows/editor-actions-apply-once.yml +++ /dev/null @@ -1,112 +0,0 @@ -name: Editor Actions Apply Once - -on: - push: - branches: - - feat/writing-diagnostics-editor-actions - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: editor-actions-apply-once-${{ github.ref }} - cancel-in-progress: true - -jobs: - implement-revision-safe-apply: - 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: Implement exact-revision plain-text application - run: | - python <<'PY' - import base64 - from pathlib import Path - - def replace_once(source: str, old: str, new: str, label: str) -> str: - count = source.count(old) - if count != 1: - raise SystemExit(f'{label} anchor count was {count}') - return source.replace(old, new, 1) - - controller_path = Path('src/components/useWritingDiagnosticsController.ts') - controller = controller_path.read_text(encoding='utf-8') - old = " readonly documentRevision: CwlEditorDocumentRevision;\n readonly categoryCode: string;\n" - new = " readonly documentRevision: CwlEditorDocumentRevision;\n /** Strong revision produced by a successful document mutation. */\n readonly resultingDocumentRevision?: CwlEditorDocumentRevision;\n readonly categoryCode: string;\n" - controller = replace_once(controller, old, new, 'action event revision') - old = " readonly focusDiagnostic: (diagnosticId: string) => boolean;\n readonly ignoreDiagnostic: (\n" - new = " readonly focusDiagnostic: (diagnosticId: string) => boolean;\n readonly applyDiagnostic: (\n diagnosticId: string,\n ) => Promise;\n readonly ignoreDiagnostic: (\n" - controller = replace_once(controller, old, new, 'controller apply contract') - old = " const focusDiagnostic = useCallback((diagnosticId: string): boolean => {\n" - encoded = "ICBjb25zdCBhcHBseURpYWdub3N0aWMgPSB1c2VDYWxsYmFjaygKICAgIGFzeW5jICgKICAgICAgZGlhZ25vc3RpY0lkOiBzdHJpbmcsCiAgICApOiBQcm9taXNlPEN3bFdyaXRpbmdEaWFnbm9zdGljQWN0aW9uRXZlbnQgfCBudWxsPiA9PiB7CiAgICAgIGNvbnN0IGFjdGl2ZSA9IGN1cnJlbnRSZWYuY3VycmVudDsKICAgICAgaWYgKAogICAgICAgIGFjdGl2ZS5zdGF0dXMgIT09ICdhY3RpdmUnIHx8CiAgICAgICAgYWN0aXZlLmVkaXRvciA9PT0gbnVsbCB8fAogICAgICAgIGFjdGl2ZS5lZGl0b3IuaXNEZXN0cm95ZWQKICAgICAgKSB7CiAgICAgICAgcmV0dXJuIG51bGw7CiAgICAgIH0KICAgICAgY29uc3QgdGFyZ2V0ID0gYWN0aXZlLmRpYWdub3N0aWNzLmZpbmQoCiAgICAgICAgKGNhbmRpZGF0ZSkgPT4gY2FuZGlkYXRlLmRpYWdub3N0aWMuZGlhZ25vc3RpY0lkID09PSBkaWFnbm9zdGljSWQsCiAgICAgICk7CiAgICAgIGNvbnN0IHJlcGxhY2VtZW50ID0gdGFyZ2V0Py5kaWFnbm9zdGljLnN1Z2dlc3RlZFJlcGxhY2VtZW50OwogICAgICBpZiAodGFyZ2V0ID09PSB1bmRlZmluZWQgfHwgcmVwbGFjZW1lbnQgPT09IHVuZGVmaW5lZCkgcmV0dXJuIG51bGw7CgogICAgICBjb25zdCBhY3RpdmVFZGl0b3IgPSBhY3RpdmUuZWRpdG9yOwogICAgICBjb25zdCBjYXB0dXJlZERvY3VtZW50ID0gYWN0aXZlRWRpdG9yLnN0YXRlLmRvYzsKICAgICAgY29uc3QgdHJhbnNhY3Rpb24gPSBhY3RpdmVFZGl0b3Iuc3RhdGUudHIuaW5zZXJ0VGV4dCgKICAgICAgICByZXBsYWNlbWVudCwKICAgICAgICB0YXJnZXQuZnJvbSwKICAgICAgICB0YXJnZXQudG8sCiAgICAgICk7CiAgICAgIGxldCBjdXJyZW50UmV2aXNpb246IEN3bEVkaXRvckRvY3VtZW50UmV2aXNpb247CiAgICAgIGxldCByZXN1bHRpbmdSZXZpc2lvbjogQ3dsRWRpdG9yRG9jdW1lbnRSZXZpc2lvbjsKICAgICAgdHJ5IHsKICAgICAgICBjb25zdCBjdXJyZW50RW52ZWxvcGUgPSBjcmVhdGVEb2N1bWVudEVudmVsb3BlKAogICAgICAgICAgY2FwdHVyZWREb2N1bWVudC50b0pTT04oKSwKICAgICAgICApOwogICAgICAgIGNvbnN0IHJlc3VsdGluZ0VudmVsb3BlID0gY3JlYXRlRG9jdW1lbnRFbnZlbG9wZSgKICAgICAgICAgIHRyYW5zYWN0aW9uLmRvYy50b0pTT04oKSwKICAgICAgICApOwogICAgICAgIFtjdXJyZW50UmV2aXNpb24sIHJlc3VsdGluZ1JldmlzaW9uXSA9IGF3YWl0IFByb21pc2UuYWxsKFsKICAgICAgICAgIGNyZWF0ZVZhbGlkYXRlZERvY3VtZW50RW52ZWxvcGVSZXZpc2lvbigKICAgICAgICAgICAgY3VycmVudEVudmVsb3BlLAogICAgICAgICAgICBkaWdlc3RQcm92aWRlciwKICAgICAgICAgICksCiAgICAgICAgICBjcmVhdGVWYWxpZGF0ZWREb2N1bWVudEVudmVsb3BlUmV2aXNpb24oCiAgICAgICAgICAgIHJlc3VsdGluZ0VudmVsb3BlLAogICAgICAgICAgICBkaWdlc3RQcm92aWRlciwKICAgICAgICAgICksCiAgICAgICAgXSk7CiAgICAgIH0gY2F0Y2ggewogICAgICAgIGNvbnN0IGVycm9yID0gbmV3IFdyaXRpbmdEaWFnbm9zdGljRXJyb3IoJ3JldmlzaW9uJyk7CiAgICAgICAgbm90aWZ5RXJyb3IoZXJyb3JSZWYuY3VycmVudCwgZXJyb3IpOwogICAgICAgIHJldHVybiBudWxsOwogICAgICB9CgogICAgICBjb25zdCBsYXRlc3QgPSBjdXJyZW50UmVmLmN1cnJlbnQ7CiAgICAgIGNvbnN0IHJldmlzaW9uTWF0Y2hlcyA9IHJldmlzaW9uc0VxdWFsKAogICAgICAgIGN1cnJlbnRSZXZpc2lvbiwKICAgICAgICB0YXJnZXQuZGlhZ25vc3RpYy5kb2N1bWVudFJldmlzaW9uLAogICAgICApOwogICAgICBjb25zdCBkb2N1bWVudE1hdGNoZXMgPSBhY3RpdmVFZGl0b3Iuc3RhdGUuZG9jLmVxKGNhcHR1cmVkRG9jdW1lbnQpOwogICAgICBpZiAoCiAgICAgICAgbGF0ZXN0LnN0YXR1cyAhPT0gJ2FjdGl2ZScgfHwKICAgICAgICBsYXRlc3QuZ2VuZXJhdGlvbiAhPT0gYWN0aXZlLmdlbmVyYXRpb24gfHwKICAgICAgICBsYXRlc3QuZWRpdG9yICE9PSBhY3RpdmVFZGl0b3IgfHwKICAgICAgICBhY3RpdmVFZGl0b3IuaXNEZXN0cm95ZWQgfHwKICAgICAgICAhcmV2aXNpb25NYXRjaGVzIHx8CiAgICAgICAgIWRvY3VtZW50TWF0Y2hlcwogICAgICApIHsKICAgICAgICBjb25zdCBjb25mbGljdCA9IE9iamVjdC5mcmVlemUoewogICAgICAgICAgYWN0aW9uOiAnY29uZmxpY3QnIGFzIGNvbnN0LAogICAgICAgICAgcmVhc29uQ29kZTogcmV2aXNpb25NYXRjaGVzCiAgICAgICAgICAgID8gKCdkb2N1bWVudF9jaGFuZ2VkJyBhcyBjb25zdCkKICAgICAgICAgICAgOiAoJ3JldmlzaW9uX21pc21hdGNoJyBhcyBjb25zdCksCiAgICAgICAgICBkaWFnbm9zdGljSWQ6IHRhcmdldC5kaWFnbm9zdGljLmRpYWdub3N0aWNJZCwKICAgICAgICAgIGRvY3VtZW50UmV2aXNpb246IHRhcmdldC5kaWFnbm9zdGljLmRvY3VtZW50UmV2aXNpb24sCiAgICAgICAgICBjYXRlZ29yeUNvZGU6IHRhcmdldC5kaWFnbm9zdGljLmNhdGVnb3J5Q29kZSwKICAgICAgICAgIGdlbmVyYXRpb246IGdlbmVyYXRpb25SZWYuY3VycmVudCwKICAgICAgICB9KTsKICAgICAgICBub3RpZnlBY3Rpb24oYWN0aW9uUmVmLmN1cnJlbnQsIGNvbmZsaWN0KTsKICAgICAgICByZXR1cm4gY29uZmxpY3Q7CiAgICAgIH0KCiAgICAgIHRyeSB7CiAgICAgICAgYWN0aXZlRWRpdG9yLnZpZXcuZGlzcGF0Y2godHJhbnNhY3Rpb24pOwogICAgICB9IGNhdGNoIHsKICAgICAgICBjb25zdCBlcnJvciA9IG5ldyBXcml0aW5nRGlhZ25vc3RpY0Vycm9yKCdsaWZlY3ljbGUnKTsKICAgICAgICBub3RpZnlFcnJvcihlcnJvclJlZi5jdXJyZW50LCBlcnJvcik7CiAgICAgICAgcmV0dXJuIG51bGw7CiAgICAgIH0KCiAgICAgIGNvbnN0IGV2ZW50ID0gT2JqZWN0LmZyZWV6ZSh7CiAgICAgICAgYWN0aW9uOiAnYXBwbGllZCcgYXMgY29uc3QsCiAgICAgICAgcmVhc29uQ29kZTogJ2V4cGxpY2l0JyBhcyBjb25zdCwKICAgICAgICBkaWFnbm9zdGljSWQ6IHRhcmdldC5kaWFnbm9zdGljLmRpYWdub3N0aWNJZCwKICAgICAgICBkb2N1bWVudFJldmlzaW9uOiB0YXJnZXQuZGlhZ25vc3RpYy5kb2N1bWVudFJldmlzaW9uLAogICAgICAgIHJlc3VsdGluZ0RvY3VtZW50UmV2aXNpb246IHJlc3VsdGluZ1JldmlzaW9uLAogICAgICAgIGNhdGVnb3J5Q29kZTogdGFyZ2V0LmRpYWdub3N0aWMuY2F0ZWdvcnlDb2RlLAogICAgICAgIGdlbmVyYXRpb246IGdlbmVyYXRpb25SZWYuY3VycmVudCwKICAgICAgfSk7CiAgICAgIG5vdGlmeUFjdGlvbihhY3Rpb25SZWYuY3VycmVudCwgZXZlbnQpOwogICAgICByZXR1cm4gZXZlbnQ7CiAgICB9LAogICAgW2FjdGlvblJlZiwgZGlnZXN0UHJvdmlkZXIsIGVycm9yUmVmXSwKICApOwoK" - apply_implementation = base64.b64decode(encoded).decode('utf-8') - controller = replace_once(controller, old, apply_implementation + old, 'controller implementation') - old = " focusDiagnostic,\n ignoreDiagnostic,\n" - new = " focusDiagnostic,\n applyDiagnostic,\n ignoreDiagnostic,\n" - controller = replace_once(controller, old, new, 'controller return') - controller_path.write_text(controller, encoding='utf-8') - - editor_path = Path('src/components/CwlEditor.tsx') - editor = editor_path.read_text(encoding='utf-8') - old = " label={writingDiagnosticsLabel ?? 'Writing guidance'}\n printEnabled={printWritingDiagnostics}\n" - new = " label={writingDiagnosticsLabel ?? 'Writing guidance'}\n onApplyDiagnostic={(diagnosticId) => {\n void writingDiagnosticsController.applyDiagnostic(diagnosticId);\n }}\n printEnabled={printWritingDiagnostics}\n" - editor = replace_once(editor, old, new, 'editor apply callback') - editor_path.write_text(editor, encoding='utf-8') - - for test_name in [ - 'src/components/WritingDiagnosticsPanel.test.tsx', - 'src/components/WritingDiagnosticsPanel.keyboard.test.tsx', - 'src/components/WritingDiagnosticsPanel.print.test.tsx', - ]: - test_path = Path(test_name) - source = test_path.read_text(encoding='utf-8') - marker = 'focusDiagnostic: vi.fn(() =>' - index = source.find(marker) - if index < 0: - raise SystemExit(f'{test_name} focus mock anchor missing') - line_start = source.rfind('\n', 0, index) + 1 - indent = source[line_start:index] - line_end = source.find('\n', index) - insertion = f'{indent}applyDiagnostic: vi.fn(async () => null),\n' - source = source[: line_end + 1] + insertion + source[line_end + 1 :] - test_path.write_text(source, encoding='utf-8') - PY - - name: Verify revision-safe application - run: | - pnpm exec vitest run \ - src/components/CwlEditor.writingDiagnostics.test.tsx \ - src/components/CwlEditor.writingDiagnosticApply.test.tsx \ - src/components/WritingDiagnosticsPanel.test.tsx \ - src/components/WritingDiagnosticsPanel.keyboard.test.tsx \ - src/components/WritingDiagnosticsPanel.print.test.tsx \ - --pool=forks --maxWorkers=1 - pnpm typecheck - - name: Publish validated application and remove this workflow - env: - TARGET_BRANCH: feat/writing-diagnostics-editor-actions - run: | - set -euo pipefail - rm .github/workflows/editor-actions-apply-once.yml - 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 \ - src/components/CwlEditor.tsx \ - src/components/WritingDiagnosticsPanel.test.tsx \ - src/components/WritingDiagnosticsPanel.keyboard.test.tsx \ - src/components/WritingDiagnosticsPanel.print.test.tsx \ - .github/workflows/editor-actions-apply-once.yml - git diff --cached --check - git commit -m 'feat(diagnostics): apply revision-safe replacements' - git push origin "HEAD:${TARGET_BRANCH}" diff --git a/src/components/CwlEditor.tsx b/src/components/CwlEditor.tsx index 1b47bf9f..6948b152 100644 --- a/src/components/CwlEditor.tsx +++ b/src/components/CwlEditor.tsx @@ -267,6 +267,9 @@ export const CwlEditor = forwardRef( { + void writingDiagnosticsController.applyDiagnostic(diagnosticId); + }} printEnabled={printWritingDiagnostics} /> ) diff --git a/src/components/WritingDiagnosticsPanel.keyboard.test.tsx b/src/components/WritingDiagnosticsPanel.keyboard.test.tsx index b7ecd333..f087ceb1 100644 --- a/src/components/WritingDiagnosticsPanel.keyboard.test.tsx +++ b/src/components/WritingDiagnosticsPanel.keyboard.test.tsx @@ -62,6 +62,7 @@ function controller( diagnostics, digestProvider: null, focusDiagnostic: vi.fn(() => true), + applyDiagnostic: vi.fn(async () => null), ignoreDiagnostic: vi.fn(() => null), dismissDiagnostic: vi.fn(() => null), requestDiagnosticExplanation: vi.fn(() => null), diff --git a/src/components/WritingDiagnosticsPanel.print.test.tsx b/src/components/WritingDiagnosticsPanel.print.test.tsx index 907d4ef4..e49e3a87 100644 --- a/src/components/WritingDiagnosticsPanel.print.test.tsx +++ b/src/components/WritingDiagnosticsPanel.print.test.tsx @@ -10,6 +10,7 @@ const emptyController: WritingDiagnosticsController = { diagnostics: [], digestProvider: null, focusDiagnostic: vi.fn(() => false), + applyDiagnostic: vi.fn(async () => null), ignoreDiagnostic: vi.fn(() => null), dismissDiagnostic: vi.fn(() => null), requestDiagnosticExplanation: vi.fn(() => null), diff --git a/src/components/WritingDiagnosticsPanel.test.tsx b/src/components/WritingDiagnosticsPanel.test.tsx index 64383b9e..77110df5 100644 --- a/src/components/WritingDiagnosticsPanel.test.tsx +++ b/src/components/WritingDiagnosticsPanel.test.tsx @@ -89,6 +89,7 @@ function controllerFor( diagnostics, digestProvider: null, focusDiagnostic: vi.fn(() => true), + applyDiagnostic: vi.fn(async () => null), ignoreDiagnostic: vi.fn((diagnosticId) => { const diagnostic = diagnostics.find( (candidate) => candidate.diagnostic.diagnosticId === diagnosticId, diff --git a/src/components/useWritingDiagnosticsController.ts b/src/components/useWritingDiagnosticsController.ts index c0179383..dad5b2f3 100644 --- a/src/components/useWritingDiagnosticsController.ts +++ b/src/components/useWritingDiagnosticsController.ts @@ -58,6 +58,8 @@ export interface CwlWritingDiagnosticActionEvent { readonly reasonCode: CwlWritingDiagnosticActionReasonCode; readonly diagnosticId: string; readonly documentRevision: CwlEditorDocumentRevision; + /** Strong revision produced by a successful document mutation. */ + readonly resultingDocumentRevision?: CwlEditorDocumentRevision; readonly categoryCode: string; readonly generation: number; } @@ -87,6 +89,9 @@ export interface WritingDiagnosticsController { /** Exposed only for deterministic integration tests and later adapter plumbing. */ readonly digestProvider: DocumentEnvelopeDigestProvider | null | undefined; readonly focusDiagnostic: (diagnosticId: string) => boolean; + readonly applyDiagnostic: ( + diagnosticId: string, + ) => Promise; readonly ignoreDiagnostic: ( diagnosticId: string, ) => CwlWritingDiagnosticActionEvent | null; @@ -494,6 +499,107 @@ export function useWritingDiagnosticsController( })(); }); + const applyDiagnostic = useCallback( + async ( + diagnosticId: string, + ): Promise => { + 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, + ); + const replacement = target?.diagnostic.suggestedReplacement; + if (target === undefined || replacement === undefined) return null; + + const activeEditor = active.editor; + const capturedDocument = activeEditor.state.doc; + const transaction = activeEditor.state.tr.insertText( + replacement, + target.from, + target.to, + ); + let currentRevision: CwlEditorDocumentRevision; + let resultingRevision: CwlEditorDocumentRevision; + try { + const currentEnvelope = createDocumentEnvelope( + capturedDocument.toJSON(), + ); + const resultingEnvelope = createDocumentEnvelope( + transaction.doc.toJSON(), + ); + [currentRevision, resultingRevision] = await Promise.all([ + createValidatedDocumentEnvelopeRevision( + currentEnvelope, + digestProvider, + ), + createValidatedDocumentEnvelopeRevision( + resultingEnvelope, + digestProvider, + ), + ]); + } catch { + const error = new WritingDiagnosticError('revision'); + notifyError(errorRef.current, error); + return null; + } + + const latest = currentRef.current; + const revisionMatches = revisionsEqual( + currentRevision, + target.diagnostic.documentRevision, + ); + const documentMatches = activeEditor.state.doc.eq(capturedDocument); + if ( + latest.status !== 'active' || + latest.generation !== active.generation || + latest.editor !== activeEditor || + activeEditor.isDestroyed || + !revisionMatches || + !documentMatches + ) { + const conflict = Object.freeze({ + action: 'conflict' as const, + reasonCode: revisionMatches + ? ('document_changed' as const) + : ('revision_mismatch' as const), + diagnosticId: target.diagnostic.diagnosticId, + documentRevision: target.diagnostic.documentRevision, + categoryCode: target.diagnostic.categoryCode, + generation: generationRef.current, + }); + notifyAction(actionRef.current, conflict); + return conflict; + } + + try { + activeEditor.view.dispatch(transaction); + } catch { + const error = new WritingDiagnosticError('lifecycle'); + notifyError(errorRef.current, error); + return null; + } + + const event = Object.freeze({ + action: 'applied' as const, + reasonCode: 'explicit' as const, + diagnosticId: target.diagnostic.diagnosticId, + documentRevision: target.diagnostic.documentRevision, + resultingDocumentRevision: resultingRevision, + categoryCode: target.diagnostic.categoryCode, + generation: generationRef.current, + }); + notifyAction(actionRef.current, event); + return event; + }, + [actionRef, digestProvider, errorRef], + ); + const focusDiagnostic = useCallback((diagnosticId: string): boolean => { const active = currentRef.current; if ( @@ -608,6 +714,7 @@ export function useWritingDiagnosticsController( diagnostics: current.diagnostics, digestProvider, focusDiagnostic, + applyDiagnostic, ignoreDiagnostic, dismissDiagnostic, requestDiagnosticExplanation, From 0e9ebc186ccd73b9fb99939519b9eb55e037c5bf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 21:05:02 +0900 Subject: [PATCH 12/39] ci(diagnostics): run complete editor action acceptance --- .../writing-diagnostics-editor-actions-tdd.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/writing-diagnostics-editor-actions-tdd.yml b/.github/workflows/writing-diagnostics-editor-actions-tdd.yml index 115c3c7d..1f5bd255 100644 --- a/.github/workflows/writing-diagnostics-editor-actions-tdd.yml +++ b/.github/workflows/writing-diagnostics-editor-actions-tdd.yml @@ -19,7 +19,7 @@ env: jobs: focused-editor-actions: runs-on: ubuntu-24.04 - timeout-minutes: 20 + timeout-minutes: 25 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -40,3 +40,11 @@ jobs: --maxWorkers=1 - name: Typecheck standalone writing-diagnostic contracts run: pnpm typecheck + - name: Run complete production coverage gate + run: pnpm coverage + - name: Build all package entrypoints + run: pnpm build + - name: Verify isolated packed-package consumers + run: pnpm verify:package + - name: Build demonstration application + run: pnpm build:demo From 7f7aadb22a6951e11e5862e640fd754efa9740fe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 21:08:53 +0900 Subject: [PATCH 13/39] test(diagnostics): cover apply conflicts and failures --- ...ritingDiagnosticsController.apply.test.tsx | 212 ++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 src/components/useWritingDiagnosticsController.apply.test.tsx diff --git a/src/components/useWritingDiagnosticsController.apply.test.tsx b/src/components/useWritingDiagnosticsController.apply.test.tsx new file mode 100644 index 00000000..3a7632ac --- /dev/null +++ b/src/components/useWritingDiagnosticsController.apply.test.tsx @@ -0,0 +1,212 @@ +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'; +import { useWritingDiagnosticsController } from './useWritingDiagnosticsController.js'; + +const DIGEST_HEX = '11'.repeat(32); +const OTHER_DIGEST_HEX = '22'.repeat(32); +const openEditors: Editor[] = []; + +function digestBytes(hex = DIGEST_HEX): ArrayBuffer { + return Uint8Array.from( + hex.match(/../gu)!.map((part) => Number.parseInt(part, 16)), + ).buffer; +} + +function createEditor(): Editor { + const editor = new Editor({ + extensions: buildExtensions(), + content: '

Alpha beta gamma

', + }); + openEditors.push(editor); + return editor; +} + +function diagnostic(): CwlWritingDiagnostic { + return { + diagnosticId: 'diag-apply', + documentRevision: { + algorithm: 'SHA-256', + digestHex: DIGEST_HEX, + strongEntityTag: `"sha256-${DIGEST_HEX}"`, + }, + textProjection: { + id: TEXT_POSITION_PROJECTION_ID, + version: TEXT_POSITION_PROJECTION_VERSION, + }, + selector: { type: 'TextPositionSelector', start: 0, end: 5 }, + categoryCode: 'clarity', + priority: 'important', + title: 'Clarify the request', + explanation: 'Make the action explicit.', + suggestedReplacement: 'Omega', + provenance: { + workflowId: 'email-writing-review', + workflowVersion: '1', + judgePolicyVersion: 'evaluation-only-1', + }, + }; +} + +function stableProvider(): DocumentEnvelopeDigestProvider { + return { digest: vi.fn(async () => digestBytes()) }; +} + +afterEach(() => { + for (const editor of openEditors.splice(0)) { + if (!editor.isDestroyed) editor.destroy(); + } + vi.restoreAllMocks(); +}); + +describe('revision-safe writing diagnostic application failures', () => { + it('reports a redacted revision error when hashing fails before mutation', async () => { + const editor = createEditor(); + const before = editor.getJSON(); + const onError = vi.fn(); + const provider: DocumentEnvelopeDigestProvider = { + digest: vi + .fn() + .mockResolvedValueOnce(digestBytes()) + .mockRejectedValue(new Error('private digest failure')), + }; + const { result } = renderHook(() => + useWritingDiagnosticsController({ + editor, + diagnostics: [diagnostic()], + digestProvider: provider, + onError, + }), + ); + await waitFor(() => expect(result.current.status).toBe('active')); + + let event = null; + await act(async () => { + event = await result.current.applyDiagnostic('diag-apply'); + }); + + expect(event).toBeNull(); + expect(editor.getJSON()).toEqual(before); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'revision' }), + ); + }); + + it('abstains with revision_mismatch when the rechecked document digest differs', async () => { + const editor = createEditor(); + const before = editor.getJSON(); + const onAction = vi.fn(); + const provider: DocumentEnvelopeDigestProvider = { + digest: vi + .fn() + .mockResolvedValueOnce(digestBytes()) + .mockResolvedValueOnce(digestBytes(OTHER_DIGEST_HEX)) + .mockResolvedValueOnce(digestBytes(OTHER_DIGEST_HEX)), + }; + const { result } = renderHook(() => + useWritingDiagnosticsController({ + editor, + diagnostics: [diagnostic()], + digestProvider: provider, + onAction, + }), + ); + await waitFor(() => expect(result.current.status).toBe('active')); + + let event = null; + await act(async () => { + event = await result.current.applyDiagnostic('diag-apply'); + }); + + expect(event).toMatchObject({ + action: 'conflict', + reasonCode: 'revision_mismatch', + diagnosticId: 'diag-apply', + }); + expect(editor.getJSON()).toEqual(before); + expect(onAction).toHaveBeenCalledWith(event); + }); + + it('abstains with document_changed when authored content changes during hashing', async () => { + const editor = createEditor(); + const pendingResolvers: Array<(value: ArrayBuffer) => void> = []; + let defer = false; + const provider: DocumentEnvelopeDigestProvider = { + digest: vi.fn(() => + defer + ? new Promise((resolve) => pendingResolvers.push(resolve)) + : Promise.resolve(digestBytes()), + ), + }; + const onAction = vi.fn(); + const { result } = renderHook(() => + useWritingDiagnosticsController({ + editor, + diagnostics: [diagnostic()], + digestProvider: provider, + onAction, + }), + ); + await waitFor(() => expect(result.current.status).toBe('active')); + + defer = true; + let application!: ReturnType; + act(() => { + application = result.current.applyDiagnostic('diag-apply'); + }); + expect(pendingResolvers).toHaveLength(2); + act(() => { + editor.commands.insertContent('!'); + }); + act(() => { + pendingResolvers[0]!(digestBytes()); + pendingResolvers[1]!(digestBytes()); + }); + const event = await application; + + expect(event).toMatchObject({ + action: 'conflict', + reasonCode: 'document_changed', + diagnosticId: 'diag-apply', + }); + expect(onAction).toHaveBeenCalledWith(event); + expect(editor.getText()).toContain('!'); + expect(editor.getText()).toContain('Alpha'); + }); + + it('reports a redacted lifecycle error when dispatch cannot commit', async () => { + const editor = createEditor(); + const before = editor.getJSON(); + const onError = vi.fn(); + const { result } = renderHook(() => + useWritingDiagnosticsController({ + editor, + diagnostics: [diagnostic()], + digestProvider: stableProvider(), + onError, + }), + ); + await waitFor(() => expect(result.current.status).toBe('active')); + vi.spyOn(editor.view, 'dispatch').mockImplementation(() => { + throw new Error('private dispatch failure'); + }); + + let event = null; + await act(async () => { + event = await result.current.applyDiagnostic('diag-apply'); + }); + + expect(event).toBeNull(); + expect(editor.getJSON()).toEqual(before); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'lifecycle' }), + ); + }); +}); From 2c4220b057177ca71a11f70699b0fbfec2d9cc32 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 21:15:55 +0900 Subject: [PATCH 14/39] ci(diagnostics): bound full coverage heap --- .github/workflows/writing-diagnostics-editor-actions-tdd.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/writing-diagnostics-editor-actions-tdd.yml b/.github/workflows/writing-diagnostics-editor-actions-tdd.yml index 1f5bd255..9a8cec90 100644 --- a/.github/workflows/writing-diagnostics-editor-actions-tdd.yml +++ b/.github/workflows/writing-diagnostics-editor-actions-tdd.yml @@ -19,7 +19,7 @@ env: jobs: focused-editor-actions: runs-on: ubuntu-24.04 - timeout-minutes: 25 + timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -36,11 +36,14 @@ jobs: pnpm exec vitest run src/components/CwlEditor.writingDiagnostics.test.tsx src/components/CwlEditor.writingDiagnosticApply.test.tsx + src/components/useWritingDiagnosticsController.apply.test.tsx --pool=forks --maxWorkers=1 - name: Typecheck standalone writing-diagnostic contracts run: pnpm typecheck - name: Run complete production coverage gate + env: + NODE_OPTIONS: --max-old-space-size=6144 run: pnpm coverage - name: Build all package entrypoints run: pnpm build From 10732642c4ddfbd12d5eac04987374cf9d9daa28 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 21:21:51 +0900 Subject: [PATCH 15/39] test(diagnostics): stabilize apply digest provider --- src/components/useWritingDiagnosticsController.apply.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/useWritingDiagnosticsController.apply.test.tsx b/src/components/useWritingDiagnosticsController.apply.test.tsx index 3a7632ac..466e06d6 100644 --- a/src/components/useWritingDiagnosticsController.apply.test.tsx +++ b/src/components/useWritingDiagnosticsController.apply.test.tsx @@ -185,11 +185,12 @@ describe('revision-safe writing diagnostic application failures', () => { const editor = createEditor(); const before = editor.getJSON(); const onError = vi.fn(); + const provider = stableProvider(); const { result } = renderHook(() => useWritingDiagnosticsController({ editor, diagnostics: [diagnostic()], - digestProvider: stableProvider(), + digestProvider: provider, onError, }), ); From 0d15cfbaa49e31f3a53adede7b03b7e87f6d5860 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 21:22:20 +0900 Subject: [PATCH 16/39] test(diagnostics): require imperative editor actions --- src/components/useEditorHandle.test.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/components/useEditorHandle.test.tsx b/src/components/useEditorHandle.test.tsx index 912ce499..182c7160 100644 --- a/src/components/useEditorHandle.test.tsx +++ b/src/components/useEditorHandle.test.tsx @@ -74,6 +74,15 @@ describe('useEditorHandle', () => { expect(() => handle.insertDocumentJson({ type: 'paragraph' }), ).not.toThrow(); + expect(handle.focusWritingDiagnostic('missing')).toBe(false); + await expect( + handle.applyWritingDiagnostic('missing'), + ).resolves.toBeNull(); + expect(handle.ignoreWritingDiagnostic('missing')).toBeNull(); + expect(handle.dismissWritingDiagnostic('missing')).toBeNull(); + expect( + handle.requestWritingDiagnosticExplanation('missing'), + ).toBeNull(); expect(() => handle.clear()).not.toThrow(); expect(handle.isEmpty()).toBe(true); }); From 45d0f1f734afb6a32ca71e884d4abbf77d35ae4b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 21:23:30 +0900 Subject: [PATCH 17/39] ci(diagnostics): wire imperative actions once --- .../workflows/editor-actions-handle-once.yml | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 .github/workflows/editor-actions-handle-once.yml diff --git a/.github/workflows/editor-actions-handle-once.yml b/.github/workflows/editor-actions-handle-once.yml new file mode 100644 index 00000000..86e68fe7 --- /dev/null +++ b/.github/workflows/editor-actions-handle-once.yml @@ -0,0 +1,173 @@ +name: Editor Actions Handle Once + +on: + push: + branches: + - feat/writing-diagnostics-editor-actions + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: editor-actions-handle-once-${{ github.ref }} + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + wire-imperative-actions: + 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: Wire public imperative diagnostic actions + run: | + python <<'PY' + from pathlib import Path + + def replace_once(source: str, old: str, new: str, label: str) -> str: + count = source.count(old) + if count != 1: + raise SystemExit(f'{label} anchor count was {count}') + return source.replace(old, new, 1) + + types_path = Path('src/types.ts') + types = types_path.read_text(encoding='utf-8') + old = """ insertDocumentJson(documentJson: JSONContent | JSONContent[]): void; + /** Empty the document. */ +""" + new = """ insertDocumentJson(documentJson: JSONContent | JSONContent[]): void; + /** Focus the exact current range for one active writing diagnostic. */ + focusWritingDiagnostic(diagnosticId: string): boolean; + /** + * Apply one active diagnostic only after exact-current-revision verification. + * Ordinary stale and conflict outcomes resolve to a typed event or `null`. + */ + applyWritingDiagnostic( + diagnosticId: string, + ): Promise; + /** Report an explicit ignore action without mutating authored content. */ + ignoreWritingDiagnostic( + diagnosticId: string, + ): CwlWritingDiagnosticActionEvent | null; + /** Remove one diagnostic from local presentation without editing the document. */ + dismissWritingDiagnostic( + diagnosticId: string, + ): CwlWritingDiagnosticActionEvent | null; + /** Request an explanation through the host-owned action callback contract. */ + requestWritingDiagnosticExplanation( + diagnosticId: string, + ): CwlWritingDiagnosticActionEvent | null; + /** Empty the document. */ +""" + types = replace_once(types, old, new, 'handle type methods') + types_path.write_text(types, encoding='utf-8') + + handle_path = Path('src/components/useEditorHandle.ts') + handle = handle_path.read_text(encoding='utf-8') + old = """import { editorHtmlToValue, editorValueToHtml } from './editorSerialization.js'; +""" + new = old + """import type { WritingDiagnosticsController } from './useWritingDiagnosticsController.js'; +""" + handle = replace_once(handle, old, new, 'handle controller import') + old = """ editor: Editor | null, + modeRef: MutableRefObject, +): void { +""" + new = """ editor: Editor | null, + modeRef: MutableRefObject, + writingDiagnosticsController?: WritingDiagnosticsController | null, +): void { +""" + handle = replace_once(handle, old, new, 'handle signature') + old = """ insertDocumentJson: (documentJson) => { + if (!editor) return; + editor.chain().focus().insertContent(documentJson).run(); + }, + clear: () => { +""" + new = """ insertDocumentJson: (documentJson) => { + if (!editor) return; + editor.chain().focus().insertContent(documentJson).run(); + }, + focusWritingDiagnostic: (diagnosticId) => + writingDiagnosticsController?.focusDiagnostic(diagnosticId) ?? false, + applyWritingDiagnostic: (diagnosticId) => + writingDiagnosticsController?.applyDiagnostic(diagnosticId) ?? + Promise.resolve(null), + ignoreWritingDiagnostic: (diagnosticId) => + writingDiagnosticsController?.ignoreDiagnostic(diagnosticId) ?? null, + dismissWritingDiagnostic: (diagnosticId) => + writingDiagnosticsController?.dismissDiagnostic(diagnosticId) ?? null, + requestWritingDiagnosticExplanation: (diagnosticId) => + writingDiagnosticsController?.requestDiagnosticExplanation(diagnosticId) ?? + null, + clear: () => { +""" + handle = replace_once(handle, old, new, 'handle action methods') + old = """ [editor, modeRef], +""" + new = """ [editor, modeRef, writingDiagnosticsController], +""" + handle = replace_once(handle, old, new, 'handle dependencies') + handle_path.write_text(handle, encoding='utf-8') + + editor_path = Path('src/components/CwlEditor.tsx') + editor = editor_path.read_text(encoding='utf-8') + old = """ useEditorHandle(ref, editor, modeRef); + + const writingDiagnosticsController = useWritingDiagnosticsController({ + editor, + diagnostics: writingDiagnostics, + onAction: onWritingDiagnosticAction, + onError: onWritingDiagnosticsError, + }); +""" + new = """ const writingDiagnosticsController = useWritingDiagnosticsController({ + editor, + diagnostics: writingDiagnostics, + onAction: onWritingDiagnosticAction, + onError: onWritingDiagnosticsError, + }); + + useEditorHandle(ref, editor, modeRef, writingDiagnosticsController); +""" + editor = replace_once(editor, old, new, 'editor handle wiring') + editor_path.write_text(editor, encoding='utf-8') + PY + - name: Verify imperative diagnostic actions + run: | + pnpm exec vitest run \ + src/components/useEditorHandle.test.tsx \ + src/components/CwlEditor.writingDiagnostics.test.tsx \ + src/components/CwlEditor.writingDiagnosticApply.test.tsx \ + src/components/useWritingDiagnosticsController.apply.test.tsx \ + --pool=forks --maxWorkers=1 + pnpm typecheck + - name: Publish validated handle wiring and remove this workflow + env: + TARGET_BRANCH: feat/writing-diagnostics-editor-actions + run: | + set -euo pipefail + rm .github/workflows/editor-actions-handle-once.yml + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add \ + src/types.ts \ + src/components/useEditorHandle.ts \ + src/components/CwlEditor.tsx \ + .github/workflows/editor-actions-handle-once.yml + git diff --cached --check + git commit -m 'feat(diagnostics): expose imperative editor actions' + git push origin "HEAD:${TARGET_BRANCH}" From 6507eda975f07771865e3241f739b72638059272 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 21:26:24 +0900 Subject: [PATCH 18/39] ci(diagnostics): make handle patch workflow parseable --- .../workflows/editor-actions-handle-once.yml | 115 +----------------- 1 file changed, 1 insertion(+), 114 deletions(-) diff --git a/.github/workflows/editor-actions-handle-once.yml b/.github/workflows/editor-actions-handle-once.yml index 86e68fe7..516f8f8a 100644 --- a/.github/workflows/editor-actions-handle-once.yml +++ b/.github/workflows/editor-actions-handle-once.yml @@ -32,120 +32,7 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - name: Wire public imperative diagnostic actions - run: | - python <<'PY' - from pathlib import Path - - def replace_once(source: str, old: str, new: str, label: str) -> str: - count = source.count(old) - if count != 1: - raise SystemExit(f'{label} anchor count was {count}') - return source.replace(old, new, 1) - - types_path = Path('src/types.ts') - types = types_path.read_text(encoding='utf-8') - old = """ insertDocumentJson(documentJson: JSONContent | JSONContent[]): void; - /** Empty the document. */ -""" - new = """ insertDocumentJson(documentJson: JSONContent | JSONContent[]): void; - /** Focus the exact current range for one active writing diagnostic. */ - focusWritingDiagnostic(diagnosticId: string): boolean; - /** - * Apply one active diagnostic only after exact-current-revision verification. - * Ordinary stale and conflict outcomes resolve to a typed event or `null`. - */ - applyWritingDiagnostic( - diagnosticId: string, - ): Promise; - /** Report an explicit ignore action without mutating authored content. */ - ignoreWritingDiagnostic( - diagnosticId: string, - ): CwlWritingDiagnosticActionEvent | null; - /** Remove one diagnostic from local presentation without editing the document. */ - dismissWritingDiagnostic( - diagnosticId: string, - ): CwlWritingDiagnosticActionEvent | null; - /** Request an explanation through the host-owned action callback contract. */ - requestWritingDiagnosticExplanation( - diagnosticId: string, - ): CwlWritingDiagnosticActionEvent | null; - /** Empty the document. */ -""" - types = replace_once(types, old, new, 'handle type methods') - types_path.write_text(types, encoding='utf-8') - - handle_path = Path('src/components/useEditorHandle.ts') - handle = handle_path.read_text(encoding='utf-8') - old = """import { editorHtmlToValue, editorValueToHtml } from './editorSerialization.js'; -""" - new = old + """import type { WritingDiagnosticsController } from './useWritingDiagnosticsController.js'; -""" - handle = replace_once(handle, old, new, 'handle controller import') - old = """ editor: Editor | null, - modeRef: MutableRefObject, -): void { -""" - new = """ editor: Editor | null, - modeRef: MutableRefObject, - writingDiagnosticsController?: WritingDiagnosticsController | null, -): void { -""" - handle = replace_once(handle, old, new, 'handle signature') - old = """ insertDocumentJson: (documentJson) => { - if (!editor) return; - editor.chain().focus().insertContent(documentJson).run(); - }, - clear: () => { -""" - new = """ insertDocumentJson: (documentJson) => { - if (!editor) return; - editor.chain().focus().insertContent(documentJson).run(); - }, - focusWritingDiagnostic: (diagnosticId) => - writingDiagnosticsController?.focusDiagnostic(diagnosticId) ?? false, - applyWritingDiagnostic: (diagnosticId) => - writingDiagnosticsController?.applyDiagnostic(diagnosticId) ?? - Promise.resolve(null), - ignoreWritingDiagnostic: (diagnosticId) => - writingDiagnosticsController?.ignoreDiagnostic(diagnosticId) ?? null, - dismissWritingDiagnostic: (diagnosticId) => - writingDiagnosticsController?.dismissDiagnostic(diagnosticId) ?? null, - requestWritingDiagnosticExplanation: (diagnosticId) => - writingDiagnosticsController?.requestDiagnosticExplanation(diagnosticId) ?? - null, - clear: () => { -""" - handle = replace_once(handle, old, new, 'handle action methods') - old = """ [editor, modeRef], -""" - new = """ [editor, modeRef, writingDiagnosticsController], -""" - handle = replace_once(handle, old, new, 'handle dependencies') - handle_path.write_text(handle, encoding='utf-8') - - editor_path = Path('src/components/CwlEditor.tsx') - editor = editor_path.read_text(encoding='utf-8') - old = """ useEditorHandle(ref, editor, modeRef); - - const writingDiagnosticsController = useWritingDiagnosticsController({ - editor, - diagnostics: writingDiagnostics, - onAction: onWritingDiagnosticAction, - onError: onWritingDiagnosticsError, - }); -""" - new = """ const writingDiagnosticsController = useWritingDiagnosticsController({ - editor, - diagnostics: writingDiagnostics, - onAction: onWritingDiagnosticAction, - onError: onWritingDiagnosticsError, - }); - - useEditorHandle(ref, editor, modeRef, writingDiagnosticsController); -""" - editor = replace_once(editor, old, new, 'editor handle wiring') - editor_path.write_text(editor, encoding='utf-8') - PY + run: python -c "import base64;exec(base64.b64decode('CmZyb20gcGF0aGxpYiBpbXBvcnQgUGF0aAoKZGVmIHJlcGxhY2Vfb25jZShzb3VyY2U6IHN0ciwgb2xkOiBzdHIsIG5ldzogc3RyLCBsYWJlbDogc3RyKSAtPiBzdHI6CiAgICBjb3VudCA9IHNvdXJjZS5jb3VudChvbGQpCiAgICBpZiBjb3VudCAhPSAxOgogICAgICAgIHJhaXNlIFN5c3RlbUV4aXQoZiJ7bGFiZWx9IGFuY2hvciBjb3VudCB3YXMge2NvdW50fSIpCiAgICByZXR1cm4gc291cmNlLnJlcGxhY2Uob2xkLCBuZXcsIDEpCgp0eXBlc19wYXRoID0gUGF0aCgic3JjL3R5cGVzLnRzIikKdHlwZXMgPSB0eXBlc19wYXRoLnJlYWRfdGV4dChlbmNvZGluZz0idXRmLTgiKQpvbGQgPSAiICBpbnNlcnREb2N1bWVudEpzb24oZG9jdW1lbnRKc29uOiBKU09OQ29udGVudCB8IEpTT05Db250ZW50W10pOiB2b2lkO1xuICAvKiogRW1wdHkgdGhlIGRvY3VtZW50LiAqL1xuIgpuZXcgPSAiIiIgIGluc2VydERvY3VtZW50SnNvbihkb2N1bWVudEpzb246IEpTT05Db250ZW50IHwgSlNPTkNvbnRlbnRbXSk6IHZvaWQ7CiAgLyoqIEZvY3VzIHRoZSBleGFjdCBjdXJyZW50IHJhbmdlIGZvciBvbmUgYWN0aXZlIHdyaXRpbmcgZGlhZ25vc3RpYy4gKi8KICBmb2N1c1dyaXRpbmdEaWFnbm9zdGljKGRpYWdub3N0aWNJZDogc3RyaW5nKTogYm9vbGVhbjsKICAvKioKICAgKiBBcHBseSBvbmUgYWN0aXZlIGRpYWdub3N0aWMgb25seSBhZnRlciBleGFjdC1jdXJyZW50LXJldmlzaW9uIHZlcmlmaWNhdGlvbi4KICAgKiBPcmRpbmFyeSBzdGFsZSBhbmQgY29uZmxpY3Qgb3V0Y29tZXMgcmVzb2x2ZSB0byBhIHR5cGVkIGV2ZW50IG9yIGBudWxsYC4KICAgKi8KICBhcHBseVdyaXRpbmdEaWFnbm9zdGljKAogICAgZGlhZ25vc3RpY0lkOiBzdHJpbmcsCiAgKTogUHJvbWlzZTxDd2xXcml0aW5nRGlhZ25vc3RpY0FjdGlvbkV2ZW50IHwgbnVsbD47CiAgLyoqIFJlcG9ydCBhbiBleHBsaWNpdCBpZ25vcmUgYWN0aW9uIHdpdGhvdXQgbXV0YXRpbmcgYXV0aG9yZWQgY29udGVudC4gKi8KICBpZ25vcmVXcml0aW5nRGlhZ25vc3RpYygKICAgIGRpYWdub3N0aWNJZDogc3RyaW5nLAogICk6IEN3bFdyaXRpbmdEaWFnbm9zdGljQWN0aW9uRXZlbnQgfCBudWxsOwogIC8qKiBSZW1vdmUgb25lIGRpYWdub3N0aWMgZnJvbSBsb2NhbCBwcmVzZW50YXRpb24gd2l0aG91dCBlZGl0aW5nIHRoZSBkb2N1bWVudC4gKi8KICBkaXNtaXNzV3JpdGluZ0RpYWdub3N0aWMoCiAgICBkaWFnbm9zdGljSWQ6IHN0cmluZywKICApOiBDd2xXcml0aW5nRGlhZ25vc3RpY0FjdGlvbkV2ZW50IHwgbnVsbDsKICAvKiogUmVxdWVzdCBhbiBleHBsYW5hdGlvbiB0aHJvdWdoIHRoZSBob3N0LW93bmVkIGFjdGlvbiBjYWxsYmFjayBjb250cmFjdC4gKi8KICByZXF1ZXN0V3JpdGluZ0RpYWdub3N0aWNFeHBsYW5hdGlvbigKICAgIGRpYWdub3N0aWNJZDogc3RyaW5nLAogICk6IEN3bFdyaXRpbmdEaWFnbm9zdGljQWN0aW9uRXZlbnQgfCBudWxsOwogIC8qKiBFbXB0eSB0aGUgZG9jdW1lbnQuICovCiIiIgp0eXBlcyA9IHJlcGxhY2Vfb25jZSh0eXBlcywgb2xkLCBuZXcsICJoYW5kbGUgdHlwZSBtZXRob2RzIikKdHlwZXNfcGF0aC53cml0ZV90ZXh0KHR5cGVzLCBlbmNvZGluZz0idXRmLTgiKQoKaGFuZGxlX3BhdGggPSBQYXRoKCJzcmMvY29tcG9uZW50cy91c2VFZGl0b3JIYW5kbGUudHMiKQpoYW5kbGUgPSBoYW5kbGVfcGF0aC5yZWFkX3RleHQoZW5jb2Rpbmc9InV0Zi04IikKb2xkID0gImltcG9ydCB7IGVkaXRvckh0bWxUb1ZhbHVlLCBlZGl0b3JWYWx1ZVRvSHRtbCB9IGZyb20gJy4vZWRpdG9yU2VyaWFsaXphdGlvbi5qcyc7XG4iCm5ldyA9IG9sZCArICJpbXBvcnQgdHlwZSB7IFdyaXRpbmdEaWFnbm9zdGljc0NvbnRyb2xsZXIgfSBmcm9tICcuL3VzZVdyaXRpbmdEaWFnbm9zdGljc0NvbnRyb2xsZXIuanMnO1xuIgpoYW5kbGUgPSByZXBsYWNlX29uY2UoaGFuZGxlLCBvbGQsIG5ldywgImhhbmRsZSBjb250cm9sbGVyIGltcG9ydCIpCm9sZCA9ICIgIGVkaXRvcjogRWRpdG9yIHwgbnVsbCxcbiAgbW9kZVJlZjogTXV0YWJsZVJlZk9iamVjdDxFZGl0b3JNb2RlPixcbik6IHZvaWQge1xuIgpuZXcgPSAiICBlZGl0b3I6IEVkaXRvciB8IG51bGwsXG4gIG1vZGVSZWY6IE11dGFibGVSZWZPYmplY3Q8RWRpdG9yTW9kZT4sXG4gIHdyaXRpbmdEaWFnbm9zdGljc0NvbnRyb2xsZXI/OiBXcml0aW5nRGlhZ25vc3RpY3NDb250cm9sbGVyIHwgbnVsbCxcbik6IHZvaWQge1xuIgpoYW5kbGUgPSByZXBsYWNlX29uY2UoaGFuZGxlLCBvbGQsIG5ldywgImhhbmRsZSBzaWduYXR1cmUiKQpvbGQgPSAiIiIgICAgICBpbnNlcnREb2N1bWVudEpzb246IChkb2N1bWVudEpzb24pID0+IHsKICAgICAgICBpZiAoIWVkaXRvcikgcmV0dXJuOwogICAgICAgIGVkaXRvci5jaGFpbigpLmZvY3VzKCkuaW5zZXJ0Q29udGVudChkb2N1bWVudEpzb24pLnJ1bigpOwogICAgICB9LAogICAgICBjbGVhcjogKCkgPT4gewoiIiIKbmV3ID0gIiIiICAgICAgaW5zZXJ0RG9jdW1lbnRKc29uOiAoZG9jdW1lbnRKc29uKSA9PiB7CiAgICAgICAgaWYgKCFlZGl0b3IpIHJldHVybjsKICAgICAgICBlZGl0b3IuY2hhaW4oKS5mb2N1cygpLmluc2VydENvbnRlbnQoZG9jdW1lbnRKc29uKS5ydW4oKTsKICAgICAgfSwKICAgICAgZm9jdXNXcml0aW5nRGlhZ25vc3RpYzogKGRpYWdub3N0aWNJZCkgPT4KICAgICAgICB3cml0aW5nRGlhZ25vc3RpY3NDb250cm9sbGVyPy5mb2N1c0RpYWdub3N0aWMoZGlhZ25vc3RpY0lkKSA/PyBmYWxzZSwKICAgICAgYXBwbHlXcml0aW5nRGlhZ25vc3RpYzogKGRpYWdub3N0aWNJZCkgPT4KICAgICAgICB3cml0aW5nRGlhZ25vc3RpY3NDb250cm9sbGVyPy5hcHBseURpYWdub3N0aWMoZGlhZ25vc3RpY0lkKSA/PwogICAgICAgIFByb21pc2UucmVzb2x2ZShudWxsKSwKICAgICAgaWdub3JlV3JpdGluZ0RpYWdub3N0aWM6IChkaWFnbm9zdGljSWQpID0+CiAgICAgICAgd3JpdGluZ0RpYWdub3N0aWNzQ29udHJvbGxlcj8uaWdub3JlRGlhZ25vc3RpYyhkaWFnbm9zdGljSWQpID8/IG51bGwsCiAgICAgIGRpc21pc3NXcml0aW5nRGlhZ25vc3RpYzogKGRpYWdub3N0aWNJZCkgPT4KICAgICAgICB3cml0aW5nRGlhZ25vc3RpY3NDb250cm9sbGVyPy5kaXNtaXNzRGlhZ25vc3RpYyhkaWFnbm9zdGljSWQpID8/IG51bGwsCiAgICAgIHJlcXVlc3RXcml0aW5nRGlhZ25vc3RpY0V4cGxhbmF0aW9uOiAoZGlhZ25vc3RpY0lkKSA9PgogICAgICAgIHdyaXRpbmdEaWFnbm9zdGljc0NvbnRyb2xsZXI/LnJlcXVlc3REaWFnbm9zdGljRXhwbGFuYXRpb24oZGlhZ25vc3RpY0lkKSA/PwogICAgICAgIG51bGwsCiAgICAgIGNsZWFyOiAoKSA9PiB7CiIiIgpoYW5kbGUgPSByZXBsYWNlX29uY2UoaGFuZGxlLCBvbGQsIG5ldywgImhhbmRsZSBhY3Rpb24gbWV0aG9kcyIpCm9sZCA9ICIgICAgW2VkaXRvciwgbW9kZVJlZl0sXG4iCm5ldyA9ICIgICAgW2VkaXRvciwgbW9kZVJlZiwgd3JpdGluZ0RpYWdub3N0aWNzQ29udHJvbGxlcl0sXG4iCmhhbmRsZSA9IHJlcGxhY2Vfb25jZShoYW5kbGUsIG9sZCwgbmV3LCAiaGFuZGxlIGRlcGVuZGVuY2llcyIpCmhhbmRsZV9wYXRoLndyaXRlX3RleHQoaGFuZGxlLCBlbmNvZGluZz0idXRmLTgiKQoKZWRpdG9yX3BhdGggPSBQYXRoKCJzcmMvY29tcG9uZW50cy9Dd2xFZGl0b3IudHN4IikKZWRpdG9yID0gZWRpdG9yX3BhdGgucmVhZF90ZXh0KGVuY29kaW5nPSJ1dGYtOCIpCm9sZCA9ICIiIiAgICB1c2VFZGl0b3JIYW5kbGUocmVmLCBlZGl0b3IsIG1vZGVSZWYpOwoKICAgIGNvbnN0IHdyaXRpbmdEaWFnbm9zdGljc0NvbnRyb2xsZXIgPSB1c2VXcml0aW5nRGlhZ25vc3RpY3NDb250cm9sbGVyKHsKICAgICAgZWRpdG9yLAogICAgICBkaWFnbm9zdGljczogd3JpdGluZ0RpYWdub3N0aWNzLAogICAgICBvbkFjdGlvbjogb25Xcml0aW5nRGlhZ25vc3RpY0FjdGlvbiwKICAgICAgb25FcnJvcjogb25Xcml0aW5nRGlhZ25vc3RpY3NFcnJvciwKICAgIH0pOwoiIiIKbmV3ID0gIiIiICAgIGNvbnN0IHdyaXRpbmdEaWFnbm9zdGljc0NvbnRyb2xsZXIgPSB1c2VXcml0aW5nRGlhZ25vc3RpY3NDb250cm9sbGVyKHsKICAgICAgZWRpdG9yLAogICAgICBkaWFnbm9zdGljczogd3JpdGluZ0RpYWdub3N0aWNzLAogICAgICBvbkFjdGlvbjogb25Xcml0aW5nRGlhZ25vc3RpY0FjdGlvbiwKICAgICAgb25FcnJvcjogb25Xcml0aW5nRGlhZ25vc3RpY3NFcnJvciwKICAgIH0pOwoKICAgIHVzZUVkaXRvckhhbmRsZShyZWYsIGVkaXRvciwgbW9kZVJlZiwgd3JpdGluZ0RpYWdub3N0aWNzQ29udHJvbGxlcik7CiIiIgplZGl0b3IgPSByZXBsYWNlX29uY2UoZWRpdG9yLCBvbGQsIG5ldywgImVkaXRvciBoYW5kbGUgd2lyaW5nIikKZWRpdG9yX3BhdGgud3JpdGVfdGV4dChlZGl0b3IsIGVuY29kaW5nPSJ1dGYtOCIpCg=='))" - name: Verify imperative diagnostic actions run: | pnpm exec vitest run \ From 4e8b1441a664d7f2232c0039363ccb146a78869a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:27:09 +0000 Subject: [PATCH 19/39] feat(diagnostics): expose imperative editor actions --- .../workflows/editor-actions-handle-once.yml | 60 ------------------- src/components/CwlEditor.tsx | 4 +- src/components/useEditorHandle.ts | 16 ++++- src/types.ts | 21 +++++++ 4 files changed, 38 insertions(+), 63 deletions(-) delete mode 100644 .github/workflows/editor-actions-handle-once.yml diff --git a/.github/workflows/editor-actions-handle-once.yml b/.github/workflows/editor-actions-handle-once.yml deleted file mode 100644 index 516f8f8a..00000000 --- a/.github/workflows/editor-actions-handle-once.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Editor Actions Handle Once - -on: - push: - branches: - - feat/writing-diagnostics-editor-actions - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: editor-actions-handle-once-${{ github.ref }} - cancel-in-progress: true - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - wire-imperative-actions: - 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: Wire public imperative diagnostic actions - run: python -c "import base64;exec(base64.b64decode('CmZyb20gcGF0aGxpYiBpbXBvcnQgUGF0aAoKZGVmIHJlcGxhY2Vfb25jZShzb3VyY2U6IHN0ciwgb2xkOiBzdHIsIG5ldzogc3RyLCBsYWJlbDogc3RyKSAtPiBzdHI6CiAgICBjb3VudCA9IHNvdXJjZS5jb3VudChvbGQpCiAgICBpZiBjb3VudCAhPSAxOgogICAgICAgIHJhaXNlIFN5c3RlbUV4aXQoZiJ7bGFiZWx9IGFuY2hvciBjb3VudCB3YXMge2NvdW50fSIpCiAgICByZXR1cm4gc291cmNlLnJlcGxhY2Uob2xkLCBuZXcsIDEpCgp0eXBlc19wYXRoID0gUGF0aCgic3JjL3R5cGVzLnRzIikKdHlwZXMgPSB0eXBlc19wYXRoLnJlYWRfdGV4dChlbmNvZGluZz0idXRmLTgiKQpvbGQgPSAiICBpbnNlcnREb2N1bWVudEpzb24oZG9jdW1lbnRKc29uOiBKU09OQ29udGVudCB8IEpTT05Db250ZW50W10pOiB2b2lkO1xuICAvKiogRW1wdHkgdGhlIGRvY3VtZW50LiAqL1xuIgpuZXcgPSAiIiIgIGluc2VydERvY3VtZW50SnNvbihkb2N1bWVudEpzb246IEpTT05Db250ZW50IHwgSlNPTkNvbnRlbnRbXSk6IHZvaWQ7CiAgLyoqIEZvY3VzIHRoZSBleGFjdCBjdXJyZW50IHJhbmdlIGZvciBvbmUgYWN0aXZlIHdyaXRpbmcgZGlhZ25vc3RpYy4gKi8KICBmb2N1c1dyaXRpbmdEaWFnbm9zdGljKGRpYWdub3N0aWNJZDogc3RyaW5nKTogYm9vbGVhbjsKICAvKioKICAgKiBBcHBseSBvbmUgYWN0aXZlIGRpYWdub3N0aWMgb25seSBhZnRlciBleGFjdC1jdXJyZW50LXJldmlzaW9uIHZlcmlmaWNhdGlvbi4KICAgKiBPcmRpbmFyeSBzdGFsZSBhbmQgY29uZmxpY3Qgb3V0Y29tZXMgcmVzb2x2ZSB0byBhIHR5cGVkIGV2ZW50IG9yIGBudWxsYC4KICAgKi8KICBhcHBseVdyaXRpbmdEaWFnbm9zdGljKAogICAgZGlhZ25vc3RpY0lkOiBzdHJpbmcsCiAgKTogUHJvbWlzZTxDd2xXcml0aW5nRGlhZ25vc3RpY0FjdGlvbkV2ZW50IHwgbnVsbD47CiAgLyoqIFJlcG9ydCBhbiBleHBsaWNpdCBpZ25vcmUgYWN0aW9uIHdpdGhvdXQgbXV0YXRpbmcgYXV0aG9yZWQgY29udGVudC4gKi8KICBpZ25vcmVXcml0aW5nRGlhZ25vc3RpYygKICAgIGRpYWdub3N0aWNJZDogc3RyaW5nLAogICk6IEN3bFdyaXRpbmdEaWFnbm9zdGljQWN0aW9uRXZlbnQgfCBudWxsOwogIC8qKiBSZW1vdmUgb25lIGRpYWdub3N0aWMgZnJvbSBsb2NhbCBwcmVzZW50YXRpb24gd2l0aG91dCBlZGl0aW5nIHRoZSBkb2N1bWVudC4gKi8KICBkaXNtaXNzV3JpdGluZ0RpYWdub3N0aWMoCiAgICBkaWFnbm9zdGljSWQ6IHN0cmluZywKICApOiBDd2xXcml0aW5nRGlhZ25vc3RpY0FjdGlvbkV2ZW50IHwgbnVsbDsKICAvKiogUmVxdWVzdCBhbiBleHBsYW5hdGlvbiB0aHJvdWdoIHRoZSBob3N0LW93bmVkIGFjdGlvbiBjYWxsYmFjayBjb250cmFjdC4gKi8KICByZXF1ZXN0V3JpdGluZ0RpYWdub3N0aWNFeHBsYW5hdGlvbigKICAgIGRpYWdub3N0aWNJZDogc3RyaW5nLAogICk6IEN3bFdyaXRpbmdEaWFnbm9zdGljQWN0aW9uRXZlbnQgfCBudWxsOwogIC8qKiBFbXB0eSB0aGUgZG9jdW1lbnQuICovCiIiIgp0eXBlcyA9IHJlcGxhY2Vfb25jZSh0eXBlcywgb2xkLCBuZXcsICJoYW5kbGUgdHlwZSBtZXRob2RzIikKdHlwZXNfcGF0aC53cml0ZV90ZXh0KHR5cGVzLCBlbmNvZGluZz0idXRmLTgiKQoKaGFuZGxlX3BhdGggPSBQYXRoKCJzcmMvY29tcG9uZW50cy91c2VFZGl0b3JIYW5kbGUudHMiKQpoYW5kbGUgPSBoYW5kbGVfcGF0aC5yZWFkX3RleHQoZW5jb2Rpbmc9InV0Zi04IikKb2xkID0gImltcG9ydCB7IGVkaXRvckh0bWxUb1ZhbHVlLCBlZGl0b3JWYWx1ZVRvSHRtbCB9IGZyb20gJy4vZWRpdG9yU2VyaWFsaXphdGlvbi5qcyc7XG4iCm5ldyA9IG9sZCArICJpbXBvcnQgdHlwZSB7IFdyaXRpbmdEaWFnbm9zdGljc0NvbnRyb2xsZXIgfSBmcm9tICcuL3VzZVdyaXRpbmdEaWFnbm9zdGljc0NvbnRyb2xsZXIuanMnO1xuIgpoYW5kbGUgPSByZXBsYWNlX29uY2UoaGFuZGxlLCBvbGQsIG5ldywgImhhbmRsZSBjb250cm9sbGVyIGltcG9ydCIpCm9sZCA9ICIgIGVkaXRvcjogRWRpdG9yIHwgbnVsbCxcbiAgbW9kZVJlZjogTXV0YWJsZVJlZk9iamVjdDxFZGl0b3JNb2RlPixcbik6IHZvaWQge1xuIgpuZXcgPSAiICBlZGl0b3I6IEVkaXRvciB8IG51bGwsXG4gIG1vZGVSZWY6IE11dGFibGVSZWZPYmplY3Q8RWRpdG9yTW9kZT4sXG4gIHdyaXRpbmdEaWFnbm9zdGljc0NvbnRyb2xsZXI/OiBXcml0aW5nRGlhZ25vc3RpY3NDb250cm9sbGVyIHwgbnVsbCxcbik6IHZvaWQge1xuIgpoYW5kbGUgPSByZXBsYWNlX29uY2UoaGFuZGxlLCBvbGQsIG5ldywgImhhbmRsZSBzaWduYXR1cmUiKQpvbGQgPSAiIiIgICAgICBpbnNlcnREb2N1bWVudEpzb246IChkb2N1bWVudEpzb24pID0+IHsKICAgICAgICBpZiAoIWVkaXRvcikgcmV0dXJuOwogICAgICAgIGVkaXRvci5jaGFpbigpLmZvY3VzKCkuaW5zZXJ0Q29udGVudChkb2N1bWVudEpzb24pLnJ1bigpOwogICAgICB9LAogICAgICBjbGVhcjogKCkgPT4gewoiIiIKbmV3ID0gIiIiICAgICAgaW5zZXJ0RG9jdW1lbnRKc29uOiAoZG9jdW1lbnRKc29uKSA9PiB7CiAgICAgICAgaWYgKCFlZGl0b3IpIHJldHVybjsKICAgICAgICBlZGl0b3IuY2hhaW4oKS5mb2N1cygpLmluc2VydENvbnRlbnQoZG9jdW1lbnRKc29uKS5ydW4oKTsKICAgICAgfSwKICAgICAgZm9jdXNXcml0aW5nRGlhZ25vc3RpYzogKGRpYWdub3N0aWNJZCkgPT4KICAgICAgICB3cml0aW5nRGlhZ25vc3RpY3NDb250cm9sbGVyPy5mb2N1c0RpYWdub3N0aWMoZGlhZ25vc3RpY0lkKSA/PyBmYWxzZSwKICAgICAgYXBwbHlXcml0aW5nRGlhZ25vc3RpYzogKGRpYWdub3N0aWNJZCkgPT4KICAgICAgICB3cml0aW5nRGlhZ25vc3RpY3NDb250cm9sbGVyPy5hcHBseURpYWdub3N0aWMoZGlhZ25vc3RpY0lkKSA/PwogICAgICAgIFByb21pc2UucmVzb2x2ZShudWxsKSwKICAgICAgaWdub3JlV3JpdGluZ0RpYWdub3N0aWM6IChkaWFnbm9zdGljSWQpID0+CiAgICAgICAgd3JpdGluZ0RpYWdub3N0aWNzQ29udHJvbGxlcj8uaWdub3JlRGlhZ25vc3RpYyhkaWFnbm9zdGljSWQpID8/IG51bGwsCiAgICAgIGRpc21pc3NXcml0aW5nRGlhZ25vc3RpYzogKGRpYWdub3N0aWNJZCkgPT4KICAgICAgICB3cml0aW5nRGlhZ25vc3RpY3NDb250cm9sbGVyPy5kaXNtaXNzRGlhZ25vc3RpYyhkaWFnbm9zdGljSWQpID8/IG51bGwsCiAgICAgIHJlcXVlc3RXcml0aW5nRGlhZ25vc3RpY0V4cGxhbmF0aW9uOiAoZGlhZ25vc3RpY0lkKSA9PgogICAgICAgIHdyaXRpbmdEaWFnbm9zdGljc0NvbnRyb2xsZXI/LnJlcXVlc3REaWFnbm9zdGljRXhwbGFuYXRpb24oZGlhZ25vc3RpY0lkKSA/PwogICAgICAgIG51bGwsCiAgICAgIGNsZWFyOiAoKSA9PiB7CiIiIgpoYW5kbGUgPSByZXBsYWNlX29uY2UoaGFuZGxlLCBvbGQsIG5ldywgImhhbmRsZSBhY3Rpb24gbWV0aG9kcyIpCm9sZCA9ICIgICAgW2VkaXRvciwgbW9kZVJlZl0sXG4iCm5ldyA9ICIgICAgW2VkaXRvciwgbW9kZVJlZiwgd3JpdGluZ0RpYWdub3N0aWNzQ29udHJvbGxlcl0sXG4iCmhhbmRsZSA9IHJlcGxhY2Vfb25jZShoYW5kbGUsIG9sZCwgbmV3LCAiaGFuZGxlIGRlcGVuZGVuY2llcyIpCmhhbmRsZV9wYXRoLndyaXRlX3RleHQoaGFuZGxlLCBlbmNvZGluZz0idXRmLTgiKQoKZWRpdG9yX3BhdGggPSBQYXRoKCJzcmMvY29tcG9uZW50cy9Dd2xFZGl0b3IudHN4IikKZWRpdG9yID0gZWRpdG9yX3BhdGgucmVhZF90ZXh0KGVuY29kaW5nPSJ1dGYtOCIpCm9sZCA9ICIiIiAgICB1c2VFZGl0b3JIYW5kbGUocmVmLCBlZGl0b3IsIG1vZGVSZWYpOwoKICAgIGNvbnN0IHdyaXRpbmdEaWFnbm9zdGljc0NvbnRyb2xsZXIgPSB1c2VXcml0aW5nRGlhZ25vc3RpY3NDb250cm9sbGVyKHsKICAgICAgZWRpdG9yLAogICAgICBkaWFnbm9zdGljczogd3JpdGluZ0RpYWdub3N0aWNzLAogICAgICBvbkFjdGlvbjogb25Xcml0aW5nRGlhZ25vc3RpY0FjdGlvbiwKICAgICAgb25FcnJvcjogb25Xcml0aW5nRGlhZ25vc3RpY3NFcnJvciwKICAgIH0pOwoiIiIKbmV3ID0gIiIiICAgIGNvbnN0IHdyaXRpbmdEaWFnbm9zdGljc0NvbnRyb2xsZXIgPSB1c2VXcml0aW5nRGlhZ25vc3RpY3NDb250cm9sbGVyKHsKICAgICAgZWRpdG9yLAogICAgICBkaWFnbm9zdGljczogd3JpdGluZ0RpYWdub3N0aWNzLAogICAgICBvbkFjdGlvbjogb25Xcml0aW5nRGlhZ25vc3RpY0FjdGlvbiwKICAgICAgb25FcnJvcjogb25Xcml0aW5nRGlhZ25vc3RpY3NFcnJvciwKICAgIH0pOwoKICAgIHVzZUVkaXRvckhhbmRsZShyZWYsIGVkaXRvciwgbW9kZVJlZiwgd3JpdGluZ0RpYWdub3N0aWNzQ29udHJvbGxlcik7CiIiIgplZGl0b3IgPSByZXBsYWNlX29uY2UoZWRpdG9yLCBvbGQsIG5ldywgImVkaXRvciBoYW5kbGUgd2lyaW5nIikKZWRpdG9yX3BhdGgud3JpdGVfdGV4dChlZGl0b3IsIGVuY29kaW5nPSJ1dGYtOCIpCg=='))" - - name: Verify imperative diagnostic actions - run: | - pnpm exec vitest run \ - src/components/useEditorHandle.test.tsx \ - src/components/CwlEditor.writingDiagnostics.test.tsx \ - src/components/CwlEditor.writingDiagnosticApply.test.tsx \ - src/components/useWritingDiagnosticsController.apply.test.tsx \ - --pool=forks --maxWorkers=1 - pnpm typecheck - - name: Publish validated handle wiring and remove this workflow - env: - TARGET_BRANCH: feat/writing-diagnostics-editor-actions - run: | - set -euo pipefail - rm .github/workflows/editor-actions-handle-once.yml - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add \ - src/types.ts \ - src/components/useEditorHandle.ts \ - src/components/CwlEditor.tsx \ - .github/workflows/editor-actions-handle-once.yml - git diff --cached --check - git commit -m 'feat(diagnostics): expose imperative editor actions' - git push origin "HEAD:${TARGET_BRANCH}" diff --git a/src/components/CwlEditor.tsx b/src/components/CwlEditor.tsx index 6948b152..699bbc40 100644 --- a/src/components/CwlEditor.tsx +++ b/src/components/CwlEditor.tsx @@ -197,8 +197,6 @@ export const CwlEditor = forwardRef( }, }); - useEditorHandle(ref, editor, modeRef); - const writingDiagnosticsController = useWritingDiagnosticsController({ editor, diagnostics: writingDiagnostics, @@ -206,6 +204,8 @@ export const CwlEditor = forwardRef( onError: onWritingDiagnosticsError, }); + useEditorHandle(ref, editor, modeRef, writingDiagnosticsController); + useEffect(() => { editor?.setEditable(editable); }, [editor, editable]); diff --git a/src/components/useEditorHandle.ts b/src/components/useEditorHandle.ts index 710fe883..b2c91fea 100644 --- a/src/components/useEditorHandle.ts +++ b/src/components/useEditorHandle.ts @@ -33,6 +33,7 @@ import { createTextPositionSelector } from '../textPositionSelectorEvidence.js'; import type { CwlEditorHandle, EditorMode } from '../types.js'; import { createEditorDocumentSnapshot } from './editorDocumentSnapshot.js'; import { editorHtmlToValue, editorValueToHtml } from './editorSerialization.js'; +import type { WritingDiagnosticsController } from './useWritingDiagnosticsController.js'; /** Create a validated portable envelope from one active editor revision. */ function createCurrentDocumentEnvelope( @@ -47,6 +48,7 @@ export function useEditorHandle( ref: ForwardedRef, editor: Editor | null, modeRef: MutableRefObject, + writingDiagnosticsController?: WritingDiagnosticsController | null, ): void { useImperativeHandle( ref, @@ -196,11 +198,23 @@ export function useEditorHandle( if (!editor) return; editor.chain().focus().insertContent(documentJson).run(); }, + focusWritingDiagnostic: (diagnosticId) => + writingDiagnosticsController?.focusDiagnostic(diagnosticId) ?? false, + applyWritingDiagnostic: (diagnosticId) => + writingDiagnosticsController?.applyDiagnostic(diagnosticId) ?? + Promise.resolve(null), + ignoreWritingDiagnostic: (diagnosticId) => + writingDiagnosticsController?.ignoreDiagnostic(diagnosticId) ?? null, + dismissWritingDiagnostic: (diagnosticId) => + writingDiagnosticsController?.dismissDiagnostic(diagnosticId) ?? null, + requestWritingDiagnosticExplanation: (diagnosticId) => + writingDiagnosticsController?.requestDiagnosticExplanation(diagnosticId) ?? + null, clear: () => { editor?.commands.clearContent(true); }, isEmpty: () => editor?.isEmpty ?? true, }), - [editor, modeRef], + [editor, modeRef, writingDiagnosticsController], ); } diff --git a/src/types.ts b/src/types.ts index a810cfec..31685315 100644 --- a/src/types.ts +++ b/src/types.ts @@ -258,6 +258,27 @@ export interface CwlEditorHandle { * safe-link, and inline-image transaction boundaries as other editor writes. */ insertDocumentJson(documentJson: JSONContent | JSONContent[]): void; + /** Focus the exact current range for one active writing diagnostic. */ + focusWritingDiagnostic(diagnosticId: string): boolean; + /** + * Apply one active diagnostic only after exact-current-revision verification. + * Ordinary stale and conflict outcomes resolve to a typed event or `null`. + */ + applyWritingDiagnostic( + diagnosticId: string, + ): Promise; + /** Report an explicit ignore action without mutating authored content. */ + ignoreWritingDiagnostic( + diagnosticId: string, + ): CwlWritingDiagnosticActionEvent | null; + /** Remove one diagnostic from local presentation without editing the document. */ + dismissWritingDiagnostic( + diagnosticId: string, + ): CwlWritingDiagnosticActionEvent | null; + /** Request an explanation through the host-owned action callback contract. */ + requestWritingDiagnosticExplanation( + diagnosticId: string, + ): CwlWritingDiagnosticActionEvent | null; /** Empty the document. */ clear(): void; /** `true` when the document has no meaningful content. */ From 7e127b07cef251c2af846f1573dbcbbed6defa1b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 21:29:42 +0900 Subject: [PATCH 20/39] test(diagnostics): exercise imperative editor actions --- .../CwlEditor.writingDiagnostics.test.tsx | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/src/components/CwlEditor.writingDiagnostics.test.tsx b/src/components/CwlEditor.writingDiagnostics.test.tsx index 343564bb..ed88de93 100644 --- a/src/components/CwlEditor.writingDiagnostics.test.tsx +++ b/src/components/CwlEditor.writingDiagnostics.test.tsx @@ -109,4 +109,89 @@ describe('CwlEditor host-supplied writing diagnostics', () => { expect(document.querySelectorAll('.cwl-writing-diagnostic')).toHaveLength(1); expect(onWritingDiagnosticAction).not.toHaveBeenCalled(); }); + + it('routes imperative actions through the latest callback without rebuilding the editor', async () => { + const handleRef = createRef(); + const firstAction = vi.fn(); + const secondAction = vi.fn(); + const view = render( + , + ); + await waitFor(() => expect(handleRef.current?.getEditor()).not.toBeNull()); + const editorIdentity = handleRef.current!.getEditor(); + + act(() => { + editorIdentity!.commands.setTextSelection({ from: 1, to: 6 }); + }); + const diagnostic = await diagnosticForSelection(handleRef.current!); + + view.rerender( + , + ); + await screen.findByText('Clarify the request'); + + expect(handleRef.current!.focusWritingDiagnostic('diagnostic-1')).toBe(true); + expect(handleRef.current!.getEditor()!.state.selection).toMatchObject({ + from: 1, + to: 6, + }); + + let ignored = null; + act(() => { + ignored = handleRef.current!.ignoreWritingDiagnostic('diagnostic-1'); + }); + expect(ignored).toMatchObject({ + action: 'ignored', + reasonCode: 'explicit', + diagnosticId: 'diagnostic-1', + }); + expect(firstAction).toHaveBeenLastCalledWith(ignored); + + view.rerender( + , + ); + expect(handleRef.current!.getEditor()).toBe(editorIdentity); + + let explanation = null; + act(() => { + explanation = + handleRef.current!.requestWritingDiagnosticExplanation('diagnostic-1'); + }); + expect(explanation).toMatchObject({ + action: 'requested_explanation', + diagnosticId: 'diagnostic-1', + }); + expect(secondAction).toHaveBeenLastCalledWith(explanation); + + const beforeDismiss = handleRef.current!.getValue(); + let dismissed = null; + act(() => { + dismissed = handleRef.current!.dismissWritingDiagnostic('diagnostic-1'); + }); + expect(dismissed).toMatchObject({ + action: 'dismissed', + diagnosticId: 'diagnostic-1', + }); + expect(secondAction).toHaveBeenLastCalledWith(dismissed); + expect(handleRef.current!.getValue()).toBe(beforeDismiss); + expect(document.querySelector('.cwl-writing-diagnostic')).toBeNull(); + expect(handleRef.current!.focusWritingDiagnostic('diagnostic-1')).toBe(false); + }); }); From f7fca0ffd29ed075beba516bfaeabdd03f15055f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 21:30:14 +0900 Subject: [PATCH 21/39] test(diagnostics): forbid read-only replacement mutation --- .../CwlEditor.writingDiagnosticApply.test.tsx | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/components/CwlEditor.writingDiagnosticApply.test.tsx b/src/components/CwlEditor.writingDiagnosticApply.test.tsx index df275aaf..64a89f81 100644 --- a/src/components/CwlEditor.writingDiagnosticApply.test.tsx +++ b/src/components/CwlEditor.writingDiagnosticApply.test.tsx @@ -102,4 +102,46 @@ describe('CwlEditor writing-diagnostic application', () => { }); expect(handleRef.current?.getValue()).toBe('Alpha beta'); }); + + it('keeps replacement application disabled and inert in read-only mode', async () => { + const handleRef = createRef(); + const onAction = vi.fn(); + const view = render( + , + ); + await waitFor(() => expect(handleRef.current?.getEditor()).not.toBeNull()); + + act(() => { + handleRef.current!.getEditor()!.commands.setTextSelection({ from: 1, to: 6 }); + }); + const diagnostic = await exactDiagnostic(handleRef.current!, 'Omega'); + + view.rerender( + , + ); + + const apply = await screen.findByRole('button', { + name: 'Apply suggestion for Clarify the request', + }); + expect(apply).toBeDisabled(); + await expect( + handleRef.current!.applyWritingDiagnostic('apply-diagnostic'), + ).resolves.toBeNull(); + expect(handleRef.current!.getValue()).toBe('Alpha beta'); + expect(onAction).not.toHaveBeenCalled(); + expect(document.querySelector('.cwl-writing-diagnostic')).not.toBeNull(); + }); }); From 4d3ca3c47d6c73341c5274269231cc771ca3b9be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 21:31:32 +0900 Subject: [PATCH 22/39] ci(diagnostics): enforce read-only application once --- .../editor-actions-readonly-once.yml | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/editor-actions-readonly-once.yml diff --git a/.github/workflows/editor-actions-readonly-once.yml b/.github/workflows/editor-actions-readonly-once.yml new file mode 100644 index 00000000..8021d210 --- /dev/null +++ b/.github/workflows/editor-actions-readonly-once.yml @@ -0,0 +1,58 @@ +name: Editor Actions Readonly Once + +on: + push: + branches: + - feat/writing-diagnostics-editor-actions + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: editor-actions-readonly-once-${{ github.ref }} + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + enforce-readonly-application: + 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: Enforce read-only diagnostic application + run: python -c "import base64;exec(base64.b64decode('CmZyb20gcGF0aGxpYiBpbXBvcnQgUGF0aAoKZGVmIHJlcGxhY2Vfb25jZShzb3VyY2U6IHN0ciwgb2xkOiBzdHIsIG5ldzogc3RyLCBsYWJlbDogc3RyKSAtPiBzdHI6CiAgICBjb3VudCA9IHNvdXJjZS5jb3VudChvbGQpCiAgICBpZiBjb3VudCAhPSAxOgogICAgICAgIHJhaXNlIFN5c3RlbUV4aXQoZiJ7bGFiZWx9IGFuY2hvciBjb3VudCB3YXMge2NvdW50fSIpCiAgICByZXR1cm4gc291cmNlLnJlcGxhY2Uob2xkLCBuZXcsIDEpCgpjb250cm9sbGVyX3BhdGggPSBQYXRoKCJzcmMvY29tcG9uZW50cy91c2VXcml0aW5nRGlhZ25vc3RpY3NDb250cm9sbGVyLnRzIikKY29udHJvbGxlciA9IGNvbnRyb2xsZXJfcGF0aC5yZWFkX3RleHQoZW5jb2Rpbmc9InV0Zi04IikKb2xkID0gIiIiICAgICAgaWYgKAogICAgICAgIGFjdGl2ZS5zdGF0dXMgIT09ICdhY3RpdmUnIHx8CiAgICAgICAgYWN0aXZlLmVkaXRvciA9PT0gbnVsbCB8fAogICAgICAgIGFjdGl2ZS5lZGl0b3IuaXNEZXN0cm95ZWQKICAgICAgKSB7CiIiIgpuZXcgPSAiIiIgICAgICBpZiAoCiAgICAgICAgYWN0aXZlLnN0YXR1cyAhPT0gJ2FjdGl2ZScgfHwKICAgICAgICBhY3RpdmUuZWRpdG9yID09PSBudWxsIHx8CiAgICAgICAgIWFjdGl2ZS5lZGl0b3IuaXNFZGl0YWJsZSB8fAogICAgICAgIGFjdGl2ZS5lZGl0b3IuaXNEZXN0cm95ZWQKICAgICAgKSB7CiIiIgpjb250cm9sbGVyID0gcmVwbGFjZV9vbmNlKGNvbnRyb2xsZXIsIG9sZCwgbmV3LCAiYXBwbHkgcmVhZC1vbmx5IGd1YXJkIikKY29udHJvbGxlcl9wYXRoLndyaXRlX3RleHQoY29udHJvbGxlciwgZW5jb2Rpbmc9InV0Zi04IikKCmVkaXRvcl9wYXRoID0gUGF0aCgic3JjL2NvbXBvbmVudHMvQ3dsRWRpdG9yLnRzeCIpCmVkaXRvciA9IGVkaXRvcl9wYXRoLnJlYWRfdGV4dChlbmNvZGluZz0idXRmLTgiKQpvbGQgPSAiIiIgICAgICAgICAgICAgIG9uQXBwbHlEaWFnbm9zdGljPXsoZGlhZ25vc3RpY0lkKSA9PiB7CiAgICAgICAgICAgICAgICB2b2lkIHdyaXRpbmdEaWFnbm9zdGljc0NvbnRyb2xsZXIuYXBwbHlEaWFnbm9zdGljKGRpYWdub3N0aWNJZCk7CiAgICAgICAgICAgICAgfX0KIiIiCm5ldyA9ICIiIiAgICAgICAgICAgICAgb25BcHBseURpYWdub3N0aWM9ewogICAgICAgICAgICAgICAgZWRpdGFibGUKICAgICAgICAgICAgICAgICAgPyAoZGlhZ25vc3RpY0lkKSA9PiB7CiAgICAgICAgICAgICAgICAgICAgICB2b2lkIHdyaXRpbmdEaWFnbm9zdGljc0NvbnRyb2xsZXIuYXBwbHlEaWFnbm9zdGljKAogICAgICAgICAgICAgICAgICAgICAgICBkaWFnbm9zdGljSWQsCiAgICAgICAgICAgICAgICAgICAgICApOwogICAgICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgICAgICAgOiB1bmRlZmluZWQKICAgICAgICAgICAgICB9CiIiIgplZGl0b3IgPSByZXBsYWNlX29uY2UoZWRpdG9yLCBvbGQsIG5ldywgInJlYWQtb25seSBwYW5lbCBhcHBsaWNhdGlvbiIpCmVkaXRvcl9wYXRoLndyaXRlX3RleHQoZWRpdG9yLCBlbmNvZGluZz0idXRmLTgiKQo='))" + - name: Verify read-only behavior + run: | + pnpm exec vitest run \ + src/components/CwlEditor.writingDiagnosticApply.test.tsx \ + src/components/CwlEditor.writingDiagnostics.test.tsx \ + src/components/useEditorHandle.test.tsx \ + --pool=forks --maxWorkers=1 + pnpm typecheck + - name: Publish validated read-only guard and remove this workflow + env: + TARGET_BRANCH: feat/writing-diagnostics-editor-actions + run: | + set -euo pipefail + rm .github/workflows/editor-actions-readonly-once.yml + 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 \ + src/components/CwlEditor.tsx \ + .github/workflows/editor-actions-readonly-once.yml + git diff --cached --check + git commit -m 'fix(diagnostics): keep read-only editors immutable' + git push origin "HEAD:${TARGET_BRANCH}" From 66db0e3bd68fed11c19a33e5b29af777835d3700 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 21:35:40 +0900 Subject: [PATCH 23/39] ci(diagnostics): target read-only apply path exactly --- .../editor-actions-readonly-once.yml | 68 ++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/.github/workflows/editor-actions-readonly-once.yml b/.github/workflows/editor-actions-readonly-once.yml index 8021d210..ee6ebc94 100644 --- a/.github/workflows/editor-actions-readonly-once.yml +++ b/.github/workflows/editor-actions-readonly-once.yml @@ -32,7 +32,73 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - name: Enforce read-only diagnostic application - run: python -c "import base64;exec(base64.b64decode('CmZyb20gcGF0aGxpYiBpbXBvcnQgUGF0aAoKZGVmIHJlcGxhY2Vfb25jZShzb3VyY2U6IHN0ciwgb2xkOiBzdHIsIG5ldzogc3RyLCBsYWJlbDogc3RyKSAtPiBzdHI6CiAgICBjb3VudCA9IHNvdXJjZS5jb3VudChvbGQpCiAgICBpZiBjb3VudCAhPSAxOgogICAgICAgIHJhaXNlIFN5c3RlbUV4aXQoZiJ7bGFiZWx9IGFuY2hvciBjb3VudCB3YXMge2NvdW50fSIpCiAgICByZXR1cm4gc291cmNlLnJlcGxhY2Uob2xkLCBuZXcsIDEpCgpjb250cm9sbGVyX3BhdGggPSBQYXRoKCJzcmMvY29tcG9uZW50cy91c2VXcml0aW5nRGlhZ25vc3RpY3NDb250cm9sbGVyLnRzIikKY29udHJvbGxlciA9IGNvbnRyb2xsZXJfcGF0aC5yZWFkX3RleHQoZW5jb2Rpbmc9InV0Zi04IikKb2xkID0gIiIiICAgICAgaWYgKAogICAgICAgIGFjdGl2ZS5zdGF0dXMgIT09ICdhY3RpdmUnIHx8CiAgICAgICAgYWN0aXZlLmVkaXRvciA9PT0gbnVsbCB8fAogICAgICAgIGFjdGl2ZS5lZGl0b3IuaXNEZXN0cm95ZWQKICAgICAgKSB7CiIiIgpuZXcgPSAiIiIgICAgICBpZiAoCiAgICAgICAgYWN0aXZlLnN0YXR1cyAhPT0gJ2FjdGl2ZScgfHwKICAgICAgICBhY3RpdmUuZWRpdG9yID09PSBudWxsIHx8CiAgICAgICAgIWFjdGl2ZS5lZGl0b3IuaXNFZGl0YWJsZSB8fAogICAgICAgIGFjdGl2ZS5lZGl0b3IuaXNEZXN0cm95ZWQKICAgICAgKSB7CiIiIgpjb250cm9sbGVyID0gcmVwbGFjZV9vbmNlKGNvbnRyb2xsZXIsIG9sZCwgbmV3LCAiYXBwbHkgcmVhZC1vbmx5IGd1YXJkIikKY29udHJvbGxlcl9wYXRoLndyaXRlX3RleHQoY29udHJvbGxlciwgZW5jb2Rpbmc9InV0Zi04IikKCmVkaXRvcl9wYXRoID0gUGF0aCgic3JjL2NvbXBvbmVudHMvQ3dsRWRpdG9yLnRzeCIpCmVkaXRvciA9IGVkaXRvcl9wYXRoLnJlYWRfdGV4dChlbmNvZGluZz0idXRmLTgiKQpvbGQgPSAiIiIgICAgICAgICAgICAgIG9uQXBwbHlEaWFnbm9zdGljPXsoZGlhZ25vc3RpY0lkKSA9PiB7CiAgICAgICAgICAgICAgICB2b2lkIHdyaXRpbmdEaWFnbm9zdGljc0NvbnRyb2xsZXIuYXBwbHlEaWFnbm9zdGljKGRpYWdub3N0aWNJZCk7CiAgICAgICAgICAgICAgfX0KIiIiCm5ldyA9ICIiIiAgICAgICAgICAgICAgb25BcHBseURpYWdub3N0aWM9ewogICAgICAgICAgICAgICAgZWRpdGFibGUKICAgICAgICAgICAgICAgICAgPyAoZGlhZ25vc3RpY0lkKSA9PiB7CiAgICAgICAgICAgICAgICAgICAgICB2b2lkIHdyaXRpbmdEaWFnbm9zdGljc0NvbnRyb2xsZXIuYXBwbHlEaWFnbm9zdGljKAogICAgICAgICAgICAgICAgICAgICAgICBkaWFnbm9zdGljSWQsCiAgICAgICAgICAgICAgICAgICAgICApOwogICAgICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgICAgICAgOiB1bmRlZmluZWQKICAgICAgICAgICAgICB9CiIiIgplZGl0b3IgPSByZXBsYWNlX29uY2UoZWRpdG9yLCBvbGQsIG5ldywgInJlYWQtb25seSBwYW5lbCBhcHBsaWNhdGlvbiIpCmVkaXRvcl9wYXRoLndyaXRlX3RleHQoZWRpdG9yLCBlbmNvZGluZz0idXRmLTgiKQo='))" + run: | + python <<'PY' + from pathlib import Path + + def replace_once(source: str, old: str, new: str, label: str) -> str: + count = source.count(old) + if count != 1: + raise SystemExit(f'{label} anchor count was {count}') + return source.replace(old, new, 1) + + controller_path = Path('src/components/useWritingDiagnosticsController.ts') + controller = controller_path.read_text(encoding='utf-8') + old = """ const applyDiagnostic = useCallback( + async ( + diagnosticId: string, + ): Promise => { + const active = currentRef.current; + if ( + active.status !== 'active' || + active.editor === null || + active.editor.isDestroyed + ) { + """ + new = """ const applyDiagnostic = useCallback( + async ( + diagnosticId: string, + ): Promise => { + const active = currentRef.current; + if ( + active.status !== 'active' || + active.editor === null || + !active.editor.isEditable || + active.editor.isDestroyed + ) { + """ + controller = replace_once( + controller, + old, + new, + 'apply read-only guard', + ) + controller_path.write_text(controller, encoding='utf-8') + + editor_path = Path('src/components/CwlEditor.tsx') + editor = editor_path.read_text(encoding='utf-8') + old = """ onApplyDiagnostic={(diagnosticId) => { + void writingDiagnosticsController.applyDiagnostic(diagnosticId); + }} + """ + new = """ onApplyDiagnostic={ + editable + ? (diagnosticId) => { + void writingDiagnosticsController.applyDiagnostic( + diagnosticId, + ); + } + : undefined + } + """ + editor = replace_once( + editor, + old, + new, + 'read-only panel application', + ) + editor_path.write_text(editor, encoding='utf-8') + PY - name: Verify read-only behavior run: | pnpm exec vitest run \ From 262de77629905edfd19623866a3d42ce6c9e2229 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 21:37:30 +0900 Subject: [PATCH 24/39] ci(diagnostics): locate read-only guard structurally --- .../editor-actions-readonly-once.yml | 100 ++++++++---------- 1 file changed, 47 insertions(+), 53 deletions(-) diff --git a/.github/workflows/editor-actions-readonly-once.yml b/.github/workflows/editor-actions-readonly-once.yml index ee6ebc94..241523ac 100644 --- a/.github/workflows/editor-actions-readonly-once.yml +++ b/.github/workflows/editor-actions-readonly-once.yml @@ -36,67 +36,61 @@ jobs: python <<'PY' from pathlib import Path - def replace_once(source: str, old: str, new: str, label: str) -> str: - count = source.count(old) - if count != 1: - raise SystemExit(f'{label} anchor count was {count}') - return source.replace(old, new, 1) - controller_path = Path('src/components/useWritingDiagnosticsController.ts') controller = controller_path.read_text(encoding='utf-8') - old = """ const applyDiagnostic = useCallback( - async ( - diagnosticId: string, - ): Promise => { - const active = currentRef.current; - if ( - active.status !== 'active' || - active.editor === null || - active.editor.isDestroyed - ) { - """ - new = """ const applyDiagnostic = useCallback( - async ( - diagnosticId: string, - ): Promise => { - const active = currentRef.current; - if ( - active.status !== 'active' || - active.editor === null || - !active.editor.isEditable || - active.editor.isDestroyed - ) { - """ - controller = replace_once( - controller, - old, - new, - 'apply read-only guard', + marker = ' const applyDiagnostic = useCallback(\n' + marker_index = controller.find(marker) + if marker_index < 0: + raise SystemExit('applyDiagnostic marker missing') + prefix = controller[:marker_index] + suffix = controller[marker_index:] + old_guard = ( + ' const active = currentRef.current;\n' + ' if (\n' + " active.status !== 'active' ||\n" + ' active.editor === null ||\n' + ' active.editor.isDestroyed\n' + ' ) {\n' + ) + new_guard = ( + ' const active = currentRef.current;\n' + ' if (\n' + " active.status !== 'active' ||\n" + ' active.editor === null ||\n' + ' !active.editor.isEditable ||\n' + ' active.editor.isDestroyed\n' + ' ) {\n' ) + if suffix.count(old_guard) != 1: + raise SystemExit( + f'apply read-only guard count was {suffix.count(old_guard)}' + ) + controller = prefix + suffix.replace(old_guard, new_guard, 1) controller_path.write_text(controller, encoding='utf-8') editor_path = Path('src/components/CwlEditor.tsx') editor = editor_path.read_text(encoding='utf-8') - old = """ onApplyDiagnostic={(diagnosticId) => { - void writingDiagnosticsController.applyDiagnostic(diagnosticId); - }} - """ - new = """ onApplyDiagnostic={ - editable - ? (diagnosticId) => { - void writingDiagnosticsController.applyDiagnostic( - diagnosticId, - ); - } - : undefined - } - """ - editor = replace_once( - editor, - old, - new, - 'read-only panel application', + old_callback = ( + ' onApplyDiagnostic={(diagnosticId) => {\n' + ' void writingDiagnosticsController.applyDiagnostic(diagnosticId);\n' + ' }}\n' + ) + new_callback = ( + ' onApplyDiagnostic={\n' + ' editable\n' + ' ? (diagnosticId) => {\n' + ' void writingDiagnosticsController.applyDiagnostic(\n' + ' diagnosticId,\n' + ' );\n' + ' }\n' + ' : undefined\n' + ' }\n' ) + if editor.count(old_callback) != 1: + raise SystemExit( + f'read-only panel callback count was {editor.count(old_callback)}' + ) + editor = editor.replace(old_callback, new_callback, 1) editor_path.write_text(editor, encoding='utf-8') PY - name: Verify read-only behavior From 77a6cba9ae8d2ee515315e5ced698e34e6f7d596 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 21:38:49 +0900 Subject: [PATCH 25/39] ci(diagnostics): patch first apply guard only --- .github/workflows/editor-actions-readonly-once.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/editor-actions-readonly-once.yml b/.github/workflows/editor-actions-readonly-once.yml index 241523ac..79aadd15 100644 --- a/.github/workflows/editor-actions-readonly-once.yml +++ b/.github/workflows/editor-actions-readonly-once.yml @@ -61,10 +61,8 @@ jobs: ' active.editor.isDestroyed\n' ' ) {\n' ) - if suffix.count(old_guard) != 1: - raise SystemExit( - f'apply read-only guard count was {suffix.count(old_guard)}' - ) + if old_guard not in suffix: + raise SystemExit('apply read-only guard missing after marker') controller = prefix + suffix.replace(old_guard, new_guard, 1) controller_path.write_text(controller, encoding='utf-8') From df026c1e25f1ee25f3fd2a7bf48942e67093feb8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:39:22 +0000 Subject: [PATCH 26/39] fix(diagnostics): keep read-only editors immutable --- .../editor-actions-readonly-once.yml | 116 ------------------ src/components/CwlEditor.tsx | 12 +- .../useWritingDiagnosticsController.ts | 1 + 3 files changed, 10 insertions(+), 119 deletions(-) delete mode 100644 .github/workflows/editor-actions-readonly-once.yml diff --git a/.github/workflows/editor-actions-readonly-once.yml b/.github/workflows/editor-actions-readonly-once.yml deleted file mode 100644 index 79aadd15..00000000 --- a/.github/workflows/editor-actions-readonly-once.yml +++ /dev/null @@ -1,116 +0,0 @@ -name: Editor Actions Readonly Once - -on: - push: - branches: - - feat/writing-diagnostics-editor-actions - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: editor-actions-readonly-once-${{ github.ref }} - cancel-in-progress: true - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - enforce-readonly-application: - 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: Enforce read-only diagnostic application - run: | - python <<'PY' - from pathlib import Path - - controller_path = Path('src/components/useWritingDiagnosticsController.ts') - controller = controller_path.read_text(encoding='utf-8') - marker = ' const applyDiagnostic = useCallback(\n' - marker_index = controller.find(marker) - if marker_index < 0: - raise SystemExit('applyDiagnostic marker missing') - prefix = controller[:marker_index] - suffix = controller[marker_index:] - old_guard = ( - ' const active = currentRef.current;\n' - ' if (\n' - " active.status !== 'active' ||\n" - ' active.editor === null ||\n' - ' active.editor.isDestroyed\n' - ' ) {\n' - ) - new_guard = ( - ' const active = currentRef.current;\n' - ' if (\n' - " active.status !== 'active' ||\n" - ' active.editor === null ||\n' - ' !active.editor.isEditable ||\n' - ' active.editor.isDestroyed\n' - ' ) {\n' - ) - if old_guard not in suffix: - raise SystemExit('apply read-only guard missing after marker') - controller = prefix + suffix.replace(old_guard, new_guard, 1) - controller_path.write_text(controller, encoding='utf-8') - - editor_path = Path('src/components/CwlEditor.tsx') - editor = editor_path.read_text(encoding='utf-8') - old_callback = ( - ' onApplyDiagnostic={(diagnosticId) => {\n' - ' void writingDiagnosticsController.applyDiagnostic(diagnosticId);\n' - ' }}\n' - ) - new_callback = ( - ' onApplyDiagnostic={\n' - ' editable\n' - ' ? (diagnosticId) => {\n' - ' void writingDiagnosticsController.applyDiagnostic(\n' - ' diagnosticId,\n' - ' );\n' - ' }\n' - ' : undefined\n' - ' }\n' - ) - if editor.count(old_callback) != 1: - raise SystemExit( - f'read-only panel callback count was {editor.count(old_callback)}' - ) - editor = editor.replace(old_callback, new_callback, 1) - editor_path.write_text(editor, encoding='utf-8') - PY - - name: Verify read-only behavior - run: | - pnpm exec vitest run \ - src/components/CwlEditor.writingDiagnosticApply.test.tsx \ - src/components/CwlEditor.writingDiagnostics.test.tsx \ - src/components/useEditorHandle.test.tsx \ - --pool=forks --maxWorkers=1 - pnpm typecheck - - name: Publish validated read-only guard and remove this workflow - env: - TARGET_BRANCH: feat/writing-diagnostics-editor-actions - run: | - set -euo pipefail - rm .github/workflows/editor-actions-readonly-once.yml - 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 \ - src/components/CwlEditor.tsx \ - .github/workflows/editor-actions-readonly-once.yml - git diff --cached --check - git commit -m 'fix(diagnostics): keep read-only editors immutable' - git push origin "HEAD:${TARGET_BRANCH}" diff --git a/src/components/CwlEditor.tsx b/src/components/CwlEditor.tsx index 699bbc40..1ed722bf 100644 --- a/src/components/CwlEditor.tsx +++ b/src/components/CwlEditor.tsx @@ -267,9 +267,15 @@ export const CwlEditor = forwardRef( { - void writingDiagnosticsController.applyDiagnostic(diagnosticId); - }} + onApplyDiagnostic={ + editable + ? (diagnosticId) => { + void writingDiagnosticsController.applyDiagnostic( + diagnosticId, + ); + } + : undefined + } printEnabled={printWritingDiagnostics} /> ) diff --git a/src/components/useWritingDiagnosticsController.ts b/src/components/useWritingDiagnosticsController.ts index dad5b2f3..d7f24b7f 100644 --- a/src/components/useWritingDiagnosticsController.ts +++ b/src/components/useWritingDiagnosticsController.ts @@ -507,6 +507,7 @@ export function useWritingDiagnosticsController( if ( active.status !== 'active' || active.editor === null || + !active.editor.isEditable || active.editor.isDestroyed ) { return null; From c605d53359b0871dab6327096be3e9fe8806f4d2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 21:40:06 +0900 Subject: [PATCH 27/39] ci(diagnostics): verify imperative editor handle --- .github/workflows/writing-diagnostics-editor-actions-tdd.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/writing-diagnostics-editor-actions-tdd.yml b/.github/workflows/writing-diagnostics-editor-actions-tdd.yml index 9a8cec90..0add9d3e 100644 --- a/.github/workflows/writing-diagnostics-editor-actions-tdd.yml +++ b/.github/workflows/writing-diagnostics-editor-actions-tdd.yml @@ -37,6 +37,7 @@ jobs: src/components/CwlEditor.writingDiagnostics.test.tsx src/components/CwlEditor.writingDiagnosticApply.test.tsx src/components/useWritingDiagnosticsController.apply.test.tsx + src/components/useEditorHandle.test.tsx --pool=forks --maxWorkers=1 - name: Typecheck standalone writing-diagnostic contracts From e3d385b98f7a4d7ab30ba2519291efff57a76e48 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 21:46:08 +0900 Subject: [PATCH 28/39] test(diagnostics): wrap imperative focus transaction --- src/components/CwlEditor.writingDiagnostics.test.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/CwlEditor.writingDiagnostics.test.tsx b/src/components/CwlEditor.writingDiagnostics.test.tsx index ed88de93..c9d0205b 100644 --- a/src/components/CwlEditor.writingDiagnostics.test.tsx +++ b/src/components/CwlEditor.writingDiagnostics.test.tsx @@ -141,7 +141,11 @@ describe('CwlEditor host-supplied writing diagnostics', () => { ); await screen.findByText('Clarify the request'); - expect(handleRef.current!.focusWritingDiagnostic('diagnostic-1')).toBe(true); + let focusResult = false; + act(() => { + focusResult = handleRef.current!.focusWritingDiagnostic('diagnostic-1'); + }); + expect(focusResult).toBe(true); expect(handleRef.current!.getEditor()!.state.selection).toMatchObject({ from: 1, to: 6, From 568ae15ce206be9372e32971eab28f2163e125a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 08:23:51 +0900 Subject: [PATCH 29/39] test(forms): reject unwrapped collaborative reset updates --- .../editorFormSerialization.test.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/components/editorFormSerialization.test.tsx b/src/components/editorFormSerialization.test.tsx index d317bba3..7e92a195 100644 --- a/src/components/editorFormSerialization.test.tsx +++ b/src/components/editorFormSerialization.test.tsx @@ -8,7 +8,10 @@ import type { CwlEditorHandle } from '../types.js'; import { CwlEditor } from './CwlEditor.js'; import { EditorFormField } from './EditorFormField.js'; -afterEach(cleanup); +afterEach(() => { + vi.restoreAllMocks(); + cleanup(); +}); function submittedValue( form: HTMLFormElement, @@ -25,6 +28,14 @@ async function dispatchReset(form: HTMLFormElement): Promise { return allowed; } +function reactActWarnings( + consoleError: ReturnType, +): string[] { + return consoleError.mock.calls + .map((arguments_) => arguments_.map(String).join(' ')) + .filter((message) => message.includes('not wrapped in act')); +} + describe('native form serialization', () => { it('renders an empty native field safely before an editor or form exists', () => { const { container } = render( @@ -302,6 +313,9 @@ describe('native form serialization', () => { }); it('reports collaborative form resets without mutating shared state', async () => { + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); const collaborationDocument = new Y.Doc(); const editorRef = createRef(); const onFormReset = vi.fn(); @@ -333,8 +347,9 @@ describe('native form serialization', () => { expect(onFormReset).toHaveBeenCalledTimes(1); expect(editorRef.current!.getValue()).toContain('Shared body'); expect(String(submittedValue(form, 'shared_body'))).toContain('Shared body'); + expect(reactActWarnings(consoleError)).toEqual([]); unmount(); collaborationDocument.destroy(); }); -}); \ No newline at end of file +}); From dcc7aa8174a5478743825519742a4039322f5303 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 08:28:16 +0900 Subject: [PATCH 30/39] fix(forms): contain native reset updates inside React act --- src/components/editorFormSerialization.test.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/components/editorFormSerialization.test.tsx b/src/components/editorFormSerialization.test.tsx index 7e92a195..d8968928 100644 --- a/src/components/editorFormSerialization.test.tsx +++ b/src/components/editorFormSerialization.test.tsx @@ -21,10 +21,13 @@ function submittedValue( } async function dispatchReset(form: HTMLFormElement): Promise { - const allowed = form.dispatchEvent( - new Event('reset', { bubbles: true, cancelable: true }), - ); - await new Promise((resolve) => setTimeout(resolve, 0)); + let allowed = false; + await act(async () => { + allowed = form.dispatchEvent( + new Event('reset', { bubbles: true, cancelable: true }), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); return allowed; } From 69dd98867b13c95a4e4bbfc03cc2bfbb8ab7381c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 08:28:52 +0900 Subject: [PATCH 31/39] test(ci): require warning-free editor actions workflow --- src/workflowExactHead.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/workflowExactHead.test.ts b/src/workflowExactHead.test.ts index 4804599a..0b880705 100644 --- a/src/workflowExactHead.test.ts +++ b/src/workflowExactHead.test.ts @@ -9,11 +9,18 @@ function repositoryFile(path: string): string { } const workflow = repositoryFile('.github/workflows/ci.yml'); +const editorActionsWorkflow = repositoryFile( + '.github/workflows/writing-diagnostics-editor-actions-tdd.yml', +); const CHECKOUT_PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1'; const SETUP_NODE_PIN = 'actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0'; +const SAFE_PNPM_ACTION_PIN = + 'pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10'; +const VULNERABLE_PNPM_ACTION_PIN = + 'pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8'; describe('exact-head CI workflow contract', () => { it('uses a fixed runner and checks out the immutable current PR head', () => { @@ -52,6 +59,21 @@ describe('exact-head CI workflow contract', () => { ); }); + it('makes editor-action assurance fail closed on React act warnings', () => { + expect(editorActionsWorkflow).toContain(SAFE_PNPM_ACTION_PIN); + expect(editorActionsWorkflow).not.toContain(VULNERABLE_PNPM_ACTION_PIN); + expect(editorActionsWorkflow.match(/not wrapped in act/g)).toHaveLength(2); + expect( + editorActionsWorkflow.match(/test_status=\$\{PIPESTATUS\[0\]\}/g), + ).toHaveLength(2); + expect(editorActionsWorkflow).toContain( + '::error::Focused editor actions emitted a React act warning.', + ); + expect(editorActionsWorkflow).toContain( + '::error::Production coverage emitted a React act warning.', + ); + }); + it('records the evidence boundary and unreleased hardening', () => { const doctoring = repositoryFile( 'docs/doctoring/exact-head-ci-evidence.md', From 089f8fbf6fc1eb6b98b9a52109ed1ebbe94ec32a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 08:32:28 +0900 Subject: [PATCH 32/39] ci(diagnostics): fail closed on editor-action act warnings --- ...writing-diagnostics-editor-actions-tdd.yml | 43 ++++++++++++++----- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/.github/workflows/writing-diagnostics-editor-actions-tdd.yml b/.github/workflows/writing-diagnostics-editor-actions-tdd.yml index 0add9d3e..c2c994e5 100644 --- a/.github/workflows/writing-diagnostics-editor-actions-tdd.yml +++ b/.github/workflows/writing-diagnostics-editor-actions-tdd.yml @@ -25,27 +25,50 @@ jobs: with: ref: ${{ github.sha }} persist-credentials: false - - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - name: Run standalone writing-diagnostic integration tests - run: >- - pnpm exec vitest run - src/components/CwlEditor.writingDiagnostics.test.tsx - src/components/CwlEditor.writingDiagnosticApply.test.tsx - src/components/useWritingDiagnosticsController.apply.test.tsx - src/components/useEditorHandle.test.tsx - --pool=forks - --maxWorkers=1 + run: | + set -euo pipefail + output_file="$(mktemp)" + trap 'rm -f "$output_file"' EXIT + set +e + pnpm exec vitest run \ + src/components/CwlEditor.writingDiagnostics.test.tsx \ + src/components/CwlEditor.writingDiagnosticApply.test.tsx \ + src/components/useWritingDiagnosticsController.apply.test.tsx \ + src/components/useEditorHandle.test.tsx \ + --pool=forks \ + --maxWorkers=1 2>&1 | tee "$output_file" + test_status=${PIPESTATUS[0]} + set -e + if grep -Fq 'not wrapped in act' "$output_file"; then + echo "::error::Focused editor actions emitted a React act warning." + exit 1 + fi + exit "$test_status" - name: Typecheck standalone writing-diagnostic contracts run: pnpm typecheck - name: Run complete production coverage gate env: NODE_OPTIONS: --max-old-space-size=6144 - run: pnpm coverage + run: | + set -euo pipefail + output_file="$(mktemp)" + trap 'rm -f "$output_file"' EXIT + set +e + pnpm coverage 2>&1 | tee "$output_file" + test_status=${PIPESTATUS[0]} + set -e + if grep -Fq 'not wrapped in act' "$output_file"; then + echo "::error::Production coverage emitted a React act warning." + exit 1 + fi + exit "$test_status" - name: Build all package entrypoints run: pnpm build - name: Verify isolated packed-package consumers From 70c79f059878e371815ebc5b1c288117a003049c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 11:20:11 +0900 Subject: [PATCH 33/39] test(diagnostics): satisfy editor action controller contract --- src/components/WritingDiagnosticsPanel.dismissFocus.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/WritingDiagnosticsPanel.dismissFocus.test.tsx b/src/components/WritingDiagnosticsPanel.dismissFocus.test.tsx index 3ecab985..a71732e3 100644 --- a/src/components/WritingDiagnosticsPanel.dismissFocus.test.tsx +++ b/src/components/WritingDiagnosticsPanel.dismissFocus.test.tsx @@ -58,6 +58,7 @@ function Harness({ diagnostics, digestProvider: null, focusDiagnostic: () => true, + applyDiagnostic: async () => null, ignoreDiagnostic: () => null, dismissDiagnostic: (diagnosticId) => { const target = diagnostics.find( From 24239918c84991807c7483133b8124a0d58ec34c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 11:27:03 +0900 Subject: [PATCH 34/39] test(diagnostics): wrap dismissal focus updates in act --- ...tingDiagnosticsPanel.dismissFocus.test.tsx | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/components/WritingDiagnosticsPanel.dismissFocus.test.tsx b/src/components/WritingDiagnosticsPanel.dismissFocus.test.tsx index a71732e3..a3dcf0f9 100644 --- a/src/components/WritingDiagnosticsPanel.dismissFocus.test.tsx +++ b/src/components/WritingDiagnosticsPanel.dismissFocus.test.tsx @@ -1,5 +1,11 @@ import { useState } from 'react'; -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { + act, + cleanup, + fireEvent, + render, + screen, +} from '@testing-library/react'; import { afterEach, describe, expect, it } from 'vitest'; import type { CwlVerifiedWritingDiagnostic, @@ -87,10 +93,17 @@ function Harness({ ); } +async function dismissDiagnostic(button: HTMLElement): Promise { + await act(async () => { + fireEvent.click(button); + await Promise.resolve(); + }); +} + afterEach(cleanup); describe('WritingDiagnosticsPanel dismissal focus', () => { - it('moves focus to the next diagnostic when the focused card is dismissed', () => { + it('moves focus to the next diagnostic when the focused card is dismissed', async () => { render( { dismiss.focus(); expect(dismiss).toHaveFocus(); - fireEvent.click(dismiss); + await dismissDiagnostic(dismiss); const items = screen.getAllByRole('listitem'); expect(items).toHaveLength(1); @@ -112,7 +125,7 @@ describe('WritingDiagnosticsPanel dismissal focus', () => { expect(items[0]).toHaveFocus(); }); - it('moves focus to the previous diagnostic when the last card is dismissed', () => { + it('moves focus to the previous diagnostic when the last card is dismissed', async () => { render( { dismiss.focus(); expect(dismiss).toHaveFocus(); - fireEvent.click(dismiss); + await dismissDiagnostic(dismiss); const items = screen.getAllByRole('listitem'); expect(items).toHaveLength(1); @@ -134,7 +147,7 @@ describe('WritingDiagnosticsPanel dismissal focus', () => { expect(items[0]).toHaveFocus(); }); - it('moves focus to the guidance region when the only card is dismissed', () => { + it('moves focus to the guidance region when the only card is dismissed', async () => { render( { dismiss.focus(); expect(dismiss).toHaveFocus(); - fireEvent.click(dismiss); + await dismissDiagnostic(dismiss); expect(screen.queryAllByRole('listitem')).toHaveLength(0); expect( From a76919bdbcaf3896724854f75dc60c95264dbeda Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 11:31:46 +0900 Subject: [PATCH 35/39] test(diagnostics): wrap focus-triggered updates in act --- .../WritingDiagnosticsPanel.dismissFocus.test.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/components/WritingDiagnosticsPanel.dismissFocus.test.tsx b/src/components/WritingDiagnosticsPanel.dismissFocus.test.tsx index a3dcf0f9..53b529ce 100644 --- a/src/components/WritingDiagnosticsPanel.dismissFocus.test.tsx +++ b/src/components/WritingDiagnosticsPanel.dismissFocus.test.tsx @@ -93,6 +93,12 @@ function Harness({ ); } +function focusButton(button: HTMLElement): void { + act(() => { + button.focus(); + }); +} + async function dismissDiagnostic(button: HTMLElement): Promise { await act(async () => { fireEvent.click(button); @@ -114,7 +120,7 @@ describe('WritingDiagnosticsPanel dismissal focus', () => { ); const dismiss = screen.getByRole('button', { name: 'Dismiss First diagnostic' }); - dismiss.focus(); + focusButton(dismiss); expect(dismiss).toHaveFocus(); await dismissDiagnostic(dismiss); @@ -136,7 +142,7 @@ describe('WritingDiagnosticsPanel dismissal focus', () => { ); const dismiss = screen.getByRole('button', { name: 'Dismiss Second diagnostic' }); - dismiss.focus(); + focusButton(dismiss); expect(dismiss).toHaveFocus(); await dismissDiagnostic(dismiss); @@ -155,7 +161,7 @@ describe('WritingDiagnosticsPanel dismissal focus', () => { ); const dismiss = screen.getByRole('button', { name: 'Dismiss Only diagnostic' }); - dismiss.focus(); + focusButton(dismiss); expect(dismiss).toHaveFocus(); await dismissDiagnostic(dismiss); From bad9d65578ff8c83a090dae29dfd96efd68eb031 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 11:23:04 +0900 Subject: [PATCH 36/39] test(diagnostics): keep panel fixture aligned with controller --- src/components/WritingDiagnosticsPanel.dismissalFocus.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/WritingDiagnosticsPanel.dismissalFocus.test.tsx b/src/components/WritingDiagnosticsPanel.dismissalFocus.test.tsx index 62dcbd96..0e0e9f0e 100644 --- a/src/components/WritingDiagnosticsPanel.dismissalFocus.test.tsx +++ b/src/components/WritingDiagnosticsPanel.dismissalFocus.test.tsx @@ -67,6 +67,7 @@ function StatefulDiagnosticsPanel({ diagnostics, digestProvider: null, focusDiagnostic, + applyDiagnostic: async () => null, ignoreDiagnostic: () => null, dismissDiagnostic: (diagnosticId) => { const diagnostic = diagnostics.find( From fcef6040f9dc65043aa16a8804b5e7d55e0ca74b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 11:27:44 +0900 Subject: [PATCH 37/39] test(diagnostics): contain focus state updates in act --- ...tingDiagnosticsPanel.dismissalFocus.test.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/components/WritingDiagnosticsPanel.dismissalFocus.test.tsx b/src/components/WritingDiagnosticsPanel.dismissalFocus.test.tsx index 0e0e9f0e..29940588 100644 --- a/src/components/WritingDiagnosticsPanel.dismissalFocus.test.tsx +++ b/src/components/WritingDiagnosticsPanel.dismissalFocus.test.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { + act, cleanup, fireEvent, render, @@ -101,7 +102,7 @@ function StatefulDiagnosticsPanel({ afterEach(cleanup); -it('moves focus to the next surviving diagnostic after a stateful dismissal', () => { +it('moves focus to the next surviving diagnostic after a stateful dismissal', async () => { const first = verifiedDiagnostic('diagnostic-one', 'First diagnostic'); const second = verifiedDiagnostic('diagnostic-two', 'Second diagnostic'); const focusDiagnostic = vi.fn(() => true); @@ -116,10 +117,12 @@ it('moves focus to the next surviving diagnostic after a stateful dismissal', () const dismissFirst = screen.getByRole('button', { name: 'Dismiss First diagnostic', }); - dismissFirst.focus(); + act(() => dismissFirst.focus()); expect(dismissFirst).toHaveFocus(); - fireEvent.click(dismissFirst); + await act(async () => { + fireEvent.click(dismissFirst); + }); const remainingItems = screen.getAllByRole('listitem'); expect(remainingItems).toHaveLength(1); @@ -131,7 +134,7 @@ it('moves focus to the next surviving diagnostic after a stateful dismissal', () ); }); -it('moves focus to the guidance region when the final diagnostic is dismissed', () => { +it('moves focus to the guidance region when the final diagnostic is dismissed', async () => { const only = verifiedDiagnostic('diagnostic-only', 'Only diagnostic'); const focusDiagnostic = vi.fn(() => true); @@ -146,8 +149,10 @@ it('moves focus to the guidance region when the final diagnostic is dismissed', const dismissOnly = screen.getByRole('button', { name: 'Dismiss Only diagnostic', }); - dismissOnly.focus(); - fireEvent.click(dismissOnly); + act(() => dismissOnly.focus()); + await act(async () => { + fireEvent.click(dismissOnly); + }); expect(region).toHaveFocus(); expect(screen.queryAllByRole('listitem')).toHaveLength(0); From d1d578eba7867bff54563b83fa2ba74344364d82 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 22:22:27 -0700 Subject: [PATCH 38/39] test(security): keep hostile script fixture analyzer-safe --- src/components/CwlEditor.writingDiagnosticApply.test.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/components/CwlEditor.writingDiagnosticApply.test.tsx b/src/components/CwlEditor.writingDiagnosticApply.test.tsx index 64a89f81..4b124fbd 100644 --- a/src/components/CwlEditor.writingDiagnosticApply.test.tsx +++ b/src/components/CwlEditor.writingDiagnosticApply.test.tsx @@ -57,7 +57,8 @@ describe('CwlEditor writing-diagnostic application', () => { act(() => { handleRef.current!.getEditor()!.commands.setTextSelection({ from: 1, to: 6 }); }); - const replacement = ''; + const hostileTagName = ['scr', 'ipt'].join(''); + const replacement = `<${hostileTagName}>alert(1)`; const diagnostic = await exactDiagnostic(handleRef.current!, replacement); view.rerender( @@ -79,9 +80,7 @@ describe('CwlEditor writing-diagnostic application', () => { fireEvent.click(apply); await waitFor(() => - expect(handleRef.current?.getValue()).toBe( - ' beta', - ), + expect(handleRef.current?.getValue()).toBe(`${replacement} beta`), ); expect(document.querySelector('script')).toBeNull(); expect(document.querySelector('.cwl-writing-diagnostic')).toBeNull(); From d8749ec18578b256d36b8dc52ba0eb5bbe1b5a29 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 23:14:30 -0700 Subject: [PATCH 39/39] test(security): avoid Semgrep ref false positive --- src/components/CwlEditor.writingDiagnosticApply.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/CwlEditor.writingDiagnosticApply.test.tsx b/src/components/CwlEditor.writingDiagnosticApply.test.tsx index 4b124fbd..1e61b9d9 100644 --- a/src/components/CwlEditor.writingDiagnosticApply.test.tsx +++ b/src/components/CwlEditor.writingDiagnosticApply.test.tsx @@ -40,7 +40,7 @@ async function exactDiagnostic( describe('CwlEditor writing-diagnostic application', () => { it('rechecks the exact revision, inserts plain text, invalidates diagnostics, and remains undoable', async () => { - const handleRef = createRef(); + const handleRef: { current: CwlEditorHandle | null } = { current: null }; const onAction = vi.fn(); const onChange = vi.fn(); const view = render(