Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
604736e
test(diagnostics): define decoration invalidation contract
seonghobae Aug 12, 2026
d55ce90
ci(diagnostics): expose decoration TDD red state
seonghobae Aug 12, 2026
8d0cec3
feat(diagnostics): add fail-closed editor decorations
seonghobae Aug 12, 2026
da69b1b
test(diagnostics): require one shared decoration extension
seonghobae Aug 12, 2026
73a1a35
feat(diagnostics): install decorations in shared editor graph
seonghobae Aug 12, 2026
a3e5104
test(diagnostics): provide deterministic lifecycle DOM fixture
seonghobae Aug 12, 2026
954a820
ci(diagnostics): typecheck decoration contracts
seonghobae Aug 12, 2026
e25b45f
ci(diagnostics): fix command and plugin-key public types
seonghobae Aug 12, 2026
4b5c7aa
fix(diagnostics): use public command and plugin-key APIs
github-actions[bot] Aug 12, 2026
c83dd70
test(diagnostics): harden decoration metadata boundary
seonghobae Aug 12, 2026
c912db1
test(diagnostics): execute metadata boundary regressions
seonghobae Aug 12, 2026
56d3da7
fix(diagnostics): reject hostile transaction metadata
seonghobae Aug 12, 2026
1729ce3
test(diagnostics): reject semantic ARIA decoration state
seonghobae Aug 12, 2026
e5dac00
fix(diagnostics): keep decorations semantically neutral
seonghobae Aug 12, 2026
1a54c0d
test(diagnostics): keep hostile semantic fields type-safe
seonghobae Aug 12, 2026
56d206c
docs(adr): inherit strict diagnostics v1 decision
seonghobae Aug 12, 2026
dad34a4
docs(plan): inherit strict diagnostics v1 errata
seonghobae Aug 12, 2026
c5006a5
Merge feat/writing-diagnostics-projection into feat/writing-diagnosti…
seonghobae Aug 12, 2026
93230d8
chore(diagnostics): inherit ADR authority from projection
seonghobae Aug 16, 2026
5844b4e
merge(parent): synchronize diagnostics decorations 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
41 changes: 41 additions & 0 deletions .github/workflows/writing-diagnostics-decorations-tdd.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: Writing Diagnostics Decorations TDD

on:
push:
branches:
- feat/writing-diagnostics-decorations
workflow_dispatch:

permissions:
contents: read

concurrency:
group: writing-diagnostics-decorations-tdd-${{ github.ref }}
cancel-in-progress: true

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

jobs:
focused-decorations:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
persist-credentials: false
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Run decoration and invalidation contract tests
run: >-
pnpm exec vitest run
src/extensions/WritingDiagnostics.test.ts
src/extensions/WritingDiagnosticsBoundary.test.ts
src/extensions/WritingDiagnosticsKit.test.ts
- name: Typecheck command and plugin contracts
run: pnpm typecheck
266 changes: 266 additions & 0 deletions src/extensions/WritingDiagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
import { Schema } from '@tiptap/pm/model';
import { EditorState } from '@tiptap/pm/state';
import { EditorView } from '@tiptap/pm/view';
import { describe, expect, it } from 'vitest';
import {
WritingDiagnostics,
clearWritingDiagnostics,
createWritingDiagnosticsPlugin,
focusWritingDiagnostic,
installWritingDiagnostics,
writingDiagnosticsPluginKey,
type CwlResolvedWritingDiagnosticDecoration,
type WritingDiagnosticsPluginState,
} from './WritingDiagnostics.js';

const schema = new Schema({
nodes: {
doc: { content: 'paragraph+' },
paragraph: {
content: 'text*',
toDOM: () => ['p', 0],
},
text: {},
},
});

function documentWithText(text = 'Alpha beta gamma') {
return schema.node('doc', undefined, [
schema.node('paragraph', undefined, text ? [schema.text(text)] : []),
]);
}

function stateWithText(text = 'Alpha beta gamma') {
return EditorState.create({
schema,
doc: documentWithText(text),
plugins: [createWritingDiagnosticsPlugin()],
});
}

function pluginState(state: EditorState): WritingDiagnosticsPluginState {
const value = writingDiagnosticsPluginKey.getState(state);
if (!value) throw new Error('Missing writing diagnostics plugin state');
return value;
}

function diagnostic(
overrides: Partial<CwlResolvedWritingDiagnosticDecoration> = {},
): CwlResolvedWritingDiagnosticDecoration {
return {
diagnosticId: 'diag-1',
from: 1,
to: 6,
priority: 'important',
...overrides,
};
}

/** Create one deliberately forged runtime shape outside the public type. */
function diagnosticWithSemanticAttribute(
value: string,
): CwlResolvedWritingDiagnosticDecoration {
return {
...diagnostic(),
ariaInvalid: value,
} as unknown as CwlResolvedWritingDiagnosticDecoration;
}

function decorationAttributes(state: EditorState): Record<string, string> {
const [decoration] = pluginState(state).decorations.find();
if (!decoration) throw new Error('Missing writing diagnostic decoration');
return (
decoration as unknown as {
type: { attrs: Record<string, string> };
}
).type.attrs;
}

describe('WritingDiagnostics extension contract', () => {
it('exposes one stable TipTap extension and plugin key', () => {
expect(WritingDiagnostics.name).toBe('writingDiagnostics');
expect(typeof writingDiagnosticsPluginKey.getState).toBe('function');
});

it('starts empty without mutating the document or rendering decorations', () => {
const state = stateWithText();
const current = pluginState(state);

expect(state.doc.textContent).toBe('Alpha beta gamma');
expect(current.generation).toBe(-1);
expect(current.diagnostics).toEqual([]);
expect(current.focusedDiagnosticId).toBeNull();
expect(current.decorations.find()).toEqual([]);
});

it('installs verified ranges with only static privacy-minimized attributes', () => {
let state = stateWithText();
state = state.apply(
installWritingDiagnostics(state.tr, 4, [diagnostic()]),
);

const current = pluginState(state);
expect(current.generation).toBe(4);
expect(current.diagnostics).toEqual([diagnostic()]);
expect(Object.isFrozen(current.diagnostics)).toBe(true);
expect(Object.isFrozen(current.diagnostics[0])).toBe(true);
expect(decorationAttributes(state)).toEqual({
class: 'cwl-writing-diagnostic cwl-writing-diagnostic--important',
'data-cwl-diagnostic-id': 'diag-1',
});
expect(JSON.stringify(decorationAttributes(state))).not.toContain('Alpha');
});

it('never infers semantic ARIA state from category-like identifiers', () => {
let state = stateWithText();
state = state.apply(
installWritingDiagnostics(state.tr, 1, [
diagnostic({ diagnosticId: 'grammar.spelling.issue' }),
]),
);

expect(decorationAttributes(state)).toEqual({
class: 'cwl-writing-diagnostic cwl-writing-diagnostic--important',
'data-cwl-diagnostic-id': 'grammar.spelling.issue',
});
});

it('retains collapsed diagnostics without creating an inline decoration', () => {
let state = stateWithText();
state = state.apply(
installWritingDiagnostics(state.tr, 1, [
diagnostic({ from: 3, to: 3 }),
]),
);

expect(pluginState(state).diagnostics).toHaveLength(1);
expect(pluginState(state).decorations.find()).toEqual([]);
});

it('ignores duplicate and stale install generations', () => {
let state = stateWithText();
state = state.apply(
installWritingDiagnostics(state.tr, 7, [diagnostic()]),
);
const accepted = pluginState(state);

state = state.apply(
installWritingDiagnostics(state.tr, 7, [
diagnostic({ diagnosticId: 'same-generation', from: 7, to: 10 }),
]),
);
expect(pluginState(state)).toBe(accepted);

state = state.apply(
installWritingDiagnostics(state.tr, 6, [
diagnostic({ diagnosticId: 'stale-generation', from: 7, to: 10 }),
]),
);
expect(pluginState(state)).toBe(accepted);
});

it('focuses only an installed diagnostic from the active generation', () => {
let state = stateWithText();
state = state.apply(
installWritingDiagnostics(state.tr, 3, [
diagnostic(),
diagnostic({ diagnosticId: 'diag-2', from: 7, to: 11 }),
]),
);

state = state.apply(focusWritingDiagnostic(state.tr, 3, 'diag-2'));
expect(pluginState(state).focusedDiagnosticId).toBe('diag-2');

const focused = pluginState(state);
state = state.apply(focusWritingDiagnostic(state.tr, 2, 'diag-1'));
expect(pluginState(state)).toBe(focused);
state = state.apply(focusWritingDiagnostic(state.tr, 3, 'missing'));
expect(pluginState(state)).toBe(focused);
});

it('clears active state while retaining the monotonic generation fence', () => {
let state = stateWithText();
state = state.apply(
installWritingDiagnostics(state.tr, 9, [diagnostic()]),
);
state = state.apply(clearWritingDiagnostics(state.tr));

expect(pluginState(state)).toEqual(
expect.objectContaining({
generation: 9,
diagnostics: [],
focusedDiagnosticId: null,
}),
);
expect(pluginState(state).decorations.find()).toEqual([]);

const cleared = pluginState(state);
state = state.apply(
installWritingDiagnostics(state.tr, 9, [diagnostic()]),
);
expect(pluginState(state)).toBe(cleared);
});

it('clears before processing metadata on every document-changing transaction', () => {
let state = stateWithText();
state = state.apply(
installWritingDiagnostics(state.tr, 5, [diagnostic()]),
);

const transaction = installWritingDiagnostics(
state.tr.insertText('!', 2),
6,
[diagnostic({ diagnosticId: 'must-not-install' })],
);
state = state.apply(transaction);

expect(state.doc.textContent).toBe('A!lpha beta gamma');
expect(pluginState(state).generation).toBe(5);
expect(pluginState(state).diagnostics).toEqual([]);
expect(pluginState(state).decorations.find()).toEqual([]);
});

it('does not map diagnostics through an ordinary remote-like document change', () => {
let state = stateWithText();
state = state.apply(
installWritingDiagnostics(state.tr, 2, [diagnostic()]),
);

const remoteLike = state.tr.insertText('remote ', 1).setMeta('y-sync$', {
isChangeOrigin: true,
});
state = state.apply(remoteLike);

expect(pluginState(state).generation).toBe(2);
expect(pluginState(state).diagnostics).toEqual([]);
expect(pluginState(state).decorations.find()).toEqual([]);
});

it('fails closed for invalid ranges, duplicate ids, or semantic attributes', () => {
let state = stateWithText();
const invalidSets: readonly (readonly CwlResolvedWritingDiagnosticDecoration[])[] = [
[diagnostic({ from: -1 })],
[diagnostic({ from: 9, to: 8 })],
[diagnostic({ to: state.doc.content.size + 1 })],
[diagnostic(), diagnostic()],
[diagnosticWithSemanticAttribute('spelling')],
[diagnosticWithSemanticAttribute('grammar')],
];

for (const invalid of invalidSets) {
const previous = pluginState(state);
state = state.apply(installWritingDiagnostics(state.tr, 1, invalid));
expect(pluginState(state)).toBe(previous);
}
});

it('releases with the editor view and retains no external lifecycle resource', () => {
const host = document.createElement('div');
document.body.append(host);
const view = new EditorView(host, { state: stateWithText() });

expect(() => view.destroy()).not.toThrow();
expect(host.childNodes).toHaveLength(0);
host.remove();
});
});
Loading