From 604736e6f5b87773c730c4deeb34decf3f7e363d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 11:57:09 +0900 Subject: [PATCH 01/17] test(diagnostics): define decoration invalidation contract --- src/extensions/WritingDiagnostics.test.ts | 261 ++++++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 src/extensions/WritingDiagnostics.test.ts diff --git a/src/extensions/WritingDiagnostics.test.ts b/src/extensions/WritingDiagnostics.test.ts new file mode 100644 index 00000000..f1549e72 --- /dev/null +++ b/src/extensions/WritingDiagnostics.test.ts @@ -0,0 +1,261 @@ +import { Schema } from '@tiptap/pm/model'; +import { EditorState } from '@tiptap/pm/state'; +import { EditorView } from '@tiptap/pm/view'; +import { describe, expect, it } from 'vitest'; +import { + WritingDiagnostics, + clearWritingDiagnostics, + createWritingDiagnosticsPlugin, + focusWritingDiagnostic, + installWritingDiagnostics, + writingDiagnosticsPluginKey, + type CwlResolvedWritingDiagnosticDecoration, + type WritingDiagnosticsPluginState, +} from './WritingDiagnostics.js'; + +const schema = new Schema({ + nodes: { + doc: { content: 'paragraph+' }, + paragraph: { content: 'text*' }, + text: {}, + }, +}); + +function documentWithText(text = 'Alpha beta gamma') { + return schema.node('doc', undefined, [ + schema.node('paragraph', undefined, text ? [schema.text(text)] : []), + ]); +} + +function stateWithText(text = 'Alpha beta gamma') { + return EditorState.create({ + schema, + doc: documentWithText(text), + plugins: [createWritingDiagnosticsPlugin()], + }); +} + +function pluginState(state: EditorState): WritingDiagnosticsPluginState { + const value = writingDiagnosticsPluginKey.getState(state); + if (!value) throw new Error('Missing writing diagnostics plugin state'); + return value; +} + +function diagnostic( + overrides: Partial = {}, +): CwlResolvedWritingDiagnosticDecoration { + return { + diagnosticId: 'diag-1', + from: 1, + to: 6, + priority: 'important', + ...overrides, + }; +} + +function decorationAttributes(state: EditorState): Record { + const [decoration] = pluginState(state).decorations.find(); + if (!decoration) throw new Error('Missing writing diagnostic decoration'); + return ( + decoration as unknown as { + type: { attrs: Record }; + } + ).type.attrs; +} + +describe('WritingDiagnostics extension contract', () => { + it('exposes one stable TipTap extension and plugin key', () => { + expect(WritingDiagnostics.name).toBe('writingDiagnostics'); + expect(writingDiagnosticsPluginKey.key).toContain('cwlWritingDiagnostics'); + }); + + it('starts empty without mutating the document or rendering decorations', () => { + const state = stateWithText(); + const current = pluginState(state); + + expect(state.doc.textContent).toBe('Alpha beta gamma'); + expect(current.generation).toBe(-1); + expect(current.diagnostics).toEqual([]); + expect(current.focusedDiagnosticId).toBeNull(); + expect(current.decorations.find()).toEqual([]); + }); + + it('installs verified ranges with only static privacy-minimized attributes', () => { + let state = stateWithText(); + state = state.apply( + installWritingDiagnostics(state.tr, 4, [ + diagnostic({ ariaInvalid: 'spelling' }), + ]), + ); + + const current = pluginState(state); + expect(current.generation).toBe(4); + expect(current.diagnostics).toEqual([ + diagnostic({ ariaInvalid: 'spelling' }), + ]); + expect(Object.isFrozen(current.diagnostics)).toBe(true); + expect(Object.isFrozen(current.diagnostics[0])).toBe(true); + expect(decorationAttributes(state)).toEqual({ + class: 'cwl-writing-diagnostic cwl-writing-diagnostic--important', + 'data-cwl-diagnostic-id': 'diag-1', + 'aria-invalid': 'spelling', + }); + expect(JSON.stringify(decorationAttributes(state))).not.toContain('Alpha'); + }); + + it('never infers aria-invalid from category-like identifiers', () => { + let state = stateWithText(); + state = state.apply( + installWritingDiagnostics(state.tr, 1, [ + diagnostic({ diagnosticId: 'grammar.spelling.issue' }), + ]), + ); + + expect(decorationAttributes(state)).toEqual({ + class: 'cwl-writing-diagnostic cwl-writing-diagnostic--important', + 'data-cwl-diagnostic-id': 'grammar.spelling.issue', + }); + }); + + it('retains collapsed diagnostics without creating an inline decoration', () => { + let state = stateWithText(); + state = state.apply( + installWritingDiagnostics(state.tr, 1, [ + diagnostic({ from: 3, to: 3 }), + ]), + ); + + expect(pluginState(state).diagnostics).toHaveLength(1); + expect(pluginState(state).decorations.find()).toEqual([]); + }); + + it('ignores duplicate and stale install generations', () => { + let state = stateWithText(); + state = state.apply( + installWritingDiagnostics(state.tr, 7, [diagnostic()]), + ); + const accepted = pluginState(state); + + state = state.apply( + installWritingDiagnostics(state.tr, 7, [ + diagnostic({ diagnosticId: 'same-generation', from: 7, to: 10 }), + ]), + ); + expect(pluginState(state)).toBe(accepted); + + state = state.apply( + installWritingDiagnostics(state.tr, 6, [ + diagnostic({ diagnosticId: 'stale-generation', from: 7, to: 10 }), + ]), + ); + expect(pluginState(state)).toBe(accepted); + }); + + it('focuses only an installed diagnostic from the active generation', () => { + let state = stateWithText(); + state = state.apply( + installWritingDiagnostics(state.tr, 3, [ + diagnostic(), + diagnostic({ diagnosticId: 'diag-2', from: 7, to: 11 }), + ]), + ); + + state = state.apply(focusWritingDiagnostic(state.tr, 3, 'diag-2')); + expect(pluginState(state).focusedDiagnosticId).toBe('diag-2'); + + const focused = pluginState(state); + state = state.apply(focusWritingDiagnostic(state.tr, 2, 'diag-1')); + expect(pluginState(state)).toBe(focused); + state = state.apply(focusWritingDiagnostic(state.tr, 3, 'missing')); + expect(pluginState(state)).toBe(focused); + }); + + it('clears active state while retaining the monotonic generation fence', () => { + let state = stateWithText(); + state = state.apply( + installWritingDiagnostics(state.tr, 9, [diagnostic()]), + ); + state = state.apply(clearWritingDiagnostics(state.tr)); + + expect(pluginState(state)).toEqual( + expect.objectContaining({ + generation: 9, + diagnostics: [], + focusedDiagnosticId: null, + }), + ); + expect(pluginState(state).decorations.find()).toEqual([]); + + const cleared = pluginState(state); + state = state.apply( + installWritingDiagnostics(state.tr, 9, [diagnostic()]), + ); + expect(pluginState(state)).toBe(cleared); + }); + + it('clears before processing metadata on every document-changing transaction', () => { + let state = stateWithText(); + state = state.apply( + installWritingDiagnostics(state.tr, 5, [diagnostic()]), + ); + + const transaction = installWritingDiagnostics( + state.tr.insertText('!', 2), + 6, + [diagnostic({ diagnosticId: 'must-not-install' })], + ); + state = state.apply(transaction); + + expect(state.doc.textContent).toBe('A!lpha beta gamma'); + expect(pluginState(state).generation).toBe(5); + expect(pluginState(state).diagnostics).toEqual([]); + expect(pluginState(state).decorations.find()).toEqual([]); + }); + + it('does not map diagnostics through an ordinary remote-like document change', () => { + let state = stateWithText(); + state = state.apply( + installWritingDiagnostics(state.tr, 2, [diagnostic()]), + ); + + const remoteLike = state.tr.insertText('remote ', 1).setMeta('y-sync$', { + isChangeOrigin: true, + }); + state = state.apply(remoteLike); + + expect(pluginState(state).generation).toBe(2); + expect(pluginState(state).diagnostics).toEqual([]); + expect(pluginState(state).decorations.find()).toEqual([]); + }); + + it('fails closed for invalid ranges, duplicate ids, or unsupported attributes', () => { + let state = stateWithText(); + const invalidSets: readonly (readonly CwlResolvedWritingDiagnosticDecoration[])[] = [ + [diagnostic({ from: -1 })], + [diagnostic({ from: 9, to: 8 })], + [diagnostic({ to: state.doc.content.size + 1 })], + [diagnostic(), diagnostic()], + [ + diagnostic({ + ariaInvalid: 'grammar' as CwlResolvedWritingDiagnosticDecoration['ariaInvalid'], + }), + ], + ]; + + for (const invalid of invalidSets) { + const previous = pluginState(state); + state = state.apply(installWritingDiagnostics(state.tr, 1, invalid)); + expect(pluginState(state)).toBe(previous); + } + }); + + it('releases with the editor view and retains no external lifecycle resource', () => { + const host = document.createElement('div'); + document.body.append(host); + const view = new EditorView(host, { state: stateWithText() }); + + expect(() => view.destroy()).not.toThrow(); + expect(host.childNodes).toHaveLength(0); + host.remove(); + }); +}); From d55ce900a9f11a88eb8af610e2e84047a8c6614c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 11:57:29 +0900 Subject: [PATCH 02/17] ci(diagnostics): expose decoration TDD red state --- .../writing-diagnostics-decorations-tdd.yml | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/writing-diagnostics-decorations-tdd.yml diff --git a/.github/workflows/writing-diagnostics-decorations-tdd.yml b/.github/workflows/writing-diagnostics-decorations-tdd.yml new file mode 100644 index 00000000..37bf0bcb --- /dev/null +++ b/.github/workflows/writing-diagnostics-decorations-tdd.yml @@ -0,0 +1,35 @@ +name: Writing Diagnostics Decorations TDD + +on: + push: + branches: + - feat/writing-diagnostics-decorations + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: writing-diagnostics-decorations-tdd-${{ github.ref }} + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + focused-decorations: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Run decoration and invalidation contract tests + run: pnpm exec vitest run src/extensions/WritingDiagnostics.test.ts From 8d0cec39a484aa4ec840e636e17e078b13bc4437 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 11:59:51 +0900 Subject: [PATCH 03/17] feat(diagnostics): add fail-closed editor decorations --- src/extensions/WritingDiagnostics.ts | 441 +++++++++++++++++++++++++++ 1 file changed, 441 insertions(+) create mode 100644 src/extensions/WritingDiagnostics.ts diff --git a/src/extensions/WritingDiagnostics.ts b/src/extensions/WritingDiagnostics.ts new file mode 100644 index 00000000..c4300259 --- /dev/null +++ b/src/extensions/WritingDiagnostics.ts @@ -0,0 +1,441 @@ +/** + * Revision-scoped writing-diagnostic decorations for TipTap/ProseMirror. + * + * This module accepts only already-validated, already-resolved structural ranges. + * It does not call models, providers, networks, databases, host callbacks, or + * revision hashers, and it never infers language semantics from category text. + */ +import { Extension } from '@tiptap/core'; +import type { Node as ProseMirrorNode } from '@tiptap/pm/model'; +import { + Plugin, + PluginKey, + type EditorState, + type Transaction, +} from '@tiptap/pm/state'; +import { Decoration, DecorationSet } from '@tiptap/pm/view'; +import type { CwlWritingDiagnosticPriority } from '../writingDiagnostics.js'; + +const MAX_DECORATIONS = 256; +const MAX_DIAGNOSTIC_ID_CODE_UNITS = 256; +const RESOLVED_DIAGNOSTIC_FIELDS = Object.freeze([ + 'diagnosticId', + 'from', + 'to', + 'priority', + 'ariaInvalid', +] as const); +const REQUIRED_RESOLVED_DIAGNOSTIC_FIELDS = Object.freeze([ + 'diagnosticId', + 'from', + 'to', + 'priority', +] as const); +const PRIORITIES = new Set([ + 'advisory', + 'important', + 'critical', +]); + +/** Explicit host-approved ARIA invalidity mapping for mechanics guidance. */ +export type CwlWritingDiagnosticAriaInvalid = 'spelling'; + +/** + * Privacy-minimized structural range that may be rendered as one decoration. + * + * Semantic prose, source text, replacement text, model output, and confidence + * are deliberately absent. `ariaInvalid` is explicit host policy output; the + * extension never derives it from `diagnosticId` or any category-like string. + */ +export interface CwlResolvedWritingDiagnosticDecoration { + /** Opaque identifier already validated by the host contract. */ + readonly diagnosticId: string; + /** Inclusive ProseMirror position in the exact current document. */ + readonly from: number; + /** Exclusive ProseMirror position in the exact current document. */ + readonly to: number; + /** Host-selected visual priority. */ + readonly priority: CwlWritingDiagnosticPriority; + /** Optional explicit host mapping for mechanics-related accessibility state. */ + readonly ariaInvalid?: CwlWritingDiagnosticAriaInvalid; +} + +/** Immutable state owned by the writing-diagnostic ProseMirror plugin. */ +export interface WritingDiagnosticsPluginState { + /** Highest accepted monotonically increasing installation generation. */ + readonly generation: number; + /** Detached structural diagnostics for the active exact document. */ + readonly diagnostics: readonly CwlResolvedWritingDiagnosticDecoration[]; + /** Opaque focused identifier, or null when no diagnostic is focused. */ + readonly focusedDiagnosticId: string | null; + /** Inline decorations derived only from non-empty structural ranges. */ + readonly decorations: DecorationSet; +} + +interface InstallMeta { + readonly type: 'install'; + readonly generation: number; + readonly diagnostics: readonly CwlResolvedWritingDiagnosticDecoration[]; +} + +interface FocusMeta { + readonly type: 'focus'; + readonly generation: number; + readonly diagnosticId: string; +} + +interface ClearMeta { + readonly type: 'clear'; +} + +type WritingDiagnosticsMeta = InstallMeta | FocusMeta | ClearMeta; + +declare module '@tiptap/core' { + interface Commands { + writingDiagnostics: { + /** Install one exact resolved diagnostic generation. */ + installWritingDiagnostics: ( + generation: number, + diagnostics: readonly CwlResolvedWritingDiagnosticDecoration[], + ) => ReturnType; + /** Focus one diagnostic in the active generation without changing the document. */ + focusWritingDiagnostic: ( + generation: number, + diagnosticId: string, + ) => ReturnType; + /** Clear active diagnostics while retaining the monotonic generation fence. */ + clearWritingDiagnostics: () => ReturnType; + }; + } +} + +/** Stable plugin key used by commands, controllers, and deterministic tests. */ +export const writingDiagnosticsPluginKey = + new PluginKey('cwlWritingDiagnostics'); + +/** Attach a typed install operation to one ProseMirror transaction. */ +export function installWritingDiagnostics( + transaction: Transaction, + generation: number, + diagnostics: readonly CwlResolvedWritingDiagnosticDecoration[], +): Transaction { + return transaction.setMeta(writingDiagnosticsPluginKey, { + type: 'install', + generation, + diagnostics, + } satisfies InstallMeta); +} + +/** Attach a typed focus operation to one ProseMirror transaction. */ +export function focusWritingDiagnostic( + transaction: Transaction, + generation: number, + diagnosticId: string, +): Transaction { + return transaction.setMeta(writingDiagnosticsPluginKey, { + type: 'focus', + generation, + diagnosticId, + } satisfies FocusMeta); +} + +/** Attach a typed clear operation to one ProseMirror transaction. */ +export function clearWritingDiagnostics(transaction: Transaction): Transaction { + return transaction.setMeta(writingDiagnosticsPluginKey, { + type: 'clear', + } satisfies ClearMeta); +} + +/** Create the standalone ProseMirror plugin used by every Inkspan surface. */ +export function createWritingDiagnosticsPlugin(): Plugin { + return new Plugin({ + key: writingDiagnosticsPluginKey, + state: { + init: () => emptyPluginState(-1), + apply(transaction, previous) { + if (transaction.docChanged) { + return hasActiveDiagnostics(previous) + ? emptyPluginState(previous.generation) + : previous; + } + + const meta = transaction.getMeta( + writingDiagnosticsPluginKey, + ) as WritingDiagnosticsMeta | undefined; + if (meta === undefined) { + return previous; + } + if (meta.type === 'clear') { + return hasActiveDiagnostics(previous) + ? emptyPluginState(previous.generation) + : previous; + } + if (meta.type === 'focus') { + return applyFocusMeta(previous, meta); + } + return applyInstallMeta(transaction.doc, previous, meta); + }, + }, + props: { + decorations(editorState: EditorState) { + return writingDiagnosticsPluginKey.getState(editorState)?.decorations ?? null; + }, + }, + }); +} + +/** Shared TipTap extension installed exactly once in standalone and CRDT editors. */ +export const WritingDiagnostics = Extension.create({ + name: 'writingDiagnostics', + + addCommands() { + return { + installWritingDiagnostics: + (generation, diagnostics) => + ({ transaction, dispatch }) => { + if ( + !Number.isSafeInteger(generation) || + generation < 0 || + normalizeResolvedDiagnostics(transaction.doc, diagnostics) === null + ) { + return false; + } + if (dispatch) { + dispatch( + installWritingDiagnostics(transaction, generation, diagnostics), + ); + } + return true; + }, + focusWritingDiagnostic: + (generation, diagnosticId) => + ({ transaction, dispatch }) => { + if ( + !Number.isSafeInteger(generation) || + generation < 0 || + typeof diagnosticId !== 'string' || + diagnosticId.length === 0 || + diagnosticId.length > MAX_DIAGNOSTIC_ID_CODE_UNITS + ) { + return false; + } + if (dispatch) { + dispatch( + focusWritingDiagnostic(transaction, generation, diagnosticId), + ); + } + return true; + }, + clearWritingDiagnostics: + () => + ({ transaction, dispatch }) => { + if (dispatch) { + dispatch(clearWritingDiagnostics(transaction)); + } + return true; + }, + }; + }, + + addProseMirrorPlugins() { + return [createWritingDiagnosticsPlugin()]; + }, +}); + +/** Return an immutable empty state while retaining the latest generation fence. */ +function emptyPluginState(generation: number): WritingDiagnosticsPluginState { + return Object.freeze({ + generation, + diagnostics: Object.freeze([]), + focusedDiagnosticId: null, + decorations: DecorationSet.empty, + }); +} + +/** Determine whether clearing would materially change plugin state. */ +function hasActiveDiagnostics(state: WritingDiagnosticsPluginState): boolean { + return state.diagnostics.length > 0 || state.focusedDiagnosticId !== null; +} + +/** Apply one monotonic, structurally valid install operation. */ +function applyInstallMeta( + documentNode: ProseMirrorNode, + previous: WritingDiagnosticsPluginState, + meta: InstallMeta, +): WritingDiagnosticsPluginState { + if ( + !Number.isSafeInteger(meta.generation) || + meta.generation < 0 || + meta.generation <= previous.generation + ) { + return previous; + } + const diagnostics = normalizeResolvedDiagnostics( + documentNode, + meta.diagnostics, + ); + if (diagnostics === null) { + return previous; + } + + const decorations = diagnostics.flatMap((diagnostic) => { + if (diagnostic.from === diagnostic.to) { + return []; + } + const attributes: Record = { + class: `cwl-writing-diagnostic cwl-writing-diagnostic--${diagnostic.priority}`, + 'data-cwl-diagnostic-id': diagnostic.diagnosticId, + }; + if (diagnostic.ariaInvalid === 'spelling') { + attributes['aria-invalid'] = 'spelling'; + } + return [ + Decoration.inline(diagnostic.from, diagnostic.to, attributes, { + inclusiveStart: false, + inclusiveEnd: false, + }), + ]; + }); + + return Object.freeze({ + generation: meta.generation, + diagnostics, + focusedDiagnosticId: null, + decorations: DecorationSet.create(documentNode, decorations), + }); +} + +/** Apply a focus request only to the exact active generation and identifier. */ +function applyFocusMeta( + previous: WritingDiagnosticsPluginState, + meta: FocusMeta, +): WritingDiagnosticsPluginState { + if ( + meta.generation !== previous.generation || + !previous.diagnostics.some( + (diagnostic) => diagnostic.diagnosticId === meta.diagnosticId, + ) || + previous.focusedDiagnosticId === meta.diagnosticId + ) { + return previous; + } + return Object.freeze({ + ...previous, + focusedDiagnosticId: meta.diagnosticId, + }); +} + +/** + * Detach one bounded exact structural diagnostic array without invoking accessors. + */ +function normalizeResolvedDiagnostics( + documentNode: ProseMirrorNode, + input: unknown, +): readonly CwlResolvedWritingDiagnosticDecoration[] | null { + if (!Array.isArray(input) || input.length > MAX_DECORATIONS) { + return null; + } + const result: CwlResolvedWritingDiagnosticDecoration[] = []; + const identifiers = new Set(); + for (const candidate of input) { + const normalized = normalizeResolvedDiagnostic(documentNode, candidate); + if (normalized === null || identifiers.has(normalized.diagnosticId)) { + return null; + } + identifiers.add(normalized.diagnosticId); + result.push(normalized); + } + return Object.freeze(result); +} + +/** Detach one exact resolved diagnostic object. */ +function normalizeResolvedDiagnostic( + documentNode: ProseMirrorNode, + value: unknown, +): CwlResolvedWritingDiagnosticDecoration | null { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return null; + } + + let prototype: object | null; + let keys: PropertyKey[]; + try { + prototype = Object.getPrototypeOf(value); + keys = Reflect.ownKeys(value); + } catch { + return null; + } + if (prototype !== Object.prototype && prototype !== null) { + return null; + } + const allowed = new Set(RESOLVED_DIAGNOSTIC_FIELDS); + const record: Record = {}; + for (const key of keys) { + if (typeof key !== 'string' || !allowed.has(key)) { + return null; + } + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(value, key); + } catch { + return null; + } + if ( + descriptor === undefined || + descriptor.enumerable !== true || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + return null; + } + Object.defineProperty(record, key, { + value: descriptor.value, + enumerable: true, + configurable: true, + writable: true, + }); + } + for (const requiredField of REQUIRED_RESOLVED_DIAGNOSTIC_FIELDS) { + if (!Object.prototype.hasOwnProperty.call(record, requiredField)) { + return null; + } + } + + const diagnosticId = record.diagnosticId; + const from = record.from; + const to = record.to; + const priority = record.priority; + const ariaInvalid = record.ariaInvalid; + if ( + typeof diagnosticId !== 'string' || + diagnosticId.length === 0 || + diagnosticId.length > MAX_DIAGNOSTIC_ID_CODE_UNITS || + !Number.isSafeInteger(from) || + !Number.isSafeInteger(to) || + (from as number) < 0 || + (to as number) < (from as number) || + (to as number) > documentNode.content.size || + typeof priority !== 'string' || + !PRIORITIES.has(priority as CwlWritingDiagnosticPriority) || + (ariaInvalid !== undefined && ariaInvalid !== 'spelling') + ) { + return null; + } + + const normalized: CwlResolvedWritingDiagnosticDecoration = { + diagnosticId, + from: from as number, + to: to as number, + priority: priority as CwlWritingDiagnosticPriority, + }; + if (ariaInvalid === 'spelling') { + Object.defineProperty(normalized, 'ariaInvalid', { + value: 'spelling', + enumerable: true, + configurable: false, + writable: false, + }); + } + return Object.freeze(normalized); +} + +export default WritingDiagnostics; From da69b1b872b1024612511a6a97f7e32a13d8c5ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 12:00:13 +0900 Subject: [PATCH 04/17] test(diagnostics): require one shared decoration extension --- src/extensions/WritingDiagnosticsKit.test.ts | 23 ++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/extensions/WritingDiagnosticsKit.test.ts diff --git a/src/extensions/WritingDiagnosticsKit.test.ts b/src/extensions/WritingDiagnosticsKit.test.ts new file mode 100644 index 00000000..9025becf --- /dev/null +++ b/src/extensions/WritingDiagnosticsKit.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { buildExtensions } from './kit.js'; + +describe('shared WritingDiagnostics extension graph', () => { + it('installs the decoration extension exactly once in the default graph', () => { + const names = buildExtensions().map((extension) => extension.name); + + expect(names.filter((name) => name === 'writingDiagnostics')).toHaveLength(1); + }); + + it('does not duplicate the shared extension when hosts append other extensions', () => { + const additional = buildExtensions().find( + (extension) => extension.name === 'placeholder', + ); + if (!additional) throw new Error('Missing additional extension fixture'); + + const names = buildExtensions({ additionalExtensions: [additional] }).map( + (extension) => extension.name, + ); + + expect(names.filter((name) => name === 'writingDiagnostics')).toHaveLength(1); + }); +}); From 73a1a35d9e92f039a8fa3a7c7cc6f2ea50d1a7a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 12:00:36 +0900 Subject: [PATCH 05/17] feat(diagnostics): install decorations in shared editor graph --- src/extensions/kit.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/extensions/kit.ts b/src/extensions/kit.ts index 71554bc2..aab748db 100644 --- a/src/extensions/kit.ts +++ b/src/extensions/kit.ts @@ -16,6 +16,7 @@ import type { } from './SafeClipboard.js'; import { SafeClipboard } from './SafeClipboardExtension.js'; import { SafeLink, isSafeLinkHref } from './SafeLink.js'; +import { WritingDiagnostics } from './WritingDiagnostics.js'; import type { ImageConfig } from '../types.js'; /** Options for constructing the shared Inkspan extension collection. */ @@ -65,6 +66,7 @@ export function buildExtensions( config: options.clipboard, onError: options.onClipboardError, }), + WritingDiagnostics, Placeholder.configure({ placeholder: options.placeholder ?? 'Start writing…', }), From a3e51046c81985d395440854a5835fe430b4856d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 12:03:18 +0900 Subject: [PATCH 06/17] test(diagnostics): provide deterministic lifecycle DOM fixture --- src/extensions/WritingDiagnostics.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/extensions/WritingDiagnostics.test.ts b/src/extensions/WritingDiagnostics.test.ts index f1549e72..8ffb7660 100644 --- a/src/extensions/WritingDiagnostics.test.ts +++ b/src/extensions/WritingDiagnostics.test.ts @@ -16,7 +16,10 @@ import { const schema = new Schema({ nodes: { doc: { content: 'paragraph+' }, - paragraph: { content: 'text*' }, + paragraph: { + content: 'text*', + toDOM: () => ['p', 0], + }, text: {}, }, }); From 954a8206c279871bbd889717d442dcade9f2fb96 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 12:03:47 +0900 Subject: [PATCH 07/17] ci(diagnostics): typecheck decoration contracts --- .github/workflows/writing-diagnostics-decorations-tdd.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/writing-diagnostics-decorations-tdd.yml b/.github/workflows/writing-diagnostics-decorations-tdd.yml index 37bf0bcb..d8e297cb 100644 --- a/.github/workflows/writing-diagnostics-decorations-tdd.yml +++ b/.github/workflows/writing-diagnostics-decorations-tdd.yml @@ -32,4 +32,9 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - name: Run decoration and invalidation contract tests - run: pnpm exec vitest run src/extensions/WritingDiagnostics.test.ts + run: >- + pnpm exec vitest run + src/extensions/WritingDiagnostics.test.ts + src/extensions/WritingDiagnosticsKit.test.ts + - name: Typecheck command and plugin contracts + run: pnpm typecheck From e25b45f6125556a69483a3d752119dc9ed19000c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 12:05:25 +0900 Subject: [PATCH 08/17] ci(diagnostics): fix command and plugin-key public types --- .../workflows/decorations-type-fix-once.yml | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 .github/workflows/decorations-type-fix-once.yml diff --git a/.github/workflows/decorations-type-fix-once.yml b/.github/workflows/decorations-type-fix-once.yml new file mode 100644 index 00000000..26d1bfde --- /dev/null +++ b/.github/workflows/decorations-type-fix-once.yml @@ -0,0 +1,77 @@ +name: Decorations Type Fix Once + +on: + push: + branches: + - feat/writing-diagnostics-decorations + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: decorations-type-fix-once-${{ github.ref }} + cancel-in-progress: true + +jobs: + apply-type-fix: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + 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: Apply exact public-type corrections + run: | + set -euo pipefail + python <<'PY' + from pathlib import Path + + source_path = Path('src/extensions/WritingDiagnostics.ts') + source = source_path.read_text(encoding='utf-8') + replacements = ( + ('({ transaction, dispatch }) => {', '({ tr, dispatch }) => {', 3), + ('normalizeResolvedDiagnostics(transaction.doc, diagnostics)', 'normalizeResolvedDiagnostics(tr.doc, diagnostics)', 1), + ('installWritingDiagnostics(transaction, generation, diagnostics)', 'installWritingDiagnostics(tr, generation, diagnostics)', 1), + ('focusWritingDiagnostic(transaction, generation, diagnosticId)', 'focusWritingDiagnostic(tr, generation, diagnosticId)', 1), + ('clearWritingDiagnostics(transaction)', 'clearWritingDiagnostics(tr)', 1), + ) + for old, new, expected_count in replacements: + if source.count(old) != expected_count: + raise SystemExit(f'unexpected occurrence count for {old!r}') + source = source.replace(old, new) + source_path.write_text(source, encoding='utf-8') + + test_path = Path('src/extensions/WritingDiagnostics.test.ts') + test_source = test_path.read_text(encoding='utf-8') + old_test = " expect(writingDiagnosticsPluginKey.key).toContain('cwlWritingDiagnostics');\n" + new_test = " expect(typeof writingDiagnosticsPluginKey.getState).toBe('function');\n" + if test_source.count(old_test) != 1: + raise SystemExit('unexpected plugin-key assertion count') + test_path.write_text(test_source.replace(old_test, new_test), encoding='utf-8') + PY + - name: Verify focused contracts and public types + run: | + pnpm exec vitest run \ + src/extensions/WritingDiagnostics.test.ts \ + src/extensions/WritingDiagnosticsKit.test.ts + pnpm typecheck + - name: Remove one-shot workflow and publish validated correction + env: + TARGET_BRANCH: feat/writing-diagnostics-decorations + run: | + set -euo pipefail + rm .github/workflows/decorations-type-fix-once.yml + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add src/extensions/WritingDiagnostics.ts src/extensions/WritingDiagnostics.test.ts .github/workflows/decorations-type-fix-once.yml + git diff --cached --check + git commit -m 'fix(diagnostics): use public command and plugin-key APIs' + git push origin "HEAD:${TARGET_BRANCH}" From 4b5c7aa5d449ecdc6a3cbda227f3ed09c3654d33 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 03:06:04 +0000 Subject: [PATCH 09/17] fix(diagnostics): use public command and plugin-key APIs --- .../workflows/decorations-type-fix-once.yml | 77 ------------------- src/extensions/WritingDiagnostics.test.ts | 2 +- src/extensions/WritingDiagnostics.ts | 14 ++-- 3 files changed, 8 insertions(+), 85 deletions(-) delete mode 100644 .github/workflows/decorations-type-fix-once.yml diff --git a/.github/workflows/decorations-type-fix-once.yml b/.github/workflows/decorations-type-fix-once.yml deleted file mode 100644 index 26d1bfde..00000000 --- a/.github/workflows/decorations-type-fix-once.yml +++ /dev/null @@ -1,77 +0,0 @@ -name: Decorations Type Fix Once - -on: - push: - branches: - - feat/writing-diagnostics-decorations - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: decorations-type-fix-once-${{ github.ref }} - cancel-in-progress: true - -jobs: - apply-type-fix: - runs-on: ubuntu-24.04 - timeout-minutes: 10 - 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: Apply exact public-type corrections - run: | - set -euo pipefail - python <<'PY' - from pathlib import Path - - source_path = Path('src/extensions/WritingDiagnostics.ts') - source = source_path.read_text(encoding='utf-8') - replacements = ( - ('({ transaction, dispatch }) => {', '({ tr, dispatch }) => {', 3), - ('normalizeResolvedDiagnostics(transaction.doc, diagnostics)', 'normalizeResolvedDiagnostics(tr.doc, diagnostics)', 1), - ('installWritingDiagnostics(transaction, generation, diagnostics)', 'installWritingDiagnostics(tr, generation, diagnostics)', 1), - ('focusWritingDiagnostic(transaction, generation, diagnosticId)', 'focusWritingDiagnostic(tr, generation, diagnosticId)', 1), - ('clearWritingDiagnostics(transaction)', 'clearWritingDiagnostics(tr)', 1), - ) - for old, new, expected_count in replacements: - if source.count(old) != expected_count: - raise SystemExit(f'unexpected occurrence count for {old!r}') - source = source.replace(old, new) - source_path.write_text(source, encoding='utf-8') - - test_path = Path('src/extensions/WritingDiagnostics.test.ts') - test_source = test_path.read_text(encoding='utf-8') - old_test = " expect(writingDiagnosticsPluginKey.key).toContain('cwlWritingDiagnostics');\n" - new_test = " expect(typeof writingDiagnosticsPluginKey.getState).toBe('function');\n" - if test_source.count(old_test) != 1: - raise SystemExit('unexpected plugin-key assertion count') - test_path.write_text(test_source.replace(old_test, new_test), encoding='utf-8') - PY - - name: Verify focused contracts and public types - run: | - pnpm exec vitest run \ - src/extensions/WritingDiagnostics.test.ts \ - src/extensions/WritingDiagnosticsKit.test.ts - pnpm typecheck - - name: Remove one-shot workflow and publish validated correction - env: - TARGET_BRANCH: feat/writing-diagnostics-decorations - run: | - set -euo pipefail - rm .github/workflows/decorations-type-fix-once.yml - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add src/extensions/WritingDiagnostics.ts src/extensions/WritingDiagnostics.test.ts .github/workflows/decorations-type-fix-once.yml - git diff --cached --check - git commit -m 'fix(diagnostics): use public command and plugin-key APIs' - git push origin "HEAD:${TARGET_BRANCH}" diff --git a/src/extensions/WritingDiagnostics.test.ts b/src/extensions/WritingDiagnostics.test.ts index 8ffb7660..5c1d4814 100644 --- a/src/extensions/WritingDiagnostics.test.ts +++ b/src/extensions/WritingDiagnostics.test.ts @@ -69,7 +69,7 @@ function decorationAttributes(state: EditorState): Record { describe('WritingDiagnostics extension contract', () => { it('exposes one stable TipTap extension and plugin key', () => { expect(WritingDiagnostics.name).toBe('writingDiagnostics'); - expect(writingDiagnosticsPluginKey.key).toContain('cwlWritingDiagnostics'); + expect(typeof writingDiagnosticsPluginKey.getState).toBe('function'); }); it('starts empty without mutating the document or rendering decorations', () => { diff --git a/src/extensions/WritingDiagnostics.ts b/src/extensions/WritingDiagnostics.ts index c4300259..655a3e7b 100644 --- a/src/extensions/WritingDiagnostics.ts +++ b/src/extensions/WritingDiagnostics.ts @@ -192,24 +192,24 @@ export const WritingDiagnostics = Extension.create({ return { installWritingDiagnostics: (generation, diagnostics) => - ({ transaction, dispatch }) => { + ({ tr, dispatch }) => { if ( !Number.isSafeInteger(generation) || generation < 0 || - normalizeResolvedDiagnostics(transaction.doc, diagnostics) === null + normalizeResolvedDiagnostics(tr.doc, diagnostics) === null ) { return false; } if (dispatch) { dispatch( - installWritingDiagnostics(transaction, generation, diagnostics), + installWritingDiagnostics(tr, generation, diagnostics), ); } return true; }, focusWritingDiagnostic: (generation, diagnosticId) => - ({ transaction, dispatch }) => { + ({ tr, dispatch }) => { if ( !Number.isSafeInteger(generation) || generation < 0 || @@ -221,16 +221,16 @@ export const WritingDiagnostics = Extension.create({ } if (dispatch) { dispatch( - focusWritingDiagnostic(transaction, generation, diagnosticId), + focusWritingDiagnostic(tr, generation, diagnosticId), ); } return true; }, clearWritingDiagnostics: () => - ({ transaction, dispatch }) => { + ({ tr, dispatch }) => { if (dispatch) { - dispatch(clearWritingDiagnostics(transaction)); + dispatch(clearWritingDiagnostics(tr)); } return true; }, From c83dd70f2b6ff5005245014dc9436361f1bbf4e3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 12:08:15 +0900 Subject: [PATCH 10/17] test(diagnostics): harden decoration metadata boundary --- .../WritingDiagnosticsBoundary.test.ts | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 src/extensions/WritingDiagnosticsBoundary.test.ts diff --git a/src/extensions/WritingDiagnosticsBoundary.test.ts b/src/extensions/WritingDiagnosticsBoundary.test.ts new file mode 100644 index 00000000..33cf492d --- /dev/null +++ b/src/extensions/WritingDiagnosticsBoundary.test.ts @@ -0,0 +1,219 @@ +import { Schema } from '@tiptap/pm/model'; +import { EditorState } from '@tiptap/pm/state'; +import { describe, expect, it, vi } from 'vitest'; +import { + createWritingDiagnosticsPlugin, + writingDiagnosticsPluginKey, + type CwlResolvedWritingDiagnosticDecoration, + type WritingDiagnosticsPluginState, +} from './WritingDiagnostics.js'; + +const schema = new Schema({ + nodes: { + doc: { content: 'paragraph+' }, + paragraph: { content: 'text*', toDOM: () => ['p', 0] }, + text: {}, + }, +}); + +function stateWithText() { + return EditorState.create({ + schema, + doc: schema.node('doc', undefined, [ + schema.node('paragraph', undefined, [schema.text('Alpha beta gamma')]), + ]), + plugins: [createWritingDiagnosticsPlugin()], + }); +} + +function pluginState(state: EditorState): WritingDiagnosticsPluginState { + const result = writingDiagnosticsPluginKey.getState(state); + if (!result) throw new Error('Missing plugin state'); + return result; +} + +function diagnostic( + overrides: Partial = {}, +): CwlResolvedWritingDiagnosticDecoration { + return { + diagnosticId: 'diag-1', + from: 1, + to: 6, + priority: 'important', + ...overrides, + }; +} + +function applyForgedMeta(state: EditorState, meta: unknown): EditorState { + return state.apply(state.tr.setMeta(writingDiagnosticsPluginKey, meta)); +} + +describe('WritingDiagnostics transaction metadata boundary', () => { + it('ignores null, primitive, unknown, and revoked metadata without throwing', () => { + let state = stateWithText(); + const initial = pluginState(state); + const revoked = Proxy.revocable( + { type: 'clear' }, + {}, + ); + revoked.revoke(); + + for (const meta of [null, false, 'clear', { type: 'unknown' }, revoked.proxy]) { + expect(() => { + state = applyForgedMeta(state, meta); + }).not.toThrow(); + expect(pluginState(state)).toBe(initial); + } + }); + + it('ignores install metadata whose diagnostics array is revoked or trap-backed', () => { + let state = stateWithText(); + const initial = pluginState(state); + const revoked = Proxy.revocable([diagnostic()], {}); + revoked.revoke(); + const trapBacked = new Proxy([diagnostic()], { + get(target, key, receiver) { + if (key === 'length' || key === Symbol.iterator) { + throw new Error('private diagnostics-array detail'); + } + return Reflect.get(target, key, receiver); + }, + }); + + for (const diagnostics of [revoked.proxy, trapBacked]) { + expect(() => { + state = applyForgedMeta(state, { + type: 'install', + generation: 1, + diagnostics, + }); + }).not.toThrow(); + expect(pluginState(state)).toBe(initial); + } + }); + + it('never evaluates resolved-diagnostic accessors', () => { + let state = stateWithText(); + const getter = vi.fn(() => 1); + const candidate = { + diagnosticId: 'diag-accessor', + to: 4, + priority: 'important', + } as Record; + Object.defineProperty(candidate, 'from', { + enumerable: true, + get: getter, + }); + + state = applyForgedMeta(state, { + type: 'install', + generation: 1, + diagnostics: [candidate], + }); + + expect(getter).not.toHaveBeenCalled(); + expect(pluginState(state).generation).toBe(-1); + }); + + it('rejects extra, symbol, inherited, missing, and non-enumerable fields', () => { + let state = stateWithText(); + const inherited = Object.assign( + Object.create({ inherited: true }), + diagnostic({ diagnosticId: 'inherited' }), + ); + const missing = { ...diagnostic({ diagnosticId: 'missing' }) } as Record< + string, + unknown + >; + delete missing.priority; + const hidden = { ...diagnostic({ diagnosticId: 'hidden' }) } as Record< + string, + unknown + >; + Object.defineProperty(hidden, 'from', { + value: 1, + enumerable: false, + }); + const candidates = [ + { ...diagnostic({ diagnosticId: 'extra' }), extra: true }, + { + ...diagnostic({ diagnosticId: 'symbol' }), + [Symbol('private')]: true, + }, + inherited, + missing, + hidden, + ]; + + for (const candidate of candidates) { + const previous = pluginState(state); + state = applyForgedMeta(state, { + type: 'install', + generation: 1, + diagnostics: [candidate], + }); + expect(pluginState(state)).toBe(previous); + } + }); + + it('rejects oversized sets and invalid scalar fields', () => { + let state = stateWithText(); + const tooMany = Array.from({ length: 257 }, (_, index) => + diagnostic({ diagnosticId: `diag-${index}` }), + ); + const invalid = [ + diagnostic({ diagnosticId: '' }), + diagnostic({ diagnosticId: 'x'.repeat(257) }), + diagnostic({ from: Number.NaN }), + diagnostic({ to: Number.POSITIVE_INFINITY }), + diagnostic({ priority: 'urgent' as CwlResolvedWritingDiagnosticDecoration['priority'] }), + ]; + + for (const diagnostics of [tooMany, ...invalid.map((item) => [item])]) { + const previous = pluginState(state); + state = applyForgedMeta(state, { + type: 'install', + generation: 1, + diagnostics, + }); + expect(pluginState(state)).toBe(previous); + } + }); + + it('accepts an empty exact generation as a monotonic fence', () => { + let state = stateWithText(); + state = applyForgedMeta(state, { + type: 'install', + generation: 4, + diagnostics: [], + }); + + expect(pluginState(state)).toEqual( + expect.objectContaining({ + generation: 4, + diagnostics: [], + focusedDiagnosticId: null, + }), + ); + expect(pluginState(state).decorations.find()).toEqual([]); + }); + + it('keeps opaque hostile-looking identifiers as inert data attributes only', () => { + let state = stateWithText(); + const diagnosticId = ''; + state = applyForgedMeta(state, { + type: 'install', + generation: 1, + diagnostics: [diagnostic({ diagnosticId })], + }); + + const [decoration] = pluginState(state).decorations.find(); + const attributes = ( + decoration as unknown as { type: { attrs: Record } } + ).type.attrs; + expect(attributes['data-cwl-diagnostic-id']).toBe(diagnosticId); + expect(attributes).not.toHaveProperty('title'); + expect(attributes).not.toHaveProperty('href'); + expect(attributes).not.toHaveProperty('onclick'); + }); +}); From c912db1f0e9bfe1b2ee331a3c897e53c3da89398 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 12:20:26 +0900 Subject: [PATCH 11/17] test(diagnostics): execute metadata boundary regressions --- .github/workflows/writing-diagnostics-decorations-tdd.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/writing-diagnostics-decorations-tdd.yml b/.github/workflows/writing-diagnostics-decorations-tdd.yml index d8e297cb..af415ff0 100644 --- a/.github/workflows/writing-diagnostics-decorations-tdd.yml +++ b/.github/workflows/writing-diagnostics-decorations-tdd.yml @@ -35,6 +35,7 @@ jobs: run: >- pnpm exec vitest run src/extensions/WritingDiagnostics.test.ts + src/extensions/WritingDiagnosticsBoundary.test.ts src/extensions/WritingDiagnosticsKit.test.ts - name: Typecheck command and plugin contracts run: pnpm typecheck From 56d3da78c7330636f635699eec302b5ab7d0c2ae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 12:23:12 +0900 Subject: [PATCH 12/17] fix(diagnostics): reject hostile transaction metadata --- src/extensions/WritingDiagnostics.ts | 59 +++++++++++++++++----------- 1 file changed, 36 insertions(+), 23 deletions(-) diff --git a/src/extensions/WritingDiagnostics.ts b/src/extensions/WritingDiagnostics.ts index 655a3e7b..8fbd274a 100644 --- a/src/extensions/WritingDiagnostics.ts +++ b/src/extensions/WritingDiagnostics.ts @@ -159,21 +159,30 @@ export function createWritingDiagnosticsPlugin(): Plugin MAX_DECORATIONS) { - return null; - } - const result: CwlResolvedWritingDiagnosticDecoration[] = []; - const identifiers = new Set(); - for (const candidate of input) { - const normalized = normalizeResolvedDiagnostic(documentNode, candidate); - if (normalized === null || identifiers.has(normalized.diagnosticId)) { + try { + if (!Array.isArray(input) || input.length > MAX_DECORATIONS) { return null; } - identifiers.add(normalized.diagnosticId); - result.push(normalized); + const result: CwlResolvedWritingDiagnosticDecoration[] = []; + const identifiers = new Set(); + for (const candidate of input) { + const normalized = normalizeResolvedDiagnostic(documentNode, candidate); + if (normalized === null || identifiers.has(normalized.diagnosticId)) { + return null; + } + identifiers.add(normalized.diagnosticId); + result.push(normalized); + } + return Object.freeze(result); + } catch { + return null; } - return Object.freeze(result); } /** Detach one exact resolved diagnostic object. */ From 1729ce3f6fdec4d3bd0b3f0b61bbcbcc978836a1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:47:43 +0900 Subject: [PATCH 13/17] test(diagnostics): reject semantic ARIA decoration state --- src/extensions/WritingDiagnostics.test.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/extensions/WritingDiagnostics.test.ts b/src/extensions/WritingDiagnostics.test.ts index 5c1d4814..a56d04a5 100644 --- a/src/extensions/WritingDiagnostics.test.ts +++ b/src/extensions/WritingDiagnostics.test.ts @@ -86,27 +86,22 @@ describe('WritingDiagnostics extension contract', () => { it('installs verified ranges with only static privacy-minimized attributes', () => { let state = stateWithText(); state = state.apply( - installWritingDiagnostics(state.tr, 4, [ - diagnostic({ ariaInvalid: 'spelling' }), - ]), + installWritingDiagnostics(state.tr, 4, [diagnostic()]), ); const current = pluginState(state); expect(current.generation).toBe(4); - expect(current.diagnostics).toEqual([ - diagnostic({ ariaInvalid: 'spelling' }), - ]); + expect(current.diagnostics).toEqual([diagnostic()]); expect(Object.isFrozen(current.diagnostics)).toBe(true); expect(Object.isFrozen(current.diagnostics[0])).toBe(true); expect(decorationAttributes(state)).toEqual({ class: 'cwl-writing-diagnostic cwl-writing-diagnostic--important', 'data-cwl-diagnostic-id': 'diag-1', - 'aria-invalid': 'spelling', }); expect(JSON.stringify(decorationAttributes(state))).not.toContain('Alpha'); }); - it('never infers aria-invalid from category-like identifiers', () => { + it('never infers semantic ARIA state from category-like identifiers', () => { let state = stateWithText(); state = state.apply( installWritingDiagnostics(state.tr, 1, [ @@ -231,13 +226,14 @@ describe('WritingDiagnostics extension contract', () => { expect(pluginState(state).decorations.find()).toEqual([]); }); - it('fails closed for invalid ranges, duplicate ids, or unsupported attributes', () => { + it('fails closed for invalid ranges, duplicate ids, or semantic attributes', () => { let state = stateWithText(); const invalidSets: readonly (readonly CwlResolvedWritingDiagnosticDecoration[])[] = [ [diagnostic({ from: -1 })], [diagnostic({ from: 9, to: 8 })], [diagnostic({ to: state.doc.content.size + 1 })], [diagnostic(), diagnostic()], + [diagnostic({ ariaInvalid: 'spelling' })], [ diagnostic({ ariaInvalid: 'grammar' as CwlResolvedWritingDiagnosticDecoration['ariaInvalid'], @@ -261,4 +257,4 @@ describe('WritingDiagnostics extension contract', () => { expect(host.childNodes).toHaveLength(0); host.remove(); }); -}); +}); \ No newline at end of file From e5dac005b016739e6f784783ce22525fec7e1ad0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:51:13 +0900 Subject: [PATCH 14/17] fix(diagnostics): keep decorations semantically neutral --- src/extensions/WritingDiagnostics.ts | 32 ++++++---------------------- 1 file changed, 6 insertions(+), 26 deletions(-) diff --git a/src/extensions/WritingDiagnostics.ts b/src/extensions/WritingDiagnostics.ts index 8fbd274a..7857dbb7 100644 --- a/src/extensions/WritingDiagnostics.ts +++ b/src/extensions/WritingDiagnostics.ts @@ -23,7 +23,6 @@ const RESOLVED_DIAGNOSTIC_FIELDS = Object.freeze([ 'from', 'to', 'priority', - 'ariaInvalid', ] as const); const REQUIRED_RESOLVED_DIAGNOSTIC_FIELDS = Object.freeze([ 'diagnosticId', @@ -37,15 +36,12 @@ const PRIORITIES = new Set([ 'critical', ]); -/** Explicit host-approved ARIA invalidity mapping for mechanics guidance. */ -export type CwlWritingDiagnosticAriaInvalid = 'spelling'; - /** * Privacy-minimized structural range that may be rendered as one decoration. * - * Semantic prose, source text, replacement text, model output, and confidence - * are deliberately absent. `ariaInvalid` is explicit host policy output; the - * extension never derives it from `diagnosticId` or any category-like string. + * Semantic prose, source text, replacement text, model output, confidence, and + * semantic accessibility assertions are deliberately absent. The extension + * never derives behavior from `diagnosticId` or any category-like string. */ export interface CwlResolvedWritingDiagnosticDecoration { /** Opaque identifier already validated by the host contract. */ @@ -56,8 +52,6 @@ export interface CwlResolvedWritingDiagnosticDecoration { readonly to: number; /** Host-selected visual priority. */ readonly priority: CwlWritingDiagnosticPriority; - /** Optional explicit host mapping for mechanics-related accessibility state. */ - readonly ariaInvalid?: CwlWritingDiagnosticAriaInvalid; } /** Immutable state owned by the writing-diagnostic ProseMirror plugin. */ @@ -295,9 +289,6 @@ function applyInstallMeta( class: `cwl-writing-diagnostic cwl-writing-diagnostic--${diagnostic.priority}`, 'data-cwl-diagnostic-id': diagnostic.diagnosticId, }; - if (diagnostic.ariaInvalid === 'spelling') { - attributes['aria-invalid'] = 'spelling'; - } return [ Decoration.inline(diagnostic.from, diagnostic.to, attributes, { inclusiveStart: false, @@ -417,7 +408,6 @@ function normalizeResolvedDiagnostic( const from = record.from; const to = record.to; const priority = record.priority; - const ariaInvalid = record.ariaInvalid; if ( typeof diagnosticId !== 'string' || diagnosticId.length === 0 || @@ -428,27 +418,17 @@ function normalizeResolvedDiagnostic( (to as number) < (from as number) || (to as number) > documentNode.content.size || typeof priority !== 'string' || - !PRIORITIES.has(priority as CwlWritingDiagnosticPriority) || - (ariaInvalid !== undefined && ariaInvalid !== 'spelling') + !PRIORITIES.has(priority as CwlWritingDiagnosticPriority) ) { return null; } - const normalized: CwlResolvedWritingDiagnosticDecoration = { + return Object.freeze({ diagnosticId, from: from as number, to: to as number, priority: priority as CwlWritingDiagnosticPriority, - }; - if (ariaInvalid === 'spelling') { - Object.defineProperty(normalized, 'ariaInvalid', { - value: 'spelling', - enumerable: true, - configurable: false, - writable: false, - }); - } - return Object.freeze(normalized); + }); } export default WritingDiagnostics; From 1a54c0dc875676499d1dc6409738bd015364e229 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:53:26 +0900 Subject: [PATCH 15/17] test(diagnostics): keep hostile semantic fields type-safe --- src/extensions/WritingDiagnostics.test.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/extensions/WritingDiagnostics.test.ts b/src/extensions/WritingDiagnostics.test.ts index a56d04a5..48f2adc9 100644 --- a/src/extensions/WritingDiagnostics.test.ts +++ b/src/extensions/WritingDiagnostics.test.ts @@ -56,6 +56,16 @@ function diagnostic( }; } +/** Create one deliberately forged runtime shape outside the public type. */ +function diagnosticWithSemanticAttribute( + value: string, +): CwlResolvedWritingDiagnosticDecoration { + return { + ...diagnostic(), + ariaInvalid: value, + } as unknown as CwlResolvedWritingDiagnosticDecoration; +} + function decorationAttributes(state: EditorState): Record { const [decoration] = pluginState(state).decorations.find(); if (!decoration) throw new Error('Missing writing diagnostic decoration'); @@ -233,12 +243,8 @@ describe('WritingDiagnostics extension contract', () => { [diagnostic({ from: 9, to: 8 })], [diagnostic({ to: state.doc.content.size + 1 })], [diagnostic(), diagnostic()], - [diagnostic({ ariaInvalid: 'spelling' })], - [ - diagnostic({ - ariaInvalid: 'grammar' as CwlResolvedWritingDiagnosticDecoration['ariaInvalid'], - }), - ], + [diagnosticWithSemanticAttribute('spelling')], + [diagnosticWithSemanticAttribute('grammar')], ]; for (const invalid of invalidSets) { @@ -257,4 +263,4 @@ describe('WritingDiagnostics extension contract', () => { expect(host.childNodes).toHaveLength(0); host.remove(); }); -}); \ No newline at end of file +}); From 56d206cbf76cd5af56a241b951c8886f01edf400 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:19:18 +0900 Subject: [PATCH 16/17] docs(adr): inherit strict diagnostics v1 decision --- ...ting-diagnostics-v1-strict-invalidation.md | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 docs/adr/0028-writing-diagnostics-v1-strict-invalidation.md diff --git a/docs/adr/0028-writing-diagnostics-v1-strict-invalidation.md b/docs/adr/0028-writing-diagnostics-v1-strict-invalidation.md new file mode 100644 index 00000000..10587ee0 --- /dev/null +++ b/docs/adr/0028-writing-diagnostics-v1-strict-invalidation.md @@ -0,0 +1,104 @@ +# ADR 0028: Strict invalidation and semantic-neutral accessibility for writing diagnostics v1 + +Status: Proposed + +## Context + +ADR 0027, its design specification, and its implementation plan established the correct high-level boundary: hosts own every semantic writing judgment while Inkspan owns deterministic validation, revision/selector integrity, presentation, accessibility, and ordinary editor transactions. + +Two lower-level clauses nevertheless permitted incompatible first-release interpretations: + +1. some prose allowed a diagnostic to survive a local transaction when ProseMirror mapping appeared valid, while the implementation plan's global contract required every local or collaborative document change to invalidate every active diagnostic; and +2. the decoration plan proposed `aria-invalid="spelling"` when a host category “maps to mechanics,” although `categoryCode` is deliberately opaque and the v1 contract contains no explicit semantic accessibility field. + +Leaving those ambiguities unresolved would force implementers either to invent semantic mapping from an opaque string or to maintain two competing stale-diagnostic lifecycles. + +## Alternatives considered + +### Preserve diagnostics through transaction mapping + +Rejected for v1. ProseMirror can map structural positions, but position continuity does not prove that a host model's semantic judgment still applies to changed prose. This also conflicts with the already implemented controller/decorations direction and complicates collaborative edits, asynchronous digest races, application revalidation, testing, and host refresh ownership. + +### Derive ARIA validity from `categoryCode` + +Rejected. `categoryCode` is host-defined opaque metadata. Comparing it with strings such as `spelling`, `grammar`, `mechanics`, or language-specific equivalents would be the semantic keyword fallback that ADR 0027 explicitly prohibits. + +### Add an explicit semantic ARIA enum to v1 + +Deferred. A future version may add a bounded, explicitly declared accessibility semantic if evidence shows that decoration-level `aria-invalid` is interoperable and useful across supported browser/assistive-technology combinations. That addition requires its own versioned contract, tests, compatibility review, and ADR. + +### Strictly invalidate on every document change and keep decorations semantically neutral + +Selected for v1 because it is deterministic, explainable, provider-neutral, privacy-minimized, compatible with the existing implementation stack, and safe under both standalone and collaborative editing. + +## Decision + +For writing diagnostics v1: + +- every transaction with `docChanged === true`, whether local or collaborative, invalidates the complete active diagnostic generation before any further display or mutation authority can be used; +- no diagnostic range is preserved, remapped, repaired, or re-admitted through ProseMirror mapping, nearest-text search, quote search, keyword search, or semantic guessing; +- a host that wants current guidance after a document change must submit a new diagnostic set bound to a newly derived exact document revision; +- final application still performs exact current-state verification under the implementation plan, but mapping is not an alternate admission path; +- inline decorations contain static Inkspan classes, priority styling, and an opaque diagnostic identifier only; +- Inkspan does not derive `aria-invalid`, spelling/grammar state, or any other semantic accessibility assertion from `categoryCode`, title, explanation, replacement, confidence, provenance, or source text; +- category, priority, title, explanation, and actions remain available as plain text in the named diagnostics panel, while underlines are a visual supplement rather than the sole information channel. + +This ADR narrows and supersedes only the conflicting lifecycle and decoration-accessibility clauses in ADR 0027, the 2026-08-12 design specification, and the original implementation plan. Their product/host/model authority, security, privacy, revision, packaging, and release decisions remain in force. + +## Consequences + +### Positive + +- one lifecycle applies to local edits, remote edits, digest races, focus, action callbacks, and replacement application; +- no hidden semantic classifier is introduced into Inkspan; +- stale diagnostics fail closed without pretending position continuity proves meaning continuity; +- hosts receive a clear refresh responsibility; +- accessibility remains complete through explicit panel text and actions without unsupported semantic ARIA claims. + +### Trade-offs + +- even an unrelated document edit invalidates all active diagnostics in v1; +- hosts may perform more review refreshes; +- Inkspan does not preserve diagnostic continuity across edits until a future, separately governed evidence model exists. + +These costs are accepted because deterministic invalidation is safer and easier to validate than a partially semantic remapping policy. + +## Failure and recovery + +- A document-changing transaction clears active decorations and marks the generation stale. +- A digest or selector result completing for an invalidated generation is discarded. +- Apply/ignore/dismiss/explain operations against a missing or stale generation return typed non-mutating results. +- The host may recover only by supplying a newly admitted diagnostic set for the current exact revision. +- No offline or model-unavailable fallback fabricates a diagnostic. + +## Security and privacy impact + +The decision prevents opaque host category strings from gaining semantic execution or accessibility authority. It also prevents stale model output from being moved onto changed content. No additional authored text, replacement text, prompt, model output, tenant identifier, provider credential, or document envelope enters telemetry or DOM attributes. + +## Accessibility impact + +The panel must expose a named region, count, ordered list, category, priority, title, explanation, affected-range navigation, and explicit Apply/Ignore/Dismiss/Explain controls. Information must remain available without color, hover, pointer input, animation, or generated CSS content. Asynchronous arrival must not steal focus. An assertive alert is reserved for an actual application conflict; ordinary actions use polite status messaging. + +## Compatibility and migration + +The decision is additive to hosts that do not enable writing diagnostics. Diagnostics remain noncanonical review state, so no document-envelope, persistence, collaboration, or database migration is required. Existing Task 1–4 implementation work already follows strict invalidation and, after the corresponding runtime correction, semantic-neutral decoration behavior. Downstream tasks must consume the same exact contract; predecessor checks and reviews do not transfer after any stack refresh. + +A future public contract may add explicit accessibility semantics or evidence-backed diagnostic continuity only through a new versioned type and compatibility plan. Opaque v1 category strings do not acquire new meaning retrospectively. + +## Verification + +Acceptance requires deterministic tests proving: + +- local and Yjs remote `docChanged` transactions invalidate the complete generation; +- no transaction mapping preserves diagnostics in v1; +- stale asynchronous work cannot install decorations or emit actions; +- decoration attributes contain no title, explanation, replacement, category text, model output, HTML, or derived semantic ARIA state; +- diagnostics with category codes such as `spelling`, `grammar`, `mechanics`, multilingual equivalents, or attacker-controlled lookalikes receive identical semantic-neutral decoration handling; +- the accessible panel exposes host strings as text and remains usable without visual-only cues; +- Inkspan produces no diagnostic when the host supplies none. + +## Rollback or supersession + +Rollback removes the optional diagnostic surface without altering canonical document envelopes, revisions, persistence records, collaboration data, or host storage. The host simply stops supplying diagnostics and the editor retains its ordinary deterministic behavior. + +A future diagnostic-continuity design may supersede strict invalidation only with a versioned evidence model that proves target identity across changes, defines standalone/collaborative parity, contains privacy and accessibility semantics, and passes cross-engine and realistic semantic-integrity validation. A future semantic ARIA field likewise requires an explicit typed contract rather than inference from opaque strings. From dad34a4a1e2e69209008b9a289a747395a7630a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:19:44 +0900 Subject: [PATCH 17/17] docs(plan): inherit strict diagnostics v1 errata --- ...-writing-diagnostics-v1-contract-errata.md | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-12-writing-diagnostics-v1-contract-errata.md diff --git a/docs/superpowers/plans/2026-08-12-writing-diagnostics-v1-contract-errata.md b/docs/superpowers/plans/2026-08-12-writing-diagnostics-v1-contract-errata.md new file mode 100644 index 00000000..a377f872 --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-writing-diagnostics-v1-contract-errata.md @@ -0,0 +1,71 @@ +# Writing Diagnostics v1 Implementation Plan Errata + +Status: Required companion to `2026-08-12-writing-diagnostics-implementation.md` + +ADR 0028 resolves two contradictions discovered after the original atomic implementation plan was written. Every remaining task and acceptance review must apply this errata. Task 10 must fold these corrections into the canonical ADR, design, plan, PRD, TRD, contracts, threat model, operability, traceability, and CHANGELOG so the final protected-main documentation has no parallel instruction set. + +## Global lifecycle correction + +The following rule replaces every original-plan or design clause that permits transaction-local preservation, mapping, remapping, repair, or re-admission of an existing diagnostic: + +> Any local or collaborative transaction with `docChanged === true` invalidates the complete active diagnostic generation. Version 1 never preserves or remaps a diagnostic across changed document content. A host must submit a new set bound to the new exact revision. + +ProseMirror mapping may be used internally for ordinary editor behavior, but it is not evidence that a model judgment still targets the same meaning and is not diagnostic admission authority. + +## Task 3 correction: decoration attributes + +Replace the proposed attribute set with: + +```text +class="cwl-writing-diagnostic cwl-writing-diagnostic--{priority}" +data-cwl-diagnostic-id="opaque-id" +``` + +Do not add `aria-invalid`, spelling/grammar state, category semantics, title, explanation, replacement, confidence, provenance, model output, or HTML to decoration attributes. `categoryCode` remains opaque; it cannot be matched against words such as `spelling`, `grammar`, or `mechanics` to derive behavior or ARIA state. + +Task 3 tests must include attacker-controlled, multilingual, and lookalike category codes and prove identical semantic-neutral decoration handling. + +## Task 4 correction: controller state + +The controller's transaction subscriber invalidates both `verifying` and `active` generations before stale asynchronous work can publish. It does not map verified ranges after any document change. All old digest/selector completions are generation-fenced and discarded. + +## Task 5 correction: accessible panel + +The named panel is the semantic accessibility surface. It exposes category, priority, title, explanation, count, ordered position, affected-range navigation, and explicit Apply/Ignore/Dismiss/Explain actions as React text and native controls. + +- Do not infer semantic ARIA state from `categoryCode` or any other host text. +- Do not place selected source text in action names or attributes. +- New asynchronous diagnostics do not move focus. +- Previous/next navigation is explicit and roving; no undocumented global shortcut is added. +- Ordinary action completion uses a polite status region. +- An assertive alert is reserved for an actual application conflict. +- Information remains available without color, hover, pointer input, animation, or generated CSS content. + +## Task 6 correction: application + +Apply rechecks the exact current document revision immediately before the ordinary ProseMirror transaction. A stale generation cannot be rescued through range mapping or text search. A successful application invalidates all remaining diagnostics and produces a newly derived resulting revision. + +## Task 7 correction: collaboration + +Every remote Yjs document change invalidates the complete local diagnostic generation. A relative position or mapped ProseMirror position is not proof that the host judgment remains semantically current. Awareness payloads never carry diagnostics or review state. + +## Task 9 correction: assurance + +Cross-engine and hostile-input evidence must prove: + +- strict invalidation after every local or remote document change; +- no nearest-text, quote, keyword, category, or transaction-mapping recovery; +- no semantic ARIA derivation from opaque host fields; +- accessible information remains present through the panel and native actions; +- no diagnostics are produced when the host supplies none. + +## Task 10 reconciliation requirement + +Before the feature stack can become Ready, the original ADR 0027 and design/plan prose must be edited so they directly express ADR 0028. This errata is temporary planning evidence, not the desired final duplicate source of truth. The final documentation contract tests must fail if either of the superseded claims reappears: + +1. a diagnostic can survive `docChanged` through transaction mapping; or +2. Inkspan derives `aria-invalid` or other semantic state from an opaque category string. + +## Acceptance impact + +Existing Task 1–4 runtime direction is compatible with this correction. Downstream Task 5–12 branches must be based on exact predecessor heads that contain or explicitly consume this errata, and all exact-head CI/review evidence must be regenerated after any affected branch is refreshed.