Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion packages/superdoc/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@
"word-benchmark-sidecar": "node ../../devtools/word-benchmark-sidecar/server.js",
"build": "vite build && pnpm run build:cdn",
"build:dev": "SUPERDOC_SKIP_DTS=1 vite build",
"postbuild": "node ./scripts/check-tsconfig-type-surface.cjs && node ./scripts/ensure-types.cjs && node ./scripts/audit-bundle.cjs && node ./scripts/audit-declarations.cjs && node ./scripts/check-export-coverage.cjs && node ./scripts/report-declaration-reachability.cjs",
"postbuild": "node ./scripts/check-tsconfig-type-surface.cjs && node ./scripts/ensure-types.cjs && node ./scripts/audit-bundle.cjs && node ./scripts/audit-declarations.cjs && node ./scripts/check-export-coverage.cjs && node ./scripts/verify-public-facade-emit.cjs && node ./scripts/report-declaration-reachability.cjs",
"audit:declarations": "node ./scripts/audit-declarations.cjs",
"audit:declarations:informational": "node ./scripts/audit-declarations.cjs --informational",
"check:jsdoc": "node ./scripts/check-jsdoc.cjs",
Expand Down
8 changes: 8 additions & 0 deletions packages/superdoc/scripts/ensure-types.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,14 @@ const cjsDeclarationShims = [
source: path.join(distRoot, 'superdoc/src/super-editor.d.ts'),
target: './super-editor.js',
},
// SD-3178: explicit public facade root entry. The CJS shim is generated
// now so that Phase 4 (the `package.json#exports` flip) does not need a
// separate pipeline change.
{
file: path.join(distRoot, 'superdoc/src/public/index.d.cts'),
source: path.join(distRoot, 'superdoc/src/public/index.d.ts'),
target: './index.js',
},
];

function isValidIdentifier(name) {
Expand Down
202 changes: 202 additions & 0 deletions packages/superdoc/scripts/verify-public-facade-emit.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
#!/usr/bin/env node
/**
* SD-3178 (Phase 3 of SD-3175): verify the explicit public facade emits a
* declaration tree that is safe to ship.
*
* Runs as a postbuild step under `packages/superdoc/`. Loads the emitted
* `dist/superdoc/src/public/index.d.ts` and `index.d.cts` with the
* TypeScript compiler API and asserts:
*
* 1. The expected symbol set is exported from each declaration file.
* 2. The ESM and CJS declarations agree on the exported names.
* 3. The command signature surface survives the facade emit. This is
* the SD-2965 regression vector: specific command signatures getting
* dropped or failing to flow through the facade. `EditorCommands` is
* `CoreCommands & ExtensionCommands & AllCommandSignatures & Record<string, AnyCommand>`,
* so the trailing `Record<string, AnyCommand>` makes any indexer
* lookup resolve even when the specific signatures are missing.
* The probe asserts the RETURN TYPE of two commands (`setBold`,
* `insertComment`) is `boolean`, not the `AnyCommand` fallback's
* `unknown`. Two commands from two signature sources (formatting +
* comments) catch partial drops a single-command probe would miss.
* 4. The emitted declarations contain no private workspace specifiers
* (`@superdoc/*`), no package-manager internals (`.pnpm/`), and no
* absolute local paths into the repo or `node_modules`.
*
* Note: relative declaration references into the per-package dist
* tree (e.g. `../../../super-editor/src/index.js`) are expected at
* this phase. The dts pipeline relocates `@superdoc/super-editor`
* specifiers into the relocated declaration tree so consumers do
* not see the workspace specifier. Later SD-3178 follow-ups reduce
* how much the facade depends on that broader declaration graph.
*
* Exits non-zero on any failure. Designed to fail loud and early.
*/
'use strict';

const fs = require('node:fs');
const path = require('node:path');

const repoRoot = path.resolve(__dirname, '..', '..', '..');
const distRoot = path.resolve(__dirname, '..', 'dist');
const FACADE_ESM = path.join(distRoot, 'superdoc', 'src', 'public', 'index.d.ts');
const FACADE_CJS = path.join(distRoot, 'superdoc', 'src', 'public', 'index.d.cts');

const EXPECTED_NAMES = ['Config', 'Editor', 'EditorCommands', 'SuperDoc'].sort();

let ts;
try {
ts = require('typescript');
} catch (err) {
console.error('[verify-public-facade-emit] typescript is not available in this package.');
process.exit(1);
}

function loadFile(file) {
if (!fs.existsSync(file)) {
console.error(`[verify-public-facade-emit] missing facade declaration: ${file}`);
console.error('Run `pnpm --filter superdoc run build` first.');
process.exit(1);
}
return fs.readFileSync(file, 'utf8');
}

function listExportedNames(file) {
const program = ts.createProgram({
rootNames: [file],
options: {
moduleResolution: ts.ModuleResolutionKind.Bundler,
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ESNext,
noEmit: true,
skipLibCheck: true,
},
});
const checker = program.getTypeChecker();
const src = program.getSourceFile(file);
const symbol = checker.getSymbolAtLocation(src) ?? (src && src.symbol);
if (!symbol) {
return { names: [], program, checker };
}
return {
names: [...new Set(checker.getExportsOfModule(symbol).map((s) => s.getName()))].sort(),
program,
checker,
};
}

let failed = false;

// (1) Symbol set.
const esm = listExportedNames(FACADE_ESM);
if (JSON.stringify(esm.names) !== JSON.stringify(EXPECTED_NAMES)) {
console.error(`[verify-public-facade-emit] ESM facade exports drifted.`);
console.error(' expected: ' + EXPECTED_NAMES.join(', '));
console.error(' actual: ' + esm.names.join(', '));
console.error(' If this addition is intentional, update EXPECTED_NAMES in this script and link');
console.error(' the PR to SD-3175 (path-as-contract umbrella) for reviewer sign-off.');
failed = true;
}

// (2) ESM/CJS parity.
const cjs = listExportedNames(FACADE_CJS);
if (JSON.stringify(esm.names) !== JSON.stringify(cjs.names)) {
const importOnly = esm.names.filter((n) => !cjs.names.includes(n));
const requireOnly = cjs.names.filter((n) => !esm.names.includes(n));
console.error('[verify-public-facade-emit] ESM/CJS facade declarations disagree on exports.');
if (importOnly.length) console.error(' ESM-only: ' + importOnly.join(', '));
if (requireOnly.length) console.error(' CJS-only: ' + requireOnly.join(', '));
console.error(' Fix the CJS shim generator (packages/superdoc/scripts/ensure-types.cjs).');
failed = true;
}

// (3) Command signature survival: assert two commands return `boolean`,
// not the `AnyCommand` fallback. See header for why a bare resolution
// check is not enough (the `Record<string, AnyCommand>` intersection
// always satisfies the indexer).
{
const probe = `
import type { EditorCommands } from ${JSON.stringify(FACADE_ESM)};
type ReturnsBoolean<F> = F extends (...args: any[]) => boolean ? true : false;
// Direct assignment of literal \`true\` to the conditional result. If the
// signature is missing and the indexer falls back to AnyCommand, the
// conditional resolves to \`false\` and the assignment fails with TS2322.
// Casts of the form \`true as Result\` would mask the failure by
// laundering through \`never\`, so the literal stays un-cast on purpose.
// setBold comes from FormattingCommandAugmentations.
const __setBoldOk: ReturnsBoolean<EditorCommands['setBold']> = true;
void __setBoldOk;
// insertComment comes from CommentCommands. Two sources catches partial
// drops a single-command probe would miss.
const __insertCommentOk: ReturnsBoolean<EditorCommands['insertComment']> = true;
void __insertCommentOk;
`;
const probePath = path.join(distRoot, '__public-facade-command-signature-probe.ts');
fs.writeFileSync(probePath, probe, 'utf8');
try {
const program = ts.createProgram({
rootNames: [probePath],
options: {
moduleResolution: ts.ModuleResolutionKind.Bundler,
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ESNext,
strict: true,
skipLibCheck: true,
noEmit: true,
types: [],
},
});
const diagnostics = [
...program.getSemanticDiagnostics(),
...program.getDeclarationDiagnostics(),
];
if (diagnostics.length > 0) {
console.error('[verify-public-facade-emit] command signature probe failed.');
console.error(' A command (setBold or insertComment) does not return `boolean` through the facade.');
console.error(' This is the SD-2965 regression vector: specific command signatures were dropped or failed to flow through the facade, and EditorCommands fell back to the `AnyCommand` indexer.');
for (const d of diagnostics) {
const msg = typeof d.messageText === 'string'
? d.messageText
: ts.flattenDiagnosticMessageText(d.messageText, '\n');
console.error(' - ' + msg);
}
failed = true;
}
} finally {
try { fs.unlinkSync(probePath); } catch (_) {}
}
}

// (4) No internal leaks in emitted code (strip JSDoc/line comments first so
// that comments referencing `@superdoc/super-editor` in prose are not flagged).
function stripComments(source) {
return source
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/[^\n]*/g, '$1');
}

const LEAK_PATTERNS = [
{ name: 'private workspace specifier', re: /(?:from\s+['"]|require\(['"])@superdoc\//g },
{ name: 'pnpm internal path', re: /\.pnpm\//g },
{ name: 'absolute source path', re: /['"][\/\\][^'"\n]*\/(packages|node_modules)\//g },
];

for (const file of [FACADE_ESM, FACADE_CJS]) {
const code = stripComments(loadFile(file));
for (const pattern of LEAK_PATTERNS) {
const matches = code.match(pattern.re);
if (matches && matches.length > 0) {
console.error(`[verify-public-facade-emit] leak in ${path.relative(repoRoot, file)}:`);
console.error(` ${pattern.name}: ${matches.slice(0, 5).join(', ')}`);
failed = true;
}
}
}

if (failed) {
console.error('');
console.error('[verify-public-facade-emit] FAILED. The public facade emit is not safe to advance.');
process.exit(1);
}

console.log(`[verify-public-facade-emit] OK. Facade emits cleanly: ${esm.names.length} exports, ESM/CJS in parity, command signatures survive.`);
23 changes: 23 additions & 0 deletions packages/superdoc/src/public/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { SuperDoc, Editor } from './index.js';

/**
* Smoke test for the public facade root entry (SD-3178).
*
* The two runtime re-exports (`SuperDoc`, `Editor`) need coverage so the
* facade file does not show 0% on the unit-test coverage report. The
* verification of declaration emit (symbol set, ESM/CJS parity, augmentation
* survival) lives in `packages/superdoc/scripts/verify-public-facade-emit.cjs`,
* which runs as a postbuild step.
*/
describe('public facade (root)', () => {
it('re-exports SuperDoc as a constructor', () => {
expect(typeof SuperDoc).toBe('function');
expect(SuperDoc.name).toBe('SuperDoc');
});

it('re-exports Editor as a constructor', () => {
expect(typeof Editor).toBe('function');
expect(Editor.name).toBe('Editor');
});
});
33 changes: 33 additions & 0 deletions packages/superdoc/src/public/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* SuperDoc public facade: root entry.
*
* SD-3178 (Phase 3 of SD-3175). This file is the first real source-side
* public facade for SuperDoc. The intent is path-as-contract: anything
* exported from `packages/superdoc/src/public/**` is supported public API,
* and anything outside is implementation detail. Subsequent PRs in the
* SD-3175 umbrella expand this directory entry-by-entry; Phase 4 (the
* contract switch) flips `package.json#exports` to point at the emitted
* declarations under this tree.
*
* Rules for this file:
* - AIDEV-NOTE: Named exports only. No `export *` from implementation
* barrels. `export *` re-introduces the leak this facade exists to
* close - see SD-3175 (path-as-contract umbrella) for context.
* - Explicit `.js` source specifiers (the dts plugin emits `.js`
* specifiers; source consistency keeps the two aligned).
* - AIDEV-NOTE: Adding or removing an export here is a deliberate
* public-API decision. In the same PR, update the `EXPECTED_NAMES`
* list in `packages/superdoc/scripts/verify-public-facade-emit.cjs`
* and link to SD-3175 (or a child ticket) for reviewer sign-off.
* Skipping the EXPECTED_NAMES update fails the postbuild gate.
*
* The initial export set deliberately mirrors the symbols validated by
* SD-3177 (the emit feasibility spike): a runtime value with a typed
* surface (`SuperDoc`, `Config`) plus the augmentation-bearing pair
* (`Editor`, `EditorCommands`). This guarantees the same regression
* tests that proved the pipeline keep proving it.
*/
export { SuperDoc } from '../core/SuperDoc.js';
export type { Config } from '../core/types/index.js';
export { Editor } from '@superdoc/super-editor';
export type { EditorCommands } from '@superdoc/super-editor';
10 changes: 10 additions & 0 deletions packages/superdoc/vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,16 @@ export default defineConfig(({ mode, command }) => {
'super-editor/docx-zipper': '@core/DocxZipper',
'super-editor/converter': '@core/super-converter/SuperConverter',
'super-editor/file-zipper': '@core/super-converter/zipper.js',
// SD-3178 (Phase 3 of SD-3175): root entry of the explicit public
// facade. Build emits the artifact alongside the existing entries
// so the facade declarations are available for postbuild
// verification.
// AIDEV-NOTE: `package.json#exports` is intentionally not yet
// updated to point at this entry. Phase 4 (a separate child of
// SD-3175) owns the contract switch. Adding a `./public` entry
// here without that ticket ships a new public subpath under the
// radar.
'public': 'src/public/index.ts',
},
external: [
'yjs',
Expand Down
Loading