Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
54bc3c2
test(diagnostics): define inverse text projection contract
seonghobae Aug 12, 2026
b607c86
ci(diagnostics): expose inverse projection TDD red state
seonghobae Aug 12, 2026
ebc9c2e
feat(diagnostics): share Unicode grapheme boundary classification
seonghobae Aug 12, 2026
8363cc2
refactor(diagnostics): share selector grapheme boundary logic
seonghobae Aug 12, 2026
be7fbd3
feat(diagnostics): resolve exact text projection selectors
seonghobae Aug 12, 2026
6630fde
ci(diagnostics): expose inverse projection failures
seonghobae Aug 12, 2026
1d3c052
fix(diagnostics): separate block leaf projections correctly
seonghobae Aug 12, 2026
fe0892e
test(diagnostics): mark intentional invalid projection fixtures
seonghobae Aug 12, 2026
f713ec4
fix(diagnostics): contain hostile grapheme segmenters
seonghobae Aug 12, 2026
410e99a
refactor(diagnostics): harden and compact inverse projection
seonghobae Aug 12, 2026
763598b
test(diagnostics): harden structural and hostile projection boundaries
seonghobae Aug 12, 2026
c429a5d
ci(diagnostics): enforce exact projection coverage
seonghobae Aug 12, 2026
dce44ae
test(diagnostics): cover forward grapheme failure contracts
seonghobae Aug 12, 2026
07c535a
test(diagnostics): cover exact inverse reflection outcomes
seonghobae Aug 12, 2026
f6df0b7
ci(diagnostics): remove unreachable projection postcondition
seonghobae Aug 12, 2026
4d61c62
refactor(diagnostics): remove unreachable projection postcondition
github-actions[bot] Aug 12, 2026
3490118
ci(diagnostics): include projection boundary coverage
seonghobae Aug 12, 2026
e9fe5e1
ci(diagnostics): remove completed one-shot refactor
seonghobae Aug 12, 2026
8b5da10
test(diagnostics): require inverse projection public exports
seonghobae Aug 12, 2026
2fe8f27
ci(diagnostics): verify inverse projection exports
seonghobae Aug 12, 2026
e668918
feat(diagnostics): export inverse selector projection subpath
seonghobae Aug 12, 2026
3ab5197
feat(diagnostics): export inverse projection from root
seonghobae Aug 12, 2026
5c19c0e
build(diagnostics): include inverse projection declarations
seonghobae Aug 12, 2026
dff205a
test(package): verify inverse selector consumers
seonghobae Aug 12, 2026
9c1e912
ci(diagnostics): run inverse projection package acceptance
seonghobae Aug 12, 2026
f42f150
docs(adr): inherit strict diagnostics v1 decision
seonghobae Aug 12, 2026
5d99a4d
docs(plan): inherit strict diagnostics v1 errata
seonghobae Aug 12, 2026
1e16e79
Merge feat/writing-diagnostics-contract into feat/writing-diagnostics…
seonghobae Aug 12, 2026
5e49f37
chore(diagnostics): inherit ADR 0028/0029 authority
seonghobae Aug 16, 2026
760021c
fix(stack): preserve parent ADR gate while synchronizing projection lane
seonghobae Aug 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions .github/workflows/writing-diagnostics-projection-tdd.yml
Original file line number Diff line number Diff line change
@@ -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
39 changes: 33 additions & 6 deletions scripts/verify-text-position-selector-subpath-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
Expand All @@ -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',
);
Expand All @@ -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',
Expand Down
52 changes: 52 additions & 0 deletions src/graphemeBoundary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/** Internal result of checking one UTF-16 code-unit offset against grapheme boundaries. */

Check notice on line 1 in src/graphemeBoundary.ts

View workflow job for this annotation

GitHub Actions / focused-projection

Statements 26/26; functions 1/1; branches 10/10.
export type GraphemeBoundaryState = 'boundary' | 'inside_grapheme' | 'unavailable';

interface GraphemeSegment {
readonly index: number;
}

interface GraphemeSegmenter {
segment(input: string): Iterable<GraphemeSegment>;
}

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';
}
}
9 changes: 9 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
15 changes: 12 additions & 3 deletions src/text-position-selector/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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';
31 changes: 4 additions & 27 deletions src/textPositionSelectorEvidence.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';

Check notice on line 1 in src/textPositionSelectorEvidence.ts

View workflow job for this annotation

GitHub Actions / focused-projection

Statements 52/52; functions 6/6; branches 11/11.
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;
Expand Down Expand Up @@ -60,21 +61,6 @@
}
}

interface GraphemeSegment {
readonly index: number;
}

interface GraphemeSegmenter {
segment(input: string): Iterable<GraphemeSegment>;
}

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);
Expand All @@ -87,20 +73,11 @@

/** 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<number>([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');
}
}
Expand Down
Loading