diff --git a/.github/workflows/writing-diagnostics-decorations-tdd.yml b/.github/workflows/writing-diagnostics-decorations-tdd.yml new file mode 100644 index 00000000..af415ff0 --- /dev/null +++ b/.github/workflows/writing-diagnostics-decorations-tdd.yml @@ -0,0 +1,41 @@ +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 + src/extensions/WritingDiagnosticsBoundary.test.ts + src/extensions/WritingDiagnosticsKit.test.ts + - name: Typecheck command and plugin contracts + run: pnpm typecheck diff --git a/src/extensions/WritingDiagnostics.test.ts b/src/extensions/WritingDiagnostics.test.ts new file mode 100644 index 00000000..48f2adc9 --- /dev/null +++ b/src/extensions/WritingDiagnostics.test.ts @@ -0,0 +1,266 @@ +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*', + toDOM: () => ['p', 0], + }, + 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, + }; +} + +/** 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'); + 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(typeof writingDiagnosticsPluginKey.getState).toBe('function'); + }); + + 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()]), + ); + + const current = pluginState(state); + expect(current.generation).toBe(4); + 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', + }); + expect(JSON.stringify(decorationAttributes(state))).not.toContain('Alpha'); + }); + + it('never infers semantic ARIA state 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 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()], + [diagnosticWithSemanticAttribute('spelling')], + [diagnosticWithSemanticAttribute('grammar')], + ]; + + 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(); + }); +}); diff --git a/src/extensions/WritingDiagnostics.ts b/src/extensions/WritingDiagnostics.ts new file mode 100644 index 00000000..7857dbb7 --- /dev/null +++ b/src/extensions/WritingDiagnostics.ts @@ -0,0 +1,434 @@ +/** + * 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', +] as const); +const REQUIRED_RESOLVED_DIAGNOSTIC_FIELDS = Object.freeze([ + 'diagnosticId', + 'from', + 'to', + 'priority', +] as const); +const PRIORITIES = new Set([ + 'advisory', + 'important', + 'critical', +]); + +/** + * Privacy-minimized structural range that may be rendered as one decoration. + * + * 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. */ + 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; +} + +/** 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 rawMeta = transaction.getMeta(writingDiagnosticsPluginKey) as unknown; + if (rawMeta === undefined) { + return previous; + } + try { + if (typeof rawMeta !== 'object' || rawMeta === null) { + return previous; + } + const meta = rawMeta as WritingDiagnosticsMeta; + if (meta.type === 'clear') { + return hasActiveDiagnostics(previous) + ? emptyPluginState(previous.generation) + : previous; + } + if (meta.type === 'focus') { + return applyFocusMeta(previous, meta); + } + if (meta.type === 'install') { + return applyInstallMeta(transaction.doc, previous, meta); + } + return previous; + } catch { + return previous; + } + }, + }, + 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) => + ({ tr, dispatch }) => { + if ( + !Number.isSafeInteger(generation) || + generation < 0 || + normalizeResolvedDiagnostics(tr.doc, diagnostics) === null + ) { + return false; + } + if (dispatch) { + dispatch( + installWritingDiagnostics(tr, generation, diagnostics), + ); + } + return true; + }, + focusWritingDiagnostic: + (generation, diagnosticId) => + ({ tr, 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(tr, generation, diagnosticId), + ); + } + return true; + }, + clearWritingDiagnostics: + () => + ({ tr, dispatch }) => { + if (dispatch) { + dispatch(clearWritingDiagnostics(tr)); + } + 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, + }; + 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 { + try { + 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); + } catch { + return null; + } +} + +/** 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; + 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) + ) { + return null; + } + + return Object.freeze({ + diagnosticId, + from: from as number, + to: to as number, + priority: priority as CwlWritingDiagnosticPriority, + }); +} + +export default WritingDiagnostics; 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'); + }); +}); 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); + }); +}); 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…', }),