diff --git a/.github/workflows/writing-diagnostics-contract-tdd.yml b/.github/workflows/writing-diagnostics-contract-tdd.yml new file mode 100644 index 00000000..583f6201 --- /dev/null +++ b/.github/workflows/writing-diagnostics-contract-tdd.yml @@ -0,0 +1,130 @@ +name: Writing Diagnostics Contract TDD + +on: + push: + branches: + - feat/writing-diagnostics-contract + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: writing-diagnostics-contract-tdd-${{ github.ref }} + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + focused-contract: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + 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 focused contract coverage + id: focused_coverage + continue-on-error: true + run: >- + pnpm exec vitest run + src/writingDiagnostics.test.ts + src/writingDiagnosticsBoundary.test.ts + src/writingDiagnosticsExports.test.ts + --coverage + --coverage.include=src/writingDiagnostics.ts + --coverage.reporter=text + --coverage.reporter=json + --coverage.reporter=json-summary + - name: Report and enforce exact contract coverage + run: | + node <<'NODE' + const { readFileSync } = require('node:fs'); + const coverage = JSON.parse(readFileSync('coverage/coverage-final.json', 'utf8')); + const [filePath, fileCoverage] = Object.entries(coverage).find( + ([candidate]) => candidate.endsWith('/src/writingDiagnostics.ts'), + ) ?? []; + if (!filePath || !fileCoverage) { + console.error('::error file=src/writingDiagnostics.ts,line=1::Focused coverage record is missing.'); + process.exit(1); + } + + 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 total = (values) => Object.values(values).reduce( + (sum, value) => sum + (Array.isArray(value) ? value.length : 1), + 0, + ); + const covered = (values) => Object.values(values).reduce( + (sum, value) => sum + (Array.isArray(value) + ? value.filter((count) => count > 0).length + : Number(value > 0)), + 0, + ); + const statementTotal = total(fileCoverage.s); + const statementCovered = covered(fileCoverage.s); + const functionTotal = total(fileCoverage.f); + const functionCovered = covered(fileCoverage.f); + const branchTotal = total(fileCoverage.b); + const branchCovered = covered(fileCoverage.b); + console.log( + `::notice file=src/writingDiagnostics.ts,line=1::` + + `Statements ${statementCovered}/${statementTotal}; ` + + `functions ${functionCovered}/${functionTotal}; ` + + `branches ${branchCovered}/${branchTotal}.`, + ); + + if ( + missingStatementLines.length || + missingFunctionLines.length || + missingBranches.length || + process.env.FOCUSED_COVERAGE_OUTCOME !== 'success' + ) { + console.error( + `::error file=src/writingDiagnostics.ts,line=1::` + + `Missing statement lines: ${missingStatementLines.join(', ') || 'none'}; ` + + `missing function lines: ${missingFunctionLines.join(', ') || 'none'}; ` + + `missing branches line:index: ${missingBranches.join(', ') || 'none'}.`, + ); + process.exit(1); + } + NODE + env: + FOCUSED_COVERAGE_OUTCOME: ${{ steps.focused_coverage.outcome }} + - name: Typecheck public source contracts + run: pnpm typecheck + - name: Run complete production coverage gate + run: pnpm coverage + - name: Build all package entrypoints + run: pnpm build + - name: Verify isolated package consumers + run: pnpm verify:package + - name: Build the demonstration application + run: pnpm build:demo diff --git a/src/index.ts b/src/index.ts index f7905449..8c1f20fb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -50,6 +50,20 @@ export type { TextPositionSelectorEvidenceErrorCode, } from './textPositionSelectorEvidence.js'; +// Host-owned, revision-scoped writing diagnostic contract. +export { + DEFAULT_WRITING_DIAGNOSTIC_LIMITS, + WritingDiagnosticError, + validateWritingDiagnostics, +} from './writingDiagnostics.js'; +export type { + CwlWritingDiagnostic, + CwlWritingDiagnosticPriority, + CwlWritingDiagnosticProvenance, + WritingDiagnosticErrorCode, + WritingDiagnosticLimits, +} from './writingDiagnostics.js'; + // Versioned, lossless persistence boundary. export { DEFAULT_DOCUMENT_ENVELOPE_LIMITS, diff --git a/src/writing-diagnostics/index.ts b/src/writing-diagnostics/index.ts new file mode 100644 index 00000000..83c7afd0 --- /dev/null +++ b/src/writing-diagnostics/index.ts @@ -0,0 +1,19 @@ +/** + * Framework-independent public surface for host-supplied writing diagnostics. + * + * This subpath validates bounded revision-scoped diagnostic proposals. It does + * not import React, create editor instances, call models/providers or networks, + * persist authored content, infer language quality, or mutate a document. + */ +export { + DEFAULT_WRITING_DIAGNOSTIC_LIMITS, + WritingDiagnosticError, + validateWritingDiagnostics, +} from '../writingDiagnostics.js'; +export type { + CwlWritingDiagnostic, + CwlWritingDiagnosticPriority, + CwlWritingDiagnosticProvenance, + WritingDiagnosticErrorCode, + WritingDiagnosticLimits, +} from '../writingDiagnostics.js'; diff --git a/src/writingDiagnostics.test.ts b/src/writingDiagnostics.test.ts new file mode 100644 index 00000000..02d337e6 --- /dev/null +++ b/src/writingDiagnostics.test.ts @@ -0,0 +1,344 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + DEFAULT_WRITING_DIAGNOSTIC_LIMITS, + WritingDiagnosticError, + validateWritingDiagnostics, + type CwlWritingDiagnostic, + type WritingDiagnosticLimits, +} from './writingDiagnostics.js'; + +const DIGEST = 'a'.repeat(64); + +function validDiagnostic( + overrides: Partial = {}, +): CwlWritingDiagnostic { + return { + diagnosticId: 'diag-1', + documentRevision: { + algorithm: 'SHA-256', + digestHex: DIGEST, + strongEntityTag: `"sha256-${DIGEST}"`, + }, + textProjection: { + id: 'inkspan-prosemirror-text', + version: 1, + }, + selector: { + type: 'TextPositionSelector', + start: 1, + end: 3, + }, + categoryCode: 'grammar.subject_verb', + priority: 'important', + title: 'Check agreement', + explanation: 'The host judged this range as worth reviewing.', + suggestedReplacement: 'were', + confidence: 0.75, + provenance: { + workflowId: 'naruon-writing-review', + workflowVersion: '2026-08-12', + judgePolicyVersion: 'criterion-set-v3', + orchestrationMode: 'quality', + }, + ...overrides, + }; +} + +function expectCode( + input: unknown, + code: WritingDiagnosticError['code'], + limits?: WritingDiagnosticLimits, +): void { + try { + validateWritingDiagnostics(input, limits); + } catch (error) { + expect(error).toBeInstanceOf(WritingDiagnosticError); + expect((error as WritingDiagnosticError).code).toBe(code); + return; + } + throw new Error(`Expected WritingDiagnosticError(${code})`); +} + +describe('validateWritingDiagnostics', () => { + it('returns a deeply detached and frozen host-ordered tuple', () => { + const first = validDiagnostic(); + const second = validDiagnostic({ + diagnosticId: 'diag-2', + selector: { type: 'TextPositionSelector', start: 8, end: 8 }, + suggestedReplacement: undefined, + confidence: undefined, + }); + const input = [first, second]; + + const result = validateWritingDiagnostics(input); + + expect(result).toHaveLength(2); + expect(result.map((entry) => entry.diagnosticId)).toEqual(['diag-1', 'diag-2']); + expect(result).not.toBe(input); + expect(result[0]).not.toBe(first); + expect(result[0].documentRevision).not.toBe(first.documentRevision); + expect(result[0].selector).not.toBe(first.selector); + expect(result[0].provenance).not.toBe(first.provenance); + expect(Object.isFrozen(result)).toBe(true); + expect(Object.isFrozen(result[0])).toBe(true); + expect(Object.isFrozen(result[0].documentRevision)).toBe(true); + expect(Object.isFrozen(result[0].textProjection)).toBe(true); + expect(Object.isFrozen(result[0].selector)).toBe(true); + expect(Object.isFrozen(result[0].provenance)).toBe(true); + }); + + it('accepts an empty diagnostic array', () => { + expect(validateWritingDiagnostics([])).toEqual([]); + }); + + it('rejects duplicate diagnostic identifiers', () => { + expectCode([validDiagnostic(), validDiagnostic()], 'conflict'); + }); + + it('rejects non-array input and array own-property surprises', () => { + expectCode({}, 'contract'); + const input = [validDiagnostic()]; + Object.defineProperty(input, 'extra', { value: true, enumerable: true }); + expectCode(input, 'contract'); + }); + + it('rejects too many diagnostics before inspecting members', () => { + const member = new Proxy(validDiagnostic(), { + ownKeys() { + throw new Error('member should not be inspected'); + }, + }); + const input = Array.from( + { length: DEFAULT_WRITING_DIAGNOSTIC_LIMITS.maxDiagnostics + 1 }, + () => member, + ); + expectCode(input, 'limit'); + }); + + it('rejects unexpected, inherited, accessor-backed, non-enumerable, and symbol fields', () => { + expectCode([{ ...validDiagnostic(), unexpected: true }], 'contract'); + + const inherited = Object.create({ inherited: 'value' }) as CwlWritingDiagnostic; + Object.assign(inherited, validDiagnostic()); + expectCode([inherited], 'contract'); + + const accessor = { ...validDiagnostic() } as Record; + const getter = vi.fn(() => 'secret'); + Object.defineProperty(accessor, 'title', { enumerable: true, get: getter }); + expectCode([accessor], 'contract'); + expect(getter).not.toHaveBeenCalled(); + + const hidden = { ...validDiagnostic() } as Record; + Object.defineProperty(hidden, 'hidden', { value: true, enumerable: false }); + expectCode([hidden], 'contract'); + + const symbol = { ...validDiagnostic(), [Symbol('private')]: true }; + expectCode([symbol], 'contract'); + }); + + it('redacts hostile reflection failures', () => { + const input = [ + new Proxy(validDiagnostic(), { + ownKeys() { + throw new Error('private diagnostic payload'); + }, + }), + ]; + + try { + validateWritingDiagnostics(input); + } catch (error) { + expect(error).toBeInstanceOf(WritingDiagnosticError); + expect((error as Error).message).not.toContain('private diagnostic payload'); + return; + } + throw new Error('Expected hostile reflection to fail closed'); + }); + + it('bounds identifiers, metadata, prose, and replacement text', () => { + expectCode( + [validDiagnostic({ diagnosticId: 'x'.repeat(257) })], + 'limit', + ); + expectCode( + [validDiagnostic({ categoryCode: 'x'.repeat(129) })], + 'limit', + ); + expectCode([validDiagnostic({ title: 'x'.repeat(257) })], 'limit'); + expectCode( + [validDiagnostic({ explanation: 'x'.repeat(4001) })], + 'limit', + ); + expectCode( + [validDiagnostic({ suggestedReplacement: 'x'.repeat(20_001) })], + 'limit', + ); + expectCode( + [ + validDiagnostic({ + provenance: { + workflowId: 'x'.repeat(129), + workflowVersion: '1', + judgePolicyVersion: '1', + }, + }), + ], + 'limit', + ); + }); + + it('rejects empty required identifiers and malformed string runtime values', () => { + expectCode([validDiagnostic({ diagnosticId: '' })], 'contract'); + expectCode([validDiagnostic({ categoryCode: '' })], 'contract'); + expectCode([validDiagnostic({ title: '' })], 'contract'); + expectCode( + [validDiagnostic({ title: 7 as unknown as string })], + 'contract', + ); + expectCode( + [ + validDiagnostic({ + suggestedReplacement: 7 as unknown as string, + }), + ], + 'contract', + ); + }); + + it('accepts only the finite priority contract', () => { + for (const priority of ['advisory', 'important', 'critical'] as const) { + expect(validateWritingDiagnostics([validDiagnostic({ priority })])[0].priority).toBe( + priority, + ); + } + expectCode( + [validDiagnostic({ priority: 'urgent' as CwlWritingDiagnostic['priority'] })], + 'contract', + ); + }); + + it('accepts confidence only in the closed interval from zero through one', () => { + expect(validateWritingDiagnostics([validDiagnostic({ confidence: 0 })])[0].confidence).toBe(0); + expect(validateWritingDiagnostics([validDiagnostic({ confidence: 1 })])[0].confidence).toBe(1); + for (const confidence of [-0.01, 1.01, Number.NaN, Number.POSITIVE_INFINITY]) { + expectCode([validDiagnostic({ confidence })], 'contract'); + } + }); + + it('requires an exact SHA-256 revision shape and matching strong entity tag', () => { + expectCode( + [ + validDiagnostic({ + documentRevision: { + algorithm: 'SHA-256', + digestHex: 'A'.repeat(64), + strongEntityTag: `"sha256-${'A'.repeat(64)}"`, + }, + }), + ], + 'revision', + ); + expectCode( + [ + validDiagnostic({ + documentRevision: { + algorithm: 'SHA-256', + digestHex: DIGEST, + strongEntityTag: '"sha256-wrong"', + }, + }), + ], + 'revision', + ); + }); + + it('requires the exact supported text projection', () => { + expectCode( + [ + validDiagnostic({ + textProjection: { + id: 'other' as 'inkspan-prosemirror-text', + version: 1, + }, + }), + ], + 'projection', + ); + expectCode( + [ + validDiagnostic({ + textProjection: { + id: 'inkspan-prosemirror-text', + version: 2 as 1, + }, + }), + ], + 'projection', + ); + }); + + it('requires finite non-negative integral selectors in source order', () => { + for (const selector of [ + { type: 'TextPositionSelector' as const, start: -1, end: 1 }, + { type: 'TextPositionSelector' as const, start: 0.5, end: 1 }, + { type: 'TextPositionSelector' as const, start: 3, end: 2 }, + { type: 'TextPositionSelector' as const, start: 0, end: Number.POSITIVE_INFINITY }, + ]) { + expectCode([validDiagnostic({ selector })], 'selector'); + } + expect( + validateWritingDiagnostics([ + validDiagnostic({ + selector: { type: 'TextPositionSelector', start: 3, end: 3 }, + }), + ])[0].selector, + ).toEqual({ type: 'TextPositionSelector', start: 3, end: 3 }); + }); + + it('strictly validates provenance without evaluating accessors', () => { + const getter = vi.fn(() => 'secret'); + const provenance = { + workflowId: 'wf', + workflowVersion: '1', + judgePolicyVersion: '2', + } as Record; + Object.defineProperty(provenance, 'orchestrationMode', { + enumerable: true, + get: getter, + }); + expectCode( + [validDiagnostic({ provenance: provenance as CwlWritingDiagnostic['provenance'] })], + 'contract', + ); + expect(getter).not.toHaveBeenCalled(); + }); + + it('supports stricter caller limits without mutating defaults', () => { + const stricterLimits = { + ...DEFAULT_WRITING_DIAGNOSTIC_LIMITS, + maxTitleCodeUnits: 32, + }; + const result = validateWritingDiagnostics([validDiagnostic()], stricterLimits); + expect(result[0].title).toBe('Check agreement'); + expectCode( + [validDiagnostic({ title: 'x'.repeat(33) })], + 'limit', + stricterLimits, + ); + expect(DEFAULT_WRITING_DIAGNOSTIC_LIMITS.maxTitleCodeUnits).toBe(256); + }); + + it('rejects malformed limit configuration without reading diagnostics', () => { + const diagnostics = new Proxy([validDiagnostic()], { + get() { + throw new Error('diagnostics should not be read'); + }, + }); + expect(() => + validateWritingDiagnostics(diagnostics, { + ...DEFAULT_WRITING_DIAGNOSTIC_LIMITS, + maxDiagnostics: 0, + }), + ).toThrow(WritingDiagnosticError); + }); +}); diff --git a/src/writingDiagnostics.ts b/src/writingDiagnostics.ts new file mode 100644 index 00000000..b303a5cd --- /dev/null +++ b/src/writingDiagnostics.ts @@ -0,0 +1,604 @@ +import type { CwlEditorDocumentRevision } from './documentEnvelopeRevision.js'; +import { + TEXT_POSITION_PROJECTION_ID, + TEXT_POSITION_PROJECTION_VERSION, + type CwlEditorTextPositionSelector, + type CwlEditorTextProjectionIdentity, +} from './textPositionSelectorEvidence.js'; + +/** Host-controlled presentation priority for one advisory writing diagnostic. */ +export type CwlWritingDiagnosticPriority = + | 'advisory' + | 'important' + | 'critical'; + +/** Bounded provenance identifiers supplied by the host that produced a diagnostic. */ +export interface CwlWritingDiagnosticProvenance { + /** Opaque host workflow identity. */ + readonly workflowId: string; + /** Opaque host workflow version. */ + readonly workflowVersion: string; + /** Opaque version of the host's judge/evaluation policy. */ + readonly judgePolicyVersion: string; + /** Optional opaque host orchestration-mode identity. */ + readonly orchestrationMode?: string; +} + +/** + * Provider-neutral advisory writing diagnostic supplied by a host application. + * + * Inkspan treats every semantic field as untrusted proposal data. The editor + * validates only structure, bounds, revision/projection identity, selectors, + * and lifecycle state; it does not infer grammar, spelling, tone, clarity, + * pragmatics, technical quality, or actionability. + */ +export interface CwlWritingDiagnostic { + /** Opaque host identifier unique within one submitted diagnostic set. */ + readonly diagnosticId: string; + /** Exact strong revision for the immutable document snapshot judged by the host. */ + readonly documentRevision: CwlEditorDocumentRevision; + /** Exact Inkspan text-projection identity used by `selector`. */ + readonly textProjection: CwlEditorTextProjectionIdentity; + /** W3C text-position selector over the declared projection. */ + readonly selector: CwlEditorTextPositionSelector; + /** Opaque host category code; Inkspan does not derive semantics from it. */ + readonly categoryCode: string; + /** Host-selected presentation priority. */ + readonly priority: CwlWritingDiagnosticPriority; + /** Human-readable host-supplied title rendered as plain text. */ + readonly title: string; + /** Human-readable host-supplied explanation rendered as plain text. */ + readonly explanation: string; + /** Optional plain-text replacement proposal. */ + readonly suggestedReplacement?: string; + /** Optional host confidence on the closed interval `[0, 1]`. */ + readonly confidence?: number; + /** Opaque, privacy-minimized host provenance identifiers. */ + readonly provenance: Readonly; +} + +/** Optional stricter local resource ceilings for diagnostic validation. */ +export interface WritingDiagnosticLimits { + /** Maximum number of diagnostics accepted for one immutable editor snapshot. */ + readonly maxDiagnostics?: number; + /** Maximum UTF-16 code-unit length of `diagnosticId`. */ + readonly maxDiagnosticIdCodeUnits?: number; + /** Maximum UTF-16 code-unit length of `categoryCode`. */ + readonly maxCategoryCodeUnits?: number; + /** Maximum UTF-16 code-unit length of each provenance identifier. */ + readonly maxProvenanceCodeUnits?: number; + /** Maximum UTF-16 code-unit length of `title`. */ + readonly maxTitleCodeUnits?: number; + /** Maximum UTF-16 code-unit length of `explanation`. */ + readonly maxExplanationCodeUnits?: number; + /** Maximum UTF-16 code-unit length of a plain-text replacement. */ + readonly maxReplacementCodeUnits?: number; +} + +interface ResolvedWritingDiagnosticLimits { + readonly maxDiagnostics: number; + readonly maxDiagnosticIdCodeUnits: number; + readonly maxCategoryCodeUnits: number; + readonly maxProvenanceCodeUnits: number; + readonly maxTitleCodeUnits: number; + readonly maxExplanationCodeUnits: number; + readonly maxReplacementCodeUnits: number; +} + +/** Hard package ceilings for one host-supplied writing-diagnostic set. */ +export const DEFAULT_WRITING_DIAGNOSTIC_LIMITS = Object.freeze({ + maxDiagnostics: 256, + maxDiagnosticIdCodeUnits: 256, + maxCategoryCodeUnits: 128, + maxProvenanceCodeUnits: 128, + maxTitleCodeUnits: 256, + maxExplanationCodeUnits: 4_000, + maxReplacementCodeUnits: 20_000, +}) satisfies Readonly; + +/** Stable public failure classifications for writing-diagnostic handling. */ +export type WritingDiagnosticErrorCode = + | 'contract' + | 'limit' + | 'revision' + | 'projection' + | 'selector' + | 'conflict' + | 'lifecycle'; + +const ERROR_MESSAGES: Readonly> = + Object.freeze({ + contract: 'Writing diagnostic input is invalid.', + limit: 'Writing diagnostic input exceeds the supported limit.', + revision: 'Writing diagnostic revision evidence is invalid.', + projection: 'Writing diagnostic text projection is unsupported.', + selector: 'Writing diagnostic text selector is invalid.', + conflict: 'Writing diagnostic input contains conflicting identifiers.', + lifecycle: 'Writing diagnostic lifecycle state is invalid.', + }); + +/** Raised when untrusted host diagnostic data cannot cross Inkspan's local boundary. */ +export class WritingDiagnosticError extends TypeError { + /** Stable redacted public failure classification. */ + readonly code: WritingDiagnosticErrorCode; + + constructor(code: WritingDiagnosticErrorCode) { + super(ERROR_MESSAGES[code]); + this.name = 'WritingDiagnosticError'; + this.code = code; + } +} + +const DIAGNOSTIC_FIELDS = Object.freeze([ + 'diagnosticId', + 'documentRevision', + 'textProjection', + 'selector', + 'categoryCode', + 'priority', + 'title', + 'explanation', + 'suggestedReplacement', + 'confidence', + 'provenance', +] as const); +const REQUIRED_DIAGNOSTIC_FIELDS = Object.freeze([ + 'diagnosticId', + 'documentRevision', + 'textProjection', + 'selector', + 'categoryCode', + 'priority', + 'title', + 'explanation', + 'provenance', +] as const); +const REVISION_FIELDS = Object.freeze([ + 'algorithm', + 'digestHex', + 'strongEntityTag', +] as const); +const PROJECTION_FIELDS = Object.freeze(['id', 'version'] as const); +const SELECTOR_FIELDS = Object.freeze(['type', 'start', 'end'] as const); +const PROVENANCE_FIELDS = Object.freeze([ + 'workflowId', + 'workflowVersion', + 'judgePolicyVersion', + 'orchestrationMode', +] as const); +const REQUIRED_PROVENANCE_FIELDS = Object.freeze([ + 'workflowId', + 'workflowVersion', + 'judgePolicyVersion', +] as const); +const LIMIT_FIELDS = Object.freeze([ + 'maxDiagnostics', + 'maxDiagnosticIdCodeUnits', + 'maxCategoryCodeUnits', + 'maxProvenanceCodeUnits', + 'maxTitleCodeUnits', + 'maxExplanationCodeUnits', + 'maxReplacementCodeUnits', +] as const); +const PRIORITIES = new Set([ + 'advisory', + 'important', + 'critical', +]); +const LOWERCASE_SHA256 = /^[0-9a-f]{64}$/u; + +type DataRecord = Readonly>; + +/** + * Validate and detach one complete host-supplied diagnostic set. + * + * The function performs no semantic text analysis and invokes no host callbacks, + * model/provider APIs, network services, persistence services, or editor + * transactions. Every accepted object is copied from own enumerable data + * properties only and the returned tuple is deeply frozen. + */ +export function validateWritingDiagnostics( + input: unknown, + limits?: WritingDiagnosticLimits, +): readonly CwlWritingDiagnostic[] { + return validateWritingDiagnosticsWithLimits(input, resolveLimits(limits)); +} + +function validateWritingDiagnosticsWithLimits( + input: unknown, + limits: ResolvedWritingDiagnosticLimits, +): readonly CwlWritingDiagnostic[] { + if (!safeArrayIsArray(input)) { + throw new WritingDiagnosticError('contract'); + } + + const length = readArrayLength(input); + if (length > limits.maxDiagnostics) { + throw new WritingDiagnosticError('limit'); + } + validateDenseExactArray(input, length); + + const result: CwlWritingDiagnostic[] = []; + const diagnosticIds = new Set(); + for (let index = 0; index < length; index += 1) { + const descriptor = safeOwnDescriptor(input, String(index), 'contract'); + if (descriptor === undefined || !isEnumerableDataDescriptor(descriptor)) { + throw new WritingDiagnosticError('contract'); + } + const diagnostic = validateDiagnostic(descriptor.value, limits); + if (diagnosticIds.has(diagnostic.diagnosticId)) { + throw new WritingDiagnosticError('conflict'); + } + diagnosticIds.add(diagnostic.diagnosticId); + result.push(diagnostic); + } + return Object.freeze(result); +} + +function validateDiagnostic( + value: unknown, + limits: ResolvedWritingDiagnosticLimits, +): CwlWritingDiagnostic { + const record = readExactObject( + value, + DIAGNOSTIC_FIELDS, + REQUIRED_DIAGNOSTIC_FIELDS, + 'contract', + ); + + const diagnosticId = boundedRequiredString( + record.diagnosticId, + limits.maxDiagnosticIdCodeUnits, + ); + const categoryCode = boundedRequiredString( + record.categoryCode, + limits.maxCategoryCodeUnits, + ); + const title = boundedRequiredString(record.title, limits.maxTitleCodeUnits); + const explanation = boundedString( + record.explanation, + limits.maxExplanationCodeUnits, + true, + ); + const suggestedReplacement = optionalBoundedString( + record.suggestedReplacement, + limits.maxReplacementCodeUnits, + ); + const priority = validatePriority(record.priority); + const confidence = validateConfidence(record.confidence); + const documentRevision = validateRevision(record.documentRevision); + const textProjection = validateProjection(record.textProjection); + const selector = validateSelector(record.selector); + const provenance = validateProvenance(record.provenance, limits); + + const detached: CwlWritingDiagnostic = { + diagnosticId, + documentRevision, + textProjection, + selector, + categoryCode, + priority, + title, + explanation, + provenance, + }; + if (suggestedReplacement !== undefined) { + Object.defineProperty(detached, 'suggestedReplacement', { + value: suggestedReplacement, + enumerable: true, + configurable: false, + writable: false, + }); + } + if (confidence !== undefined) { + Object.defineProperty(detached, 'confidence', { + value: confidence, + enumerable: true, + configurable: false, + writable: false, + }); + } + return Object.freeze(detached); +} + +function validateRevision(value: unknown): CwlEditorDocumentRevision { + const record = readExactObject( + value, + REVISION_FIELDS, + REVISION_FIELDS, + 'revision', + ); + const digestHex = record.digestHex; + if ( + record.algorithm !== 'SHA-256' || + typeof digestHex !== 'string' || + digestHex.length !== 64 || + !LOWERCASE_SHA256.test(digestHex) || + record.strongEntityTag !== `"sha256-${digestHex}"` + ) { + throw new WritingDiagnosticError('revision'); + } + return Object.freeze({ + algorithm: 'SHA-256' as const, + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }); +} + +function validateProjection(value: unknown): CwlEditorTextProjectionIdentity { + const record = readExactObject( + value, + PROJECTION_FIELDS, + PROJECTION_FIELDS, + 'projection', + ); + if ( + record.id !== TEXT_POSITION_PROJECTION_ID || + record.version !== TEXT_POSITION_PROJECTION_VERSION + ) { + throw new WritingDiagnosticError('projection'); + } + return Object.freeze({ + id: TEXT_POSITION_PROJECTION_ID, + version: TEXT_POSITION_PROJECTION_VERSION, + }); +} + +function validateSelector(value: unknown): CwlEditorTextPositionSelector { + const record = readExactObject( + value, + SELECTOR_FIELDS, + SELECTOR_FIELDS, + 'selector', + ); + const start = record.start; + const end = record.end; + if ( + record.type !== 'TextPositionSelector' || + !Number.isSafeInteger(start) || + !Number.isSafeInteger(end) || + (start as number) < 0 || + (end as number) < (start as number) + ) { + throw new WritingDiagnosticError('selector'); + } + return Object.freeze({ + type: 'TextPositionSelector' as const, + start: start as number, + end: end as number, + }); +} + +function validateProvenance( + value: unknown, + limits: ResolvedWritingDiagnosticLimits, +): Readonly { + const record = readExactObject( + value, + PROVENANCE_FIELDS, + REQUIRED_PROVENANCE_FIELDS, + 'contract', + ); + const workflowId = boundedRequiredString( + record.workflowId, + limits.maxProvenanceCodeUnits, + ); + const workflowVersion = boundedRequiredString( + record.workflowVersion, + limits.maxProvenanceCodeUnits, + ); + const judgePolicyVersion = boundedRequiredString( + record.judgePolicyVersion, + limits.maxProvenanceCodeUnits, + ); + const orchestrationMode = optionalBoundedString( + record.orchestrationMode, + limits.maxProvenanceCodeUnits, + ); + const result: CwlWritingDiagnosticProvenance = { + workflowId, + workflowVersion, + judgePolicyVersion, + }; + if (orchestrationMode !== undefined) { + Object.defineProperty(result, 'orchestrationMode', { + value: orchestrationMode, + enumerable: true, + configurable: false, + writable: false, + }); + } + return Object.freeze(result); +} + +function validatePriority(value: unknown): CwlWritingDiagnosticPriority { + if ( + typeof value !== 'string' || + !PRIORITIES.has(value as CwlWritingDiagnosticPriority) + ) { + throw new WritingDiagnosticError('contract'); + } + return value as CwlWritingDiagnosticPriority; +} + +function validateConfidence(value: unknown): number | undefined { + if (value === undefined) { + return undefined; + } + if ( + typeof value !== 'number' || + !Number.isFinite(value) || + value < 0 || + value > 1 + ) { + throw new WritingDiagnosticError('contract'); + } + return value; +} + +function boundedRequiredString(value: unknown, maxCodeUnits: number): string { + return boundedString(value, maxCodeUnits, false); +} + +function optionalBoundedString( + value: unknown, + maxCodeUnits: number, +): string | undefined { + if (value === undefined) { + return undefined; + } + return boundedString(value, maxCodeUnits, true); +} + +function boundedString( + value: unknown, + maxCodeUnits: number, + allowEmpty: boolean, +): string { + if (typeof value !== 'string' || (!allowEmpty && value.length === 0)) { + throw new WritingDiagnosticError('contract'); + } + if (value.length > maxCodeUnits) { + throw new WritingDiagnosticError('limit'); + } + return value; +} + +function resolveLimits( + limits: WritingDiagnosticLimits | undefined, +): ResolvedWritingDiagnosticLimits { + if (limits === undefined) { + return DEFAULT_WRITING_DIAGNOSTIC_LIMITS; + } + const record = readExactObject(limits, LIMIT_FIELDS, [], 'contract'); + const resolved: Record = { + ...DEFAULT_WRITING_DIAGNOSTIC_LIMITS, + }; + for (const key of LIMIT_FIELDS) { + const value = record[key]; + if (value === undefined) { + continue; + } + const hardMaximum = DEFAULT_WRITING_DIAGNOSTIC_LIMITS[key]; + if ( + !Number.isSafeInteger(value) || + (value as number) < 1 || + (value as number) > hardMaximum + ) { + throw new WritingDiagnosticError('contract'); + } + resolved[key] = value as number; + } + return Object.freeze(resolved); +} + +function readExactObject( + value: unknown, + allowedKeys: readonly K[], + requiredKeys: readonly K[], + errorCode: WritingDiagnosticErrorCode, +): DataRecord { + if ( + typeof value !== 'object' || + value === null || + safeArrayIsArray(value) + ) { + throw new WritingDiagnosticError(errorCode); + } + + let prototype: object | null; + let keys: PropertyKey[]; + try { + prototype = Object.getPrototypeOf(value); + keys = Reflect.ownKeys(value); + } catch { + throw new WritingDiagnosticError(errorCode); + } + if (prototype !== Object.prototype && prototype !== null) { + throw new WritingDiagnosticError(errorCode); + } + + const allowed = new Set(allowedKeys); + const result: Record = {}; + for (const key of keys) { + if (typeof key !== 'string' || !allowed.has(key)) { + throw new WritingDiagnosticError(errorCode); + } + const descriptor = safeOwnDescriptor(value, key, errorCode); + if (descriptor === undefined || !isEnumerableDataDescriptor(descriptor)) { + throw new WritingDiagnosticError(errorCode); + } + Object.defineProperty(result, key, { + value: descriptor.value, + enumerable: true, + configurable: true, + writable: true, + }); + } + for (const key of requiredKeys) { + if (!Object.prototype.hasOwnProperty.call(result, key)) { + throw new WritingDiagnosticError(errorCode); + } + } + return result; +} + +function safeArrayIsArray(value: unknown): value is unknown[] { + try { + return Array.isArray(value); + } catch { + throw new WritingDiagnosticError('contract'); + } +} + +function readArrayLength(value: unknown[]): number { + const descriptor = safeOwnDescriptor(value, 'length', 'contract'); + if ( + descriptor === undefined || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') || + typeof descriptor.value !== 'number' || + !Number.isSafeInteger(descriptor.value) || + descriptor.value < 0 + ) { + throw new WritingDiagnosticError('contract'); + } + return descriptor.value; +} + +function validateDenseExactArray(value: unknown[], length: number): void { + let keys: PropertyKey[]; + try { + keys = Reflect.ownKeys(value); + } catch { + throw new WritingDiagnosticError('contract'); + } + if (keys.length !== length + 1 || !keys.includes('length')) { + throw new WritingDiagnosticError('contract'); + } + for (let index = 0; index < length; index += 1) { + if (!keys.includes(String(index))) { + throw new WritingDiagnosticError('contract'); + } + } +} + +function safeOwnDescriptor( + value: object, + key: PropertyKey, + errorCode: WritingDiagnosticErrorCode, +): PropertyDescriptor | undefined { + try { + return Object.getOwnPropertyDescriptor(value, key); + } catch { + throw new WritingDiagnosticError(errorCode); + } +} + +function isEnumerableDataDescriptor( + descriptor: PropertyDescriptor, +): descriptor is PropertyDescriptor & { value: unknown } { + return ( + descriptor.enumerable === true && + Object.prototype.hasOwnProperty.call(descriptor, 'value') + ); +} diff --git a/src/writingDiagnosticsBoundary.test.ts b/src/writingDiagnosticsBoundary.test.ts new file mode 100644 index 00000000..a8c416fc --- /dev/null +++ b/src/writingDiagnosticsBoundary.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest'; +import { + WritingDiagnosticError, + validateWritingDiagnostics, + type CwlWritingDiagnostic, + type WritingDiagnosticLimits, +} from './writingDiagnostics.js'; + +const DIGEST = 'b'.repeat(64); + +function diagnostic( + overrides: Partial = {}, +): CwlWritingDiagnostic { + return { + diagnosticId: 'boundary-diagnostic', + documentRevision: { + algorithm: 'SHA-256', + digestHex: DIGEST, + strongEntityTag: `"sha256-${DIGEST}"`, + }, + textProjection: { + id: 'inkspan-prosemirror-text', + version: 1, + }, + selector: { + type: 'TextPositionSelector', + start: 0, + end: 1, + }, + categoryCode: 'host.category', + priority: 'advisory', + title: 'Host title', + explanation: '', + provenance: { + workflowId: 'workflow', + workflowVersion: '1', + judgePolicyVersion: '1', + }, + ...overrides, + }; +} + +function expectCode( + input: unknown, + code: WritingDiagnosticError['code'], + limits?: WritingDiagnosticLimits, +): void { + try { + validateWritingDiagnostics(input, limits); + } catch (error) { + expect(error).toBeInstanceOf(WritingDiagnosticError); + expect((error as WritingDiagnosticError).code).toBe(code); + return; + } + throw new Error(`Expected WritingDiagnosticError(${code})`); +} + +describe('writing diagnostic hostile-boundary coverage', () => { + it('accepts a genuinely partial stricter-limit object', () => { + const result = validateWritingDiagnostics([diagnostic()], { + maxTitleCodeUnits: 32, + }); + + expect(result[0].title).toBe('Host title'); + }); + + it('rejects non-object diagnostic and nested contract values', () => { + expectCode([null], 'contract'); + expectCode([[]], 'contract'); + expectCode(['diagnostic'], 'contract'); + expectCode( + [diagnostic({ documentRevision: [] as unknown as CwlWritingDiagnostic['documentRevision'] })], + 'revision', + ); + }); + + it('rejects a missing required own field', () => { + const { title: omittedTitle, ...missingTitle } = diagnostic(); + expect(omittedTitle).toBe('Host title'); + + expectCode([missingTitle], 'contract'); + }); + + it('redacts a revoked array proxy before any member access', () => { + const revocable = Proxy.revocable([diagnostic()], {}); + revocable.revoke(); + + expectCode(revocable.proxy, 'contract'); + }); + + it('rejects an invalid array length descriptor', () => { + const target = [diagnostic()]; + const input = new Proxy(target, { + getOwnPropertyDescriptor(currentTarget, key) { + if (key === 'length') { + return { + value: '1', + writable: true, + enumerable: false, + configurable: false, + }; + } + return Reflect.getOwnPropertyDescriptor(currentTarget, key); + }, + }); + + expectCode(input, 'contract'); + }); + + it('redacts an array own-key reflection failure', () => { + const input = new Proxy([diagnostic()], { + ownKeys() { + throw new Error('private array key material'); + }, + }); + + expectCode(input, 'contract'); + }); + + it('rejects a dense-array key set that omits the required index', () => { + const target = [diagnostic()]; + const replacement = diagnostic({ diagnosticId: 'replacement' }); + const input = new Proxy(target, { + ownKeys() { + return ['1', 'length']; + }, + getOwnPropertyDescriptor(currentTarget, key) { + if (key === '1') { + return { + value: replacement, + writable: true, + enumerable: true, + configurable: true, + }; + } + return Reflect.getOwnPropertyDescriptor(currentTarget, key); + }, + }); + + expectCode(input, 'contract'); + }); + + it('rejects an index hidden after the exact key inventory', () => { + const target = [diagnostic()]; + const input = new Proxy(target, { + getOwnPropertyDescriptor(currentTarget, key) { + if (key === '0') { + return undefined; + } + return Reflect.getOwnPropertyDescriptor(currentTarget, key); + }, + }); + + expectCode(input, 'contract'); + }); + + it('rejects an accessor-backed array member without evaluating it', () => { + const input: unknown[] = []; + Object.defineProperty(input, '0', { + enumerable: true, + configurable: true, + get() { + throw new Error('array accessor must never execute'); + }, + }); + + expectCode(input, 'contract'); + }); + + it('redacts an own-property-descriptor reflection failure', () => { + const input = new Proxy([diagnostic()], { + getOwnPropertyDescriptor(currentTarget, key) { + if (key === 'length') { + throw new Error('private descriptor material'); + } + return Reflect.getOwnPropertyDescriptor(currentTarget, key); + }, + }); + + expectCode(input, 'contract'); + }); +}); diff --git a/src/writingDiagnosticsExports.test.ts b/src/writingDiagnosticsExports.test.ts new file mode 100644 index 00000000..4dd2abeb --- /dev/null +++ b/src/writingDiagnosticsExports.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import * as rootSurface from './index.js'; +import * as subpathSurface from './writing-diagnostics/index.js'; +import { + DEFAULT_WRITING_DIAGNOSTIC_LIMITS, + WritingDiagnosticError, + validateWritingDiagnostics, + type CwlWritingDiagnostic, + type CwlWritingDiagnosticPriority, + type CwlWritingDiagnosticProvenance, + type WritingDiagnosticErrorCode, + type WritingDiagnosticLimits, +} from './writingDiagnostics.js'; +import type { + CwlWritingDiagnostic as RootDiagnostic, + CwlWritingDiagnosticPriority as RootPriority, + CwlWritingDiagnosticProvenance as RootProvenance, + WritingDiagnosticErrorCode as RootErrorCode, + WritingDiagnosticLimits as RootLimits, +} from './index.js'; +import type { + CwlWritingDiagnostic as SubpathDiagnostic, + CwlWritingDiagnosticPriority as SubpathPriority, + CwlWritingDiagnosticProvenance as SubpathProvenance, + WritingDiagnosticErrorCode as SubpathErrorCode, + WritingDiagnosticLimits as SubpathLimits, +} from './writing-diagnostics/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 rootTypeContract: readonly true[] = [ + true as Exact, + true as Exact, + true as Exact, + true as Exact, + true as Exact, +]; +const subpathTypeContract: readonly true[] = [ + true as Exact, + true as Exact, + true as Exact, + true as Exact, + true as Exact, +]; + +describe('writing diagnostic public source exports', () => { + it('re-exports one identical runtime contract from the root surface', () => { + expect(rootSurface.DEFAULT_WRITING_DIAGNOSTIC_LIMITS).toBe( + DEFAULT_WRITING_DIAGNOSTIC_LIMITS, + ); + expect(rootSurface.WritingDiagnosticError).toBe(WritingDiagnosticError); + expect(rootSurface.validateWritingDiagnostics).toBe( + validateWritingDiagnostics, + ); + expect(rootTypeContract).toEqual([true, true, true, true, true]); + }); + + it('exposes the same framework-independent contract from the subpath', () => { + expect(subpathSurface.DEFAULT_WRITING_DIAGNOSTIC_LIMITS).toBe( + DEFAULT_WRITING_DIAGNOSTIC_LIMITS, + ); + expect(subpathSurface.WritingDiagnosticError).toBe(WritingDiagnosticError); + expect(subpathSurface.validateWritingDiagnostics).toBe( + validateWritingDiagnostics, + ); + expect(subpathTypeContract).toEqual([true, true, true, true, true]); + }); +});