From 54bc3c269e74d639d4752135006629e0e6cf7b0e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 11:16:38 +0900 Subject: [PATCH 01/27] test(diagnostics): define inverse text projection contract --- src/writingDiagnosticProjection.test.ts | 355 ++++++++++++++++++++++++ 1 file changed, 355 insertions(+) create mode 100644 src/writingDiagnosticProjection.test.ts diff --git a/src/writingDiagnosticProjection.test.ts b/src/writingDiagnosticProjection.test.ts new file mode 100644 index 00000000..478c3494 --- /dev/null +++ b/src/writingDiagnosticProjection.test.ts @@ -0,0 +1,355 @@ +import { Schema, type Node as ProseMirrorNode } from '@tiptap/pm/model'; +import { TextSelection } from '@tiptap/pm/state'; +import { describe, expect, it } from 'vitest'; +import { + TEXT_POSITION_PROJECTION_ID, + TEXT_POSITION_PROJECTION_VERSION, + createTextPositionSelector, + type CwlEditorTextPositionSelector, + type CwlEditorTextProjectionIdentity, +} from './textPositionSelectorEvidence.js'; +import { + WritingDiagnosticProjectionError, + buildTextProjectionMap, + resolveTextPositionSelector, +} from './writingDiagnosticProjection.js'; + +const schema = new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { content: 'inline*', group: 'block' }, + heading: { content: 'inline*', group: 'block' }, + blockquote: { content: 'block+', group: 'block' }, + bullet_list: { content: 'list_item+', group: 'block' }, + list_item: { content: 'paragraph block*' }, + table: { content: 'table_row+', group: 'block' }, + table_row: { content: 'table_cell+' }, + table_cell: { content: 'paragraph+' }, + hard_break: { inline: true, group: 'inline', selectable: false }, + inline_atom: { inline: true, group: 'inline', atom: true }, + block_atom: { group: 'block', atom: true }, + text: { group: 'inline' }, + }, +}); + +const projectionIdentity: CwlEditorTextProjectionIdentity = { + id: TEXT_POSITION_PROJECTION_ID, + version: TEXT_POSITION_PROJECTION_VERSION, +}; + +function documentWith(content: readonly Record[]): ProseMirrorNode { + return schema.nodeFromJSON({ type: 'doc', content }); +} + +function textRange( + documentNode: ProseMirrorNode, + needle: string, + occurrence = 0, +): Readonly<{ from: number; to: number }> { + let remaining = occurrence; + let result: { from: number; to: number } | null = null; + documentNode.descendants((node, position) => { + if (!node.isText || result) return; + let searchFrom = 0; + while (searchFrom <= node.text!.length) { + const index = node.text!.indexOf(needle, searchFrom); + if (index < 0) return; + if (remaining === 0) { + result = { + from: position + index, + to: position + index + needle.length, + }; + return; + } + remaining -= 1; + searchFrom = index + Math.max(needle.length, 1); + } + }); + if (!result) throw new Error(`Missing text fixture: ${needle}`); + return Object.freeze(result); +} + +function selectorForProjectedText( + projection: string, + needle: string, +): CwlEditorTextPositionSelector { + const codeUnitStart = projection.indexOf(needle); + if (codeUnitStart < 0) throw new Error(`Missing projection fixture: ${needle}`); + return { + type: 'TextPositionSelector', + start: Array.from(projection.slice(0, codeUnitStart)).length, + end: Array.from(projection.slice(0, codeUnitStart + needle.length)).length, + }; +} + +function expectProjectionError( + callback: () => unknown, + code: WritingDiagnosticProjectionError['code'], +): void { + try { + callback(); + } catch (error) { + expect(error).toBeInstanceOf(WritingDiagnosticProjectionError); + expect((error as WritingDiagnosticProjectionError).code).toBe(code); + return; + } + throw new Error(`Expected WritingDiagnosticProjectionError(${code})`); +} + +describe('buildTextProjectionMap', () => { + it('matches ProseMirror textBetween for nested blocks, tables, and leaf nodes', () => { + const documentNode = documentWith([ + { type: 'paragraph', content: [{ type: 'text', text: 'Alpha' }] }, + { type: 'heading', content: [{ type: 'text', text: '한글😀' }] }, + { + type: 'bullet_list', + content: [ + { + type: 'list_item', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: '첫째' }] }, + ], + }, + { + type: 'list_item', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'second' }] }, + ], + }, + ], + }, + { + type: 'table', + content: [ + { + type: 'table_row', + content: [ + { + type: 'table_cell', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'cell-a' }] }, + ], + }, + { + type: 'table_cell', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'cell-b' }] }, + ], + }, + ], + }, + ], + }, + { + type: 'paragraph', + content: [ + { type: 'text', text: 'before' }, + { type: 'hard_break' }, + { type: 'inline_atom' }, + { type: 'text', text: 'after' }, + ], + }, + { type: 'block_atom' }, + ]); + + const result = buildTextProjectionMap(documentNode); + + expect(result.text).toBe( + documentNode.textBetween( + 0, + documentNode.content.size, + '\n', + '\uFFFC', + ), + ); + expect(result.boundaryPositions).toHaveLength(Array.from(result.text).length + 1); + expect(Object.isFrozen(result)).toBe(true); + expect(Object.isFrozen(result.boundaryPositions)).toBe(true); + expect(Object.isFrozen(result.ambiguousBoundaryOffsets)).toBe(true); + }); + + it('maps simple text code-point boundaries to exact ProseMirror positions', () => { + const documentNode = documentWith([ + { type: 'paragraph', content: [{ type: 'text', text: 'A😀B' }] }, + ]); + + const result = buildTextProjectionMap(documentNode); + + expect(result.text).toBe('A😀B'); + expect(result.boundaryPositions).toEqual([1, 2, 4, 5]); + expect(result.ambiguousBoundaryOffsets).toEqual([]); + }); +}); + +describe('resolveTextPositionSelector', () => { + it('resolves multilingual projected text to the exact structural range', () => { + const documentNode = documentWith([ + { type: 'paragraph', content: [{ type: 'text', text: 'English' }] }, + { type: 'heading', content: [{ type: 'text', text: '한글😀문장' }] }, + ]); + const projection = documentNode.textBetween( + 0, + documentNode.content.size, + '\n', + '\uFFFC', + ); + + expect( + resolveTextPositionSelector( + documentNode, + selectorForProjectedText(projection, '글😀문'), + projectionIdentity, + ), + ).toEqual(textRange(documentNode, '글😀문')); + }); + + it('round-trips exact text selections across multilingual and Unicode fixtures', () => { + const fixtures = [ + { text: 'Hello world', selected: 'ello' }, + { text: '가나다라마', selected: '나다라' }, + { text: 'A😀B', selected: '😀' }, + { text: 'e\u0301x', selected: 'e\u0301' }, + { text: 'אבגדה', selected: 'בגד' }, + ]; + + for (const fixture of fixtures) { + const documentNode = documentWith([ + { type: 'paragraph', content: [{ type: 'text', text: fixture.text }] }, + ]); + const range = textRange(documentNode, fixture.selected); + const evidence = createTextPositionSelector( + documentNode, + TextSelection.create(documentNode, range.from, range.to), + ); + + expect( + resolveTextPositionSelector( + documentNode, + evidence.selector, + evidence.textProjection, + ), + ).toEqual(range); + } + }); + + it('rejects unsupported projection identities', () => { + const documentNode = documentWith([ + { type: 'paragraph', content: [{ type: 'text', text: 'text' }] }, + ]); + const selector = { type: 'TextPositionSelector', start: 1, end: 2 } as const; + + expectProjectionError( + () => + resolveTextPositionSelector( + documentNode, + selector, + { id: 'other', version: 1 } as CwlEditorTextProjectionIdentity, + ), + 'projection', + ); + expectProjectionError( + () => + resolveTextPositionSelector( + documentNode, + selector, + { + id: TEXT_POSITION_PROJECTION_ID, + version: 2, + } as CwlEditorTextProjectionIdentity, + ), + 'projection', + ); + }); + + it('rejects malformed, reversed, and out-of-range selectors', () => { + const documentNode = documentWith([ + { type: 'paragraph', content: [{ type: 'text', text: 'text' }] }, + ]); + const selectors = [ + { type: 'Other', start: 0, end: 1 }, + { type: 'TextPositionSelector', start: -1, end: 1 }, + { type: 'TextPositionSelector', start: 0.5, end: 1 }, + { type: 'TextPositionSelector', start: 2, end: 1 }, + { type: 'TextPositionSelector', start: 0, end: 5 }, + { type: 'TextPositionSelector', start: 0, end: Number.POSITIVE_INFINITY }, + ]; + + for (const selector of selectors) { + expectProjectionError( + () => + resolveTextPositionSelector( + documentNode, + selector as CwlEditorTextPositionSelector, + projectionIdentity, + ), + 'selector', + ); + } + }); + + it('fails closed when a selector splits a grapheme cluster', () => { + const documentNode = documentWith([ + { type: 'paragraph', content: [{ type: 'text', text: 'e\u0301x' }] }, + ]); + + expectProjectionError( + () => + resolveTextPositionSelector( + documentNode, + { type: 'TextPositionSelector', start: 1, end: 2 }, + projectionIdentity, + ), + 'grapheme_boundary', + ); + }); + + it('fails closed when a projected boundary has multiple text positions', () => { + const documentNode = documentWith([ + { type: 'paragraph' }, + { type: 'paragraph' }, + ]); + const map = buildTextProjectionMap(documentNode); + + expect(map.text).toBe(''); + expect(map.ambiguousBoundaryOffsets).toEqual([0]); + expectProjectionError( + () => + resolveTextPositionSelector( + documentNode, + { type: 'TextPositionSelector', start: 0, end: 0 }, + projectionIdentity, + ), + 'ambiguous_boundary', + ); + }); + + it('fails closed when Intl.Segmenter is unavailable', () => { + const documentNode = documentWith([ + { type: 'paragraph', content: [{ type: 'text', text: 'text' }] }, + ]); + const intl = Intl as unknown as { Segmenter?: unknown }; + const original = intl.Segmenter; + try { + Object.defineProperty(intl, 'Segmenter', { + value: undefined, + configurable: true, + writable: true, + }); + expectProjectionError( + () => + resolveTextPositionSelector( + documentNode, + { type: 'TextPositionSelector', start: 1, end: 2 }, + projectionIdentity, + ), + 'segmenter_unavailable', + ); + } finally { + Object.defineProperty(intl, 'Segmenter', { + value: original, + configurable: true, + writable: true, + }); + } + }); +}); From b607c861aba10bd2315b458c8c52215580926c0b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 11:21:11 +0900 Subject: [PATCH 02/27] ci(diagnostics): expose inverse projection TDD red state --- .../writing-diagnostics-projection-tdd.yml | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/writing-diagnostics-projection-tdd.yml diff --git a/.github/workflows/writing-diagnostics-projection-tdd.yml b/.github/workflows/writing-diagnostics-projection-tdd.yml new file mode 100644 index 00000000..f6c89222 --- /dev/null +++ b/.github/workflows/writing-diagnostics-projection-tdd.yml @@ -0,0 +1,35 @@ +name: Writing Diagnostics Projection TDD + +on: + push: + branches: + - feat/writing-diagnostics-projection + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: writing-diagnostics-projection-tdd-${{ github.ref }} + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + focused-projection: + 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 inverse projection contract tests + run: pnpm exec vitest run src/writingDiagnosticProjection.test.ts From ebc9c2e798d592f04fe7550e4be07b3012cab089 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 11:25:21 +0900 Subject: [PATCH 03/27] feat(diagnostics): share Unicode grapheme boundary classification --- src/graphemeBoundary.ts | 48 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/graphemeBoundary.ts diff --git a/src/graphemeBoundary.ts b/src/graphemeBoundary.ts new file mode 100644 index 00000000..f82fc37d --- /dev/null +++ b/src/graphemeBoundary.ts @@ -0,0 +1,48 @@ +/** Internal result of checking one UTF-16 code-unit offset against grapheme boundaries. */ +export type GraphemeBoundaryState = 'boundary' | 'inside_grapheme' | 'unavailable'; + +interface GraphemeSegment { + readonly index: number; +} + +interface GraphemeSegmenter { + segment(input: string): Iterable; +} + +interface GraphemeSegmenterConstructor { + new ( + locales?: string | readonly string[], + options?: { readonly granularity: 'grapheme' }, + ): GraphemeSegmenter; +} + +/** + * Classify one UTF-16 code-unit offset using the runtime's Unicode grapheme segmenter. + * + * Callers retain authority over their public error type. Returning an explicit + * unsupported state keeps forward and inverse selector paths on one segmentation + * implementation without coupling their public contracts. + */ +export function classifyGraphemeBoundary( + text: string, + codeUnitOffset: number, +): GraphemeBoundaryState { + const Segmenter = ( + Intl as unknown as { Segmenter?: GraphemeSegmenterConstructor } + ).Segmenter; + if (typeof Segmenter !== 'function') { + return 'unavailable'; + } + + if (codeUnitOffset === 0 || codeUnitOffset === text.length) { + return 'boundary'; + } + for (const segment of new Segmenter(undefined, { granularity: 'grapheme' }).segment( + text, + )) { + if (segment.index === codeUnitOffset) { + return 'boundary'; + } + } + return 'inside_grapheme'; +} From 8363cc272cdb82949ecf74fba6e9bf247a1652d7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 11:25:57 +0900 Subject: [PATCH 04/27] refactor(diagnostics): share selector grapheme boundary logic --- src/textPositionSelectorEvidence.ts | 31 ++++------------------------- 1 file changed, 4 insertions(+), 27 deletions(-) diff --git a/src/textPositionSelectorEvidence.ts b/src/textPositionSelectorEvidence.ts index 25af8340..e8ed2bdd 100644 --- a/src/textPositionSelectorEvidence.ts +++ b/src/textPositionSelectorEvidence.ts @@ -1,6 +1,7 @@ import type { Node as ProseMirrorNode } from '@tiptap/pm/model'; import type { Selection } from '@tiptap/pm/state'; import type { CwlEditorDocumentRevision } from './documentEnvelopeRevision.js'; +import { classifyGraphemeBoundary } from './graphemeBoundary.js'; /** Stable identity of Inkspan's first W3C-compatible logical text projection. */ export const TEXT_POSITION_PROJECTION_ID = 'inkspan-prosemirror-text' as const; @@ -60,21 +61,6 @@ export class TextPositionSelectorEvidenceError extends Error { } } -interface GraphemeSegment { - readonly index: number; -} - -interface GraphemeSegmenter { - segment(input: string): Iterable; -} - -interface GraphemeSegmenterConstructor { - new ( - locales?: string | readonly string[], - options?: { readonly granularity: 'grapheme' }, - ): GraphemeSegmenter; -} - /** Project a prefix of one ProseMirror document under the versioned v1 rules. */ function projectDocumentPrefix(documentNode: ProseMirrorNode, to: number): string { return documentNode.textBetween(0, to, BLOCK_SEPARATOR, LEAF_TEXT); @@ -87,20 +73,11 @@ function codePointLength(value: string): number { /** Require a position to coincide with a Unicode grapheme-cluster boundary. */ function assertGraphemeBoundary(text: string, codeUnitOffset: number): void { - const Segmenter = ( - Intl as unknown as { Segmenter?: GraphemeSegmenterConstructor } - ).Segmenter; - if (typeof Segmenter !== 'function') { + const boundaryState = classifyGraphemeBoundary(text, codeUnitOffset); + if (boundaryState === 'unavailable') { throw new TextPositionSelectorEvidenceError('segmenter_unavailable'); } - - const boundaries = new Set([0, text.length]); - for (const segment of new Segmenter(undefined, { granularity: 'grapheme' }).segment( - text, - )) { - boundaries.add(segment.index); - } - if (!boundaries.has(codeUnitOffset)) { + if (boundaryState !== 'boundary') { throw new TextPositionSelectorEvidenceError('grapheme_boundary'); } } From be7fbd3a412905bda5e0abd01b2de25b32a68d30 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 11:26:58 +0900 Subject: [PATCH 05/27] feat(diagnostics): resolve exact text projection selectors --- src/writingDiagnosticProjection.ts | 223 +++++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 src/writingDiagnosticProjection.ts diff --git a/src/writingDiagnosticProjection.ts b/src/writingDiagnosticProjection.ts new file mode 100644 index 00000000..c00dcc02 --- /dev/null +++ b/src/writingDiagnosticProjection.ts @@ -0,0 +1,223 @@ +import type { Node as ProseMirrorNode } from '@tiptap/pm/model'; +import { classifyGraphemeBoundary } from './graphemeBoundary.js'; +import { + TEXT_POSITION_PROJECTION_ID, + TEXT_POSITION_PROJECTION_VERSION, + type CwlEditorTextPositionSelector, + type CwlEditorTextProjectionIdentity, +} from './textPositionSelectorEvidence.js'; + +const BLOCK_SEPARATOR = '\n'; +const LEAF_TEXT = '\uFFFC'; + +/** Stable failure codes for inverse writing-diagnostic text projection. */ +export type WritingDiagnosticProjectionErrorCode = + | 'projection' + | 'selector' + | 'grapheme_boundary' + | 'segmenter_unavailable' + | 'ambiguous_boundary'; + +const ERROR_MESSAGES: Readonly< + Record +> = Object.freeze({ + projection: 'Writing diagnostic text projection is unsupported.', + selector: 'Writing diagnostic text selector is invalid.', + grapheme_boundary: + 'Writing diagnostic selectors require grapheme-cluster boundaries.', + segmenter_unavailable: + 'Writing diagnostic selectors require Unicode grapheme segmentation support.', + ambiguous_boundary: + 'Writing diagnostic selector boundary is structurally ambiguous.', +}); + +/** Raised when a projected selector cannot map to one exact structural range. */ +export class WritingDiagnosticProjectionError extends RangeError { + /** Stable redacted public failure classification. */ + readonly code: WritingDiagnosticProjectionErrorCode; + + constructor(code: WritingDiagnosticProjectionErrorCode) { + super(ERROR_MESSAGES[code]); + this.name = 'WritingDiagnosticProjectionError'; + this.code = code; + } +} + +/** + * One deterministic projection plus an inverse boundary map. + * + * `boundaryPositions[n]` is the exact ProseMirror position for Unicode-code-point + * boundary `n`, or `null` when no single structural position can represent that + * projected boundary. Invalid offsets are repeated in + * `ambiguousBoundaryOffsets` for bounded diagnostics and operator evidence. + */ +export interface CwlWritingDiagnosticTextProjectionMap { + /** Exact v1 projected text emitted by ProseMirror `textBetween`. */ + readonly text: string; + /** ProseMirror position for every Unicode-code-point boundary. */ + readonly boundaryPositions: readonly (number | null)[]; + /** Projection offsets that do not have exactly one structural position. */ + readonly ambiguousBoundaryOffsets: readonly number[]; +} + +/** + * Build the exact inverse map for Inkspan text-projection version 1. + * + * The traversal mirrors ProseMirror's `Fragment.textBetween` contract: logical + * document order, `\n` before a block when prior emitted content has not yet + * been separated, and U+FFFC for non-text leaf nodes. The implementation does + * not search for text, repair selectors, inspect language semantics, or mutate + * the document. + */ +export function buildTextProjectionMap( + documentNode: ProseMirrorNode, +): Readonly { + const textParts: string[] = []; + const boundaryCandidates: Set[] = [new Set()]; + let codePointOffset = 0; + let separated = true; + + const currentCandidates = (): Set => + boundaryCandidates[codePointOffset] ?? + (() => { + const candidates = new Set(); + boundaryCandidates[codePointOffset] = candidates; + return candidates; + })(); + + const addBoundaryCandidate = (position: number): void => { + currentCandidates().add(position); + }; + + const appendProjectedCodePoint = (value: string): void => { + textParts.push(value); + codePointOffset += 1; + boundaryCandidates.push(new Set()); + }; + + documentNode.descendants((node, position) => { + if (node.isText) { + const text = node.text ?? ''; + let codeUnitOffset = 0; + for (const character of text) { + addBoundaryCandidate(position + codeUnitOffset); + appendProjectedCodePoint(character); + codeUnitOffset += character.length; + addBoundaryCandidate(position + codeUnitOffset); + } + separated = false; + return false; + } + + if (node.isLeaf) { + addBoundaryCandidate(position); + appendProjectedCodePoint(LEAF_TEXT); + addBoundaryCandidate(position + node.nodeSize); + separated = false; + return false; + } + + if (!separated && node.isBlock) { + appendProjectedCodePoint(BLOCK_SEPARATOR); + separated = true; + } + if (node.inlineContent) { + addBoundaryCandidate(position + 1); + } + return true; + }); + + const boundaryPositions = boundaryCandidates.map((candidates) => + candidates.size === 1 ? candidates.values().next().value ?? null : null, + ); + const ambiguousBoundaryOffsets = boundaryCandidates.flatMap( + (candidates, offset) => (candidates.size === 1 ? [] : [offset]), + ); + + return Object.freeze({ + text: textParts.join(''), + boundaryPositions: Object.freeze(boundaryPositions), + ambiguousBoundaryOffsets: Object.freeze(ambiguousBoundaryOffsets), + }); +} + +/** + * Resolve one revision-scoped W3C text-position selector to ProseMirror positions. + * + * Callers must still verify the diagnostic's declared strong document revision + * against the same immutable document snapshot. This function validates the + * projection and selector, requires grapheme boundaries, and fails closed when + * an offset cannot map to one exact position. It never performs nearest-text or + * semantic fallback. + */ +export function resolveTextPositionSelector( + documentNode: ProseMirrorNode, + selector: CwlEditorTextPositionSelector, + textProjection: CwlEditorTextProjectionIdentity, +): Readonly<{ from: number; to: number }> { + if ( + textProjection.id !== TEXT_POSITION_PROJECTION_ID || + textProjection.version !== TEXT_POSITION_PROJECTION_VERSION + ) { + throw new WritingDiagnosticProjectionError('projection'); + } + if ( + selector.type !== 'TextPositionSelector' || + !Number.isSafeInteger(selector.start) || + !Number.isSafeInteger(selector.end) || + selector.start < 0 || + selector.end < selector.start + ) { + throw new WritingDiagnosticProjectionError('selector'); + } + + const projection = buildTextProjectionMap(documentNode); + if (selector.end >= projection.boundaryPositions.length) { + throw new WritingDiagnosticProjectionError('selector'); + } + + const codeUnitBoundaries = codePointBoundaryCodeUnits(projection.text); + assertProjectedGraphemeBoundary( + projection.text, + codeUnitBoundaries[selector.start]!, + ); + assertProjectedGraphemeBoundary( + projection.text, + codeUnitBoundaries[selector.end]!, + ); + + const from = projection.boundaryPositions[selector.start]; + const to = projection.boundaryPositions[selector.end]; + if (from === null || from === undefined || to === null || to === undefined) { + throw new WritingDiagnosticProjectionError('ambiguous_boundary'); + } + if (from > to) { + throw new WritingDiagnosticProjectionError('ambiguous_boundary'); + } + return Object.freeze({ from, to }); +} + +/** Return UTF-16 offsets for every Unicode-code-point boundary in one string. */ +function codePointBoundaryCodeUnits(text: string): readonly number[] { + const offsets = [0]; + let codeUnitOffset = 0; + for (const character of text) { + codeUnitOffset += character.length; + offsets.push(codeUnitOffset); + } + return offsets; +} + +/** Convert the shared grapheme classifier into this module's public error type. */ +function assertProjectedGraphemeBoundary( + text: string, + codeUnitOffset: number, +): void { + const boundaryState = classifyGraphemeBoundary(text, codeUnitOffset); + if (boundaryState === 'unavailable') { + throw new WritingDiagnosticProjectionError('segmenter_unavailable'); + } + if (boundaryState !== 'boundary') { + throw new WritingDiagnosticProjectionError('grapheme_boundary'); + } +} From 6630fdef0be09c9409068a23e6aeaf174e80b236 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 11:28:24 +0900 Subject: [PATCH 06/27] ci(diagnostics): expose inverse projection failures --- .../writing-diagnostics-projection-tdd.yml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/writing-diagnostics-projection-tdd.yml b/.github/workflows/writing-diagnostics-projection-tdd.yml index f6c89222..7f7cdfe4 100644 --- a/.github/workflows/writing-diagnostics-projection-tdd.yml +++ b/.github/workflows/writing-diagnostics-projection-tdd.yml @@ -32,4 +32,20 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - name: Run inverse projection contract tests - run: pnpm exec vitest run src/writingDiagnosticProjection.test.ts + run: | + set -euo pipefail + if output="$(pnpm exec vitest run src/writingDiagnosticProjection.test.ts 2>&1)"; then + printf '%s\n' "$output" + exit 0 + fi + printf '%s\n' "$output" + while IFS= read -r line; do + [[ -z "$line" ]] && continue + escaped="${line//'%'/'%25'}" + escaped="${escaped//$'\r'/'%0D'}" + escaped="${escaped//$'\n'/'%0A'}" + echo "::error file=src/writingDiagnosticProjection.test.ts,line=1::$escaped" + done <<< "$output" + exit 1 + - name: Typecheck inverse projection contracts + run: pnpm typecheck From 1d3c052fed37725d4cdb22d271237c9395c9073c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 11:30:15 +0900 Subject: [PATCH 07/27] fix(diagnostics): separate block leaf projections correctly --- src/writingDiagnosticProjection.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/writingDiagnosticProjection.ts b/src/writingDiagnosticProjection.ts index c00dcc02..2c03c0a3 100644 --- a/src/writingDiagnosticProjection.ts +++ b/src/writingDiagnosticProjection.ts @@ -96,6 +96,11 @@ export function buildTextProjectionMap( }; documentNode.descendants((node, position) => { + if (!separated && node.isBlock) { + appendProjectedCodePoint(BLOCK_SEPARATOR); + separated = true; + } + if (node.isText) { const text = node.text ?? ''; let codeUnitOffset = 0; @@ -117,10 +122,6 @@ export function buildTextProjectionMap( return false; } - if (!separated && node.isBlock) { - appendProjectedCodePoint(BLOCK_SEPARATOR); - separated = true; - } if (node.inlineContent) { addBoundaryCandidate(position + 1); } From fe0892e8dc0fe6863898ef2bc255fb0410a1684c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 11:32:24 +0900 Subject: [PATCH 08/27] test(diagnostics): mark intentional invalid projection fixtures --- src/writingDiagnosticProjection.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/writingDiagnosticProjection.test.ts b/src/writingDiagnosticProjection.test.ts index 478c3494..1a95a289 100644 --- a/src/writingDiagnosticProjection.test.ts +++ b/src/writingDiagnosticProjection.test.ts @@ -243,7 +243,7 @@ describe('resolveTextPositionSelector', () => { resolveTextPositionSelector( documentNode, selector, - { id: 'other', version: 1 } as CwlEditorTextProjectionIdentity, + { id: 'other', version: 1 } as unknown as CwlEditorTextProjectionIdentity, ), 'projection', ); @@ -255,7 +255,7 @@ describe('resolveTextPositionSelector', () => { { id: TEXT_POSITION_PROJECTION_ID, version: 2, - } as CwlEditorTextProjectionIdentity, + } as unknown as CwlEditorTextProjectionIdentity, ), 'projection', ); From f713ec45298986e05751bdccb6a24156a7a0ff58 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 11:36:32 +0900 Subject: [PATCH 09/27] fix(diagnostics): contain hostile grapheme segmenters --- src/graphemeBoundary.ts | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/src/graphemeBoundary.ts b/src/graphemeBoundary.ts index f82fc37d..f2c3f63e 100644 --- a/src/graphemeBoundary.ts +++ b/src/graphemeBoundary.ts @@ -19,30 +19,34 @@ interface GraphemeSegmenterConstructor { /** * Classify one UTF-16 code-unit offset using the runtime's Unicode grapheme segmenter. * - * Callers retain authority over their public error type. Returning an explicit - * unsupported state keeps forward and inverse selector paths on one segmentation - * implementation without coupling their public contracts. + * Callers retain authority over their public error type. Any unavailable, + * replaced, or throwing runtime segmenter produces one stable unsupported state + * instead of leaking host exceptions across the selector boundary. */ export function classifyGraphemeBoundary( text: string, codeUnitOffset: number, ): GraphemeBoundaryState { - const Segmenter = ( - Intl as unknown as { Segmenter?: GraphemeSegmenterConstructor } - ).Segmenter; - if (typeof Segmenter !== 'function') { - return 'unavailable'; - } + try { + const Segmenter = ( + Intl as unknown as { Segmenter?: GraphemeSegmenterConstructor } + ).Segmenter; + if (typeof Segmenter !== 'function') { + return 'unavailable'; + } - if (codeUnitOffset === 0 || codeUnitOffset === text.length) { - return 'boundary'; - } - for (const segment of new Segmenter(undefined, { granularity: 'grapheme' }).segment( - text, - )) { - if (segment.index === codeUnitOffset) { + if (codeUnitOffset === 0 || codeUnitOffset === text.length) { return 'boundary'; } + for (const segment of new Segmenter(undefined, { + granularity: 'grapheme', + }).segment(text)) { + if (segment.index === codeUnitOffset) { + return 'boundary'; + } + } + return 'inside_grapheme'; + } catch { + return 'unavailable'; } - return 'inside_grapheme'; } From 410e99ae2c5341d578eb3c82eb359499ee5fdcdd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 11:37:54 +0900 Subject: [PATCH 10/27] refactor(diagnostics): harden and compact inverse projection --- src/writingDiagnosticProjection.ts | 149 ++++++++++++++++++++++------- 1 file changed, 115 insertions(+), 34 deletions(-) diff --git a/src/writingDiagnosticProjection.ts b/src/writingDiagnosticProjection.ts index 2c03c0a3..f4e63ba4 100644 --- a/src/writingDiagnosticProjection.ts +++ b/src/writingDiagnosticProjection.ts @@ -9,6 +9,8 @@ import { const BLOCK_SEPARATOR = '\n'; const LEAF_TEXT = '\uFFFC'; +const PROJECTION_FIELDS = Object.freeze(['id', 'version'] as const); +const SELECTOR_FIELDS = Object.freeze(['type', 'start', 'end'] as const); /** Stable failure codes for inverse writing-diagnostic text projection. */ export type WritingDiagnosticProjectionErrorCode = @@ -65,7 +67,9 @@ export interface CwlWritingDiagnosticTextProjectionMap { * * The traversal mirrors ProseMirror's `Fragment.textBetween` contract: logical * document order, `\n` before a block when prior emitted content has not yet - * been separated, and U+FFFC for non-text leaf nodes. The implementation does + * been separated, and U+FFFC for non-text leaf nodes. One scalar-or-null slot is + * retained per projected boundary instead of a Set per code point, keeping the + * map linear and bounded by the projected text length. The implementation does * not search for text, repair selectors, inspect language semantics, or mutate * the document. */ @@ -73,26 +77,23 @@ export function buildTextProjectionMap( documentNode: ProseMirrorNode, ): Readonly { const textParts: string[] = []; - const boundaryCandidates: Set[] = [new Set()]; + const boundaryCandidates: Array = [undefined]; let codePointOffset = 0; let separated = true; - const currentCandidates = (): Set => - boundaryCandidates[codePointOffset] ?? - (() => { - const candidates = new Set(); - boundaryCandidates[codePointOffset] = candidates; - return candidates; - })(); - const addBoundaryCandidate = (position: number): void => { - currentCandidates().add(position); + const existing = boundaryCandidates[codePointOffset]; + if (existing === undefined) { + boundaryCandidates[codePointOffset] = position; + } else if (existing !== position) { + boundaryCandidates[codePointOffset] = null; + } }; const appendProjectedCodePoint = (value: string): void => { textParts.push(value); codePointOffset += 1; - boundaryCandidates.push(new Set()); + boundaryCandidates.push(undefined); }; documentNode.descendants((node, position) => { @@ -102,7 +103,7 @@ export function buildTextProjectionMap( } if (node.isText) { - const text = node.text ?? ''; + const text = node.text!; let codeUnitOffset = 0; for (const character of text) { addBoundaryCandidate(position + codeUnitOffset); @@ -128,11 +129,11 @@ export function buildTextProjectionMap( return true; }); - const boundaryPositions = boundaryCandidates.map((candidates) => - candidates.size === 1 ? candidates.values().next().value ?? null : null, + const boundaryPositions = boundaryCandidates.map((candidate) => + candidate === undefined ? null : candidate, ); - const ambiguousBoundaryOffsets = boundaryCandidates.flatMap( - (candidates, offset) => (candidates.size === 1 ? [] : [offset]), + const ambiguousBoundaryOffsets = boundaryPositions.flatMap( + (candidate, offset) => (candidate === null ? [offset] : []), ); return Object.freeze({ @@ -146,50 +147,63 @@ export function buildTextProjectionMap( * Resolve one revision-scoped W3C text-position selector to ProseMirror positions. * * Callers must still verify the diagnostic's declared strong document revision - * against the same immutable document snapshot. This function validates the - * projection and selector, requires grapheme boundaries, and fails closed when - * an offset cannot map to one exact position. It never performs nearest-text or - * semantic fallback. + * against the same immutable document snapshot. This function reads only exact + * own enumerable data properties from the untrusted selector/projection values, + * validates grapheme boundaries, and fails closed when an offset cannot map to + * one exact position. It never performs nearest-text or semantic fallback. */ export function resolveTextPositionSelector( documentNode: ProseMirrorNode, selector: CwlEditorTextPositionSelector, textProjection: CwlEditorTextProjectionIdentity, ): Readonly<{ from: number; to: number }> { + const projectionRecord = readExactDataObject( + textProjection, + PROJECTION_FIELDS, + 'projection', + ); if ( - textProjection.id !== TEXT_POSITION_PROJECTION_ID || - textProjection.version !== TEXT_POSITION_PROJECTION_VERSION + projectionRecord.id !== TEXT_POSITION_PROJECTION_ID || + projectionRecord.version !== TEXT_POSITION_PROJECTION_VERSION ) { throw new WritingDiagnosticProjectionError('projection'); } + + const selectorRecord = readExactDataObject( + selector, + SELECTOR_FIELDS, + 'selector', + ); + const start = selectorRecord.start; + const end = selectorRecord.end; if ( - selector.type !== 'TextPositionSelector' || - !Number.isSafeInteger(selector.start) || - !Number.isSafeInteger(selector.end) || - selector.start < 0 || - selector.end < selector.start + selectorRecord.type !== 'TextPositionSelector' || + !Number.isSafeInteger(start) || + !Number.isSafeInteger(end) || + (start as number) < 0 || + (end as number) < (start as number) ) { throw new WritingDiagnosticProjectionError('selector'); } const projection = buildTextProjectionMap(documentNode); - if (selector.end >= projection.boundaryPositions.length) { + if ((end as number) >= projection.boundaryPositions.length) { throw new WritingDiagnosticProjectionError('selector'); } const codeUnitBoundaries = codePointBoundaryCodeUnits(projection.text); assertProjectedGraphemeBoundary( projection.text, - codeUnitBoundaries[selector.start]!, + codeUnitBoundaries[start as number]!, ); assertProjectedGraphemeBoundary( projection.text, - codeUnitBoundaries[selector.end]!, + codeUnitBoundaries[end as number]!, ); - const from = projection.boundaryPositions[selector.start]; - const to = projection.boundaryPositions[selector.end]; - if (from === null || from === undefined || to === null || to === undefined) { + const from = projection.boundaryPositions[start as number]; + const to = projection.boundaryPositions[end as number]; + if (from === null || to === null) { throw new WritingDiagnosticProjectionError('ambiguous_boundary'); } if (from > to) { @@ -222,3 +236,70 @@ function assertProjectedGraphemeBoundary( throw new WritingDiagnosticProjectionError('grapheme_boundary'); } } + +/** Read one exact own-data object without invoking inherited or accessor code. */ +function readExactDataObject( + value: unknown, + expectedFields: readonly K[], + errorCode: Extract< + WritingDiagnosticProjectionErrorCode, + 'projection' | 'selector' + >, +): Readonly> { + let isArray: boolean; + let prototype: object | null; + let keys: PropertyKey[]; + try { + isArray = Array.isArray(value); + if (typeof value !== 'object' || value === null || isArray) { + throw new WritingDiagnosticProjectionError(errorCode); + } + prototype = Object.getPrototypeOf(value); + keys = Reflect.ownKeys(value); + } catch (error) { + if (error instanceof WritingDiagnosticProjectionError) { + throw error; + } + throw new WritingDiagnosticProjectionError(errorCode); + } + + if (prototype !== Object.prototype && prototype !== null) { + throw new WritingDiagnosticProjectionError(errorCode); + } + if (keys.length !== expectedFields.length) { + throw new WritingDiagnosticProjectionError(errorCode); + } + + const expected = new Set(expectedFields); + const result = {} as Record; + for (const key of keys) { + if (typeof key !== 'string' || !expected.has(key)) { + throw new WritingDiagnosticProjectionError(errorCode); + } + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(value, key); + } catch { + throw new WritingDiagnosticProjectionError(errorCode); + } + if ( + descriptor === undefined || + descriptor.enumerable !== true || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + throw new WritingDiagnosticProjectionError(errorCode); + } + Object.defineProperty(result, key, { + value: descriptor.value, + enumerable: true, + configurable: true, + writable: true, + }); + } + for (const field of expectedFields) { + if (!Object.prototype.hasOwnProperty.call(result, field)) { + throw new WritingDiagnosticProjectionError(errorCode); + } + } + return result; +} From 763598be593da6a7054e7fc9b983d2290e2ff8a6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 11:39:25 +0900 Subject: [PATCH 11/27] test(diagnostics): harden structural and hostile projection boundaries --- ...ritingDiagnosticProjectionBoundary.test.ts | 365 ++++++++++++++++++ 1 file changed, 365 insertions(+) create mode 100644 src/writingDiagnosticProjectionBoundary.test.ts diff --git a/src/writingDiagnosticProjectionBoundary.test.ts b/src/writingDiagnosticProjectionBoundary.test.ts new file mode 100644 index 00000000..d20b6f59 --- /dev/null +++ b/src/writingDiagnosticProjectionBoundary.test.ts @@ -0,0 +1,365 @@ +import { Schema, type Node as ProseMirrorNode } from '@tiptap/pm/model'; +import { TextSelection } from '@tiptap/pm/state'; +import { describe, expect, it, vi } from 'vitest'; +import { + TEXT_POSITION_PROJECTION_ID, + TEXT_POSITION_PROJECTION_VERSION, + createTextPositionSelector, + type CwlEditorTextPositionSelector, + type CwlEditorTextProjectionIdentity, +} from './textPositionSelectorEvidence.js'; +import { + WritingDiagnosticProjectionError, + buildTextProjectionMap, + resolveTextPositionSelector, +} from './writingDiagnosticProjection.js'; + +const schema = new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { content: 'inline*', group: 'block' }, + block_atom: { group: 'block', atom: true }, + inline_atom: { inline: true, group: 'inline', atom: true }, + text: { group: 'inline' }, + }, +}); + +const projectionIdentity: CwlEditorTextProjectionIdentity = { + id: TEXT_POSITION_PROJECTION_ID, + version: TEXT_POSITION_PROJECTION_VERSION, +}; + +function documentWith(content: readonly Record[]): ProseMirrorNode { + return schema.nodeFromJSON({ type: 'doc', content }); +} + +function textOccurrenceRange( + documentNode: ProseMirrorNode, + needle: string, + occurrence = 0, +): Readonly<{ from: number; to: number }> { + let remaining = occurrence; + let result: { from: number; to: number } | null = null; + documentNode.descendants((node, position) => { + if (!node.isText || result) return; + let cursor = 0; + while (cursor <= node.text!.length) { + const index = node.text!.indexOf(needle, cursor); + if (index < 0) return; + if (remaining === 0) { + result = { + from: position + index, + to: position + index + needle.length, + }; + return; + } + remaining -= 1; + cursor = index + Math.max(needle.length, 1); + } + }); + if (!result) throw new Error(`Missing text occurrence: ${needle}`); + return Object.freeze(result); +} + +function expectProjectionError( + callback: () => unknown, + code: WritingDiagnosticProjectionError['code'], +): WritingDiagnosticProjectionError { + try { + callback(); + } catch (error) { + expect(error).toBeInstanceOf(WritingDiagnosticProjectionError); + expect((error as WritingDiagnosticProjectionError).code).toBe(code); + return error as WritingDiagnosticProjectionError; + } + throw new Error(`Expected WritingDiagnosticProjectionError(${code})`); +} + +describe('inverse projection structural boundaries', () => { + it('round-trips a selection spanning two text blocks and their separator', () => { + const documentNode = documentWith([ + { type: 'paragraph', content: [{ type: 'text', text: 'Alpha' }] }, + { type: 'paragraph', content: [{ type: 'text', text: 'Beta' }] }, + ]); + const first = textOccurrenceRange(documentNode, 'pha'); + const second = textOccurrenceRange(documentNode, 'Be'); + const range = { from: first.from, to: second.to }; + const evidence = createTextPositionSelector( + documentNode, + TextSelection.create(documentNode, range.from, range.to), + ); + + expect( + resolveTextPositionSelector( + documentNode, + evidence.selector, + evidence.textProjection, + ), + ).toEqual(range); + }); + + it('round-trips the selected occurrence without searching repeated text', () => { + const documentNode = documentWith([ + { type: 'paragraph', content: [{ type: 'text', text: 'repeat' }] }, + { type: 'paragraph', content: [{ type: 'text', text: 'repeat' }] }, + ]); + const range = textOccurrenceRange(documentNode, 'repeat', 1); + const evidence = createTextPositionSelector( + documentNode, + TextSelection.create(documentNode, range.from, range.to), + ); + + expect( + resolveTextPositionSelector( + documentNode, + evidence.selector, + evidence.textProjection, + ), + ).toEqual(range); + }); + + it('keeps block atoms separated while inline atoms remain inline', () => { + const documentNode = documentWith([ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'a' }, + { type: 'inline_atom' }, + { type: 'text', text: 'b' }, + ], + }, + { type: 'block_atom' }, + ]); + + expect(buildTextProjectionMap(documentNode).text).toBe('a\uFFFCb\n\uFFFC'); + }); + + it('fails closed when a malformed document reports decreasing positions', () => { + const textNode = (text: string) => ({ + isBlock: false, + isText: true, + isLeaf: true, + inlineContent: false, + nodeSize: text.length, + text, + }); + const malformedDocument = { + descendants(callback: (node: ReturnType, position: number) => void) { + callback(textNode('a'), 5); + callback(textNode('b'), 1); + }, + } as unknown as ProseMirrorNode; + + expectProjectionError( + () => + resolveTextPositionSelector( + malformedDocument, + { type: 'TextPositionSelector', start: 0, end: 2 }, + projectionIdentity, + ), + 'ambiguous_boundary', + ); + }); +}); + +describe('inverse projection hostile input boundary', () => { + it('never evaluates projection or selector accessors', () => { + const documentNode = documentWith([ + { type: 'paragraph', content: [{ type: 'text', text: 'text' }] }, + ]); + const projectionGetter = vi.fn(() => TEXT_POSITION_PROJECTION_ID); + const projection = { version: TEXT_POSITION_PROJECTION_VERSION } as Record< + string, + unknown + >; + Object.defineProperty(projection, 'id', { + enumerable: true, + get: projectionGetter, + }); + expectProjectionError( + () => + resolveTextPositionSelector( + documentNode, + { type: 'TextPositionSelector', start: 0, end: 1 }, + projection as unknown as CwlEditorTextProjectionIdentity, + ), + 'projection', + ); + expect(projectionGetter).not.toHaveBeenCalled(); + + const selectorGetter = vi.fn(() => 0); + const selector = { + type: 'TextPositionSelector', + end: 1, + } as Record; + Object.defineProperty(selector, 'start', { + enumerable: true, + get: selectorGetter, + }); + expectProjectionError( + () => + resolveTextPositionSelector( + documentNode, + selector as unknown as CwlEditorTextPositionSelector, + projectionIdentity, + ), + 'selector', + ); + expect(selectorGetter).not.toHaveBeenCalled(); + }); + + it('rejects extra, symbol, inherited, missing, non-enumerable, array, and null fields', () => { + const documentNode = documentWith([ + { type: 'paragraph', content: [{ type: 'text', text: 'text' }] }, + ]); + const validSelector = { + type: 'TextPositionSelector', + start: 0, + end: 1, + } as const; + + for (const projection of [ + { ...projectionIdentity, extra: true }, + { ...projectionIdentity, [Symbol('private')]: true }, + Object.assign(Object.create({ inherited: true }), projectionIdentity), + { id: TEXT_POSITION_PROJECTION_ID }, + null, + [], + ]) { + expectProjectionError( + () => + resolveTextPositionSelector( + documentNode, + validSelector, + projection as unknown as CwlEditorTextProjectionIdentity, + ), + 'projection', + ); + } + + const hiddenProjection = { ...projectionIdentity } as Record; + Object.defineProperty(hiddenProjection, 'id', { + value: TEXT_POSITION_PROJECTION_ID, + enumerable: false, + }); + expectProjectionError( + () => + resolveTextPositionSelector( + documentNode, + validSelector, + hiddenProjection as unknown as CwlEditorTextProjectionIdentity, + ), + 'projection', + ); + }); + + it('redacts revoked proxies and descriptor failures', () => { + const documentNode = documentWith([ + { type: 'paragraph', content: [{ type: 'text', text: 'text' }] }, + ]); + const revoked = Proxy.revocable({ ...projectionIdentity }, {}); + revoked.revoke(); + const revokedError = expectProjectionError( + () => + resolveTextPositionSelector( + documentNode, + { type: 'TextPositionSelector', start: 0, end: 1 }, + revoked.proxy as CwlEditorTextProjectionIdentity, + ), + 'projection', + ); + expect(revokedError.message).not.toContain('revoked'); + + const selector = new Proxy( + { type: 'TextPositionSelector', start: 0, end: 1 }, + { + getOwnPropertyDescriptor() { + throw new Error('private selector payload'); + }, + }, + ); + const selectorError = expectProjectionError( + () => + resolveTextPositionSelector( + documentNode, + selector as CwlEditorTextPositionSelector, + projectionIdentity, + ), + 'selector', + ); + expect(selectorError.message).not.toContain('private selector payload'); + }); + + it('accepts exact null-prototype data records', () => { + const documentNode = documentWith([ + { type: 'paragraph', content: [{ type: 'text', text: 'text' }] }, + ]); + const projection = Object.assign(Object.create(null), projectionIdentity); + const selector = Object.assign(Object.create(null), { + type: 'TextPositionSelector', + start: 1, + end: 3, + }); + + expect( + resolveTextPositionSelector(documentNode, selector, projection), + ).toEqual({ from: 2, to: 4 }); + }); +}); + +describe('shared grapheme runtime failure boundary', () => { + it('converts a throwing Intl.Segmenter getter into one stable error', () => { + const documentNode = documentWith([ + { type: 'paragraph', content: [{ type: 'text', text: 'text' }] }, + ]); + const original = Object.getOwnPropertyDescriptor(Intl, 'Segmenter'); + try { + Object.defineProperty(Intl, 'Segmenter', { + configurable: true, + get() { + throw new Error('private runtime detail'); + }, + }); + const error = expectProjectionError( + () => + resolveTextPositionSelector( + documentNode, + { type: 'TextPositionSelector', start: 1, end: 2 }, + projectionIdentity, + ), + 'segmenter_unavailable', + ); + expect(error.message).not.toContain('private runtime detail'); + } finally { + if (original) Object.defineProperty(Intl, 'Segmenter', original); + } + }); + + it('converts a throwing segmenter constructor into one stable error', () => { + const documentNode = documentWith([ + { type: 'paragraph', content: [{ type: 'text', text: 'text' }] }, + ]); + const original = Object.getOwnPropertyDescriptor(Intl, 'Segmenter'); + try { + Object.defineProperty(Intl, 'Segmenter', { + configurable: true, + value: class ThrowingSegmenter { + constructor() { + throw new Error('private constructor detail'); + } + }, + }); + expectProjectionError( + () => + resolveTextPositionSelector( + documentNode, + { type: 'TextPositionSelector', start: 1, end: 2 }, + projectionIdentity, + ), + 'segmenter_unavailable', + ); + } finally { + if (original) Object.defineProperty(Intl, 'Segmenter', original); + } + }); +}); From c429a5dc2e90348bad7a9abe73f2cdab32942aec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 11:40:14 +0900 Subject: [PATCH 12/27] ci(diagnostics): enforce exact projection coverage --- .../writing-diagnostics-projection-tdd.yml | 96 +++++++++++++++---- 1 file changed, 80 insertions(+), 16 deletions(-) diff --git a/.github/workflows/writing-diagnostics-projection-tdd.yml b/.github/workflows/writing-diagnostics-projection-tdd.yml index 7f7cdfe4..ec38e7cf 100644 --- a/.github/workflows/writing-diagnostics-projection-tdd.yml +++ b/.github/workflows/writing-diagnostics-projection-tdd.yml @@ -19,7 +19,7 @@ env: jobs: focused-projection: runs-on: ubuntu-24.04 - timeout-minutes: 15 + timeout-minutes: 20 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -31,21 +31,85 @@ jobs: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - - name: Run inverse projection contract tests + - name: Collect forward and inverse projection coverage + id: focused_coverage + continue-on-error: true + run: >- + pnpm exec vitest run + src/writingDiagnosticProjection.test.ts + src/writingDiagnosticProjectionBoundary.test.ts + --coverage + --coverage.include=src/graphemeBoundary.ts + --coverage.include=src/textPositionSelectorEvidence.ts + --coverage.include=src/writingDiagnosticProjection.ts + --coverage.reporter=text + --coverage.reporter=json + --coverage.reporter=json-summary + - name: Report and enforce exact projection coverage run: | - set -euo pipefail - if output="$(pnpm exec vitest run src/writingDiagnosticProjection.test.ts 2>&1)"; then - printf '%s\n' "$output" - exit 0 - fi - printf '%s\n' "$output" - while IFS= read -r line; do - [[ -z "$line" ]] && continue - escaped="${line//'%'/'%25'}" - escaped="${escaped//$'\r'/'%0D'}" - escaped="${escaped//$'\n'/'%0A'}" - echo "::error file=src/writingDiagnosticProjection.test.ts,line=1::$escaped" - done <<< "$output" - exit 1 + node <<'NODE' + const { readFileSync } = require('node:fs'); + const coverage = JSON.parse(readFileSync('coverage/coverage-final.json', 'utf8')); + const requiredSuffixes = [ + '/src/graphemeBoundary.ts', + '/src/textPositionSelectorEvidence.ts', + '/src/writingDiagnosticProjection.ts', + ]; + let failed = process.env.FOCUSED_COVERAGE_OUTCOME !== 'success'; + + for (const suffix of requiredSuffixes) { + const entry = Object.entries(coverage).find(([path]) => path.endsWith(suffix)); + const displayPath = suffix.slice(1); + if (!entry) { + console.error(`::error file=${displayPath},line=1::Focused coverage record is missing.`); + failed = true; + continue; + } + const [, fileCoverage] = entry; + const missingStatementLines = [...new Set( + Object.entries(fileCoverage.s) + .filter(([, count]) => count === 0) + .map(([id]) => fileCoverage.statementMap[id].start.line), + )].sort((a, b) => a - b); + const missingFunctionLines = [...new Set( + Object.entries(fileCoverage.f) + .filter(([, count]) => count === 0) + .map(([id]) => fileCoverage.fnMap[id].decl.start.line), + )].sort((a, b) => a - b); + const missingBranches = []; + for (const [id, counts] of Object.entries(fileCoverage.b)) { + counts.forEach((count, index) => { + if (count === 0) { + const branch = fileCoverage.branchMap[id]; + const location = branch.locations?.[index] ?? branch.loc; + missingBranches.push(`${location.start.line}:${index}`); + } + }); + } + const statementTotal = Object.keys(fileCoverage.s).length; + const statementCovered = Object.values(fileCoverage.s).filter((count) => count > 0).length; + const functionTotal = Object.keys(fileCoverage.f).length; + const functionCovered = Object.values(fileCoverage.f).filter((count) => count > 0).length; + const branchCounts = Object.values(fileCoverage.b).flat(); + const branchTotal = branchCounts.length; + const branchCovered = branchCounts.filter((count) => count > 0).length; + console.log( + `::notice file=${displayPath},line=1::Statements ${statementCovered}/${statementTotal}; ` + + `functions ${functionCovered}/${functionTotal}; branches ${branchCovered}/${branchTotal}.`, + ); + if (missingStatementLines.length || missingFunctionLines.length || missingBranches.length) { + console.error( + `::error file=${displayPath},line=1::` + + `Missing statement lines: ${missingStatementLines.join(', ') || 'none'}; ` + + `missing function lines: ${missingFunctionLines.join(', ') || 'none'}; ` + + `missing branches line:index: ${missingBranches.join(', ') || 'none'}.`, + ); + failed = true; + } + } + if (failed) process.exit(1); + NODE + env: + FOCUSED_COVERAGE_OUTCOME: ${{ steps.focused_coverage.outcome }} - name: Typecheck inverse projection contracts run: pnpm typecheck From dce44ae626b0d5faebc76d14159eab79cf78c086 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 11:42:07 +0900 Subject: [PATCH 13/27] test(diagnostics): cover forward grapheme failure contracts --- ...xtPositionSelectorEvidenceBoundary.test.ts | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 src/textPositionSelectorEvidenceBoundary.test.ts diff --git a/src/textPositionSelectorEvidenceBoundary.test.ts b/src/textPositionSelectorEvidenceBoundary.test.ts new file mode 100644 index 00000000..278e2086 --- /dev/null +++ b/src/textPositionSelectorEvidenceBoundary.test.ts @@ -0,0 +1,72 @@ +import { Schema } from '@tiptap/pm/model'; +import { TextSelection } from '@tiptap/pm/state'; +import { describe, expect, it } from 'vitest'; +import { + TextPositionSelectorEvidenceError, + createTextPositionSelector, +} from './textPositionSelectorEvidence.js'; + +const schema = new Schema({ + nodes: { + doc: { content: 'paragraph+' }, + paragraph: { content: 'text*' }, + text: {}, + }, +}); + +function documentWithText(text: string) { + return schema.node('doc', undefined, [ + schema.node('paragraph', undefined, text ? [schema.text(text)] : []), + ]); +} + +function expectEvidenceError( + callback: () => unknown, + code: TextPositionSelectorEvidenceError['code'], +): TextPositionSelectorEvidenceError { + try { + callback(); + } catch (error) { + expect(error).toBeInstanceOf(TextPositionSelectorEvidenceError); + expect((error as TextPositionSelectorEvidenceError).code).toBe(code); + return error as TextPositionSelectorEvidenceError; + } + throw new Error(`Expected TextPositionSelectorEvidenceError(${code})`); +} + +describe('forward selector grapheme boundary', () => { + it('rejects a structural position inside one combining grapheme', () => { + const documentNode = documentWithText('e\u0301x'); + + expectEvidenceError( + () => + createTextPositionSelector( + documentNode, + TextSelection.create(documentNode, 2, 3), + ), + 'grapheme_boundary', + ); + }); + + it('fails closed when the runtime grapheme segmenter is unavailable', () => { + const documentNode = documentWithText('text'); + const original = Object.getOwnPropertyDescriptor(Intl, 'Segmenter'); + try { + Object.defineProperty(Intl, 'Segmenter', { + configurable: true, + value: undefined, + }); + const error = expectEvidenceError( + () => + createTextPositionSelector( + documentNode, + TextSelection.create(documentNode, 1, 2), + ), + 'segmenter_unavailable', + ); + expect(error.message).not.toContain('undefined'); + } finally { + if (original) Object.defineProperty(Intl, 'Segmenter', original); + } + }); +}); From 07c535a748d89b42e6fa968f3fab0599a757d273 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 11:42:33 +0900 Subject: [PATCH 14/27] test(diagnostics): cover exact inverse reflection outcomes --- ...ritingDiagnosticProjectionCoverage.test.ts | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 src/writingDiagnosticProjectionCoverage.test.ts diff --git a/src/writingDiagnosticProjectionCoverage.test.ts b/src/writingDiagnosticProjectionCoverage.test.ts new file mode 100644 index 00000000..18b181f2 --- /dev/null +++ b/src/writingDiagnosticProjectionCoverage.test.ts @@ -0,0 +1,109 @@ +import type { Node as ProseMirrorNode } from '@tiptap/pm/model'; +import { describe, expect, it } from 'vitest'; +import { + TEXT_POSITION_PROJECTION_ID, + TEXT_POSITION_PROJECTION_VERSION, + type CwlEditorTextPositionSelector, + type CwlEditorTextProjectionIdentity, +} from './textPositionSelectorEvidence.js'; +import { + WritingDiagnosticProjectionError, + buildTextProjectionMap, + resolveTextPositionSelector, +} from './writingDiagnosticProjection.js'; + +const projectionIdentity: CwlEditorTextProjectionIdentity = { + id: TEXT_POSITION_PROJECTION_ID, + version: TEXT_POSITION_PROJECTION_VERSION, +}; + +function emptyTraversalDocument(): ProseMirrorNode { + return { + descendants() { + // Deliberately expose no structural boundary candidate. + }, + } as unknown as ProseMirrorNode; +} + +function expectCode( + callback: () => unknown, + code: WritingDiagnosticProjectionError['code'], +): void { + try { + callback(); + } catch (error) { + expect(error).toBeInstanceOf(WritingDiagnosticProjectionError); + expect((error as WritingDiagnosticProjectionError).code).toBe(code); + return; + } + throw new Error(`Expected WritingDiagnosticProjectionError(${code})`); +} + +describe('inverse projection defensive coverage', () => { + it('marks a projection with no structural candidate as ambiguous', () => { + expect(buildTextProjectionMap(emptyTraversalDocument())).toEqual({ + text: '', + boundaryPositions: [null], + ambiguousBoundaryOffsets: [0], + }); + }); + + it('rejects an unexpected projection key even when the key count matches', () => { + const projection = { + id: TEXT_POSITION_PROJECTION_ID, + unexpected: TEXT_POSITION_PROJECTION_VERSION, + }; + + expectCode( + () => + resolveTextPositionSelector( + emptyTraversalDocument(), + { type: 'TextPositionSelector', start: 0, end: 0 }, + projection as unknown as CwlEditorTextProjectionIdentity, + ), + 'projection', + ); + }); + + it('rejects a same-count symbol key without reflecting its description', () => { + const privateKey = Symbol('private projection key'); + const projection = { + id: TEXT_POSITION_PROJECTION_ID, + [privateKey]: TEXT_POSITION_PROJECTION_VERSION, + }; + + expectCode( + () => + resolveTextPositionSelector( + emptyTraversalDocument(), + { type: 'TextPositionSelector', start: 0, end: 0 }, + projection as unknown as CwlEditorTextProjectionIdentity, + ), + 'projection', + ); + }); + + it('rejects a field that disappears after the exact key inventory', () => { + const target = { + type: 'TextPositionSelector', + start: 0, + end: 0, + }; + const selector = new Proxy(target, { + getOwnPropertyDescriptor(currentTarget, key) { + if (key === 'start') return undefined; + return Reflect.getOwnPropertyDescriptor(currentTarget, key); + }, + }); + + expectCode( + () => + resolveTextPositionSelector( + emptyTraversalDocument(), + selector as CwlEditorTextPositionSelector, + projectionIdentity, + ), + 'selector', + ); + }); +}); From f6df0b76c117c4afa76afb5726cc43214b538cd6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 11:43:29 +0900 Subject: [PATCH 15/27] ci(diagnostics): remove unreachable projection postcondition --- .../workflows/projection-refactor-once.yml | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 .github/workflows/projection-refactor-once.yml diff --git a/.github/workflows/projection-refactor-once.yml b/.github/workflows/projection-refactor-once.yml new file mode 100644 index 00000000..f66aa9bc --- /dev/null +++ b/.github/workflows/projection-refactor-once.yml @@ -0,0 +1,81 @@ +name: Projection Refactor Once + +on: + push: + branches: + - feat/writing-diagnostics-projection + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: projection-refactor-once-${{ github.ref }} + cancel-in-progress: true + +jobs: + remove-unreachable-field-loop: + 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: Remove the logically unreachable postcondition loop + id: refactor + run: | + set -euo pipefail + python <<'PY' + from pathlib import Path + + path = Path('src/writingDiagnosticProjection.ts') + source = path.read_text(encoding='utf-8') + old = """ for (const field of expectedFields) { + if (!Object.prototype.hasOwnProperty.call(result, field)) { + throw new WritingDiagnosticProjectionError(errorCode); + } + } + return result; + } + """ + new = """ return result; + } + """ + if old not in source: + Path('/tmp/refactor-state').write_text('unchanged', encoding='utf-8') + raise SystemExit(0) + if source.count(old) != 1: + raise SystemExit('unexpected duplicate exact postcondition loop') + path.write_text(source.replace(old, new), encoding='utf-8') + Path('/tmp/refactor-state').write_text('changed', encoding='utf-8') + PY + - name: Verify focused tests and type safety + run: | + pnpm exec vitest run \ + src/writingDiagnosticProjection.test.ts \ + src/writingDiagnosticProjectionBoundary.test.ts \ + src/writingDiagnosticProjectionCoverage.test.ts \ + src/textPositionSelectorEvidenceBoundary.test.ts + pnpm typecheck + - name: Commit the source-only refactor + env: + TARGET_BRANCH: feat/writing-diagnostics-projection + run: | + set -euo pipefail + if [[ "$(cat /tmp/refactor-state)" == 'unchanged' ]]; then + echo 'The unreachable loop is already absent.' + exit 0 + fi + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add src/writingDiagnosticProjection.ts + git diff --cached --check + git commit -m 'refactor(diagnostics): remove unreachable projection postcondition' + git push origin "HEAD:${TARGET_BRANCH}" From 4d61c625e39f4e313624dbd8776c4b32706e401b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:44:12 +0000 Subject: [PATCH 16/27] refactor(diagnostics): remove unreachable projection postcondition --- src/writingDiagnosticProjection.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/writingDiagnosticProjection.ts b/src/writingDiagnosticProjection.ts index f4e63ba4..e80116f7 100644 --- a/src/writingDiagnosticProjection.ts +++ b/src/writingDiagnosticProjection.ts @@ -296,10 +296,5 @@ function readExactDataObject( writable: true, }); } - for (const field of expectedFields) { - if (!Object.prototype.hasOwnProperty.call(result, field)) { - throw new WritingDiagnosticProjectionError(errorCode); - } - } return result; } From 34901189756dd83671fdef5b2116df90b3af813d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 11:44:42 +0900 Subject: [PATCH 17/27] ci(diagnostics): include projection boundary coverage --- .github/workflows/writing-diagnostics-projection-tdd.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/writing-diagnostics-projection-tdd.yml b/.github/workflows/writing-diagnostics-projection-tdd.yml index ec38e7cf..43751612 100644 --- a/.github/workflows/writing-diagnostics-projection-tdd.yml +++ b/.github/workflows/writing-diagnostics-projection-tdd.yml @@ -38,6 +38,8 @@ jobs: pnpm exec vitest run src/writingDiagnosticProjection.test.ts src/writingDiagnosticProjectionBoundary.test.ts + src/writingDiagnosticProjectionCoverage.test.ts + src/textPositionSelectorEvidenceBoundary.test.ts --coverage --coverage.include=src/graphemeBoundary.ts --coverage.include=src/textPositionSelectorEvidence.ts From e9fe5e17e9b54f8ca7a5b0e8750bd9d079ea58e0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 11:45:31 +0900 Subject: [PATCH 18/27] ci(diagnostics): remove completed one-shot refactor --- .../workflows/projection-refactor-once.yml | 81 ------------------- 1 file changed, 81 deletions(-) delete mode 100644 .github/workflows/projection-refactor-once.yml diff --git a/.github/workflows/projection-refactor-once.yml b/.github/workflows/projection-refactor-once.yml deleted file mode 100644 index f66aa9bc..00000000 --- a/.github/workflows/projection-refactor-once.yml +++ /dev/null @@ -1,81 +0,0 @@ -name: Projection Refactor Once - -on: - push: - branches: - - feat/writing-diagnostics-projection - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: projection-refactor-once-${{ github.ref }} - cancel-in-progress: true - -jobs: - remove-unreachable-field-loop: - 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: Remove the logically unreachable postcondition loop - id: refactor - run: | - set -euo pipefail - python <<'PY' - from pathlib import Path - - path = Path('src/writingDiagnosticProjection.ts') - source = path.read_text(encoding='utf-8') - old = """ for (const field of expectedFields) { - if (!Object.prototype.hasOwnProperty.call(result, field)) { - throw new WritingDiagnosticProjectionError(errorCode); - } - } - return result; - } - """ - new = """ return result; - } - """ - if old not in source: - Path('/tmp/refactor-state').write_text('unchanged', encoding='utf-8') - raise SystemExit(0) - if source.count(old) != 1: - raise SystemExit('unexpected duplicate exact postcondition loop') - path.write_text(source.replace(old, new), encoding='utf-8') - Path('/tmp/refactor-state').write_text('changed', encoding='utf-8') - PY - - name: Verify focused tests and type safety - run: | - pnpm exec vitest run \ - src/writingDiagnosticProjection.test.ts \ - src/writingDiagnosticProjectionBoundary.test.ts \ - src/writingDiagnosticProjectionCoverage.test.ts \ - src/textPositionSelectorEvidenceBoundary.test.ts - pnpm typecheck - - name: Commit the source-only refactor - env: - TARGET_BRANCH: feat/writing-diagnostics-projection - run: | - set -euo pipefail - if [[ "$(cat /tmp/refactor-state)" == 'unchanged' ]]; then - echo 'The unreachable loop is already absent.' - exit 0 - fi - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add src/writingDiagnosticProjection.ts - git diff --cached --check - git commit -m 'refactor(diagnostics): remove unreachable projection postcondition' - git push origin "HEAD:${TARGET_BRANCH}" From 8b5da108987061fdffb4cc036256fec69de5ffc3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 11:46:29 +0900 Subject: [PATCH 19/27] test(diagnostics): require inverse projection public exports --- ...writingDiagnosticProjectionExports.test.ts | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/writingDiagnosticProjectionExports.test.ts diff --git a/src/writingDiagnosticProjectionExports.test.ts b/src/writingDiagnosticProjectionExports.test.ts new file mode 100644 index 00000000..9c2435e6 --- /dev/null +++ b/src/writingDiagnosticProjectionExports.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import * as rootSurface from './index.js'; +import * as subpathSurface from './text-position-selector/index.js'; +import { + WritingDiagnosticProjectionError, + buildTextProjectionMap, + resolveTextPositionSelector, + type CwlWritingDiagnosticTextProjectionMap, + type WritingDiagnosticProjectionErrorCode, +} from './writingDiagnosticProjection.js'; +import type { + CwlWritingDiagnosticTextProjectionMap as RootProjectionMap, + WritingDiagnosticProjectionErrorCode as RootProjectionErrorCode, +} from './index.js'; +import type { + CwlWritingDiagnosticTextProjectionMap as SubpathProjectionMap, + WritingDiagnosticProjectionErrorCode as SubpathProjectionErrorCode, +} from './text-position-selector/index.js'; + +type Exact = + (() => T extends A ? 1 : 2) extends + (() => T extends B ? 1 : 2) + ? (() => T extends B ? 1 : 2) extends + (() => T extends A ? 1 : 2) + ? true + : false + : false; + +const rootTypes: readonly true[] = [ + true as Exact, + true as Exact, +]; +const subpathTypes: readonly true[] = [ + true as Exact, + true as Exact, +]; + +describe('inverse projection public source exports', () => { + it('re-exports the exact runtime and type contract from the root surface', () => { + expect(rootSurface.WritingDiagnosticProjectionError).toBe( + WritingDiagnosticProjectionError, + ); + expect(rootSurface.buildTextProjectionMap).toBe(buildTextProjectionMap); + expect(rootSurface.resolveTextPositionSelector).toBe(resolveTextPositionSelector); + expect(rootTypes).toEqual([true, true]); + }); + + it('re-exports the same framework-independent contract from the selector subpath', () => { + expect(subpathSurface.WritingDiagnosticProjectionError).toBe( + WritingDiagnosticProjectionError, + ); + expect(subpathSurface.buildTextProjectionMap).toBe(buildTextProjectionMap); + expect(subpathSurface.resolveTextPositionSelector).toBe( + resolveTextPositionSelector, + ); + expect(subpathTypes).toEqual([true, true]); + }); +}); From 2fe8f2792240821e685679eaa31033a27a794f72 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 11:47:06 +0900 Subject: [PATCH 20/27] ci(diagnostics): verify inverse projection exports --- .github/workflows/writing-diagnostics-projection-tdd.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/writing-diagnostics-projection-tdd.yml b/.github/workflows/writing-diagnostics-projection-tdd.yml index 43751612..8610ff7a 100644 --- a/.github/workflows/writing-diagnostics-projection-tdd.yml +++ b/.github/workflows/writing-diagnostics-projection-tdd.yml @@ -40,6 +40,7 @@ jobs: src/writingDiagnosticProjectionBoundary.test.ts src/writingDiagnosticProjectionCoverage.test.ts src/textPositionSelectorEvidenceBoundary.test.ts + src/writingDiagnosticProjectionExports.test.ts --coverage --coverage.include=src/graphemeBoundary.ts --coverage.include=src/textPositionSelectorEvidence.ts From e66891843615d1ec655159b6aea4af45e2c4f481 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 11:47:58 +0900 Subject: [PATCH 21/27] feat(diagnostics): export inverse selector projection subpath --- src/text-position-selector/index.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/text-position-selector/index.ts b/src/text-position-selector/index.ts index 96e20fef..354eb91d 100644 --- a/src/text-position-selector/index.ts +++ b/src/text-position-selector/index.ts @@ -1,9 +1,9 @@ /** * React-free W3C text-position selector projection surface. * - * This subpath exposes only deterministic projection primitives. Interactive - * editor-handle capture and exact revision binding remain on the root Inkspan - * editor contract. + * This subpath exposes deterministic forward and inverse projection primitives. + * Interactive editor-handle capture and exact revision binding remain on the + * root Inkspan editor contract. */ export { TEXT_POSITION_PROJECTION_ID, @@ -16,3 +16,12 @@ export type { CwlEditorTextProjectionIdentity, TextPositionSelectorEvidenceErrorCode, } from '../textPositionSelectorEvidence.js'; +export { + WritingDiagnosticProjectionError, + buildTextProjectionMap, + resolveTextPositionSelector, +} from '../writingDiagnosticProjection.js'; +export type { + CwlWritingDiagnosticTextProjectionMap, + WritingDiagnosticProjectionErrorCode, +} from '../writingDiagnosticProjection.js'; From 3ab5197fdd7f55f8c63f7d4797e8c93d4198b722 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 11:48:44 +0900 Subject: [PATCH 22/27] feat(diagnostics): export inverse projection from root --- src/index.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/index.ts b/src/index.ts index 8c1f20fb..692ad028 100644 --- a/src/index.ts +++ b/src/index.ts @@ -49,6 +49,15 @@ export type { CwlEditorTextProjectionIdentity, TextPositionSelectorEvidenceErrorCode, } from './textPositionSelectorEvidence.js'; +export { + WritingDiagnosticProjectionError, + buildTextProjectionMap, + resolveTextPositionSelector, +} from './writingDiagnosticProjection.js'; +export type { + CwlWritingDiagnosticTextProjectionMap, + WritingDiagnosticProjectionErrorCode, +} from './writingDiagnosticProjection.js'; // Host-owned, revision-scoped writing diagnostic contract. export { From 5c19c0ef228b64ef18f6c7de9a0123e02c442995 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 11:49:37 +0900 Subject: [PATCH 23/27] build(diagnostics): include inverse projection declarations --- vite.text-position-selector.config.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/vite.text-position-selector.config.ts b/vite.text-position-selector.config.ts index e99d3280..4602dcd8 100644 --- a/vite.text-position-selector.config.ts +++ b/vite.text-position-selector.config.ts @@ -11,6 +11,8 @@ export default defineConfig({ include: [ 'src/text-position-selector', 'src/textPositionSelectorEvidence.ts', + 'src/writingDiagnosticProjection.ts', + 'src/graphemeBoundary.ts', ], exclude: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.spec.ts'], rollupTypes: false, From dff205a217111138331802f379de9f984b8797cb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 11:50:39 +0900 Subject: [PATCH 24/27] test(package): verify inverse selector consumers --- ...text-position-selector-subpath-package.mjs | 39 ++++++++++++++++--- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/scripts/verify-text-position-selector-subpath-package.mjs b/scripts/verify-text-position-selector-subpath-package.mjs index 454a57ad..ef45c654 100644 --- a/scripts/verify-text-position-selector-subpath-package.mjs +++ b/scripts/verify-text-position-selector-subpath-package.mjs @@ -123,12 +123,20 @@ import { TEXT_POSITION_PROJECTION_ID, TEXT_POSITION_PROJECTION_VERSION, TextPositionSelectorEvidenceError, + WritingDiagnosticProjectionError, + buildTextProjectionMap, createTextPositionSelector, + resolveTextPositionSelector, } from '${packageJson.name}/text-position-selector'; assert.equal(TEXT_POSITION_PROJECTION_ID, 'inkspan-prosemirror-text'); assert.equal(TEXT_POSITION_PROJECTION_VERSION, 1); assert.equal(typeof TextPositionSelectorEvidenceError, 'function'); +assert.equal(typeof WritingDiagnosticProjectionError, 'function'); +assert.equal(typeof buildTextProjectionMap, 'function'); assert.equal(typeof createTextPositionSelector, 'function'); +assert.equal(typeof resolveTextPositionSelector, 'function'); +const failure = new WritingDiagnosticProjectionError('selector'); +assert.equal(failure.code, 'selector'); `, 'utf8', ); @@ -141,7 +149,12 @@ const selector = require('${packageJson.name}/text-position-selector'); assert.equal(selector.TEXT_POSITION_PROJECTION_ID, 'inkspan-prosemirror-text'); assert.equal(selector.TEXT_POSITION_PROJECTION_VERSION, 1); assert.equal(typeof selector.TextPositionSelectorEvidenceError, 'function'); +assert.equal(typeof selector.WritingDiagnosticProjectionError, 'function'); +assert.equal(typeof selector.buildTextProjectionMap, 'function'); assert.equal(typeof selector.createTextPositionSelector, 'function'); +assert.equal(typeof selector.resolveTextPositionSelector, 'function'); +const failure = new selector.WritingDiagnosticProjectionError('ambiguous_boundary'); +assert.equal(failure.code, 'ambiguous_boundary'); `, 'utf8', ); @@ -160,26 +173,40 @@ function verifyDeclarationConsumer() { TEXT_POSITION_PROJECTION_ID, TEXT_POSITION_PROJECTION_VERSION, TextPositionSelectorEvidenceError, + WritingDiagnosticProjectionError, + buildTextProjectionMap, createTextPositionSelector, + resolveTextPositionSelector, type CwlEditorTextPositionSelector, type CwlEditorTextProjectionIdentity, + type CwlWritingDiagnosticTextProjectionMap, type TextPositionSelectorEvidenceErrorCode, + type WritingDiagnosticProjectionErrorCode, } from '${packageJson.name}/text-position-selector'; import type { Node as ProseMirrorNode } from '@tiptap/pm/model'; import type { Selection } from '@tiptap/pm/state'; declare const documentNode: ProseMirrorNode; declare const selection: Selection; -const result = createTextPositionSelector(documentNode, selection); -const selector: CwlEditorTextPositionSelector = result.selector; -const projection: CwlEditorTextProjectionIdentity = result.textProjection; -const code: TextPositionSelectorEvidenceErrorCode = 'segmenter_unavailable'; -const failure = new TextPositionSelectorEvidenceError(code); +const forward = createTextPositionSelector(documentNode, selection); +const selector: CwlEditorTextPositionSelector = forward.selector; +const projection: CwlEditorTextProjectionIdentity = forward.textProjection; +const map: CwlWritingDiagnosticTextProjectionMap = buildTextProjectionMap(documentNode); +const resolved = resolveTextPositionSelector(documentNode, selector, projection); +const evidenceCode: TextPositionSelectorEvidenceErrorCode = 'segmenter_unavailable'; +const projectionCode: WritingDiagnosticProjectionErrorCode = 'ambiguous_boundary'; +const evidenceFailure = new TextPositionSelectorEvidenceError(evidenceCode); +const projectionFailure = new WritingDiagnosticProjectionError(projectionCode); void [ selector.start, selector.end, projection.id === TEXT_POSITION_PROJECTION_ID, projection.version === TEXT_POSITION_PROJECTION_VERSION, - failure.code, + map.text, + map.boundaryPositions.length, + resolved.from, + resolved.to, + evidenceFailure.code, + projectionFailure.code, ]; `, 'utf8', From 9c1e9127ba3c40bf1c6fd336e238848e6c07fb2c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 11:52:00 +0900 Subject: [PATCH 25/27] ci(diagnostics): run inverse projection package acceptance --- .../workflows/writing-diagnostics-projection-tdd.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/writing-diagnostics-projection-tdd.yml b/.github/workflows/writing-diagnostics-projection-tdd.yml index 8610ff7a..d984f661 100644 --- a/.github/workflows/writing-diagnostics-projection-tdd.yml +++ b/.github/workflows/writing-diagnostics-projection-tdd.yml @@ -19,7 +19,7 @@ env: jobs: focused-projection: runs-on: ubuntu-24.04 - timeout-minutes: 20 + timeout-minutes: 35 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -116,3 +116,11 @@ jobs: FOCUSED_COVERAGE_OUTCOME: ${{ steps.focused_coverage.outcome }} - name: Typecheck inverse projection contracts run: pnpm typecheck + - name: Run complete production coverage gate + run: pnpm coverage + - name: Build all package entrypoints + run: pnpm build + - name: Verify isolated packed-package consumers + run: pnpm verify:package + - name: Build the demonstration application + run: pnpm build:demo From f42f1500189d730c5c28f62c14059d278e6c65e4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:10:59 +0900 Subject: [PATCH 26/27] 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 5d99a4df4cdba3572e7324fa5a71b136536e279f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:11:26 +0900 Subject: [PATCH 27/27] 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.