diff --git a/.github/workflows/writing-diagnostics-projection-tdd.yml b/.github/workflows/writing-diagnostics-projection-tdd.yml new file mode 100644 index 00000000..d984f661 --- /dev/null +++ b/.github/workflows/writing-diagnostics-projection-tdd.yml @@ -0,0 +1,126 @@ +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: 35 + 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: 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 + src/writingDiagnosticProjectionCoverage.test.ts + src/textPositionSelectorEvidenceBoundary.test.ts + src/writingDiagnosticProjectionExports.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: | + 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 + - 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 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', diff --git a/src/graphemeBoundary.ts b/src/graphemeBoundary.ts new file mode 100644 index 00000000..f2c3f63e --- /dev/null +++ b/src/graphemeBoundary.ts @@ -0,0 +1,52 @@ +/** 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. 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 { + 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) { + return 'boundary'; + } + } + return 'inside_grapheme'; + } catch { + return 'unavailable'; + } +} 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 { 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'; 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'); } } 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); + } + }); +}); diff --git a/src/writingDiagnosticProjection.test.ts b/src/writingDiagnosticProjection.test.ts new file mode 100644 index 00000000..1a95a289 --- /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 unknown as CwlEditorTextProjectionIdentity, + ), + 'projection', + ); + expectProjectionError( + () => + resolveTextPositionSelector( + documentNode, + selector, + { + id: TEXT_POSITION_PROJECTION_ID, + version: 2, + } as unknown 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, + }); + } + }); +}); diff --git a/src/writingDiagnosticProjection.ts b/src/writingDiagnosticProjection.ts new file mode 100644 index 00000000..e80116f7 --- /dev/null +++ b/src/writingDiagnosticProjection.ts @@ -0,0 +1,300 @@ +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'; +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 = + | '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. 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. + */ +export function buildTextProjectionMap( + documentNode: ProseMirrorNode, +): Readonly { + const textParts: string[] = []; + const boundaryCandidates: Array = [undefined]; + let codePointOffset = 0; + let separated = true; + + const addBoundaryCandidate = (position: number): void => { + 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(undefined); + }; + + documentNode.descendants((node, position) => { + if (!separated && node.isBlock) { + appendProjectedCodePoint(BLOCK_SEPARATOR); + separated = true; + } + + 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 (node.inlineContent) { + addBoundaryCandidate(position + 1); + } + return true; + }); + + const boundaryPositions = boundaryCandidates.map((candidate) => + candidate === undefined ? null : candidate, + ); + const ambiguousBoundaryOffsets = boundaryPositions.flatMap( + (candidate, offset) => (candidate === null ? [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 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 ( + 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 ( + 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 ((end as number) >= projection.boundaryPositions.length) { + throw new WritingDiagnosticProjectionError('selector'); + } + + const codeUnitBoundaries = codePointBoundaryCodeUnits(projection.text); + assertProjectedGraphemeBoundary( + projection.text, + codeUnitBoundaries[start as number]!, + ); + assertProjectedGraphemeBoundary( + projection.text, + codeUnitBoundaries[end as number]!, + ); + + 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) { + 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'); + } +} + +/** 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, + }); + } + return result; +} 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); + } + }); +}); 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', + ); + }); +}); 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]); + }); +}); 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,