Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
3d7f51c
test(diagnostics): define revision-bound controller contract
seonghobae Aug 12, 2026
6326860
feat(diagnostics): bind diagnostics to exact editor revisions
seonghobae Aug 12, 2026
d617041
test(diagnostics): keep controller fixtures type-safe
seonghobae Aug 12, 2026
6bf9a18
test(diagnostics): enforce controller production coverage
seonghobae Aug 12, 2026
7d43308
test(diagnostics): surface exact controller coverage gaps
seonghobae Aug 12, 2026
5999ae7
test(diagnostics): preserve feedback presentation semantics
seonghobae Aug 12, 2026
e378fba
test(diagnostics): execute feedback action contract
seonghobae Aug 12, 2026
497de78
fix(diagnostics): preserve advisory feedback presentation
seonghobae Aug 12, 2026
5c315f0
test(diagnostics): keep digest fixture identity stable
seonghobae Aug 12, 2026
d4481f2
test(diagnostics): stabilize hostile input fixture identity
seonghobae Aug 12, 2026
c5361d2
test(diagnostics): align ignore feedback with advisory contract
seonghobae Aug 12, 2026
6470f8b
test(diagnostics): exercise controller failure boundaries
seonghobae Aug 12, 2026
0bebc70
test(diagnostics): stabilize defensive coverage probes
seonghobae Aug 12, 2026
4fedd0d
test(diagnostics): cover controller boundary normalization
seonghobae Aug 12, 2026
c6216e9
ci(diagnostics): annotate controller coverage gaps
seonghobae Aug 12, 2026
d997d9b
ci(diagnostics): bound controller coverage workload
seonghobae Aug 12, 2026
b5cc856
ci(diagnostics): repair inline controller input stability
seonghobae Aug 12, 2026
670f979
test(diagnostics): stabilize defensive digest provider
seonghobae Aug 12, 2026
da3c400
fix(diagnostics): stabilize inline controller inputs
github-actions[bot] Aug 12, 2026
0f23ddf
ci(diagnostics): bound controller test workers
seonghobae Aug 12, 2026
01673b4
test(diagnostics): require stale state after projection teardown
seonghobae Aug 12, 2026
9a93047
ci(diagnostics): repair controller teardown and dead branch
seonghobae Aug 12, 2026
a2eb1da
ci(diagnostics): make controller repair whitespace-stable
seonghobae Aug 12, 2026
71d2548
test(diagnostics): cover projection generation invalidation
seonghobae Aug 12, 2026
4609f4b
fix(diagnostics): close controller teardown gaps
github-actions[bot] Aug 12, 2026
c2f6df8
ci(diagnostics): verify controller package acceptance
seonghobae Aug 12, 2026
7eafe55
chore(diagnostics): restack controller on current diagnostics authority
seonghobae Aug 16, 2026
bc7d4fb
merge(parent): synchronize diagnostics controller 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
140 changes: 140 additions & 0 deletions .github/workflows/writing-diagnostics-controller-tdd.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
name: Writing Diagnostics Controller TDD

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

permissions:
contents: read

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

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

jobs:
focused-controller:
runs-on: ubuntu-24.04
timeout-minutes: 25
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 revision-bound controller contract tests
run: >-
pnpm exec vitest run
src/components/useWritingDiagnosticsController.test.tsx
src/components/useWritingDiagnosticsController.actions.test.tsx
src/components/useWritingDiagnosticsController.boundary.test.tsx
src/components/useWritingDiagnosticsController.coverage.test.tsx
--pool=forks
--maxWorkers=1
- name: Prove complete owned production coverage
shell: bash
run: |
set +e
pnpm exec vitest run \
src/components/useWritingDiagnosticsController.test.tsx \
src/components/useWritingDiagnosticsController.actions.test.tsx \
src/components/useWritingDiagnosticsController.boundary.test.tsx \
src/components/useWritingDiagnosticsController.coverage.test.tsx \
--pool=forks \
--maxWorkers=1 \
--coverage \
--coverage.include=src/components/useWritingDiagnosticsController.ts \
--coverage.reporter=json
status=$?
node --input-type=module <<'NODE'
import fs from 'node:fs';

const sourcePath = 'src/components/useWritingDiagnosticsController.ts';
if (!fs.existsSync('coverage/coverage-final.json')) {
console.log(
`::error file=${sourcePath},line=1::Controller coverage report was not produced.`,
);
process.exit(0);
}
const report = JSON.parse(
fs.readFileSync('coverage/coverage-final.json', 'utf8'),
);
const entry = Object.entries(report).find(([path]) =>
path.endsWith(`/${sourcePath}`),
);
if (!entry) {
console.log(
`::error file=${sourcePath},line=1::Controller coverage entry is missing.`,
);
process.exit(0);
}

const [, file] = entry;
const statementEntries = Object.entries(file.s);
const functionEntries = Object.entries(file.f);
const branchEntries = Object.entries(file.b);
const statementCovered = statementEntries.filter(([, count]) => count > 0).length;
const functionCovered = functionEntries.filter(([, count]) => count > 0).length;
const branchCounts = branchEntries.flatMap(([, counts]) => counts);
const branchCovered = branchCounts.filter((count) => count > 0).length;
console.log(
`::notice file=${sourcePath},line=1::Statements ${statementCovered}/${statementEntries.length}; ` +
`functions ${functionCovered}/${functionEntries.length}; branches ${branchCovered}/${branchCounts.length}.`,
);

const missingStatements = new Set();
for (const [id, count] of statementEntries) {
if (count === 0) {
missingStatements.add(file.statementMap[id].start.line);
}
}
for (const line of [...missingStatements].sort((left, right) => left - right)) {
console.log(
`::error file=${sourcePath},line=${line}::Controller statement is not covered.`,
);
}

const missingFunctions = new Set();
for (const [id, count] of functionEntries) {
if (count === 0) {
const definition = file.fnMap[id];
missingFunctions.add(
definition.decl?.start.line ?? definition.loc.start.line,
);
}
}
for (const line of [...missingFunctions].sort((left, right) => left - right)) {
console.log(
`::error file=${sourcePath},line=${line}::Controller function is not covered.`,
);
}

for (const [id, counts] of branchEntries) {
const branch = file.branchMap[id];
counts.forEach((count, index) => {
if (count !== 0) return;
const location = branch.locations?.[index] ?? branch.loc;
console.log(
`::error file=${sourcePath},line=${location.start.line}::Controller branch ${index} is not covered.`,
);
});
}
NODE
exit "$status"
- name: Typecheck controller and public action contracts
run: pnpm typecheck
- name: Build every package entrypoint
run: pnpm build
- name: Verify packed-package consumers
run: pnpm verify:package
- name: Build the demonstration application
run: pnpm run build:demo
191 changes: 191 additions & 0 deletions src/components/useWritingDiagnosticsController.actions.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { Editor } from '@tiptap/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { DocumentEnvelopeDigestProvider } from '../documentEnvelopeRevision.js';
import { writingDiagnosticsPluginKey } from '../extensions/WritingDiagnostics.js';
import { buildExtensions } from '../extensions/kit.js';
import {
TEXT_POSITION_PROJECTION_ID,
TEXT_POSITION_PROJECTION_VERSION,
} from '../textPositionSelectorEvidence.js';
import type { CwlWritingDiagnostic } from '../writingDiagnostics.js';
import { useWritingDiagnosticsController } from './useWritingDiagnosticsController.js';

const DIGEST = '11'.repeat(32);
const openEditors: Editor[] = [];

function createEditor(): Editor {
const editor = new Editor({
extensions: buildExtensions(),
content: '<p>Alpha beta gamma</p>',
});
openEditors.push(editor);
return editor;
}

function diagnostic(id = 'diag-1'): CwlWritingDiagnostic {
return {
diagnosticId: id,
documentRevision: {
algorithm: 'SHA-256',
digestHex: DIGEST,
strongEntityTag: `"sha256-${DIGEST}"`,
},
textProjection: {
id: TEXT_POSITION_PROJECTION_ID,
version: TEXT_POSITION_PROJECTION_VERSION,
},
selector: { type: 'TextPositionSelector', start: 0, end: 5 },
categoryCode: 'host.category',
priority: 'important',
title: 'Host title',
explanation: 'Host explanation',
suggestedReplacement: 'Omega',
provenance: {
workflowId: 'workflow',
workflowVersion: '1',
judgePolicyVersion: '1',
},
};
}

function digestProvider(): DocumentEnvelopeDigestProvider {
const bytes = Uint8Array.from(
DIGEST.match(/../gu)!.map((part) => Number.parseInt(part, 16)),
);
return { digest: vi.fn(async () => bytes.slice().buffer) };
}

function installedIds(editor: Editor): string[] {
return (
writingDiagnosticsPluginKey.getState(editor.state)?.diagnostics ?? []
).map((item) => item.diagnosticId);
}

afterEach(() => {
for (const editor of openEditors.splice(0)) {
if (!editor.isDestroyed) editor.destroy();
}
});

describe('writing diagnostic feedback actions', () => {
it('reports Ignore without changing authored content or dismissing presentation', async () => {
const editor = createEditor();
const before = editor.getJSON();
const onAction = vi.fn();
const provider = digestProvider();
const diagnostics = [diagnostic()];
const { result } = renderHook(() =>
useWritingDiagnosticsController({
editor,
diagnostics,
digestProvider: provider,
onAction,
}),
);
await waitFor(() => expect(result.current.status).toBe('active'));
const generation = result.current.generation;

let event = null as ReturnType<typeof result.current.ignoreDiagnostic>;
act(() => {
event = result.current.ignoreDiagnostic('diag-1');
});

expect(event).toMatchObject({
action: 'ignored',
reasonCode: 'explicit',
diagnosticId: 'diag-1',
generation,
});
expect(editor.getJSON()).toEqual(before);
expect(installedIds(editor)).toEqual(['diag-1']);
expect(result.current.diagnostics).toHaveLength(1);
expect(result.current.generation).toBe(generation);
expect(onAction).toHaveBeenCalledTimes(1);
});

it('reports Explain without changing authored content or dismissing presentation', async () => {
const editor = createEditor();
const before = editor.getJSON();
const onAction = vi.fn();
const provider = digestProvider();
const diagnostics = [diagnostic()];
const { result } = renderHook(() =>
useWritingDiagnosticsController({
editor,
diagnostics,
digestProvider: provider,
onAction,
}),
);
await waitFor(() => expect(result.current.status).toBe('active'));
const generation = result.current.generation;

let event = null as ReturnType<
typeof result.current.requestDiagnosticExplanation
>;
act(() => {
event = result.current.requestDiagnosticExplanation('diag-1');
});

expect(event).toMatchObject({
action: 'requested_explanation',
reasonCode: 'explicit',
diagnosticId: 'diag-1',
generation,
});
expect(editor.getJSON()).toEqual(before);
expect(installedIds(editor)).toEqual(['diag-1']);
expect(result.current.diagnostics).toHaveLength(1);
expect(result.current.generation).toBe(generation);
expect(onAction).toHaveBeenCalledTimes(1);
});

it('dismisses only local presentation and never mutates authored content', async () => {
const editor = createEditor();
const before = editor.getJSON();
const provider = digestProvider();
const diagnostics = [diagnostic()];
const { result } = renderHook(() =>
useWritingDiagnosticsController({
editor,
diagnostics,
digestProvider: provider,
}),
);
await waitFor(() => expect(result.current.status).toBe('active'));
const generation = result.current.generation;

let event = null as ReturnType<typeof result.current.dismissDiagnostic>;
act(() => {
event = result.current.dismissDiagnostic('diag-1');
});

expect(event).toMatchObject({
action: 'dismissed',
reasonCode: 'explicit',
diagnosticId: 'diag-1',
generation: generation + 1,
});
expect(editor.getJSON()).toEqual(before);
expect(installedIds(editor)).toEqual([]);
expect(result.current.diagnostics).toEqual([]);
});

it('focuses only an installed current diagnostic', async () => {
const editor = createEditor();
const provider = digestProvider();
const diagnostics = [diagnostic()];
const { result } = renderHook(() =>
useWritingDiagnosticsController({
editor,
diagnostics,
digestProvider: provider,
}),
);
await waitFor(() => expect(result.current.status).toBe('active'));

expect(result.current.focusDiagnostic('missing')).toBe(false);
expect(result.current.focusDiagnostic('diag-1')).toBe(true);
});
});
Loading