diff --git a/AGENTS.md b/AGENTS.md index 0601db803c..15f8ac699b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,7 @@ Do not hand-edit `COMMAND_CATALOG`, `OPERATION_MEMBER_PATH_MAP`, `OPERATION_REFE - `pnpm dev` - dev server from `examples/` - `pnpm run generate:all` - regenerate schemas, SDK clients, tool catalogs, reference docs - `pnpm check:public-contract` - validate the published public type contract: wraps build + strict supported-root audit + consumer typecheck matrix. ~3 min. Scoped to the public type surface, not a replacement for `pnpm test` or `pnpm build`. SD-3256. +- `pnpm report:public-contract` - print the public-contract tier metadata (supported / legacy / legacy-raw / asset / deprecated). Read-only. Source of truth: `packages/superdoc/scripts/type-surface.config.cjs` (`publicContract` export). SD-3256. ## Testing diff --git a/package.json b/package.json index 447d1699fa..aaf3eb0ce2 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "check:all": "pnpm run format && pnpm run lint:fix && pnpm --prefix packages/super-editor run types:build && pnpm run test", "check:examples-demos": "bun scripts/validate-examples-demos.ts", "check:public-contract": "node scripts/check-public-contract.mjs", + "report:public-contract": "node scripts/report-public-contract.mjs", "local:publish": "pnpm --prefix packages/superdoc version prerelease --preid=local && pnpm --prefix packages/superdoc publish --registry http://localhost:4873", "update-preset-geometry": "ROOT=$(pwd) && cd ../superdoc-devtools/preset-geometry && pnpm run build && cp ./dist/index.js ./dist/index.js.map ./dist/index.d.ts \"$ROOT/packages/preset-geometry/\"", "manual-tag": "bash scripts/manual-tag.sh", diff --git a/packages/superdoc/scripts/type-surface.config.cjs b/packages/superdoc/scripts/type-surface.config.cjs index 791b8c7610..9deba19a4d 100644 --- a/packages/superdoc/scripts/type-surface.config.cjs +++ b/packages/superdoc/scripts/type-surface.config.cjs @@ -35,11 +35,18 @@ * - `rule1Allowlist`: bare `@superdoc/*` specifiers permitted in * published d.ts. Currently only the legacy public super-editor * surface per RFC Decision 1. + * - `publicContract`: SD-3256 Phase 2. Tier metadata for every + * `package.json#exports` subpath. Describes what each subpath is + * (supported / legacy / asset / deprecated), not yet enforced. + * `scripts/report-public-contract.mjs` prints this for review. * * Adding a new relocation: append one entry to `relocations` with the * package specifier, the dist target the rewriter should point at, and * the source-include patterns vite + tsconfig need. Every consumer picks * up the new entry without further edits. + * + * Adding a new public subpath: append an entry to `publicContract` with + * the correct tier. Keep it in sync with `package.json#exports`. */ const requiredEntryPoints = [ @@ -226,6 +233,65 @@ const rule1Allowlist = { '@superdoc/super-editor': 'legacy public surface (RFC Decision 1)', }; +/** + * SD-3256 Phase 2: tier metadata for every `package.json#exports` + * subpath. Describes what each entry is, not what CI enforces. No + * enforcement is wired up in this phase; the metadata exists so the + * team can review the classification before Phase 3 (./super-editor + * facade curation) and Phase 4 (ratchet against the tiers). + * + * Tier policies (target end state, not all enforced today): + * + * - `supported`: fully typed, no `any`, no accidental internals; + * supported-root strict gate hard-fails regressions. Routes + * through `src/public/**`. + * - `legacy`: must not grow accidentally; typed where supported; + * can be deprecated or migrated over time; new APIs should not + * be added here. Routes through `src/public/legacy/**`. + * - `legacy-raw`: legacy public surface that does NOT yet route + * through `src/public/legacy/**` (the export resolves directly + * to a non-curated dist path). Only `./super-editor` today. + * SD-3256 Phase 3 will curate this through + * `src/public/legacy/super-editor.ts` after team alignment on + * which exports stay public. + * - `asset`: non-type asset (e.g. CSS). Not covered by the type + * contract. + * - `deprecated`: scheduled for removal. None today. + * + * The `internal` tier is implicit: anything not exported here is + * internal and not part of the consumer promise. + * + * Sync rule: keep this list aligned with `package.json#exports`. + * Adding a new export means adding an entry here too. + */ +const publicContract = { + supported: [ + { subpath: '.', tier: 'supported', note: 'root facade; routes through src/public/index.ts' }, + { subpath: './types', tier: 'supported', note: 'type-only facade; src/public/types.ts' }, + { subpath: './ui', tier: 'supported', note: 'UI primitives; src/public/ui.ts' }, + { subpath: './ui/react', tier: 'supported', note: 'React adapter; src/public/ui-react.ts' }, + ], + legacy: [ + { subpath: './converter', tier: 'legacy', note: 'src/public/legacy/converter.ts' }, + { subpath: './docx-zipper', tier: 'legacy', note: 'src/public/legacy/docx-zipper.ts' }, + { subpath: './file-zipper', tier: 'legacy', note: 'src/public/legacy/file-zipper.ts' }, + { subpath: './headless-toolbar', tier: 'legacy', note: 'src/public/legacy/headless-toolbar.ts' }, + { subpath: './headless-toolbar/react', tier: 'legacy', note: 'src/public/legacy/headless-toolbar-react.ts' }, + { subpath: './headless-toolbar/vue', tier: 'legacy', note: 'src/public/legacy/headless-toolbar-vue.ts' }, + ], + legacyRaw: [ + { + subpath: './super-editor', + tier: 'legacy-raw', + note: 'resolves to dist/superdoc/src/super-editor.d.ts (not src/public/legacy/). SD-3256 Phase 3 will curate.', + }, + ], + asset: [ + { subpath: './style.css', tier: 'asset', note: 'CSS bundle; no types' }, + ], + deprecated: [], +}; + module.exports = { requiredEntryPoints, handwrittenDtsBlocklist, @@ -235,4 +301,5 @@ module.exports = { relocationGuardPackages, unshimmedPrivateSpecifiers, rule1Allowlist, + publicContract, }; diff --git a/scripts/report-public-contract.mjs b/scripts/report-public-contract.mjs new file mode 100644 index 0000000000..506f108760 --- /dev/null +++ b/scripts/report-public-contract.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node +/** + * SD-3256 Phase 2: print the public-contract tier metadata as a + * human-readable report. Read-only; no validation, no enforcement. + * + * Source of truth: `packages/superdoc/scripts/type-surface.config.cjs` + * (the `publicContract` export). Adding a new public subpath without + * adding it there means it is "internal" by definition of this report. + * + * Cross-checks done by this script: + * 1. Every `package.json#exports` subpath has a `publicContract` + * entry. Missing entries are listed as MISSING. + * 2. Every `publicContract` subpath actually exists in `exports`. + * Stale entries are listed as STALE. + * + * Both cross-checks are reported but do NOT change exit code in this + * phase (Phase 2 is read-only by design). The script always exits 0 + * unless it cannot load the config or package.json. + * + * Usage: + * pnpm report:public-contract + * + * Tracking: SD-3256 Phase 2. + */ + +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const require = createRequire(import.meta.url); + +const config = require(resolve(REPO_ROOT, 'packages/superdoc/scripts/type-surface.config.cjs')); +const pkg = JSON.parse(readFileSync(resolve(REPO_ROOT, 'packages/superdoc/package.json'), 'utf8')); + +const { publicContract } = config; +if (!publicContract) { + console.error('FAIL: publicContract not exported from type-surface.config.cjs'); + process.exit(1); +} + +const exportsMap = pkg.exports || {}; +const exportSubpaths = new Set(Object.keys(exportsMap)); + +const allContractEntries = [ + ...publicContract.supported, + ...publicContract.legacy, + ...publicContract.legacyRaw, + ...publicContract.asset, + ...publicContract.deprecated, +]; +const contractSubpaths = new Set(allContractEntries.map((e) => e.subpath)); + +const HR = '='.repeat(72); + +const printTier = (name, entries) => { + console.log(''); + console.log(`## ${name} (${entries.length})`); + if (entries.length === 0) { + console.log(' (none)'); + return; + } + for (const e of entries) { + console.log(` ${e.subpath.padEnd(28)} ${e.note || ''}`); + } +}; + +console.log(HR); +console.log('SuperDoc public type contract (SD-3256 Phase 2)'); +console.log(HR); +console.log(''); +console.log('Tier definitions live in:'); +console.log(' packages/superdoc/scripts/type-surface.config.cjs (publicContract)'); +console.log(''); +console.log(`Total exports in package.json: ${exportSubpaths.size}`); +console.log(`Total entries in publicContract: ${contractSubpaths.size}`); + +printTier('Supported', publicContract.supported); +printTier('Legacy (curated through src/public/legacy/**)', publicContract.legacy); +printTier('Legacy-raw (NOT yet curated; SD-3256 Phase 3 target)', publicContract.legacyRaw); +printTier('Asset (non-type)', publicContract.asset); +printTier('Deprecated', publicContract.deprecated); + +// Cross-check: missing (in exports but not in contract) +const missing = [...exportSubpaths].filter((s) => !contractSubpaths.has(s)); +// Cross-check: stale (in contract but not in exports) +const stale = [...contractSubpaths].filter((s) => !exportSubpaths.has(s)); + +console.log(''); +console.log(HR); +console.log('Cross-checks vs package.json#exports'); +console.log(HR); +if (missing.length === 0 && stale.length === 0) { + console.log('OK: every export has a contract entry and vice versa.'); +} else { + if (missing.length > 0) { + console.log(`MISSING (${missing.length}): in package.json#exports but not in publicContract:`); + for (const s of missing) console.log(` ${s}`); + } + if (stale.length > 0) { + console.log(`STALE (${stale.length}): in publicContract but not in package.json#exports:`); + for (const s of stale) console.log(` ${s}`); + } + console.log(''); + console.log('(Phase 2 is read-only; this does not fail CI. Phase 4 will gate.)'); +} + +console.log('');