diff --git a/.github/workflows/writing-diagnostics-package-tdd.yml b/.github/workflows/writing-diagnostics-package-tdd.yml new file mode 100644 index 00000000..d8f98783 --- /dev/null +++ b/.github/workflows/writing-diagnostics-package-tdd.yml @@ -0,0 +1,52 @@ +name: Writing Diagnostics Package TDD + +on: + push: + branches: + - feat/writing-diagnostics-package + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: writing-diagnostics-package-tdd-${{ github.ref }} + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + framework-neutral-package: + 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@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Run package-boundary acceptance + run: >- + pnpm exec vitest run + src/writingDiagnosticsPackage.test.ts + src/writingDiagnosticsExports.test.ts + --pool=forks + --maxWorkers=1 + - name: Typecheck package contracts + run: pnpm typecheck + - name: Run complete production coverage gate + env: + NODE_OPTIONS: --max-old-space-size=6144 + run: pnpm coverage + - name: Build all package entrypoints + run: pnpm build + - name: Verify isolated packed-package consumers + run: pnpm verify:package + - name: Build demonstration application + run: pnpm build:demo diff --git a/README.md b/README.md index f2b02332..1f684ff6 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Office Open XML renderer for DOCX, XLSX, and PPTX. or runtime font request. - **Standalone conversion utilities** — browser/Node data-URI and base64 helpers are available without React or TipTap. +- **Framework-neutral writing diagnostics** — `@contextualwisdomlab/cwl-editor/writing-diagnostics` validates host-supplied revision-bound diagnostics and resolves exact text-position ranges without importing React, Yjs, a model SDK, credentials, or network authority. Semantic judgment and mutation remain host-owned. - **AI-authored Office files** — a network-free Python package renders strict JSON to DOCX, XLSX, or PPTX with formula-injection protection, losslessness checks, atomic publication, and a bundled JSON Schema. @@ -70,6 +71,7 @@ runtime. | Envelope identity | `@contextualwisdomlab/cwl-editor/envelope-identity` | Framework-independent bounded schema identity for host-owned migration routing | | Revision evidence | `@contextualwisdomlab/cwl-editor/revision-evidence` | Framework-independent canonical envelope, strong revision, and transition evidence | | Text-position selector | `@contextualwisdomlab/cwl-editor/text-position-selector` | React-free deterministic W3C `TextPositionSelector` projection core | +| Writing diagnostics | `@contextualwisdomlab/cwl-editor/writing-diagnostics` | React-free revision-bound diagnostic validation and exact text-position projection/resolution core | | Autosave | `@contextualwisdomlab/cwl-editor/autosave` | Provider-neutral bounded single-flight persistence coordination | | Headless Markdown | `@contextualwisdomlab/cwl-editor/markdown` | React-free deterministic Markdown/HTML/email/plain-text conversion | | Styles | `@contextualwisdomlab/cwl-editor/styles.css` | Editor layout and theming | diff --git a/docs/package-distribution.md b/docs/package-distribution.md index ddb4df0e..d87f63e8 100644 --- a/docs/package-distribution.md +++ b/docs/package-distribution.md @@ -17,6 +17,7 @@ integrations. | `@contextualwisdomlab/cwl-editor/envelope-identity` | Framework-independent identity-only envelope routing for bounded schema identity inspection; migration remains host-owned | | `@contextualwisdomlab/cwl-editor/revision-evidence` | Framework-independent revision evidence and document-transition evidence for local content equality/lineage claims | | `@contextualwisdomlab/cwl-editor/text-position-selector` | `implemented_on_protected_main` — React-free text-position projection core implementing W3C `TextPositionSelector`; interactive capture, revision binding, authorization, persistence, and re-anchoring remain outside this subpath | +| `@contextualwisdomlab/cwl-editor/writing-diagnostics` | React-free revision-bound writing-diagnostic validation and text-position projection/resolution utilities; semantic review, model calls, policy, persistence, authorization, and editor mutation remain outside this subpath | | `@contextualwisdomlab/cwl-editor/markdown` | `implemented_on_active_pr` — headless deterministic Markdown/HTML/email/plain-text conversion with the same safe-link and strict inline-raster policies as the editor, without importing the React/TipTap editor graph | | `@contextualwisdomlab/cwl-editor/styles.css` | Editor layout and theming | | `@contextualwisdomlab/cwl-editor/fonts.css` | Full offline KR/EN/JP/SC/TC/VI font bundle | @@ -81,6 +82,13 @@ embedded in the npm tarball. state or bind a selector to a document revision. Hosts remain responsible for annotation identifiers/bodies, source-resource identity, authorization, tenancy, persistence, audit, and cross-revision re-anchoring. +- The writing-diagnostics subpath deliberately exposes only strict diagnostic + validation, bounded diagnostic errors, deterministic text-projection mapping, + and exact `TextPositionSelector` resolution. It imports no React, Yjs, provider, + network, credential, model, naruon, or contextual-orchestrator runtime. It never + decides whether authored text is wrong and never applies a replacement. The + host owns semantic review, policy admission, authorization, persistence, + revision capture, and any editor mutation. - Envelope identity output is routing metadata only. It does not accept an unsupported document generation as current semantics and does not move schema registry, migration, persistence, rollback, or authorization authority into @@ -109,7 +117,7 @@ production library build. The verification chain: 4. rejects internal source, tests, demos, Office files, coverage output, and workflow files from the npm tarball; 5. imports the root, collaboration, converter, autosave, envelope-identity, - revision-evidence, text-position-selector, and Markdown surfaces through their + revision-evidence, text-position-selector, writing-diagnostics, and Markdown surfaces through their dedicated packed-consumer checks, including framework-free isolation where that is part of the public contract; 6. exercises supported ESM/CommonJS entrypoints and compiles strict TypeScript diff --git a/package.json b/package.json index 4e55d924..a7bc0cc8 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,11 @@ "import": "./dist/cwl-text-position-selector.js", "require": "./dist/cwl-text-position-selector.cjs" }, + "./writing-diagnostics": { + "types": "./dist/writing-diagnostics/index.d.ts", + "import": "./dist/cwl-writing-diagnostics.js", + "require": "./dist/cwl-writing-diagnostics.cjs" + }, "./markdown": { "types": "./dist/markdown/index.d.ts", "import": "./dist/cwl-markdown.js", @@ -99,7 +104,7 @@ ], "scripts": { "dev": "vite", - "build": "tsc --noEmit && vite build && vite build --config vite.collaboration.config.ts && vite build --config vite.converter.config.ts && vite build --config vite.envelope-identity.config.ts && vite build --config vite.revision-evidence.config.ts && vite build --config vite.autosave.config.ts && vite build --config vite.text-position-selector.config.ts && vite build --config vite.markdown.config.ts && node ./scripts/copy-styles.mjs", + "build": "tsc --noEmit && vite build && vite build --config vite.collaboration.config.ts && vite build --config vite.converter.config.ts && vite build --config vite.envelope-identity.config.ts && vite build --config vite.revision-evidence.config.ts && vite build --config vite.autosave.config.ts && vite build --config vite.text-position-selector.config.ts && vite build --config vite.writing-diagnostics.config.ts && vite build --config vite.markdown.config.ts && node ./scripts/copy-styles.mjs", "build:demo": "vite build --config vite.demo.config.ts", "fonts": "node ./scripts/fetch-fonts.mjs", "preview": "vite preview", @@ -108,7 +113,7 @@ "test:watch": "vitest", "coverage": "vitest run --coverage", "test:package-config": "node --test ./scripts/revision-evidence-consumer-config.test.mjs ./scripts/release-metadata.test.mjs ./scripts/javascript-runtime-authority.test.mjs", - "verify:package": "pnpm run test:package-config && node ./tests/package/verify-package.mjs && node ./tests/package/verify-editor-placeholder-package.mjs && node ./scripts/verify-canonical-envelope-package.mjs && node ./scripts/verify-revision-evidence-package.mjs && node ./scripts/verify-framework-free-revision-evidence-package.mjs && node ./scripts/verify-framework-free-envelope-identity-package.mjs && node ./tests/package/verify-framework-free-autosave-package.mjs && node ./scripts/verify-text-position-selector-package.mjs && node ./scripts/verify-text-position-selector-subpath-package.mjs && node ./scripts/verify-markdown-subpath-package.mjs" + "verify:package": "pnpm run test:package-config && node ./tests/package/verify-package.mjs && node ./tests/package/verify-editor-placeholder-package.mjs && node ./scripts/verify-canonical-envelope-package.mjs && node ./scripts/verify-revision-evidence-package.mjs && node ./scripts/verify-framework-free-revision-evidence-package.mjs && node ./scripts/verify-framework-free-envelope-identity-package.mjs && node ./tests/package/verify-framework-free-autosave-package.mjs && node ./scripts/verify-text-position-selector-package.mjs && node ./scripts/verify-text-position-selector-subpath-package.mjs && node ./scripts/verify-writing-diagnostics-subpath-package.mjs && node ./scripts/verify-markdown-subpath-package.mjs" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", diff --git a/scripts/verify-writing-diagnostics-subpath-package.mjs b/scripts/verify-writing-diagnostics-subpath-package.mjs new file mode 100644 index 00000000..2834a76f --- /dev/null +++ b/scripts/verify-writing-diagnostics-subpath-package.mjs @@ -0,0 +1,377 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const packageJson = JSON.parse( + readFileSync(join(repositoryRoot, 'package.json'), 'utf8'), +); +const verificationRoot = mkdtempSync( + join(tmpdir(), 'inkspan-writing-diagnostics-'), +); +const extractionDirectory = join(verificationRoot, 'extracted'); +const consumerDirectory = join(verificationRoot, 'consumer'); +const packageDirectory = join( + consumerDirectory, + 'node_modules', + ...packageJson.name.split('/'), +); + +const dynamicLoaderPattern = /(?:\bimport\s*\(|\brequire\s*\()/u; +const externalRuntimeImportPattern = + /(?:\bimport\s+(?:[^'";]*?\sfrom\s*)?['"][^'"]+['"]|\bexport\s+[^'";]*?\sfrom\s*['"][^'"]+['"])/u; +const ambientAuthorityPattern = + /(?:\bfetch\s*\(|\bXMLHttpRequest\b|\bWebSocket\b|\bEventSource\b|\bprocess\.env\b|\bimport\.meta\.env\b|\bDeno\.env\b|\bBun\.env\b)/u; +const forbiddenFrameworkPattern = + /(?:@tiptap\/react|react-dom|\breact\b|\byjs\b|y-prosemirror|contextual-orchestrator|\bnaruon\b)/iu; + +/** Execute one deterministic package-consumer command. */ +function run(command, argumentsList, cwd = repositoryRoot) { + return execFileSync(command, argumentsList, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + }); +} + +/** Build one real npm tarball and install its files without executing scripts. */ +function preparePackage() { + mkdirSync(extractionDirectory, { recursive: true }); + mkdirSync(dirname(packageDirectory), { recursive: true }); + const packOutput = run('npm', [ + 'pack', + '--json', + '--ignore-scripts', + '--pack-destination', + verificationRoot, + ]); + const packResult = JSON.parse(packOutput)[0]; + assert.equal(packResult.name, packageJson.name); + assert.equal(packResult.version, packageJson.version); + const tarballPath = join(verificationRoot, packResult.filename); + assert.ok(existsSync(tarballPath)); + run('tar', ['-xzf', tarballPath, '-C', extractionDirectory]); + renameSync(join(extractionDirectory, 'package'), packageDirectory); + writeFileSync( + join(consumerDirectory, 'package.json'), + '{"name":"inkspan-writing-diagnostics-consumer","private":true,"type":"module"}\n', + 'utf8', + ); + + // The public declarations use ProseMirror model types only. The packed fixture + // is extracted without package-manager installation, so expose the repository's + // frozen dependency solely for strict declaration resolution. + const repositoryTiptap = join(repositoryRoot, 'node_modules', '@tiptap'); + const consumerTiptap = join(consumerDirectory, 'node_modules', '@tiptap'); + assert.ok(existsSync(repositoryTiptap)); + symlinkSync(repositoryTiptap, consumerTiptap, 'dir'); +} + +/** Prove emitted JavaScript carries no framework, network, or credential authority. */ +function verifyAuthorityFreeBundles() { + for (const filename of [ + 'cwl-writing-diagnostics.js', + 'cwl-writing-diagnostics.cjs', + ]) { + const bundlePath = join(packageDirectory, 'dist', filename); + const bundleSource = readFileSync(bundlePath, 'utf8'); + assert.equal( + dynamicLoaderPattern.test(bundleSource), + false, + `${filename} must not invoke dynamic module loaders`, + ); + assert.doesNotMatch( + bundleSource, + externalRuntimeImportPattern, + `${filename} must not import external runtime authority`, + ); + assert.doesNotMatch( + bundleSource, + ambientAuthorityPattern, + `${filename} must not reference ambient network or credential authority`, + ); + assert.doesNotMatch( + bundleSource, + forbiddenFrameworkPattern, + `${filename} must remain framework and collaboration neutral`, + ); + } +} + +/** Return one complete strict host diagnostic for runtime consumer checks. */ +function diagnosticLiteral() { + const digestHex = '11'.repeat(32); + return { + diagnosticId: 'consumer-diagnostic', + documentRevision: { + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }, + textProjection: { + id: 'inkspan-prosemirror-text', + version: 1, + }, + selector: { + type: 'TextPositionSelector', + start: 0, + end: 5, + }, + categoryCode: 'clarity', + priority: 'important', + title: 'Clarify the request', + explanation: 'State the requested action.', + suggestedReplacement: 'Omega', + provenance: { + workflowId: 'consumer-workflow', + workflowVersion: '1', + judgePolicyVersion: '1', + }, + }; +} + +/** Write a reusable fake structural document expression for runtime consumers. */ +function fakeDocumentSource() { + return `({ + descendants(visitor) { + visitor(Object.freeze({ + isBlock: false, + isText: true, + isLeaf: false, + inlineContent: false, + text: 'Alpha', + nodeSize: 5, + }), 1); + }, +})`; +} + +/** Exercise the exact public ESM, CommonJS, and SSR-safe subpath. */ +function verifyRuntimeConsumers() { + const diagnostic = JSON.stringify(diagnosticLiteral()); + const fakeDocument = fakeDocumentSource(); + const esmPath = join(consumerDirectory, 'consumer.mjs'); + writeFileSync( + esmPath, + `import assert from 'node:assert/strict'; +import { + DEFAULT_WRITING_DIAGNOSTIC_LIMITS, + TEXT_POSITION_PROJECTION_ID, + TEXT_POSITION_PROJECTION_VERSION, + WritingDiagnosticError, + WritingDiagnosticProjectionError, + buildTextProjectionMap, + resolveTextPositionSelector, + validateWritingDiagnostics, +} from '${packageJson.name}/writing-diagnostics'; +assert.equal(DEFAULT_WRITING_DIAGNOSTIC_LIMITS.maxDiagnostics, 256); +assert.equal(TEXT_POSITION_PROJECTION_ID, 'inkspan-prosemirror-text'); +assert.equal(TEXT_POSITION_PROJECTION_VERSION, 1); +assert.equal(typeof WritingDiagnosticError, 'function'); +assert.equal(typeof WritingDiagnosticProjectionError, 'function'); +const validated = validateWritingDiagnostics([${diagnostic}]); +assert.equal(validated.length, 1); +assert.equal(Object.isFrozen(validated), true); +const documentNode = ${fakeDocument}; +const projection = buildTextProjectionMap(documentNode); +assert.equal(projection.text, 'Alpha'); +assert.deepEqual( + resolveTextPositionSelector( + documentNode, + validated[0].selector, + validated[0].textProjection, + ), + { from: 1, to: 6 }, +); +`, + 'utf8', + ); + + const cjsPath = join(consumerDirectory, 'consumer.cjs'); + writeFileSync( + cjsPath, + `const assert = require('node:assert/strict'); +const diagnostics = require('${packageJson.name}/writing-diagnostics'); +assert.equal(diagnostics.DEFAULT_WRITING_DIAGNOSTIC_LIMITS.maxDiagnostics, 256); +assert.equal(diagnostics.TEXT_POSITION_PROJECTION_ID, 'inkspan-prosemirror-text'); +assert.equal(diagnostics.TEXT_POSITION_PROJECTION_VERSION, 1); +const validated = diagnostics.validateWritingDiagnostics([${diagnostic}]); +assert.equal(validated.length, 1); +const documentNode = ${fakeDocument}; +assert.equal(diagnostics.buildTextProjectionMap(documentNode).text, 'Alpha'); +assert.deepEqual( + diagnostics.resolveTextPositionSelector( + documentNode, + validated[0].selector, + validated[0].textProjection, + ), + { from: 1, to: 6 }, +); +const failure = new diagnostics.WritingDiagnosticProjectionError('selector'); +assert.equal(failure.code, 'selector'); +`, + 'utf8', + ); + + const ssrPath = join(consumerDirectory, 'ssr-consumer.mjs'); + writeFileSync( + ssrPath, + `import assert from 'node:assert/strict'; +import * as diagnostics from '${packageJson.name}/writing-diagnostics'; +assert.equal(typeof globalThis.document, 'undefined'); +assert.equal(typeof globalThis.window, 'undefined'); +assert.deepEqual(diagnostics.validateWritingDiagnostics([]), []); +assert.equal(typeof diagnostics.resolveTextPositionSelector, 'function'); +`, + 'utf8', + ); + + run(process.execPath, [esmPath], consumerDirectory); + run(process.execPath, [cjsPath], consumerDirectory); + run(process.execPath, [ssrPath], consumerDirectory); +} + +/** Compile one strict TypeScript consumer against only the public subpath. */ +function verifyDeclarationConsumer() { + const sourcePath = join(consumerDirectory, 'consumer.ts'); + const configurationPath = join(consumerDirectory, 'tsconfig.json'); + writeFileSync( + sourcePath, + `import { + DEFAULT_WRITING_DIAGNOSTIC_LIMITS, + TEXT_POSITION_PROJECTION_ID, + TEXT_POSITION_PROJECTION_VERSION, + WritingDiagnosticError, + WritingDiagnosticProjectionError, + buildTextProjectionMap, + resolveTextPositionSelector, + validateWritingDiagnostics, + type CwlEditorTextPositionSelector, + type CwlEditorTextPositionSelectorEvidence, + type CwlEditorTextProjectionIdentity, + type CwlWritingDiagnostic, + type CwlWritingDiagnosticPriority, + type CwlWritingDiagnosticProvenance, + type CwlWritingDiagnosticTextProjectionMap, + type WritingDiagnosticErrorCode, + type WritingDiagnosticLimits, + type WritingDiagnosticProjectionErrorCode, +} from '${packageJson.name}/writing-diagnostics'; +import type { Node as ProseMirrorNode } from '@tiptap/pm/model'; +declare const documentNode: ProseMirrorNode; +const digestHex = '11'.repeat(32); +const selector: CwlEditorTextPositionSelector = { + type: 'TextPositionSelector', + start: 0, + end: 5, +}; +const textProjection: CwlEditorTextProjectionIdentity = { + id: TEXT_POSITION_PROJECTION_ID, + version: TEXT_POSITION_PROJECTION_VERSION, +}; +const provenance: CwlWritingDiagnosticProvenance = { + workflowId: 'consumer-workflow', + workflowVersion: '1', + judgePolicyVersion: '1', +}; +const priority: CwlWritingDiagnosticPriority = 'important'; +const diagnostic: CwlWritingDiagnostic = { + diagnosticId: 'consumer-diagnostic', + documentRevision: { + algorithm: 'SHA-256', + digestHex, + strongEntityTag: \`"sha256-\${digestHex}"\`, + }, + textProjection, + selector, + categoryCode: 'clarity', + priority, + title: 'Clarify the request', + explanation: 'State the requested action.', + provenance, +}; +const limits: WritingDiagnosticLimits = { + maxDiagnostics: DEFAULT_WRITING_DIAGNOSTIC_LIMITS.maxDiagnostics, +}; +const validated = validateWritingDiagnostics([diagnostic], limits); +const map: CwlWritingDiagnosticTextProjectionMap = buildTextProjectionMap(documentNode); +const resolved = resolveTextPositionSelector( + documentNode, + validated[0]!.selector, + validated[0]!.textProjection, +); +const evidence: CwlEditorTextPositionSelectorEvidence = { + revision: diagnostic.documentRevision, + selector, + textProjection, +}; +const contractCode: WritingDiagnosticErrorCode = 'contract'; +const projectionCode: WritingDiagnosticProjectionErrorCode = 'selector'; +const contractFailure = new WritingDiagnosticError(contractCode); +const projectionFailure = new WritingDiagnosticProjectionError(projectionCode); +void [ + map.text, + resolved.from, + resolved.to, + evidence.revision.strongEntityTag, + contractFailure.code, + projectionFailure.code, +]; +`, + 'utf8', + ); + writeFileSync( + configurationPath, + `${JSON.stringify( + { + compilerOptions: { + noEmit: true, + strict: true, + skipLibCheck: false, + module: 'NodeNext', + moduleResolution: 'NodeNext', + target: 'ES2022', + lib: ['ES2022', 'DOM', 'DOM.Iterable'], + types: [], + }, + files: ['./consumer.ts'], + }, + null, + 2, + )}\n`, + 'utf8', + ); + const compilerPath = join( + repositoryRoot, + 'node_modules', + 'typescript', + 'bin', + 'tsc', + ); + assert.ok(existsSync(compilerPath)); + run(process.execPath, [compilerPath, '--project', configurationPath], consumerDirectory); +} + +try { + preparePackage(); + verifyAuthorityFreeBundles(); + verifyRuntimeConsumers(); + verifyDeclarationConsumer(); + console.log( + `Verified packed ${packageJson.name}/writing-diagnostics through authority-bounded ESM, CommonJS, SSR, and strict TypeScript consumers.`, + ); +} finally { + rmSync(verificationRoot, { recursive: true, force: true }); +} diff --git a/src/writing-diagnostics/index.ts b/src/writing-diagnostics/index.ts index 83c7afd0..2f358490 100644 --- a/src/writing-diagnostics/index.ts +++ b/src/writing-diagnostics/index.ts @@ -1,9 +1,11 @@ /** * 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. + * This subpath validates bounded revision-scoped diagnostic proposals and maps + * exact W3C text-position selectors to structural ProseMirror ranges. It does + * not import a UI framework, create editor instances, call models, providers, + * or networks, persist authored content, infer language quality, or mutate a + * document. */ export { DEFAULT_WRITING_DIAGNOSTIC_LIMITS, @@ -17,3 +19,23 @@ export type { WritingDiagnosticErrorCode, WritingDiagnosticLimits, } from '../writingDiagnostics.js'; +export { + WritingDiagnosticProjectionError, + buildTextProjectionMap, + resolveTextPositionSelector, +} from '../writingDiagnosticProjection.js'; +export type { + CwlWritingDiagnosticTextProjectionMap, + WritingDiagnosticProjectionErrorCode, +} from '../writingDiagnosticProjection.js'; +export { + TEXT_POSITION_PROJECTION_ID, + TEXT_POSITION_PROJECTION_VERSION, + TextPositionSelectorEvidenceError, +} from '../textPositionSelectorEvidence.js'; +export type { + CwlEditorTextPositionSelector, + CwlEditorTextPositionSelectorEvidence, + CwlEditorTextProjectionIdentity, + TextPositionSelectorEvidenceErrorCode, +} from '../textPositionSelectorEvidence.js'; diff --git a/src/writingDiagnosticsPackage.test.ts b/src/writingDiagnosticsPackage.test.ts new file mode 100644 index 00000000..eb41ac41 --- /dev/null +++ b/src/writingDiagnosticsPackage.test.ts @@ -0,0 +1,139 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_WRITING_DIAGNOSTIC_LIMITS, + TEXT_POSITION_PROJECTION_ID, + TEXT_POSITION_PROJECTION_VERSION, + TextPositionSelectorEvidenceError, + WritingDiagnosticError, + WritingDiagnosticProjectionError, + buildTextProjectionMap, + resolveTextPositionSelector, + validateWritingDiagnostics, +} from './writing-diagnostics/index.js'; + +const repositoryRoot = process.cwd(); +const packageJson = JSON.parse( + readFileSync(join(repositoryRoot, 'package.json'), 'utf8'), +) as { + scripts: Record; + exports: Record< + string, + string | { types?: string; import?: string; require?: string } + >; +}; + +function readRepositoryFile(path: string): string { + return readFileSync(join(repositoryRoot, path), 'utf8'); +} + +describe('writing diagnostics package boundary', () => { + it('exports the strict contract and exact selector-resolution primitives', () => { + expect(DEFAULT_WRITING_DIAGNOSTIC_LIMITS.maxDiagnostics).toBeGreaterThan(0); + expect(TEXT_POSITION_PROJECTION_ID).toBe('inkspan-prosemirror-text'); + expect(TEXT_POSITION_PROJECTION_VERSION).toBe(1); + expect(typeof WritingDiagnosticError).toBe('function'); + expect(typeof WritingDiagnosticProjectionError).toBe('function'); + expect(typeof TextPositionSelectorEvidenceError).toBe('function'); + const evidenceError = new TextPositionSelectorEvidenceError( + 'grapheme_boundary', + ); + expect(evidenceError).toMatchObject({ + name: 'TextPositionSelectorEvidenceError', + code: 'grapheme_boundary', + }); + expect(typeof validateWritingDiagnostics).toBe('function'); + expect(typeof buildTextProjectionMap).toBe('function'); + expect(typeof resolveTextPositionSelector).toBe('function'); + + const textNode = Object.freeze({ + isBlock: false, + isText: true, + isLeaf: false, + inlineContent: false, + text: 'Alpha', + nodeSize: 5, + }); + const documentNode = { + descendants( + visitor: (node: typeof textNode, position: number) => boolean | void, + ): void { + visitor(textNode, 1); + }, + } as unknown as Parameters[0]; + const projection = buildTextProjectionMap(documentNode); + expect(projection.text).toBe('Alpha'); + expect(projection.boundaryPositions).toEqual([1, 2, 3, 4, 5, 6]); + expect( + resolveTextPositionSelector( + documentNode, + { type: 'TextPositionSelector', start: 0, end: 5 }, + { + id: TEXT_POSITION_PROJECTION_ID, + version: TEXT_POSITION_PROJECTION_VERSION, + }, + ), + ).toEqual({ from: 1, to: 6 }); + }); + + it('publishes one explicit ESM, CommonJS, and declaration export map', () => { + expect(packageJson.exports['./writing-diagnostics']).toEqual({ + types: './dist/writing-diagnostics/index.d.ts', + import: './dist/cwl-writing-diagnostics.js', + require: './dist/cwl-writing-diagnostics.cjs', + }); + expect(packageJson.scripts.build).toContain( + 'vite build --config vite.writing-diagnostics.config.ts', + ); + expect(packageJson.scripts['verify:package']).toContain( + 'node ./scripts/verify-writing-diagnostics-subpath-package.mjs', + ); + }); + + it('defines a dual-format declaration build with sourcemaps', () => { + const configuration = readRepositoryFile( + 'vite.writing-diagnostics.config.ts', + ); + expect(configuration).toContain("src/writing-diagnostics/index.ts"); + expect(configuration).toContain("'cwl-writing-diagnostics.js'"); + expect(configuration).toContain("'cwl-writing-diagnostics.cjs'"); + expect(configuration).toContain("formats: ['es', 'cjs']"); + expect(configuration).toContain('sourcemap: true'); + expect(configuration).toContain('vite-plugin-dts'); + expect(configuration).not.toMatch(/@vitejs\/plugin-react|from ['"]react|from ['"]yjs/iu); + }); + + it('keeps the public barrel and packed consumer framework-neutral', () => { + const barrel = readRepositoryFile('src/writing-diagnostics/index.ts'); + expect(barrel).toContain("from '../writingDiagnostics.js'"); + expect(barrel).toContain("from '../writingDiagnosticProjection.js'"); + expect(barrel).toContain("from '../textPositionSelectorEvidence.js'"); + expect(barrel).not.toMatch( + /(?:@tiptap\/react|react(?:-dom)?|collaboration|yjs|components|extensions|fetch\s*\(|process\.env|import\.meta\.env)/iu, + ); + + const verifier = readRepositoryFile( + 'scripts/verify-writing-diagnostics-subpath-package.mjs', + ); + for (const requiredEvidence of [ + 'consumer.mjs', + 'consumer.cjs', + 'ssr-consumer.mjs', + 'consumer.ts', + 'strict: true', + 'skipLibCheck: false', + 'validateWritingDiagnostics', + 'buildTextProjectionMap', + 'resolveTextPositionSelector', + 'WritingDiagnosticProjectionError', + 'dynamicLoaderPattern', + 'ambientAuthorityPattern', + ]) { + expect(verifier).toContain(requiredEvidence); + } + expect(verifier).not.toMatch( + /from ['"](?:react|react-dom|yjs|@tiptap\/react)|require\(['"](?:react|react-dom|yjs|@tiptap\/react)/u, + ); + }); +}); diff --git a/vite.writing-diagnostics.config.ts b/vite.writing-diagnostics.config.ts new file mode 100644 index 00000000..2dbf9d9c --- /dev/null +++ b/vite.writing-diagnostics.config.ts @@ -0,0 +1,37 @@ +import { resolve } from 'node:path'; +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; + +// Framework-neutral writing-diagnostic contract build: ZERO React, React DOM, +// TipTap editor UI/view, Yjs, provider, awareness, network, credential, +// persistence, naruon, contextual-orchestrator, email, or model imports. +// ProseMirror model appears only in erased TypeScript input types. +export default defineConfig({ + plugins: [ + dts({ + include: [ + 'src/writing-diagnostics', + 'src/writingDiagnostics.ts', + 'src/writingDiagnosticProjection.ts', + 'src/textPositionSelectorEvidence.ts', + 'src/graphemeBoundary.ts', + ], + exclude: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.spec.ts'], + rollupTypes: false, + entryRoot: 'src', + }), + ], + build: { + emptyOutDir: false, + lib: { + entry: resolve(__dirname, 'src/writing-diagnostics/index.ts'), + name: 'InkspanWritingDiagnostics', + fileName: (format) => + format === 'es' + ? 'cwl-writing-diagnostics.js' + : 'cwl-writing-diagnostics.cjs', + formats: ['es', 'cjs'], + }, + sourcemap: true, + }, +});