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
34 changes: 14 additions & 20 deletions packages/superdoc/scripts/audit-declarations.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,33 +5,27 @@
* and reports:
*
* Rule 1 (FAIL in strict mode): private workspace specifier in an emitted
* declaration that is NOT covered by `_internal-shims.d.ts` and NOT a
* legacy public surface. The shim file is the registry of "known
* unresolved" private modules whose types the RFC tolerates collapsing
* to `any`; legacy public surfaces (currently `@superdoc/super-editor`)
* resolve through the published dist tree. Anything outside that
* allowlist is a leak the RFC forbids: a consumer's strict-mode build
* fails to resolve the import.
* declaration that is NOT in `RULE1_ALLOWLIST` (legacy public surfaces,
* currently only `@superdoc/super-editor`). After SD-2942 there is no
* `_internal-shims.d.ts` fallback, so any unrelocated `@superdoc/*`
* specifier on the public surface fails the build instead of riding
* through silently as `any`. If the file is present (a stale dist from
* before SD-2942), its `declare module` entries still suppress Rule 1
* for backward compatibility.
*
* Rule 2 (FAIL in strict mode): package-manager-internal paths.
* `node_modules/.pnpm/...` paths leak the local install layout into a
* declaration that consumers cannot resolve.
*
* Rule 3 (FAIL in strict mode): a relocated package reappears in
* `_internal-shims.d.ts`. The RFC's relocation pattern (SD-2842) routes
* Document API, contracts, layout-bridge, and painter-dom types through
* `superdoc`'s own dist tree; if any of those packages collapse back into
* an `any` shim, customers see the regression. This rule overlaps with
* the build-time check in `ensure-types.cjs`; keeping both lets the audit
* run as a standalone gate against any tarball, not just during a fresh
* build.
* `_internal-shims.d.ts`. With SD-2942 the file is no longer emitted
* by the build, so this rule is a no-op in steady state — kept as a
* defense if a future change re-introduces the file or runs against
* a stale tarball.
*
* Informational: the set of modules still declared in `_internal-shims.d.ts`.
* The shim file may legitimately exist for legacy or internal-only
* declarations; the RFC's audit-gate rule is "no public type may resolve
* through it", not "the file must not exist". This list is reported so
* drift is visible and the surface can be tightened over time, but its
* contents do not fail the audit.
* Informational: the set of modules still declared in `_internal-shims.d.ts`
* when the file exists. After SD-2942 the file is not emitted, so this
* section is normally absent.
*
* Default mode is strict: findings exit non-zero so a regression cannot
* ship silently. Pass `--informational` (or set
Expand Down
183 changes: 36 additions & 147 deletions packages/superdoc/scripts/ensure-types.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -123,13 +123,15 @@ if (!hasSuperDocExport) {
process.exit(1);
}

// Fix workspace package imports that aren't resolvable by consumers.
// @superdoc/common is a private workspace package — inline its types in
// the main entry. Other reachable d.ts files that import from
// @superdoc/common fall through to the ambient shim block below; those
// imports surface internal types (Comment, CommentContent, CommentJSON)
// that are not on the public surface, so collapsing them to `any` via
// the shim is correct.
// @superdoc/common is a private workspace package, so consumers can't
// resolve a bare `from '@superdoc/common'` import. The main entry
// (superdoc/src/index.d.ts) imports runtime values from it — DOCX/PDF/
// HTML constants, getFileObject, compareVersions, BlankDOCX (the last
// from a Vite `?url` import that vite-plugin-dts can't type). Strip
// that import statement and inline ambient declarations for those
// values. Type-only imports of @superdoc/common from other dist files
// are handled separately by the RELOCATION_RULES rewriter below, which
// maps bare @superdoc/common to dist/shared/common/comments-types.d.ts.
const hadWorkspaceImport = content.includes('@superdoc/common');
if (hadWorkspaceImport) {
// Replace the @superdoc/common import with inline declarations
Expand Down Expand Up @@ -492,148 +494,35 @@ if (fs.readFileSync(superEditorFacadePath, 'utf8') !== expectedSuperEditorFacade
}

// ---------------------------------------------------------------------------
// Generate ambient module declarations for private workspace packages (SD-2227)
//
// Internal .d.ts files reference @superdoc/* workspace packages that consumers
// can't install. Generate a shim so TypeScript can resolve these imports.
// ---------------------------------------------------------------------------

// Collect @superdoc/* workspace module specifiers and their named imports from
// all .d.ts files. These are private packages consumers can't install — we
// generate ambient `declare module` shims for them.
const workspaceImports = new Map(); // module → Set<name>

for (const filePath of dtsFiles) {
const fileContent = fs.readFileSync(filePath, 'utf8');

// Match: import/export { Foo, Bar } from '...' and import/export type { Foo } from '...'
const namedImports = fileContent.matchAll(/(?:import|export)\s+(?:type\s+)?\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/g);
for (const m of namedImports) {
const mod = m[2];

// Skip relative imports and already-handled packages
if (shouldSkipWorkspaceShim(mod)) continue;

if (mod.startsWith('@superdoc/')) {
if (!workspaceImports.has(mod)) workspaceImports.set(mod, new Set());
const names = m[1].split(',').map(n => n.trim().split(/\s+as\s+/)[0].trim()).filter(Boolean);
for (const name of names) workspaceImports.get(mod).add(name);
}
}

// Match: import('...').SomeName — dynamic import type references
const dynamicImports = fileContent.matchAll(/import\(['"]([^'"]+)['"]\)\.(\w+)/g);
for (const m of dynamicImports) {
const mod = m[1];
if (shouldSkipWorkspaceShim(mod)) continue;

if (mod.startsWith('@superdoc/')) {
if (!workspaceImports.has(mod)) workspaceImports.set(mod, new Set());
workspaceImports.get(mod).add(m[2]);
}
}

// Match bare @superdoc/* module references
const bareRefs = fileContent.matchAll(/['"](@superdoc\/[^'"]+)['"]/g);
for (const m of bareRefs) {
const mod = m[1];
// Skip @superdoc/super-editor (consumer-facing, not internal). All
// other @superdoc/* references (including @superdoc/common root and
// its subpaths) fall through to shim generation. The strip-and-inline
// step above handles `superdoc/src/index.d.ts`'s @superdoc/common
// import explicitly; other files importing from @superdoc/common
// resolve through the shim and collapse internal-only types
// (Comment, CommentContent, CommentJSON) to `any`. None of those
// appear on superdoc's public surface, so the collapse is safe.
if (shouldSkipWorkspaceShim(mod)) continue;
if (!workspaceImports.has(mod)) workspaceImports.set(mod, new Set());
}
}

// SD-2942: the auto-generated `_internal-shims.d.ts` mechanism was removed
// after SD-2893 drained every shim entry to zero. Previously this script
// scanned dist d.ts files for `from '@superdoc/...'` patterns and wrote a
// `declare module 'X' { export type Y = any; }` block for each unrelocated
// specifier — the "soft landing" path that quietly collapsed new private
// types to `any`. With SD-2893 complete, every reachable workspace type
// resolves through `RELOCATION_RULES` or stays bare for audit Rule 1 to
// reject. A future PR that introduces a new private `@superdoc/*` import
// is expected to fail the build at `audit-declarations.cjs` rather than
// ride through silently as `any`. The triple-slash reference directive
// previously injected into entry-point d.ts is also dropped; vite-plugin-dts
// emits clean entries and the next build overwrites any stale references.
// ---------------------------------------------------------------------------
// Write _internal-shims.d.ts
//
// Only contains auto-generated shims for @superdoc/* workspace packages.
// External packages (prosemirror-*, vue, eventemitter3, yjs, etc.) are NOT
// shimmed — ambient `declare module` overrides real types globally, breaking
// consumers who depend on those packages (IT-852).
// ---------------------------------------------------------------------------

const shimLines = [
'// Auto-generated ambient declarations for internal workspace packages.',
'// These are private @superdoc/* packages that consumers cannot install.',
'// This file prevents TypeScript errors when skipLibCheck is false.',
'//',
'// External packages (prosemirror-*, vue, eventemitter3, yjs, etc.) are NOT',
'// shimmed here — their real types come from node_modules. Ambient shims for',
'// external packages would override real types globally, breaking consumers',
'// who depend on those packages (e.g. Tiptap users need real prosemirror types).',
'//',
'// NOTE: This is a script file (no exports), so `declare module` creates',
'// global ambient declarations and top-level declarations are global.',
'',
];

// --- Auto-generated @superdoc/* workspace package shims ---

let wsCount = 0;
if (workspaceImports.size > 0) {
shimLines.push('// --- Internal workspace packages (auto-generated) ---');
shimLines.push('');
for (const [mod, names] of [...workspaceImports.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
wsCount++;
const sortedNames = [...names].sort();
const exportLines = [];
for (const n of sortedNames) {
// `default` is a reserved word and cannot appear in `export type
// default = any;`. When a file imports the default export of a
// private module (e.g. `import { default as Foo } from '@superdoc/common/components/Foo.vue'`),
// the named-imports collector picks up `default` as a name; emit
// a proper `export default` declaration instead.
if (n === 'default') {
exportLines.push(' const _default: any;');
exportLines.push(' export default _default;');
} else {
exportLines.push(` export type ${n} = any;`);
}
}
if (exportLines.length > 0) {
shimLines.push(`declare module '${mod}' {\n${exportLines.join('\n')}\n}`);
} else {
shimLines.push(`declare module '${mod}' { const _: any; export default _; }`);
}
}
}
shimLines.push('');

const shimPath = path.join(distRoot, '_internal-shims.d.ts');
fs.writeFileSync(shimPath, shimLines.join('\n'));

// Add reference directive to entry points so TypeScript includes the shims
const shimRef = '/// <reference path="../../_internal-shims.d.ts" />\n';
for (const entry of requiredEntryPoints) {
const entryPath = path.join(distRoot, entry);
const entryContent = fs.readFileSync(entryPath, 'utf8');
if (!entryContent.includes('_internal-shims.d.ts')) {
fs.writeFileSync(entryPath, shimRef + entryContent);
}
}

console.log(`[ensure-types] ✓ Generated ambient shims for ${wsCount} workspace modules`);

// SD-2842 regression net: assert that no relocated package leaked back
// into the shim file. If one shows up, a future change broke the
// rewrite or include for that package and customers would see `any`
// for those types again.
const shimContent = fs.readFileSync(shimPath, 'utf8');
const SHIM_FORBIDDEN = RELOCATION_GUARD_PACKAGES;
for (const pkg of SHIM_FORBIDDEN) {
const re = new RegExp(`declare module '${escapeRegExp(pkg)}(\\/[^']+)?'`);
if (re.test(shimContent)) {
console.error(`[ensure-types] ✗ ${pkg} appears in _internal-shims.d.ts. Its types should resolve via a relocation rewrite or fail the audit as an unrelocated leak, not via an ambient any shim. Investigate the include glob, the rewrite rule, and the shim-skip predicate for this package.`);
process.exit(1);
}
// `shouldSkipWorkspaceShim` is intentionally retained: it is no longer used
// by shim generation, but kept as documentation for the relocation policy
// (relocated specifiers + UNSHIMMED_PRIVATE_SPECIFIERS + super-editor /
// document-api legacy public surface). Future audit rules that need to
// classify workspace specifiers can reuse it.
void shouldSkipWorkspaceShim;

// Clean up artifacts from the old shim mechanism. vite-plugin-dts overwrites
// entry-point d.ts on each build, so the triple-slash references injected by
// the old code are wiped automatically; only the shim file itself persists
// across builds and needs an explicit unlink.
const legacyShimPath = path.join(distRoot, '_internal-shims.d.ts');
if (fs.existsSync(legacyShimPath)) {
fs.unlinkSync(legacyShimPath);
console.log('[ensure-types] ✓ Removed legacy _internal-shims.d.ts');
}
console.log(`[ensure-types] ✓ Verified ${SHIM_FORBIDDEN.length} relocated packages do not appear in shim file`);

console.log('[ensure-types] ✓ Verified type entry points');
Loading