diff --git a/.github/workflows/ci-superdoc.yml b/.github/workflows/ci-superdoc.yml index b0cee20a85..6cc531896a 100644 --- a/.github/workflows/ci-superdoc.yml +++ b/.github/workflows/ci-superdoc.yml @@ -154,22 +154,17 @@ jobs: # ESM, missing CDN files, unpublished `source` paths. run: node tests/consumer-typecheck/package-shape-gate.mjs - - name: Legacy public no-growth gates (SD-3176) - # No-growth snapshots for the legacy public compatibility surfaces. - # See tests/consumer-typecheck/snapshots/README.md for the policy. - # Runs after the matrix step so the packed-and-installed fixture - # is available for Snapshot B (resolved named exports). - run: | - node tests/consumer-typecheck/snapshot-super-editor-package-exports.mjs --check - node tests/consumer-typecheck/snapshot-superdoc-legacy-exports.mjs --check - - - name: Root no-growth + 4-source inventory (SD-3212 PR A0) - # The superdoc root entry resolves through four package.json#exports - # sources (types.import, types.require, import, require). Each is - # snapshotted separately and drift-gated. Cross-source mismatches - # (typed-only / runtime-only / ESM vs CJS) are reported in the - # companion .md but are not blockers on their own. - run: node tests/consumer-typecheck/snapshot-superdoc-root-exports.mjs --check + - name: Public surface no-growth snapshots (SD-3176, SD-3212) + # Unified entry point for the three snapshot families: + # - super-editor-package: @superdoc/super-editor package.json#exports keys + # - legacy: resolved exports for superdoc/* legacy subpaths + # - root: 4-source inventory (types.import, types.require, import, + # require) for the superdoc root entry. Cross-source mismatches + # are reported in the companion .md but are not blockers on their + # own. + # Runs after the matrix step so the packed-and-installed fixture is + # available. See tests/consumer-typecheck/snapshots/README.md. + run: node tests/consumer-typecheck/snapshot.mjs --all --check - name: Root classification closure gate (SD-3212 PR A1b) # Asserts the dependency-closure rule from the A1 classification: diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml index c58aba5f79..2095df7d4a 100644 --- a/.github/workflows/release-stable.yml +++ b/.github/workflows/release-stable.yml @@ -127,13 +127,10 @@ jobs: - name: Package shape gates run: node tests/consumer-typecheck/package-shape-gate.mjs - - name: Legacy public no-growth gates (SD-3176) - run: | - node tests/consumer-typecheck/snapshot-super-editor-package-exports.mjs --check - node tests/consumer-typecheck/snapshot-superdoc-legacy-exports.mjs --check - - - name: Root no-growth + 4-source inventory (SD-3212 PR A0) - run: node tests/consumer-typecheck/snapshot-superdoc-root-exports.mjs --check + - name: Public surface no-growth snapshots (SD-3176, SD-3212) + # Unified entry point for all three snapshot families + # (super-editor-package, legacy, root). Same gate as PR CI. + run: node tests/consumer-typecheck/snapshot.mjs --all --check - name: Root classification closure gate (SD-3212 PR A1b) run: node tests/consumer-typecheck/check-root-classification-closure.mjs diff --git a/.github/workflows/release-superdoc.yml b/.github/workflows/release-superdoc.yml index 49ce7b4f72..1f10d8a384 100644 --- a/.github/workflows/release-superdoc.yml +++ b/.github/workflows/release-superdoc.yml @@ -149,15 +149,11 @@ jobs: # the packed tarball. Same step as PR CI. run: node tests/consumer-typecheck/package-shape-gate.mjs - - name: Legacy public no-growth gates (SD-3176) + - name: Public surface no-growth snapshots (SD-3176, SD-3212) # Same gate as PR CI. Catches releases that bypass PR CI. - run: | - node tests/consumer-typecheck/snapshot-super-editor-package-exports.mjs --check - node tests/consumer-typecheck/snapshot-superdoc-legacy-exports.mjs --check - - - name: Root no-growth + 4-source inventory (SD-3212 PR A0) - # Same gate as PR CI. Catches releases that bypass PR CI. - run: node tests/consumer-typecheck/snapshot-superdoc-root-exports.mjs --check + # Runs the unified entry point for all three snapshot families + # (super-editor-package, legacy, root). + run: node tests/consumer-typecheck/snapshot.mjs --all --check - name: Root classification closure gate (SD-3212 PR A1b) # Same gate as PR CI. Catches releases that bypass PR CI. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aba982016b..1badd75773 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -233,7 +233,7 @@ If your PR adds a new public export from `superdoc` (a new entry in `packages/su The canonical root contract is `packages/superdoc/src/public/index.ts` (per the SD-3175 path-as-contract umbrella, finalized in SD-3212 PR C). Several automated gates enforce consistency on every PR: - **`verify-public-facade-emit.cjs`** -- verifies the curated `src/public/**` facade matches the emitted `.d.ts` for symbol set, ESM/CJS parity, leak grep, and command-signature compatibility. Adding a new export updates the corresponding `expectedNames` array in this script in the same PR. -- **`snapshot-superdoc-root-exports.mjs --check`** -- locks the root export inventory across the four `package.json#exports` sources (`types.import`, `types.require`, `import`, `require`). Drift fails CI; run with `--write` to regenerate after an intentional change. +- **`snapshot.mjs --all --check`** -- unified entry point for the three snapshot families. The `root` family locks the root export inventory across the four `package.json#exports` sources (`types.import`, `types.require`, `import`, `require`); the `legacy` family locks `superdoc/*` subpath resolved exports; the `super-editor-package` family locks `@superdoc/super-editor`'s `package.json#exports` keys. Drift fails CI; run `snapshot.mjs --family --write` to regenerate one family after an intentional change. - **`check-root-classification-closure.mjs`** -- enforces the dependency-closure rule: no `supported-root` or `legacy-root` export may reference an `internal-candidate` type in its declared public type. New exports require an entry in `tests/consumer-typecheck/snapshots/superdoc-root-classification.json`. - **`typecheck-matrix.mjs`** -- every typed public subpath has at least one matrix scenario. If you add a new subpath, add a fixture under `tests/consumer-typecheck/src/` and a corresponding entry in the matrix, and update the inventory in `docs/architecture/package-boundaries.md`. - **`check-all-public-types-fixture.mjs`** -- derives the expected type-only root export list from `superdoc-root-classification.json` (rows with `inDts && !inEsm && !inCjs`) and fails if `src/all-public-types.ts` is missing assertions or has stale ones. Runs before the matrix to catch fixture drift early. diff --git a/packages/superdoc/AGENTS.md b/packages/superdoc/AGENTS.md index 721b5166a8..46498dd4c4 100644 --- a/packages/superdoc/AGENTS.md +++ b/packages/superdoc/AGENTS.md @@ -255,7 +255,7 @@ When changing the surface, every PR must also update: Three CI gates enforce consistency and will fail the build if any of these drift: - `verify-public-facade-emit.cjs` — symbol set, ESM/CJS parity, leak grep. -- `snapshot-superdoc-root-exports.mjs --check` — 4-source no-growth lock. +- `snapshot.mjs --family root --check` — 4-source no-growth lock for the root entry. CI calls `snapshot.mjs --all --check`, which also runs `legacy` and `super-editor-package`. - `check-root-classification-closure.mjs` — no supported/legacy export references an internal-candidate type (dependency-closure rule). For overrides on the closure gate (rare; only DOM globals / upstream / generic utility types), add an entry to `OVERRIDES` in `check-root-classification-closure.mjs` with a reason string ≥ 20 chars. diff --git a/tests/consumer-typecheck/deep-type-audit.README.md b/tests/consumer-typecheck/deep-type-audit.README.md index 506bf7cf61..39eb7e993c 100644 --- a/tests/consumer-typecheck/deep-type-audit.README.md +++ b/tests/consumer-typecheck/deep-type-audit.README.md @@ -165,13 +165,14 @@ default `auto-seeded from inventory` rationale. - `typecheck-matrix.mjs`: runs `tsc --noEmit` under N consumer tsconfigs. Catches *resolution* errors and *missing exports*. Doesn't see member-level `any`. -- `snapshot-superdoc-root-exports.mjs --check`: locks the root export - inventory across the four `package.json#exports` sources independently - (types.import, types.require, import, require). Each source has its - own baseline — type sources currently 200 names, runtime sources 41 — - and drift on any of the four fails the gate. Cross-source mismatches - (typed-only, runtime-only, ESM vs CJS) are reported in the companion - `.md` as evidence, not blockers. +- `snapshot.mjs --family root --check`: locks the root export inventory + across the four `package.json#exports` sources independently (types.import, + types.require, import, require). Each source has its own baseline (type + sources currently 200 names, runtime sources 41) and drift on any of the + four fails the gate. Cross-source mismatches (typed-only, runtime-only, + ESM vs CJS) are reported in the companion `.md` as evidence, not blockers. + CI calls the unified `snapshot.mjs --all --check` which runs this family + plus the `legacy` and `super-editor-package` families. - `verify-public-facade-emit.cjs`: verifies the curated `src/public/**` facade matches the emitted `.d.ts` (symbol set, ESM/CJS parity, leak grep, command-signature probe). diff --git a/tests/consumer-typecheck/snapshot-super-editor-package-exports.mjs b/tests/consumer-typecheck/snapshot-super-editor-package-exports.mjs deleted file mode 100644 index 1eea537d32..0000000000 --- a/tests/consumer-typecheck/snapshot-super-editor-package-exports.mjs +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env node -/** - * SD-3176: no-growth gate for `@superdoc/super-editor` package-level exports map. - * - * Snapshots the keys of `packages/super-editor/package.json#exports`. New - * subpath entries (e.g. a fresh `./foo`) fail CI. Removing entries also fails - * the diff so the change gets explicit reviewer attention. - * - * Companion to `snapshot-superdoc-legacy-exports.mjs`, which catches growth - * of resolved named exports through `superdoc/super-editor` and the three - * other legacy subpaths. - * - * Usage: - * node snapshot-super-editor-package-exports.mjs --check - * node snapshot-super-editor-package-exports.mjs --write - * - * `--write` regenerates the snapshot. Only run it when the change is - * intentional and tied to SD-3175 (path-as-contract facade umbrella). - */ -import { readFileSync, writeFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname, resolve } from 'node:path'; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = resolve(HERE, '..', '..'); -const PKG = resolve(REPO_ROOT, 'packages', 'super-editor', 'package.json'); -const SNAPSHOT = resolve(HERE, 'snapshots', 'super-editor-package-exports.txt'); - -const args = process.argv.slice(2); -const mode = args.includes('--write') ? 'write' : args.includes('--check') ? 'check' : null; -if (!mode) { - console.error('Usage: snapshot-super-editor-package-exports.mjs --write | --check'); - process.exit(2); -} - -const pkg = JSON.parse(readFileSync(PKG, 'utf8')); -if (!pkg.exports || typeof pkg.exports !== 'object') { - console.error(`[SD-3176] ${PKG} has no exports map.`); - process.exit(1); -} - -const current = Object.keys(pkg.exports).sort().join('\n') + '\n'; - -if (mode === 'write') { - writeFileSync(SNAPSHOT, current, 'utf8'); - console.log(`[SD-3176] Wrote ${SNAPSHOT}`); - process.exit(0); -} - -let baseline; -try { - baseline = readFileSync(SNAPSHOT, 'utf8'); -} catch (err) { - console.error(`[SD-3176] Snapshot not found: ${SNAPSHOT}`); - console.error('Run with --write to seed the baseline.'); - process.exit(1); -} - -if (baseline === current) { - console.log('[SD-3176] super-editor package exports map: no growth.'); - process.exit(0); -} - -const baseSet = new Set(baseline.split('\n').filter(Boolean)); -const curSet = new Set(current.split('\n').filter(Boolean)); -const added = [...curSet].filter((k) => !baseSet.has(k)); -const removed = [...baseSet].filter((k) => !curSet.has(k)); - -console.error('[SD-3176] @superdoc/super-editor package.json#exports drifted:'); -if (added.length) console.error(' added: ' + added.join(', ')); -if (removed.length) console.error(' removed: ' + removed.join(', ')); -console.error(''); -console.error('Per SD-3175 (path-as-contract facade), @superdoc/super-editor is legacy compatibility surface'); -console.error('and must not grow. If this change is intentional (e.g. an approved compat shim), regenerate:'); -console.error(' node tests/consumer-typecheck/snapshot-super-editor-package-exports.mjs --write'); -console.error('and link the PR to SD-3175 or a child ticket for reviewer sign-off.'); -process.exit(1); diff --git a/tests/consumer-typecheck/snapshot-superdoc-legacy-exports.mjs b/tests/consumer-typecheck/snapshot-superdoc-legacy-exports.mjs deleted file mode 100644 index 8b786b3ae4..0000000000 --- a/tests/consumer-typecheck/snapshot-superdoc-legacy-exports.mjs +++ /dev/null @@ -1,244 +0,0 @@ -#!/usr/bin/env node -/** - * SD-3176: no-growth gate for legacy `superdoc/*` subpaths. - * - * Snapshots the resolved named exports visible through these subpaths against - * the packed-and-installed tarball: - * - superdoc/super-editor (the dangerous one; `export *` from @superdoc/super-editor) - * - superdoc/converter - * - superdoc/docx-zipper - * - superdoc/file-zipper - * - superdoc/headless-toolbar (SD-3179 reclassified from public to legacy) - * - superdoc/headless-toolbar/react - * - superdoc/headless-toolbar/vue - * - * The authoritative list is the `SUBPATHS` constant below. - * - * Source parsing is insufficient because `superdoc/src/super-editor.js` is - * `export * from '@superdoc/super-editor'`. The contract that ships is what - * a consumer sees through the published declarations. The TypeScript compiler - * resolves the re-export chain for us. - * - * Requires the fixture to be packed-and-installed first. CI runs this after - * `typecheck-matrix.mjs`, which already packs and installs the tarball. - * - * Usage: - * node snapshot-superdoc-legacy-exports.mjs --check - * node snapshot-superdoc-legacy-exports.mjs --write - * - * `--write` regenerates the snapshots. Only run it when the change is - * intentional and tied to SD-3175 (path-as-contract facade umbrella). - */ -import { readFileSync, writeFileSync, existsSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname, resolve, join } from 'node:path'; -import { createRequire } from 'node:module'; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const SNAPSHOT_DIR = resolve(HERE, 'snapshots'); -const FIXTURE_SUPERDOC = resolve(HERE, 'node_modules', 'superdoc'); - -const args = process.argv.slice(2); -const mode = args.includes('--write') ? 'write' : args.includes('--check') ? 'check' : null; -if (!mode) { - console.error('Usage: snapshot-superdoc-legacy-exports.mjs --write | --check'); - process.exit(2); -} - -if (!existsSync(FIXTURE_SUPERDOC)) { - console.error('[SD-3176] superdoc is not installed in the fixture.'); - console.error('Run `node tests/consumer-typecheck/typecheck-matrix.mjs` first (it packs and installs the tarball),'); - console.error('or `npm install ../../packages/superdoc/superdoc.tgz --no-save` from tests/consumer-typecheck.'); - process.exit(1); -} - -// Use the typescript installed in the fixture so the version matches what -// consumer-side tests already use. -const req = createRequire(join(FIXTURE_SUPERDOC, 'package.json')); -let ts; -try { - ts = req('typescript'); -} catch { - const fixtureReq = createRequire(join(HERE, 'package.json')); - ts = fixtureReq('typescript'); -} - -const superdocPkg = JSON.parse(readFileSync(join(FIXTURE_SUPERDOC, 'package.json'), 'utf8')); - -const SUBPATHS = [ - './super-editor', - './converter', - './docx-zipper', - './file-zipper', - // SD-3179 reclassified the headless-toolbar subpaths from public to - // legacy compatibility surface. See package-boundaries.md Decision 4. - './headless-toolbar', - './headless-toolbar/react', - './headless-toolbar/vue', -]; - -function resolveTypesEntries(exportsValue) { - // Returns { import: string|null, require: string|null }. Either can be set. - // Snapshot is keyed on the `import` branch; `require` is a parity check. - if (typeof exportsValue === 'string') return { import: exportsValue, require: null }; - if (exportsValue && typeof exportsValue === 'object') { - if (typeof exportsValue.types === 'string') { - return { import: exportsValue.types, require: null }; - } - if (exportsValue.types && typeof exportsValue.types === 'object') { - return { - import: exportsValue.types.import ?? exportsValue.types.default ?? null, - require: exportsValue.types.require ?? null, - }; - } - } - return { import: null, require: null }; -} - -function snapshotName(subpath) { - return 'superdoc-' + subpath.replace(/^\.\//, '').replace(/\//g, '-') + '.txt'; -} - -function formatDiagnostic(diagnostic) { - const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'); - if (!diagnostic.file || diagnostic.start == null) return message; - const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start); - return `${diagnostic.file.fileName}:${line + 1}:${character + 1} ${message}`; -} - -function listExportedNames(subpath, entryFile) { - const program = ts.createProgram({ - rootNames: [entryFile], - options: { - moduleResolution: ts.ModuleResolutionKind.Bundler, - module: ts.ModuleKind.ESNext, - target: ts.ScriptTarget.ESNext, - noEmit: true, - skipLibCheck: false, - allowJs: false, - declaration: false, - }, - }); - const diagnostics = [ - ...program.getSyntacticDiagnostics(), - ...program.getSemanticDiagnostics(), - ...program.getDeclarationDiagnostics(), - ]; - if (diagnostics.length > 0) { - const details = diagnostics.slice(0, 10).map((diagnostic) => ` - ${formatDiagnostic(diagnostic)}`).join('\n'); - const suffix = diagnostics.length > 10 ? `\n ... ${diagnostics.length - 10} more diagnostics` : ''; - throw new Error(`${subpath} declaration has TypeScript diagnostics:\n${details}${suffix}`); - } - const checker = program.getTypeChecker(); - const source = program.getSourceFile(entryFile); - if (!source) throw new Error('Cannot load source: ' + entryFile); - const symbol = checker.getSymbolAtLocation(source) ?? source.symbol; - if (!symbol) return []; - const exports = checker.getExportsOfModule(symbol); - return [...new Set(exports.map((e) => e.getName()))].sort(); -} - -let failed = false; - -for (const subpath of SUBPATHS) { - const entries = resolveTypesEntries(superdocPkg.exports?.[subpath]); - if (!entries.import) { - console.error(`[SD-3176] No ESM types entry for ${subpath} in installed superdoc.`); - failed = true; - continue; - } - const importFile = resolve(FIXTURE_SUPERDOC, entries.import); - if (!existsSync(importFile)) { - console.error(`[SD-3176] Types file missing for ${subpath}: ${importFile}`); - failed = true; - continue; - } - - let names; - try { - names = listExportedNames(subpath, importFile); - } catch (err) { - console.error(`[SD-3176] Failed to enumerate ${subpath}: ${err.message}`); - failed = true; - continue; - } - - // CJS parity check: when the entry advertises both `types.import` and - // `types.require`, both declaration files must enumerate the same names. - // `ensure-types.cjs` generates the .d.cts from the .d.ts today, so this - // is currently a no-op; it guards against a silent regression in the - // generator producing a divergent CJS surface. - if (entries.require) { - const requireFile = resolve(FIXTURE_SUPERDOC, entries.require); - if (!existsSync(requireFile)) { - console.error(`[SD-3176] CJS types file missing for ${subpath}: ${requireFile}`); - failed = true; - continue; - } - let cjsNames; - try { - cjsNames = listExportedNames(subpath, requireFile); - } catch (err) { - console.error(`[SD-3176] Failed to enumerate CJS for ${subpath}: ${err.message}`); - failed = true; - continue; - } - const importSet = new Set(names); - const requireSet = new Set(cjsNames); - const onlyImport = [...importSet].filter((n) => !requireSet.has(n)); - const onlyRequire = [...requireSet].filter((n) => !importSet.has(n)); - if (onlyImport.length || onlyRequire.length) { - console.error(`[SD-3176] ${subpath}: ESM/CJS declaration export sets differ.`); - if (onlyImport.length) console.error(' import-only: ' + onlyImport.join(', ')); - if (onlyRequire.length) console.error(' require-only: ' + onlyRequire.join(', ')); - console.error(' Fix the CJS generator (packages/superdoc/scripts/ensure-types.cjs) so the two stay in sync.'); - failed = true; - continue; - } - } - - const current = names.join('\n') + '\n'; - const snapshotPath = join(SNAPSHOT_DIR, snapshotName(subpath)); - - if (mode === 'write') { - writeFileSync(snapshotPath, current, 'utf8'); - console.log(`[SD-3176] Wrote ${snapshotPath} (${names.length} names)`); - continue; - } - - let baseline; - try { - baseline = readFileSync(snapshotPath, 'utf8'); - } catch { - console.error(`[SD-3176] Snapshot missing for ${subpath}: ${snapshotPath}`); - console.error(' Run with --write to seed the baseline.'); - failed = true; - continue; - } - - if (baseline === current) { - console.log(`[SD-3176] ${subpath}: no growth (${names.length} names).`); - continue; - } - - const baseSet = new Set(baseline.split('\n').filter(Boolean)); - const curSet = new Set(current.split('\n').filter(Boolean)); - const added = [...curSet].filter((k) => !baseSet.has(k)); - const removed = [...baseSet].filter((k) => !curSet.has(k)); - - console.error(`[SD-3176] superdoc${subpath.slice(1)} exports drifted:`); - if (added.length) console.error(' added: ' + added.join(', ')); - if (removed.length) console.error(' removed: ' + removed.join(', ')); - failed = true; -} - -if (failed && mode === 'check') { - console.error(''); - console.error('Per SD-3175 (path-as-contract facade), these legacy subpaths are no-growth.'); - console.error('If a change is intentional, regenerate the affected snapshot and link the PR'); - console.error('to SD-3175 or a child ticket for reviewer sign-off:'); - console.error(' node tests/consumer-typecheck/snapshot-superdoc-legacy-exports.mjs --write'); - process.exit(1); -} - -process.exit(0); diff --git a/tests/consumer-typecheck/snapshot.mjs b/tests/consumer-typecheck/snapshot.mjs new file mode 100644 index 0000000000..5f38eec834 --- /dev/null +++ b/tests/consumer-typecheck/snapshot.mjs @@ -0,0 +1,99 @@ +#!/usr/bin/env node +/** + * SD-3213b unified snapshot CLI. + * + * One entry point that routes to family modules under ./snapshot/. Each + * family module exports `FAMILY`, `DESCRIPTION`, and `run({ mode })` + * returning `{ code: number }`. + * + * Usage: + * node tests/consumer-typecheck/snapshot.mjs --all --check + * node tests/consumer-typecheck/snapshot.mjs --all --write + * node tests/consumer-typecheck/snapshot.mjs --family root --check + * node tests/consumer-typecheck/snapshot.mjs --family legacy --check + * node tests/consumer-typecheck/snapshot.mjs --family super-editor-package --check + * + * --check (default) compares against committed snapshots and exits non-zero + * on drift. --write regenerates snapshots in place. + * + * CI workflows call `snapshot.mjs --all --check`. The packed-tarball fixture + * must be installed first (the legacy and root families need it); the + * typecheck-matrix step in CI handles that. + */ +import * as superEditorPackage from './snapshot/super-editor-package-exports.mjs'; +import * as legacy from './snapshot/legacy-exports.mjs'; +import * as root from './snapshot/root-exports.mjs'; + +const FAMILIES = [superEditorPackage, legacy, root]; +const FAMILY_BY_NAME = new Map(FAMILIES.map((m) => [m.FAMILY, m])); + +function printUsage() { + console.error('Usage:'); + console.error(' node tests/consumer-typecheck/snapshot.mjs --all --check'); + console.error(' node tests/consumer-typecheck/snapshot.mjs --all --write'); + console.error(' node tests/consumer-typecheck/snapshot.mjs --family --check'); + console.error(' node tests/consumer-typecheck/snapshot.mjs --family --write'); + console.error(''); + console.error('Families:'); + for (const m of FAMILIES) { + console.error(` ${m.FAMILY.padEnd(24)} ${m.DESCRIPTION}`); + } +} + +function parseArgs(argv) { + const args = { all: false, family: null, mode: 'check' }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--all') args.all = true; + else if (a === '--family') args.family = argv[++i]; + else if (a === '--check') args.mode = 'check'; + else if (a === '--write') args.mode = 'write'; + else if (a === '-h' || a === '--help') args.help = true; + else { + console.error(`Unknown argument: ${a}`); + args.invalid = true; + } + } + return args; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + + if (args.help || args.invalid) { + printUsage(); + process.exit(args.invalid ? 2 : 0); + } + + if (args.all && args.family) { + console.error('--all and --family are mutually exclusive.'); + printUsage(); + process.exit(2); + } + + if (!args.all && !args.family) { + console.error('Specify either --all or --family .'); + printUsage(); + process.exit(2); + } + + const targets = args.all + ? FAMILIES + : [FAMILY_BY_NAME.get(args.family)].filter(Boolean); + + if (args.family && targets.length === 0) { + console.error(`Unknown family: ${args.family}`); + printUsage(); + process.exit(2); + } + + let exitCode = 0; + for (const mod of targets) { + console.log(`\n=== [${mod.FAMILY}] ${mod.DESCRIPTION} ===`); + const { code } = mod.run({ mode: args.mode }); + if (code !== 0) exitCode = code; + } + process.exit(exitCode); +} + +main(); diff --git a/tests/consumer-typecheck/snapshot/legacy-exports.mjs b/tests/consumer-typecheck/snapshot/legacy-exports.mjs new file mode 100644 index 0000000000..6bbd8a213e --- /dev/null +++ b/tests/consumer-typecheck/snapshot/legacy-exports.mjs @@ -0,0 +1,235 @@ +/** + * SD-3176 family: no-growth gate for legacy `superdoc/*` subpaths. + * + * Snapshots the resolved named exports visible through each legacy subpath + * against the packed-and-installed tarball: + * - superdoc/super-editor (the dangerous one; `export *` from @superdoc/super-editor) + * - superdoc/converter + * - superdoc/docx-zipper + * - superdoc/file-zipper + * - superdoc/headless-toolbar (SD-3179 reclassified from public to legacy) + * - superdoc/headless-toolbar/react + * - superdoc/headless-toolbar/vue + * + * Source parsing is insufficient because `superdoc/src/super-editor.js` is + * `export * from '@superdoc/super-editor'`. The contract that ships is what + * a consumer sees through the published declarations. The TypeScript + * compiler resolves the re-export chain for us. + * + * Requires the fixture to be packed-and-installed first. CI runs this + * after `typecheck-matrix.mjs`, which already packs and installs the + * tarball. + * + * Extracted from the standalone `snapshot-superdoc-legacy-exports.mjs` + * script during SD-3213b snapshot-script consolidation. The CLI entry + * point is now `tests/consumer-typecheck/snapshot.mjs`. + */ +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve, join } from 'node:path'; +import { createRequire } from 'node:module'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CONSUMER_TYPECHECK = resolve(HERE, '..'); +const SNAPSHOT_DIR = resolve(CONSUMER_TYPECHECK, 'snapshots'); +const FIXTURE_SUPERDOC = resolve(CONSUMER_TYPECHECK, 'node_modules', 'superdoc'); + +export const FAMILY = 'legacy'; +export const DESCRIPTION = 'superdoc/* legacy subpath resolved exports (SD-3176)'; + +const SUBPATHS = [ + './super-editor', + './converter', + './docx-zipper', + './file-zipper', + // SD-3179 reclassified the headless-toolbar subpaths from public to + // legacy compatibility surface. See package-boundaries.md Decision 4. + './headless-toolbar', + './headless-toolbar/react', + './headless-toolbar/vue', +]; + +function resolveTypesEntries(exportsValue) { + // Returns { import: string|null, require: string|null }. Either can be set. + // Snapshot is keyed on the `import` branch; `require` is a parity check. + if (typeof exportsValue === 'string') return { import: exportsValue, require: null }; + if (exportsValue && typeof exportsValue === 'object') { + if (typeof exportsValue.types === 'string') { + return { import: exportsValue.types, require: null }; + } + if (exportsValue.types && typeof exportsValue.types === 'object') { + return { + import: exportsValue.types.import ?? exportsValue.types.default ?? null, + require: exportsValue.types.require ?? null, + }; + } + } + return { import: null, require: null }; +} + +function snapshotName(subpath) { + return 'superdoc-' + subpath.replace(/^\.\//, '').replace(/\//g, '-') + '.txt'; +} + +function loadTypescript() { + const req = createRequire(join(FIXTURE_SUPERDOC, 'package.json')); + try { + return req('typescript'); + } catch { + const fixtureReq = createRequire(join(CONSUMER_TYPECHECK, 'package.json')); + return fixtureReq('typescript'); + } +} + +function formatDiagnostic(ts, diagnostic) { + const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'); + if (!diagnostic.file || diagnostic.start == null) return message; + const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start); + return `${diagnostic.file.fileName}:${line + 1}:${character + 1} ${message}`; +} + +function listExportedNames(ts, subpath, entryFile) { + const program = ts.createProgram({ + rootNames: [entryFile], + options: { + moduleResolution: ts.ModuleResolutionKind.Bundler, + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.ESNext, + noEmit: true, + skipLibCheck: false, + allowJs: false, + declaration: false, + }, + }); + const diagnostics = [ + ...program.getSyntacticDiagnostics(), + ...program.getSemanticDiagnostics(), + ...program.getDeclarationDiagnostics(), + ]; + if (diagnostics.length > 0) { + const details = diagnostics.slice(0, 10).map((diagnostic) => ` - ${formatDiagnostic(ts, diagnostic)}`).join('\n'); + const suffix = diagnostics.length > 10 ? `\n ... ${diagnostics.length - 10} more diagnostics` : ''; + throw new Error(`${subpath} declaration has TypeScript diagnostics:\n${details}${suffix}`); + } + const checker = program.getTypeChecker(); + const source = program.getSourceFile(entryFile); + if (!source) throw new Error('Cannot load source: ' + entryFile); + const symbol = checker.getSymbolAtLocation(source) ?? source.symbol; + if (!symbol) return []; + const exports = checker.getExportsOfModule(symbol); + return [...new Set(exports.map((e) => e.getName()))].sort(); +} + +/** + * @param {{ mode: 'check' | 'write' }} opts + * @returns {{ code: number }} + */ +export function run({ mode }) { + if (!existsSync(FIXTURE_SUPERDOC)) { + console.error('[SD-3176] superdoc is not installed in the fixture.'); + console.error('Run `node tests/consumer-typecheck/typecheck-matrix.mjs` first (it packs and installs the tarball),'); + console.error('or `npm install ../../packages/superdoc/superdoc.tgz --no-save` from tests/consumer-typecheck.'); + return { code: 1 }; + } + + const ts = loadTypescript(); + const superdocPkg = JSON.parse(readFileSync(join(FIXTURE_SUPERDOC, 'package.json'), 'utf8')); + let failed = false; + + for (const subpath of SUBPATHS) { + const entries = resolveTypesEntries(superdocPkg.exports?.[subpath]); + if (!entries.import) { + console.error(`[SD-3176] No ESM types entry for ${subpath} in installed superdoc.`); + failed = true; + continue; + } + const importFile = resolve(FIXTURE_SUPERDOC, entries.import); + if (!existsSync(importFile)) { + console.error(`[SD-3176] Types file missing for ${subpath}: ${importFile}`); + failed = true; + continue; + } + + let names; + try { + names = listExportedNames(ts, subpath, importFile); + } catch (err) { + console.error(`[SD-3176] Failed to enumerate ${subpath}: ${err.message}`); + failed = true; + continue; + } + + if (entries.require) { + const requireFile = resolve(FIXTURE_SUPERDOC, entries.require); + if (!existsSync(requireFile)) { + console.error(`[SD-3176] CJS types file missing for ${subpath}: ${requireFile}`); + failed = true; + continue; + } + let cjsNames; + try { + cjsNames = listExportedNames(ts, subpath, requireFile); + } catch (err) { + console.error(`[SD-3176] Failed to enumerate CJS for ${subpath}: ${err.message}`); + failed = true; + continue; + } + const importSet = new Set(names); + const requireSet = new Set(cjsNames); + const onlyImport = [...importSet].filter((n) => !requireSet.has(n)); + const onlyRequire = [...requireSet].filter((n) => !importSet.has(n)); + if (onlyImport.length || onlyRequire.length) { + console.error(`[SD-3176] ${subpath}: ESM/CJS declaration export sets differ.`); + if (onlyImport.length) console.error(' import-only: ' + onlyImport.join(', ')); + if (onlyRequire.length) console.error(' require-only: ' + onlyRequire.join(', ')); + console.error(' Fix the CJS generator (packages/superdoc/scripts/ensure-types.cjs) so the two stay in sync.'); + failed = true; + continue; + } + } + + const current = names.join('\n') + '\n'; + const snapshotPath = join(SNAPSHOT_DIR, snapshotName(subpath)); + + if (mode === 'write') { + writeFileSync(snapshotPath, current, 'utf8'); + console.log(`[SD-3176] Wrote ${snapshotPath} (${names.length} names)`); + continue; + } + + let baseline; + try { + baseline = readFileSync(snapshotPath, 'utf8'); + } catch { + console.error(`[SD-3176] Snapshot missing for ${subpath}: ${snapshotPath}`); + console.error(' Run with --write to seed the baseline.'); + failed = true; + continue; + } + + if (baseline === current) { + console.log(`[SD-3176] ${subpath}: no growth (${names.length} names).`); + continue; + } + + const baseSet = new Set(baseline.split('\n').filter(Boolean)); + const curSet = new Set(current.split('\n').filter(Boolean)); + const added = [...curSet].filter((k) => !baseSet.has(k)); + const removed = [...baseSet].filter((k) => !curSet.has(k)); + + console.error(`[SD-3176] superdoc${subpath.slice(1)} exports drifted:`); + if (added.length) console.error(' added: ' + added.join(', ')); + if (removed.length) console.error(' removed: ' + removed.join(', ')); + failed = true; + } + + if (failed && mode === 'check') { + console.error(''); + console.error('Per SD-3175 (path-as-contract facade), these legacy subpaths are no-growth.'); + console.error('If a change is intentional, regenerate the affected snapshot and link the PR'); + console.error('to SD-3175 or a child ticket for reviewer sign-off:'); + console.error(' node tests/consumer-typecheck/snapshot.mjs --family legacy --write'); + return { code: 1 }; + } + return { code: failed ? 1 : 0 }; +} diff --git a/tests/consumer-typecheck/snapshot-superdoc-root-exports.mjs b/tests/consumer-typecheck/snapshot/root-exports.mjs similarity index 54% rename from tests/consumer-typecheck/snapshot-superdoc-root-exports.mjs rename to tests/consumer-typecheck/snapshot/root-exports.mjs index 18761395fc..2f4a2ae987 100644 --- a/tests/consumer-typecheck/snapshot-superdoc-root-exports.mjs +++ b/tests/consumer-typecheck/snapshot/root-exports.mjs @@ -1,73 +1,49 @@ -#!/usr/bin/env node /** - * SD-3212 (Phase 4b PR A0): no-growth gate + evidence inventory for the - * `superdoc` ROOT entry. + * SD-3212 family: no-growth gate + evidence inventory for the `superdoc` + * ROOT entry. * - * The root entry currently resolves through four package.json#exports fields, + * The root entry resolves through four package.json#exports fields, * which can diverge: - * - types.import → dist/superdoc/src/index.d.ts - * - types.require → dist/superdoc/src/index.d.cts + * - types.import → dist/superdoc/src/public/index.d.ts + * - types.require → dist/superdoc/src/public/index.d.cts * - import → dist/superdoc.es.js * - require → dist/superdoc.cjs * * This snapshot locks the exported-name set of each of the four sources - * against drift. Cross-source mismatches are surfaced as evidence rows in - * the companion report but are NOT a drift blocker on their own; the four - * name sets each have their own committed baseline. + * against drift. Cross-source mismatches are surfaced as evidence rows + * in the companion `.md` report but are NOT a drift blocker on their own; + * the four name sets each have their own committed baseline. * - * The companion `.md` report adds evidence columns (consumer fixtures, - * JSDoc typedefs, docs/examples mentions, package-boundaries.md) so the - * downstream classification pass (PR A1) has the data in one place. + * Requires the fixture to be packed-and-installed first. The CLI runs + * this after `typecheck-matrix.mjs`, which packs and installs. * - * Modes: - * node snapshot-superdoc-root-exports.mjs --write - * node snapshot-superdoc-root-exports.mjs --check - * - * Requires the fixture to be packed-and-installed first. CI runs this - * after `typecheck-matrix.mjs`, which already packs and installs. + * Extracted from the standalone `snapshot-superdoc-root-exports.mjs` + * script during SD-3213b snapshot-script consolidation. The CLI entry + * point is now `tests/consumer-typecheck/snapshot.mjs`. */ -import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs'; +import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, resolve, join, relative } from 'node:path'; import { createRequire } from 'node:module'; const HERE = dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = resolve(HERE, '../..'); -const SNAPSHOT_DIR = resolve(HERE, 'snapshots'); -const FIXTURE_SUPERDOC = resolve(HERE, 'node_modules', 'superdoc'); +const CONSUMER_TYPECHECK = resolve(HERE, '..'); +const REPO_ROOT = resolve(CONSUMER_TYPECHECK, '..', '..'); +const SNAPSHOT_DIR = resolve(CONSUMER_TYPECHECK, 'snapshots'); +const FIXTURE_SUPERDOC = resolve(CONSUMER_TYPECHECK, 'node_modules', 'superdoc'); const SNAPSHOT_JSON = join(SNAPSHOT_DIR, 'superdoc-root-exports.json'); const SNAPSHOT_MD = join(SNAPSHOT_DIR, 'superdoc-root-exports.md'); -const args = process.argv.slice(2); -const mode = args.includes('--write') ? 'write' : args.includes('--check') ? 'check' : null; -if (!mode) { - console.error('Usage: snapshot-superdoc-root-exports.mjs --write | --check'); - process.exit(2); -} - -if (!existsSync(FIXTURE_SUPERDOC)) { - console.error('[SD-3212] superdoc is not installed in the fixture.'); - console.error('Run `node tests/consumer-typecheck/typecheck-matrix.mjs` first (packs and installs).'); - process.exit(1); -} - -// Use the typescript installed in the fixture so the version matches. -const req = createRequire(join(FIXTURE_SUPERDOC, 'package.json')); -let ts; -try { ts = req('typescript'); } catch { - ts = createRequire(join(HERE, 'package.json'))('typescript'); -} +export const FAMILY = 'root'; +export const DESCRIPTION = '4-source root entry inventory + evidence report (SD-3212 A0)'; -const superdocPkg = JSON.parse(readFileSync(join(FIXTURE_SUPERDOC, 'package.json'), 'utf8')); -const rootExport = superdocPkg.exports?.['.']; -if (!rootExport || typeof rootExport !== 'object') { - console.error('[SD-3212] No root export found in installed superdoc package.json#exports'); - process.exit(1); +function loadTypescript() { + const req = createRequire(join(FIXTURE_SUPERDOC, 'package.json')); + try { return req('typescript'); } catch { + return createRequire(join(CONSUMER_TYPECHECK, 'package.json'))('typescript'); + } } -// ----------------------------------------------------------------------- -// Resolve the four source paths from package.json#exports['.'] -// ----------------------------------------------------------------------- function resolveRootSources(rootExport) { const out = { 'types.import': null, 'types.require': null, import: null, require: null }; if (rootExport.types && typeof rootExport.types === 'object') { @@ -81,10 +57,7 @@ function resolveRootSources(rootExport) { return out; } -// ----------------------------------------------------------------------- -// Extract named exports -// ----------------------------------------------------------------------- -function enumerateDtsExports(entryFile) { +function enumerateDtsExports(ts, entryFile) { const program = ts.createProgram({ rootNames: [entryFile], options: { @@ -105,13 +78,9 @@ function enumerateDtsExports(entryFile) { return [...new Set(checker.getExportsOfModule(symbol).map((e) => e.getName()))].sort(); } -// Vite/Rollup ESM bundle output has a clean `export { a, b as c, ... };` -// block. Parse all such blocks and collect the EXPORTED (right-hand) names. function enumerateEsmBundleExports(entryFile) { const src = readFileSync(entryFile, 'utf8'); const names = new Set(); - // Match `export { ... };` blocks. The block can span multiple lines. - // Inside, each spec is `local` or `local as exported`. const blockRe = /export\s*\{([\s\S]*?)\}\s*;?/g; let m; while ((m = blockRe.exec(src))) { @@ -124,43 +93,28 @@ function enumerateEsmBundleExports(entryFile) { if (/^[$A-Z_a-z][$\w]*$/.test(name)) names.add(name); } } - // Also `export default ...` shows up as `default` export. if (/^[ \t]*export\s+default\s+/m.test(src)) names.add('default'); return [...names].sort(); } -// CJS bundle output looks like one of: -// module.exports = { Foo, Bar: ... }; -// Object.defineProperty(exports, "Foo", { ... }); -// exports.Foo = ...; -// Parse all three styles. function enumerateCjsBundleExports(entryFile) { const src = readFileSync(entryFile, 'utf8'); const names = new Set(); - // module.exports = { ... } — capture the keys (top-level only) const moduleExportsRe = /module\.exports\s*=\s*\{([\s\S]*?)\}\s*;/g; let m; while ((m = moduleExportsRe.exec(src))) { const body = m[1]; - // Match top-level keys: `name` or `name:` or `"name":` const keyRe = /(?:^|,)\s*(?:get\s+)?["']?([$A-Z_a-z][$\w]*)["']?\s*(?::|[,}\n])/g; let km; - while ((km = keyRe.exec(body))) { - names.add(km[1]); - } + while ((km = keyRe.exec(body))) names.add(km[1]); } - // Object.defineProperty(exports, "Foo", ...) or (module.exports, "Foo", ...) const defPropRe = /Object\.defineProperty\((?:module\.)?exports\s*,\s*["']([$A-Z_a-z][$\w]*)["']/g; while ((m = defPropRe.exec(src))) names.add(m[1]); - // exports.Foo = ... (top-level assignment) const expAssignRe = /(?:^|;|\n)\s*exports\.([$A-Z_a-z][$\w]*)\s*=/g; while ((m = expAssignRe.exec(src))) names.add(m[1]); return [...names].sort(); } -// ----------------------------------------------------------------------- -// Collect evidence cross-references -// ----------------------------------------------------------------------- function walkFiles(dir, exts, out = [], skip = new Set(['node_modules', 'dist', '.git', '.tmp', 'tmp'])) { if (!existsSync(dir)) return out; for (const entry of readdirSync(dir, { withFileTypes: true })) { @@ -173,7 +127,7 @@ function walkFiles(dir, exts, out = [], skip = new Set(['node_modules', 'dist', } function countFixtureImports(allNames) { - const fixtureDir = resolve(HERE, 'src'); + const fixtureDir = resolve(CONSUMER_TYPECHECK, 'src'); const files = walkFiles(fixtureDir, ['.ts', '.tsx', '.cts', '.mts']); const counts = new Map(allNames.map((n) => [n, 0])); const importBlockRe = /import\s+(?:type\s+)?\{([^}]+)\}\s*from\s+['"]superdoc['"]/g; @@ -207,8 +161,6 @@ function countMentionsIn(rootDir, allNames, exts) { const counts = new Map(allNames.map((n) => [n, 0])); if (!existsSync(rootDir)) return counts; const files = walkFiles(rootDir, exts); - // Build one big regex with all names to do a single pass per file. - // For ergonomic file size we chunk into batches of 200. for (let i = 0; i < allNames.length; i += 200) { const batch = allNames.slice(i, i + 200).filter((n) => /^[$A-Z_a-z][$\w]*$/.test(n)); if (!batch.length) continue; @@ -233,115 +185,8 @@ function inPackageBoundaries(allNames) { return set; } -// ----------------------------------------------------------------------- -// Build the report -// ----------------------------------------------------------------------- -const sources = resolveRootSources(rootExport); -const enumerated = {}; -for (const [key, relPath] of Object.entries(sources)) { - if (!relPath) { - enumerated[key] = { path: null, names: [], error: 'not declared in package.json#exports' }; - continue; - } - const abs = resolve(FIXTURE_SUPERDOC, relPath); - if (!existsSync(abs)) { - enumerated[key] = { path: relPath, names: [], error: 'file missing' }; - continue; - } - try { - if (key === 'types.import' || key === 'types.require') { - enumerated[key] = { path: relPath, names: enumerateDtsExports(abs), error: null }; - } else if (key === 'import') { - enumerated[key] = { path: relPath, names: enumerateEsmBundleExports(abs), error: null }; - } else if (key === 'require') { - enumerated[key] = { path: relPath, names: enumerateCjsBundleExports(abs), error: null }; - } - } catch (err) { - enumerated[key] = { path: relPath, names: [], error: err.message }; - } -} - -const allNames = [...new Set([ - ...enumerated['types.import'].names, - ...enumerated['types.require'].names, - ...enumerated['import'].names, - ...enumerated['require'].names, -])].sort(); - -const inDts = new Set(enumerated['types.import'].names); -const inDcts = new Set(enumerated['types.require'].names); -const inEsm = new Set(enumerated['import'].names); -const inCjs = new Set(enumerated['require'].names); - -const fixtureCounts = countFixtureImports(allNames); -const jsdocSet = readJsdocTypedefs(); -const docCounts = countMentionsIn(resolve(REPO_ROOT, 'apps/docs'), allNames, ['.md', '.mdx', '.ts', '.tsx']); -const exampleCounts = countMentionsIn(resolve(REPO_ROOT, 'examples'), allNames, ['.js', '.ts', '.tsx', '.vue', '.md']); -const demoCounts = countMentionsIn(resolve(REPO_ROOT, 'demos'), allNames, ['.js', '.ts', '.tsx', '.vue', '.md']); -const inBoundaries = inPackageBoundaries(allNames); - -const snapshot = { - generatedAt: new Date().toISOString(), - ticket: 'SD-3212 PR A0', - package: 'superdoc', - rootExport, - sources: { - 'types.import': enumerated['types.import'], - 'types.require': enumerated['types.require'], - import: enumerated['import'], - require: enumerated['require'], - }, - counts: { - 'types.import': enumerated['types.import'].names.length, - 'types.require': enumerated['types.require'].names.length, - import: enumerated['import'].names.length, - require: enumerated['require'].names.length, - union: allNames.length, - }, - divergences: { - typesImportVsRequire: { - onlyInImport: enumerated['types.import'].names.filter((n) => !inDcts.has(n)), - onlyInRequire: enumerated['types.require'].names.filter((n) => !inDts.has(n)), - }, - esmVsCjs: { - onlyInEsm: enumerated['import'].names.filter((n) => !inCjs.has(n)), - onlyInCjs: enumerated['require'].names.filter((n) => !inEsm.has(n)), - }, - typesVsRuntime: { - typedOnly: allNames.filter((n) => (inDts.has(n) || inDcts.has(n)) && !inEsm.has(n) && !inCjs.has(n)), - runtimeOnly: allNames.filter((n) => !inDts.has(n) && !inDcts.has(n) && (inEsm.has(n) || inCjs.has(n))), - }, - }, -}; - -// ----------------------------------------------------------------------- -// Drift gate -// ----------------------------------------------------------------------- -function compareLocked(actualSnapshot) { - if (!existsSync(SNAPSHOT_JSON)) { - return { ok: false, reason: `Snapshot does not exist at ${relative(REPO_ROOT, SNAPSHOT_JSON)}. Run --write.` }; - } - const committed = JSON.parse(readFileSync(SNAPSHOT_JSON, 'utf8')); - const violations = []; - for (const key of ['types.import', 'types.require', 'import', 'require']) { - const a = (actualSnapshot.sources[key]?.names || []).join(','); - const c = (committed.sources?.[key]?.names || []).join(','); - if (a !== c) { - const aSet = new Set(actualSnapshot.sources[key]?.names || []); - const cSet = new Set(committed.sources?.[key]?.names || []); - const added = [...aSet].filter((n) => !cSet.has(n)).sort(); - const removed = [...cSet].filter((n) => !aSet.has(n)).sort(); - violations.push({ source: key, added, removed }); - } - } - return { ok: violations.length === 0, violations }; -} - -// ----------------------------------------------------------------------- -// Markdown report (regenerated on --write; not a drift gate) -// ----------------------------------------------------------------------- function tick(v) { return v ? '✓' : ' '; } -function renderMarkdown() { +function renderMarkdown(snapshot, allNames, inDts, inDcts, inEsm, inCjs, fixtureCounts, jsdocSet, docCounts, exampleCounts, demoCounts, inBoundaries) { const lines = []; lines.push('# superdoc root export inventory (SD-3212 PR A0)'); lines.push(''); @@ -394,25 +239,140 @@ function renderMarkdown() { return lines.join('\n') + '\n'; } -// ----------------------------------------------------------------------- -// Main -// ----------------------------------------------------------------------- -if (mode === 'write') { - writeFileSync(SNAPSHOT_JSON, JSON.stringify(snapshot, null, 2) + '\n'); - writeFileSync(SNAPSHOT_MD, renderMarkdown()); - console.log(`[SD-3212] Wrote ${relative(REPO_ROOT, SNAPSHOT_JSON)}`); - console.log(`[SD-3212] Wrote ${relative(REPO_ROOT, SNAPSHOT_MD)}`); - console.log('Counts:'); +function compareLocked(actualSnapshot) { + if (!existsSync(SNAPSHOT_JSON)) { + return { ok: false, reason: `Snapshot does not exist at ${relative(REPO_ROOT, SNAPSHOT_JSON)}. Run --write.` }; + } + const committed = JSON.parse(readFileSync(SNAPSHOT_JSON, 'utf8')); + const violations = []; for (const key of ['types.import', 'types.require', 'import', 'require']) { - console.log(` ${key}: ${snapshot.sources[key].names.length}`); + const a = (actualSnapshot.sources[key]?.names || []).join(','); + const c = (committed.sources?.[key]?.names || []).join(','); + if (a !== c) { + const aSet = new Set(actualSnapshot.sources[key]?.names || []); + const cSet = new Set(committed.sources?.[key]?.names || []); + const added = [...aSet].filter((n) => !cSet.has(n)).sort(); + const removed = [...cSet].filter((n) => !aSet.has(n)).sort(); + violations.push({ source: key, added, removed }); + } + } + return { ok: violations.length === 0, violations }; +} + +/** + * @param {{ mode: 'check' | 'write' }} opts + * @returns {{ code: number }} + */ +export function run({ mode }) { + if (!existsSync(FIXTURE_SUPERDOC)) { + console.error('[SD-3212] superdoc is not installed in the fixture.'); + console.error('Run `node tests/consumer-typecheck/typecheck-matrix.mjs` first (packs and installs).'); + return { code: 1 }; + } + + const ts = loadTypescript(); + const superdocPkg = JSON.parse(readFileSync(join(FIXTURE_SUPERDOC, 'package.json'), 'utf8')); + const rootExport = superdocPkg.exports?.['.']; + if (!rootExport || typeof rootExport !== 'object') { + console.error('[SD-3212] No root export found in installed superdoc package.json#exports'); + return { code: 1 }; + } + + const sources = resolveRootSources(rootExport); + const enumerated = {}; + for (const [key, relPath] of Object.entries(sources)) { + if (!relPath) { + enumerated[key] = { path: null, names: [], error: 'not declared in package.json#exports' }; + continue; + } + const abs = resolve(FIXTURE_SUPERDOC, relPath); + if (!existsSync(abs)) { + enumerated[key] = { path: relPath, names: [], error: 'file missing' }; + continue; + } + try { + if (key === 'types.import' || key === 'types.require') { + enumerated[key] = { path: relPath, names: enumerateDtsExports(ts, abs), error: null }; + } else if (key === 'import') { + enumerated[key] = { path: relPath, names: enumerateEsmBundleExports(abs), error: null }; + } else if (key === 'require') { + enumerated[key] = { path: relPath, names: enumerateCjsBundleExports(abs), error: null }; + } + } catch (err) { + enumerated[key] = { path: relPath, names: [], error: err.message }; + } + } + + const allNames = [...new Set([ + ...enumerated['types.import'].names, + ...enumerated['types.require'].names, + ...enumerated['import'].names, + ...enumerated['require'].names, + ])].sort(); + + const inDts = new Set(enumerated['types.import'].names); + const inDcts = new Set(enumerated['types.require'].names); + const inEsm = new Set(enumerated['import'].names); + const inCjs = new Set(enumerated['require'].names); + + const snapshot = { + generatedAt: new Date().toISOString(), + ticket: 'SD-3212 PR A0', + package: 'superdoc', + rootExport, + sources: { + 'types.import': enumerated['types.import'], + 'types.require': enumerated['types.require'], + import: enumerated['import'], + require: enumerated['require'], + }, + counts: { + 'types.import': enumerated['types.import'].names.length, + 'types.require': enumerated['types.require'].names.length, + import: enumerated['import'].names.length, + require: enumerated['require'].names.length, + union: allNames.length, + }, + divergences: { + typesImportVsRequire: { + onlyInImport: enumerated['types.import'].names.filter((n) => !inDcts.has(n)), + onlyInRequire: enumerated['types.require'].names.filter((n) => !inDts.has(n)), + }, + esmVsCjs: { + onlyInEsm: enumerated['import'].names.filter((n) => !inCjs.has(n)), + onlyInCjs: enumerated['require'].names.filter((n) => !inEsm.has(n)), + }, + typesVsRuntime: { + typedOnly: allNames.filter((n) => (inDts.has(n) || inDcts.has(n)) && !inEsm.has(n) && !inCjs.has(n)), + runtimeOnly: allNames.filter((n) => !inDts.has(n) && !inDcts.has(n) && (inEsm.has(n) || inCjs.has(n))), + }, + }, + }; + + if (mode === 'write') { + const fixtureCounts = countFixtureImports(allNames); + const jsdocSet = readJsdocTypedefs(); + const docCounts = countMentionsIn(resolve(REPO_ROOT, 'apps/docs'), allNames, ['.md', '.mdx', '.ts', '.tsx']); + const exampleCounts = countMentionsIn(resolve(REPO_ROOT, 'examples'), allNames, ['.js', '.ts', '.tsx', '.vue', '.md']); + const demoCounts = countMentionsIn(resolve(REPO_ROOT, 'demos'), allNames, ['.js', '.ts', '.tsx', '.vue', '.md']); + const inBoundaries = inPackageBoundaries(allNames); + + writeFileSync(SNAPSHOT_JSON, JSON.stringify(snapshot, null, 2) + '\n'); + writeFileSync(SNAPSHOT_MD, renderMarkdown(snapshot, allNames, inDts, inDcts, inEsm, inCjs, fixtureCounts, jsdocSet, docCounts, exampleCounts, demoCounts, inBoundaries)); + console.log(`[SD-3212] Wrote ${relative(REPO_ROOT, SNAPSHOT_JSON)}`); + console.log(`[SD-3212] Wrote ${relative(REPO_ROOT, SNAPSHOT_MD)}`); + console.log('Counts:'); + for (const key of ['types.import', 'types.require', 'import', 'require']) { + console.log(` ${key}: ${snapshot.sources[key].names.length}`); + } + console.log(` union: ${snapshot.counts.union}`); + return { code: 0 }; } - console.log(` union: ${snapshot.counts.union}`); - process.exit(0); -} else { + const result = compareLocked(snapshot); if (result.reason) { console.error(`[SD-3212] ${result.reason}`); - process.exit(1); + return { code: 1 }; } if (!result.ok) { console.error('[SD-3212] Root export drift detected:'); @@ -423,8 +383,8 @@ if (mode === 'write') { } console.error(''); console.error('If this change is intentional, run --write and commit the updated snapshot.'); - process.exit(1); + return { code: 1 }; } console.log('[SD-3212] Root exports match the committed snapshot.'); - process.exit(0); + return { code: 0 }; } diff --git a/tests/consumer-typecheck/snapshot/super-editor-package-exports.mjs b/tests/consumer-typecheck/snapshot/super-editor-package-exports.mjs new file mode 100644 index 0000000000..727eb1a819 --- /dev/null +++ b/tests/consumer-typecheck/snapshot/super-editor-package-exports.mjs @@ -0,0 +1,68 @@ +/** + * SD-3176 family: no-growth gate for `@superdoc/super-editor`'s + * package.json#exports keys. + * + * Extracted from the standalone `snapshot-super-editor-package-exports.mjs` + * script during SD-3213b snapshot-script consolidation. The CLI entry point + * is now `tests/consumer-typecheck/snapshot.mjs`; this file exposes a `run` + * function that the CLI invokes. + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, '..', '..', '..'); +const PKG = resolve(REPO_ROOT, 'packages', 'super-editor', 'package.json'); +const SNAPSHOT = resolve(HERE, '..', 'snapshots', 'super-editor-package-exports.txt'); + +export const FAMILY = 'super-editor-package'; +export const DESCRIPTION = '@superdoc/super-editor package.json#exports keys (SD-3176)'; + +/** + * @param {{ mode: 'check' | 'write' }} opts + * @returns {{ code: number }} + */ +export function run({ mode }) { + const pkg = JSON.parse(readFileSync(PKG, 'utf8')); + if (!pkg.exports || typeof pkg.exports !== 'object') { + console.error(`[SD-3176] ${PKG} has no exports map.`); + return { code: 1 }; + } + const current = Object.keys(pkg.exports).sort().join('\n') + '\n'; + + if (mode === 'write') { + writeFileSync(SNAPSHOT, current, 'utf8'); + console.log(`[SD-3176] Wrote ${SNAPSHOT}`); + return { code: 0 }; + } + + let baseline; + try { + baseline = readFileSync(SNAPSHOT, 'utf8'); + } catch (err) { + console.error(`[SD-3176] Snapshot not found: ${SNAPSHOT}`); + console.error('Run with --write to seed the baseline.'); + return { code: 1 }; + } + + if (baseline === current) { + console.log('[SD-3176] super-editor package exports map: no growth.'); + return { code: 0 }; + } + + const baseSet = new Set(baseline.split('\n').filter(Boolean)); + const curSet = new Set(current.split('\n').filter(Boolean)); + const added = [...curSet].filter((k) => !baseSet.has(k)); + const removed = [...baseSet].filter((k) => !curSet.has(k)); + + console.error('[SD-3176] @superdoc/super-editor package.json#exports drifted:'); + if (added.length) console.error(' added: ' + added.join(', ')); + if (removed.length) console.error(' removed: ' + removed.join(', ')); + console.error(''); + console.error('Per SD-3175 (path-as-contract facade), @superdoc/super-editor is legacy compatibility surface'); + console.error('and must not grow. If this change is intentional (e.g. an approved compat shim), regenerate:'); + console.error(' node tests/consumer-typecheck/snapshot.mjs --family super-editor-package --write'); + console.error('and link the PR to SD-3175 or a child ticket for reviewer sign-off.'); + return { code: 1 }; +} diff --git a/tests/consumer-typecheck/snapshots/README.md b/tests/consumer-typecheck/snapshots/README.md index a40fe82e4e..f89d360e56 100644 --- a/tests/consumer-typecheck/snapshots/README.md +++ b/tests/consumer-typecheck/snapshots/README.md @@ -19,11 +19,10 @@ These files lock the public TypeScript surface that ships through SuperDoc's leg | `superdoc-root-classification.json` | SD-3212 PR A1 classification | Each of the 200 root names assigned a bucket (`supported-root` / `legacy-root` / `move-to-subpath` / `internal-candidate`) with rationale and confidence. Decision document for PR B (re-curation) and PR C (root types flip). Applies dependency-closure rule: any type required by a supported-root or legacy-root exported class/method is at least `legacy-root`. Not a drift gate. | | `superdoc-root-classification.md` | Companion human-review surface for the classification | Grouped by bucket with per-name rationale. | -Snapshot scripts: +Snapshot scripts (SD-3213b consolidated all three into one CLI): -- `tests/consumer-typecheck/snapshot-super-editor-package-exports.mjs` -- `tests/consumer-typecheck/snapshot-superdoc-legacy-exports.mjs` -- `tests/consumer-typecheck/snapshot-superdoc-root-exports.mjs` (SD-3212 PR A0; superdoc root entry) +- `tests/consumer-typecheck/snapshot.mjs` -- unified entry point. Dispatches to the family modules under `tests/consumer-typecheck/snapshot/` (`super-editor-package-exports.mjs`, `legacy-exports.mjs`, `root-exports.mjs`). +- CI calls `node tests/consumer-typecheck/snapshot.mjs --all --check`. ## What to do when CI fails @@ -37,13 +36,16 @@ The failure message tells you which snapshot drifted, what was added, and what w **When growth is intentional** (rare: an explicitly approved compat shim for a legacy customer, an accepted deprecation alias, or similar): 1. Make sure the PR links to SD-3175 or a child ticket so the architectural reviewer sees the justification. -2. Regenerate the affected snapshot: +2. Regenerate the affected family: ```bash - # Snapshot A (package exports map): - node tests/consumer-typecheck/snapshot-super-editor-package-exports.mjs --write + # Snapshot A (package exports map, source-only): + node tests/consumer-typecheck/snapshot.mjs --family super-editor-package --write - # Snapshot B (resolved exports — requires fixture installed): - node tests/consumer-typecheck/snapshot-superdoc-legacy-exports.mjs --write + # Snapshot B (resolved exports, requires fixture installed): + node tests/consumer-typecheck/snapshot.mjs --family legacy --write + + # Snapshot C (root entry 4-source inventory, requires fixture installed): + node tests/consumer-typecheck/snapshot.mjs --family root --write ``` 3. Commit the updated snapshot together with the change that caused it. Reviewer reads both as one decision. @@ -53,10 +55,10 @@ The failure message tells you which snapshot drifted, what was added, and what w Snapshot A (source-only, no fixture needed): ```bash -node tests/consumer-typecheck/snapshot-super-editor-package-exports.mjs --check +node tests/consumer-typecheck/snapshot.mjs --family super-editor-package --check ``` -Snapshot B requires the packed-and-installed fixture under `tests/consumer-typecheck/node_modules/superdoc/`. The matrix script sets this up: +Snapshots B and C require the packed-and-installed fixture under `tests/consumer-typecheck/node_modules/superdoc/`. The matrix script sets this up: ```bash # Either run the full matrix first (it packs and installs): node tests/consumer-typecheck/typecheck-matrix.mjs @@ -65,13 +67,14 @@ node tests/consumer-typecheck/typecheck-matrix.mjs pnpm --filter superdoc run pack:es # repo root cd tests/consumer-typecheck npm install ../../packages/superdoc/superdoc.tgz --no-save -node snapshot-superdoc-legacy-exports.mjs --check +cd ../.. +node tests/consumer-typecheck/snapshot.mjs --all --check ``` ## What this gate does NOT do -- Does not classify supported public surfaces (root `superdoc`, `superdoc/ui`, etc.). That work lives in `tests/consumer-typecheck/public-facade-policy.json` and SD-2966 / SD-3147. +- Does not classify supported public surfaces (root `superdoc`, `superdoc/ui`, etc.). Root classification lives in `tests/consumer-typecheck/snapshots/superdoc-root-classification.json` (SD-3212 PR A1); subpath facade decisions live in `packages/superdoc/scripts/verify-public-facade-emit.cjs` and `docs/architecture/package-boundaries.md` under SD-3147 / SD-3175. - Does not catch leaks through non-legacy paths. The full path-as-contract facade lands under SD-3175. - Does not lock the *types* of exported symbols, only their names. A breaking change to an existing export's shape passes this gate. -- Does not run against arbitrary subpaths. Only the files listed in the table above are tracked. The authoritative list lives in `SUBPATHS` inside `tests/consumer-typecheck/snapshot-superdoc-legacy-exports.mjs`. +- Does not run against arbitrary subpaths. Only the files listed in the table above are tracked. The authoritative list lives in `SUBPATHS` inside `tests/consumer-typecheck/snapshot/legacy-exports.mjs`. - Does not enumerate every file reachable through existing wildcard export-map keys in `@superdoc/super-editor` (e.g. `"./*"`, `"./converter/internal/*"`). Snapshot A freezes the export-map key set; Snapshot B freezes the resolved `superdoc/super-editor` named export surface. A new file added under an existing wildcard that a consumer reaches via deep import (`@superdoc/super-editor/something-new`) passes both gates. Wildcard removal or shrinkage belongs to the later compat/major phases of SD-3175.