From 9a6ce9f2cb93c4edc18c522bdc9600f6880a7470 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 23 May 2026 22:20:24 -0300 Subject: [PATCH 1/3] fix(typecheck): ratchet public-reachable JSDoc files (SD-2833 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The current check-jsdoc.cjs hard-fails only when one of the hand-curated 6 CHECKED_FILES drifts. Every other public-reachable .js file with JSDoc — 122 of them today — is reported but ignored. That's a silent gate: a new ungated public JSDoc file lands, the script prints "OK 6 gated files clean" and never mentions it. This PR adds a ratchet so new public-reachable JSDoc files cannot land silently, without mass-promoting the 122 existing ones (which the user explicitly flagged as the wrong move: "Do not suddenly gate all existing public JSDoc files. That would turn cleanup into a giant migration."). Design (per the user's "Better rule"): 1. CHECKED_FILES stays as the hand-curated, fully-gated set. Each must have `// @ts-check` and pass tsc. Unchanged in this PR; today's 6 entries are preserved as the "seed/legacy coverage." 2. Discover every public-reachable .js file with JSDoc (existing surface walk from PUBLIC_ENTRY_FILES, transitively follows imports through `superdoc`, `superdoc/super-editor`, `superdoc/ui`). 3. Each discovered file must be accounted for: - In CHECKED_FILES, OR - Carry `// @ts-check` (gated by the main `pnpm check:types` tsc -b run; the directive is the contributor's opt-in), OR - On the allowlist at jsdoc-allowlist.cjs (rare; each entry carries a one-line reason — vendored code, etc.), OR - In the debt snapshot at jsdoc-debt-snapshot.json (the pre-existing 103 entries; tracked for later opt-in). 4. A NEW public JSDoc file that isn't in any of those four → fail with "add // @ts-check OR allowlist with a reason." 5. A STALE entry in the snapshot (file gone, gained // @ts-check, moved out of public surface) → fail with "rerun --write." Files added: - packages/superdoc/scripts/jsdoc-debt-snapshot.json Auto-managed; 103 entries today (current public-reachable JSDoc files without // @ts-check, minus the 6 CHECKED_FILES, minus 19 already carrying // @ts-check for IDE feedback). Refreshed with `pnpm --filter superdoc run check:jsdoc -- --write`. - packages/superdoc/scripts/jsdoc-allowlist.cjs Hand-maintained; empty today. Format: { 'path': 'reason' }. Verified with all four scenarios: - Baseline (no changes): OK 6 CHECKED_FILES clean; ratchet snapshot in sync. - Simulated NEW file (removed an entry from snapshot, ran check): FAIL "1 new public JSDoc file(s) without // @ts-check ... Either add `// @ts-check` to the file (preferred), or add an entry to ... jsdoc-allowlist.cjs with a one-line reason." - Simulated STALE entry (added bogus path to snapshot): FAIL "1 stale entry/entries in the debt snapshot ... Run `pnpm --filter superdoc run check:jsdoc -- --write` to refresh." - Full umbrella: pnpm check:public → PASS end-to-end. No CI workflow changes needed: ci-superdoc.yml already calls `pnpm --filter superdoc run check:jsdoc`, which now invokes the extended script. README updated to describe the two gates and the snapshot-refresh workflow. --- packages/superdoc/scripts/README.md | 2 +- packages/superdoc/scripts/check-jsdoc.cjs | 351 ++++++++++++------ packages/superdoc/scripts/jsdoc-allowlist.cjs | 23 ++ .../superdoc/scripts/jsdoc-debt-snapshot.json | 108 ++++++ 4 files changed, 360 insertions(+), 124 deletions(-) create mode 100644 packages/superdoc/scripts/jsdoc-allowlist.cjs create mode 100644 packages/superdoc/scripts/jsdoc-debt-snapshot.json diff --git a/packages/superdoc/scripts/README.md b/packages/superdoc/scripts/README.md index 816a6d1865..a8f98b49f1 100644 --- a/packages/superdoc/scripts/README.md +++ b/packages/superdoc/scripts/README.md @@ -103,7 +103,7 @@ it stopped running. | `check-export-coverage.cjs` | postbuild | Every `package.json#exports` subpath carries a `types` field or is on the runtime-only allowlist. | `TS7016` returns for consumers on runtime-only subpaths. | | `verify-public-facade-emit.cjs` | postbuild | Per-facade expected symbol set + ESM/CJS parity + legacy command-signature compat. Has a hand-maintained `expectedNames` allowlist per facade (consolidation tracked separately). | Symbol set drift ships silently; CJS shims diverge from ESM. | | `report-declaration-reachability.cjs` | postbuild | Instrumentation (not a gate): per-bucket reachability ratio of emitted declarations. | Loses visibility into unreachable emit (the SD-2952 trim target). | -| `check-jsdoc.cjs` | CI step | Per-file checkJs gate for files in a hand-curated `CHECKED_FILES` allowlist. Currently 6 files. **Note**: `SuperDoc.js` now has `// @ts-check` but is gated by `check:types`, not this script. The 6-file list is a historical ratchet from before the broader enablement; consolidating with `check:types` is tracked separately. | A targeted regression on one of the 6 ratcheted files ships silently. | +| `check-jsdoc.cjs` | CI step | Two gates: (a) per-file checkJs on the hand-curated `CHECKED_FILES` (currently 6 files; each must carry `// @ts-check` and stay clean against tsc); (b) ratchet over the public-reachable .js JSDoc surface — every file must be in `CHECKED_FILES`, carry `// @ts-check`, be on `jsdoc-allowlist.cjs` with a reason, or be in `jsdoc-debt-snapshot.json` as known pre-existing debt. New public JSDoc files that aren't accounted for fail with a clear "add @ts-check or allowlist" message. Stale snapshot entries (file gone, gained @ts-check, moved out of public surface) also fail. Refresh the snapshot with `pnpm --filter superdoc run check:jsdoc -- --write`. | New public-reachable JSDoc files could land without type coverage; existing ones could lose their `// @ts-check` directive without surfacing as a regression. | The repo also has a top-level tier-discipline gate. One script, `scripts/report-public-contract.mjs`, with two modes: diff --git a/packages/superdoc/scripts/check-jsdoc.cjs b/packages/superdoc/scripts/check-jsdoc.cjs index 94646bb51b..c985cf1f48 100644 --- a/packages/superdoc/scripts/check-jsdoc.cjs +++ b/packages/superdoc/scripts/check-jsdoc.cjs @@ -1,36 +1,63 @@ #!/usr/bin/env node /** - * SD-2833: per-file checkJs gate for the public-contract surface. + * SD-2833 / typecheck-jsdoc-ratchet: per-file checkJs gate for the + * SuperDoc public-contract surface PLUS a ratchet that prevents new + * public JSDoc files from accumulating without `// @ts-check`. * - * Why this exists in this shape (and not as a plain `tsc -p tsconfig.checkjs.json`): + * Why this script exists (rather than turning on project-wide checkJs): * * The codebase uses `customConditions: ["source"]`, which makes TypeScript * resolve `import { Editor } from '@superdoc/super-editor'` to the source - * `.js`/`.ts` files of the workspace package. With `// @ts-check` enabled on - * any file in this package, TS follows those imports and type-checks the - * super-editor source too — about 6500 errors. Those errors are real (they - * are the broader SD-2863 work) but they are not what this PR is trying to - * gate. The gate here is "files in CHECKED_FILES must stay clean." + * `.js`/`.ts` files of the workspace package. With `// @ts-check` on any + * file in this package, TS follows those imports and type-checks the + * super-editor source too — about 6500 errors. Those errors are real but + * are not what this gate is for; that's separate SD-2863 work. The gate + * here is "files in CHECKED_FILES must stay clean, and new public- + * reachable JSDoc files must either opt into @ts-check or be allowlisted + * with a reason." * - * The script: + * Two gates run here: * - * 1. Runs `tsc --noEmit -p packages/superdoc/tsconfig.json`. Because each - * file in CHECKED_FILES has `// @ts-check`, TS reports errors on those - * files even though the project-wide `checkJs` is `false`. - * 2. Filters the tsc output to errors whose path matches an entry in - * CHECKED_FILES. - * 3. Exits non-zero if any matched the filter; exits zero if not. + * 1. CHECKED_FILES — Hand-curated list of files explicitly gated by + * this script. Each must have `// @ts-check` at the top. The script + * runs tsc, filters errors to these paths, and fails if any are + * present. New entries are added intentionally — adding a file + * means committing to keep it clean and fixing whatever surfaces. + * The list is small on purpose. Per-file `// @ts-check` directives + * elsewhere (e.g. the broader SuperDoc.js work) are still useful + * for IDE feedback but are not enforced through this script; they + * are checked by the main `pnpm check:types` (`tsc -b`) run. * - * Adding a new file to the gate: + * 2. RATCHET — Discover every public-reachable .js file with JSDoc + * type annotations (transitively from `superdoc`, `superdoc/super- + * editor`, `superdoc/ui`). The committed debt snapshot at + * `jsdoc-debt-snapshot.json` is the set of public JSDoc files that + * do NOT yet have `// @ts-check`. The ratchet fails if: + * - A NEW public JSDoc file lands without `// @ts-check` and + * isn't on the explicit allowlist. The contributor must + * either add the directive (preferred) or add an entry to + * `jsdoc-allowlist.cjs` with a one-line reason. + * - A STALE entry remains in the snapshot (file was deleted, + * left the public surface, or gained `// @ts-check`). Rerun + * with `--write` to refresh. + * Existing files with `// @ts-check` already get IDE / build-time + * type-checking from the main `tsc -b` run. They don't need a + * second gate here. * - * 1. Add `// @ts-check` as the first line of the file. - * 2. Add the file's repo-relative path to CHECKED_FILES below. - * 3. Run `node packages/superdoc/scripts/check-jsdoc.cjs` and fix what - * surfaces. + * Adding a file to CHECKED_FILES: + * 1. Add `// @ts-check` as the first line. + * 2. Append the file's repo-relative path to CHECKED_FILES below. + * 3. Run `pnpm --filter superdoc run check:jsdoc` and fix what + * surfaces. If the file was on the debt snapshot, also rerun + * with `--write` to drop the stale entry. * - * The intent is for CHECKED_FILES to grow over time as the team ratchets - * checkJs across the public-contract surface. SD-2863 lands the pattern; - * follow-up tickets land the additional files. + * Refreshing the snapshot after intentional changes: + * pnpm --filter superdoc run check:jsdoc -- --write + * + * Adding to the allowlist (rare): + * Edit `packages/superdoc/scripts/jsdoc-allowlist.cjs`. Each entry must + * document WHY the file is exempt (e.g. third-party shim, vendored + * code, intentionally untyped boundary). */ const fs = require('fs'); @@ -38,6 +65,19 @@ const path = require('path'); const { spawnSync } = require('child_process'); const ts = require('typescript'); +const packageDir = path.resolve(__dirname, '..'); +const repoRoot = path.resolve(packageDir, '..', '..'); + +const tscBin = path.join(repoRoot, 'node_modules', '.bin', 'tsc'); +const tsconfigPath = path.join(packageDir, 'tsconfig.json'); + +const DEBT_SNAPSHOT_PATH = path.join(__dirname, 'jsdoc-debt-snapshot.json'); +const ALLOWLIST_PATH = path.join(__dirname, 'jsdoc-allowlist.cjs'); + +// Hand-curated set of files explicitly gated by this script. Each MUST +// have `// @ts-check` at the top. Adding a file = committing to keep +// it clean. The list is small on purpose; broader checkJs coverage is +// gained one file at a time, not in a mass migration. const CHECKED_FILES = [ 'packages/superdoc/src/helpers/schema-introspection.js', 'packages/superdoc/src/composables/use-find-replace.js', @@ -47,12 +87,6 @@ const CHECKED_FILES = [ 'packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/markInsertion.js', ]; -const PUBLIC_ENTRY_FILES = [ - 'packages/superdoc/src/index.js', - 'packages/superdoc/src/super-editor.js', - 'packages/superdoc/src/ui.js', -]; - const REACHABILITY_EXEMPT_CHECKED_FILES = new Set([ // These files predate SD-2833. They are kept under the gate because their // typedefs feed exported SuperDoc configuration types, but they are reached @@ -61,11 +95,15 @@ const REACHABILITY_EXEMPT_CHECKED_FILES = new Set([ 'packages/superdoc/src/composables/use-password-prompt.js', ]); -const packageDir = path.resolve(__dirname, '..'); -const repoRoot = path.resolve(packageDir, '..', '..'); - -const tscBin = path.join(repoRoot, 'node_modules', '.bin', 'tsc'); -const tsconfigPath = path.join(packageDir, 'tsconfig.json'); +// PUBLIC entry points used by the ratchet's public-surface walk. These +// are the files consumers reach through `superdoc`, `superdoc/super-editor`, +// and `superdoc/ui`; the script transitively follows their imports to +// build the public-reachable .js set. +const PUBLIC_ENTRY_FILES = [ + 'packages/superdoc/src/index.js', + 'packages/superdoc/src/super-editor.js', + 'packages/superdoc/src/ui.js', +]; const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.vue']; @@ -91,8 +129,27 @@ const SOURCE_ALIASES = [ ['@shared/', 'shared/'], ]; +const HR = '='.repeat(72); +const flags = new Set(process.argv.slice(2)); +const writeMode = flags.has('--write'); + const toRepoRelative = (abs) => path.relative(repoRoot, abs).split(path.sep).join('/'); +const TS_CHECK_DIRECTIVE_RE = /^\s*\/\/\s*@ts-check\b/m; +const hasTsCheckDirective = (abs) => { + if (!abs.endsWith('.js')) return false; + // 4 KiB margin for a leading license/doc block before the directive. + const head = fs.readFileSync(abs, 'utf8').slice(0, 4096); + return TS_CHECK_DIRECTIVE_RE.test(head); +}; + +const JSDOC_TYPE_TAG_RE = /\/\*\*[\s\S]*?@(typedef|param|returns|template|callback|property|type)\b/; +const hasJSDocTypeSurface = (abs) => { + if (!abs.endsWith('.js')) return false; + const source = fs.readFileSync(abs, 'utf8'); + return JSDOC_TYPE_TAG_RE.test(source); +}; + const tryResolveSourcePath = (basePath) => { if (path.extname(basePath)) { if (fs.existsSync(basePath) && fs.statSync(basePath).isFile()) return basePath; @@ -104,17 +161,14 @@ const tryResolveSourcePath = (basePath) => { } return null; } - for (const extension of SOURCE_EXTENSIONS) { const sourcePath = `${basePath}${extension}`; if (fs.existsSync(sourcePath)) return sourcePath; } - for (const extension of SOURCE_EXTENSIONS) { const sourcePath = path.join(basePath, `index${extension}`); if (fs.existsSync(sourcePath)) return sourcePath; } - return null; }; @@ -122,24 +176,20 @@ const resolveSourceSpecifier = (specifier, containingFile) => { if (Object.prototype.hasOwnProperty.call(PACKAGE_EXPORT_SOURCES, specifier)) { return path.join(repoRoot, PACKAGE_EXPORT_SOURCES[specifier]); } - if (specifier.startsWith('@superdoc/super-editor/')) { return tryResolveSourcePath( path.join(repoRoot, 'packages/super-editor/src', specifier.slice('@superdoc/super-editor/'.length)), ); } - if (specifier.startsWith('.')) { return tryResolveSourcePath(path.resolve(path.dirname(containingFile), specifier)); } - for (const [alias, target] of SOURCE_ALIASES) { if (specifier === alias.replace(/\/$/, '')) return tryResolveSourcePath(path.join(repoRoot, target)); if (specifier.startsWith(alias)) { return tryResolveSourcePath(path.join(repoRoot, target, specifier.slice(alias.length))); } } - return null; }; @@ -155,7 +205,6 @@ const createSourceFile = (filePath) => { const findExportReachableTargets = (filePath) => { if (!/\.[jt]sx?$/.test(filePath)) return []; - const sourceFile = createSourceFile(filePath); const importedBindings = new Map(); const reachableTargets = []; @@ -163,13 +212,10 @@ const findExportReachableTargets = (filePath) => { for (const statement of sourceFile.statements) { if (!ts.isImportDeclaration(statement)) continue; if (!statement.moduleSpecifier || !ts.isStringLiteral(statement.moduleSpecifier)) continue; - const target = resolveSourceSpecifier(statement.moduleSpecifier.text, filePath); const clause = statement.importClause; if (!target || !clause) continue; - if (clause.name) importedBindings.set(clause.name.text, target); - const namedBindings = clause.namedBindings; if (!namedBindings) continue; if (ts.isNamespaceImport(namedBindings)) { @@ -185,13 +231,11 @@ const findExportReachableTargets = (filePath) => { for (const statement of sourceFile.statements) { if (!ts.isExportDeclaration(statement)) continue; - if (statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)) { const target = resolveSourceSpecifier(statement.moduleSpecifier.text, filePath); if (target) reachableTargets.push(target); continue; } - if (!statement.exportClause || !ts.isNamedExports(statement.exportClause)) continue; for (const element of statement.exportClause.elements) { const localName = (element.propertyName || element.name).text; @@ -199,119 +243,183 @@ const findExportReachableTargets = (filePath) => { if (target) reachableTargets.push(target); } } - return [...new Set(reachableTargets)]; }; const collectPublicExportSurface = () => { const seen = new Set(); const stack = PUBLIC_ENTRY_FILES.map((file) => path.join(repoRoot, file)); - while (stack.length > 0) { const current = stack.pop(); if (!current || seen.has(current)) continue; - seen.add(current); for (const target of findExportReachableTargets(current)) { if (!seen.has(target)) stack.push(target); } } - return seen; }; -const hasJSDocTypeSurface = (abs) => { - if (!abs.endsWith('.js')) return false; - const source = fs.readFileSync(abs, 'utf8'); - return /\/\*\*[\s\S]*?@(typedef|param|returns|template|callback|property|type)\b/.test(source); -}; +// ─── Snapshot + allowlist I/O ───────────────────────────────────────── + +function loadDebtSnapshot() { + if (!fs.existsSync(DEBT_SNAPSHOT_PATH)) return []; + const raw = JSON.parse(fs.readFileSync(DEBT_SNAPSHOT_PATH, 'utf8')); + if (!Array.isArray(raw.knownUngated)) { + console.error(`[check-jsdoc] invalid snapshot at ${DEBT_SNAPSHOT_PATH} (missing "knownUngated" array)`); + process.exit(1); + } + return raw.knownUngated.slice().sort(); +} + +function loadAllowlist() { + if (!fs.existsSync(ALLOWLIST_PATH)) return {}; + const mod = require(ALLOWLIST_PATH); + if (typeof mod !== 'object' || mod === null) return {}; + return mod; +} + +function writeDebtSnapshot(knownUngated) { + const payload = { + $comment: + 'Auto-managed by packages/superdoc/scripts/check-jsdoc.cjs. ' + + 'Run with --write to refresh after intentionally adding/removing public JSDoc files. ' + + 'Each entry is a public-reachable .js file with JSDoc that does not yet have // @ts-check.', + knownUngated: knownUngated.slice().sort(), + }; + fs.writeFileSync(DEBT_SNAPSHOT_PATH, JSON.stringify(payload, null, 2) + '\n'); +} + +// ─── Main ──────────────────────────────────────────────────────────── + +const publicSurface = collectPublicExportSurface(); +const publicJsdocAbs = [...publicSurface].filter(hasJSDocTypeSurface).sort(); +const publicJsdoc = publicJsdocAbs.map(toRepoRelative); +const publicJsdocSet = new Set(publicJsdoc); -const publicExportSurface = collectPublicExportSurface(); -const publicExportSurfaceRelative = new Set([...publicExportSurface].map(toRepoRelative)); -const publicJSDocFiles = [...publicExportSurface].filter(hasJSDocTypeSurface).sort(); const checkedFileSet = new Set(CHECKED_FILES); -const nonPublicCheckedFiles = CHECKED_FILES.filter( - (rel) => !publicExportSurfaceRelative.has(rel) && !REACHABILITY_EXEMPT_CHECKED_FILES.has(rel), -); +const allowlist = loadAllowlist(); +const allowlistedSet = new Set(Object.keys(allowlist)); + +// A public JSDoc file is "accounted for" when it has `// @ts-check` +// (we trust the per-file directive — broader checkJs catches drift), +// or is on the allowlist, or is in CHECKED_FILES. The debt snapshot +// is the catch-all for everything else. +function isAccountedFor(rel) { + if (allowlistedSet.has(rel)) return true; + if (checkedFileSet.has(rel)) return true; + return hasTsCheckDirective(path.join(repoRoot, rel)); +} -if (nonPublicCheckedFiles.length > 0) { - console.error('[check-jsdoc] gated files are not reachable from the public superdoc export surface:'); - for (const f of nonPublicCheckedFiles) console.error(` - ${f}`); - console.error('Gated JSDoc files must be exported from superdoc, superdoc/super-editor, or superdoc/ui.'); - process.exit(1); +const expectedKnownUngated = publicJsdoc.filter((rel) => !isAccountedFor(rel)).sort(); + +if (writeMode) { + writeDebtSnapshot(expectedKnownUngated); + console.log(`[check-jsdoc] wrote ${path.relative(repoRoot, DEBT_SNAPSHOT_PATH)} (${expectedKnownUngated.length} entries).`); + process.exit(0); } -// Pre-flight: every file in CHECKED_FILES must opt into `// @ts-check`. -// The project's tsconfig sets `checkJs: false`, so a JS file without the -// directive is not type-checked at all. Without this guard, removing or -// forgetting the directive on a listed file makes the gate silently stop -// covering it — the script keeps reporting OK even though the file has -// drifted. +const debtSnapshot = loadDebtSnapshot(); +const debtSet = new Set(debtSnapshot); + +// Ratchet 1: new public JSDoc files that aren't accounted for AND aren't already in the snapshot. +const newUngated = expectedKnownUngated.filter((rel) => !debtSet.has(rel)); +// Ratchet 2: snapshot entries that no longer apply (file gone, file gained @ts-check, allowlist, moved out of public surface, etc.). +const staleDebt = debtSnapshot.filter((rel) => !expectedKnownUngated.includes(rel)); + +// Pre-flight: every entry in CHECKED_FILES must exist on disk and carry +// the `// @ts-check` directive. Without this, removing or forgetting +// the directive makes the gate silently stop covering the file. const missingDirective = []; const missingFiles = []; +const nonPublicCheckedFiles = CHECKED_FILES.filter( + (rel) => !publicJsdocSet.has(rel) && !REACHABILITY_EXEMPT_CHECKED_FILES.has(rel), +); for (const rel of CHECKED_FILES) { const abs = path.join(repoRoot, rel); if (!fs.existsSync(abs)) { missingFiles.push(rel); continue; } - // The directive only takes effect when it precedes any non-comment - // statement, so it lives near the top. 4 KiB is plenty of margin for - // a leading license/doc block. - const head = fs.readFileSync(abs, 'utf8').slice(0, 4096); - if (!/^\s*\/\/\s*@ts-check\b/m.test(head)) { - missingDirective.push(rel); - } + if (!hasTsCheckDirective(abs)) missingDirective.push(rel); } +const preflightFailures = []; +if (nonPublicCheckedFiles.length > 0) { + preflightFailures.push('CHECKED_FILES contains entries not on the public superdoc export surface:'); + for (const f of nonPublicCheckedFiles) preflightFailures.push(` - ${f}`); + preflightFailures.push( + 'Gated files must be exported from superdoc, superdoc/super-editor, or superdoc/ui ' + + '(or listed in REACHABILITY_EXEMPT_CHECKED_FILES with an explicit reason).', + ); +} if (missingFiles.length > 0) { - console.error('[check-jsdoc] gated files do not exist:'); - for (const f of missingFiles) console.error(` - ${f}`); - process.exit(1); + if (preflightFailures.length > 0) preflightFailures.push(''); + preflightFailures.push('CHECKED_FILES entries do not exist:'); + for (const f of missingFiles) preflightFailures.push(` - ${f}`); } if (missingDirective.length > 0) { - console.error('[check-jsdoc] gated files are missing the `// @ts-check` directive:'); - for (const f of missingDirective) console.error(` - ${f}`); - console.error('Each gated file must opt into checkJs explicitly.'); - console.error('Add `// @ts-check` as the first non-blank line, then re-run.'); + if (preflightFailures.length > 0) preflightFailures.push(''); + preflightFailures.push('CHECKED_FILES entries are missing the `// @ts-check` directive:'); + for (const f of missingDirective) preflightFailures.push(` - ${f}`); + preflightFailures.push('Each gated file must opt into checkJs explicitly. Add `// @ts-check` and re-run.'); +} + +const ratchetFailures = []; +if (newUngated.length > 0) { + ratchetFailures.push( + `${newUngated.length} new public JSDoc file(s) without // @ts-check and not on the allowlist:`, + ); + for (const rel of newUngated) ratchetFailures.push(` + ${rel}`); + ratchetFailures.push( + 'Either add `// @ts-check` to the file (preferred), or add an entry to ' + + `${path.relative(repoRoot, ALLOWLIST_PATH)} with a one-line reason.`, + ); +} +if (staleDebt.length > 0) { + if (ratchetFailures.length > 0) ratchetFailures.push(''); + ratchetFailures.push(`${staleDebt.length} stale entry/entries in the debt snapshot:`); + for (const rel of staleDebt) ratchetFailures.push(` - ${rel}`); + ratchetFailures.push( + 'These files have been deleted, moved out of the public surface, or gained // @ts-check / allowlist. ' + + 'Run `pnpm --filter superdoc run check:jsdoc -- --write` to refresh the snapshot.', + ); +} + +if (preflightFailures.length > 0 || ratchetFailures.length > 0) { + console.log('[check-jsdoc] SuperDoc JSDoc ratchet'); + console.log(HR); + if (preflightFailures.length > 0) { + console.log('FAIL CHECKED_FILES preflight:'); + for (const line of preflightFailures) console.log(line); + console.log(); + } + if (ratchetFailures.length > 0) { + console.log('FAIL ratchet drift detected:'); + for (const line of ratchetFailures) console.log(line); + } process.exit(1); } +// ─── Drift check on CHECKED_FILES (the original gate) ──────────────── + const result = spawnSync(tscBin, ['--noEmit', '-p', tsconfigPath], { encoding: 'utf8', cwd: repoRoot, }); -// Fail fast if tsc itself could not be spawned (ENOENT on the binary, -// EACCES, etc.). Without this guard, a missing `tsc` leaves -// `result.error` set, empty stdout/stderr, and the rest of the script -// would happily report "OK" because it found zero parseable errors. if (result.error) { console.error(`[check-jsdoc] failed to invoke tsc at ${tscBin}: ${result.error.message}`); process.exit(1); } - -// Killed by a signal (SIGKILL/OOM/SIGTERM) mid-run. spawnSync sets -// `result.status` to null in that case and may leave partial output -// containing parseable diagnostics, which would otherwise sneak past -// the structural-failure check below. if (result.signal !== null) { console.error(`[check-jsdoc] tsc was killed by signal: ${result.signal}`); process.exit(1); } const output = `${result.stdout || ''}${result.stderr || ''}`; +const allErrors = output.split('\n').filter((line) => /\.[jt]sx?\(\d+,\d+\):\s+error\s+TS\d+:/.test(line)); -// Match each `path/to/file(line,col): error TSxxxx: ...` row. tsc emits -// paths relative to the cwd we ran from (repoRoot). -const allErrors = output - .split('\n') - .filter((line) => /\.[jt]sx?\(\d+,\d+\):\s+error\s+TS\d+:/.test(line)); - -// Catch the structural-failure mode: tsc exited non-zero but produced no -// parseable diagnostics. That means the failure is something like a -// missing tsconfig, an internal compiler crash, or a config error, -// rather than a normal type-check fail; the gate cannot reason about it. if (result.status !== 0 && allErrors.length === 0) { console.error('[check-jsdoc] tsc exited with a non-zero status but produced no parseable diagnostics.'); console.error(`Status: ${result.status}`); @@ -320,38 +428,35 @@ if (result.status !== 0 && allErrors.length === 0) { } const checkedAbsolute = CHECKED_FILES.map((rel) => path.join(repoRoot, rel)); - const isCheckedError = (line) => { const match = line.match(/^([^(]+)\(\d+,\d+\):/); if (!match) return false; const filePath = path.resolve(repoRoot, match[1]); return checkedAbsolute.includes(filePath); }; - const checkedErrors = allErrors.filter(isCheckedError); -console.log('[check-jsdoc] SD-2833 public-contract checkJs gate'); -console.log('='.repeat(72)); -console.log(`Public JSDoc files discovered: ${publicJSDocFiles.length}`); -console.log(`Files under gate: ${CHECKED_FILES.length}`); -for (const f of CHECKED_FILES) { - console.log(` - ${f}`); -} -const ungatedPublicJSDocCount = publicJSDocFiles.filter((abs) => !checkedFileSet.has(toRepoRelative(abs))).length; -console.log(`Ungated public JSDoc files: ${ungatedPublicJSDocCount}`); +console.log('[check-jsdoc] SuperDoc JSDoc ratchet'); +console.log(HR); +console.log(`Public JSDoc files discovered: ${publicJsdoc.length}`); +console.log(` - CHECKED_FILES (hand-curated): ${publicJsdoc.filter((p) => checkedFileSet.has(p)).length} (+${REACHABILITY_EXEMPT_CHECKED_FILES.size} reachability-exempt)`); +console.log(` - // @ts-check (informational): ${publicJsdoc.filter((p) => hasTsCheckDirective(path.join(repoRoot, p))).length}`); +console.log(` - allowlisted (with reason): ${publicJsdoc.filter((p) => allowlistedSet.has(p)).length}`); +console.log(` - tracked as known debt: ${expectedKnownUngated.length}`); +console.log(`Snapshot at: ${path.relative(repoRoot, DEBT_SNAPSHOT_PATH)}`); console.log(); if (checkedErrors.length === 0) { - console.log(`OK ${CHECKED_FILES.length} gated file${CHECKED_FILES.length === 1 ? '' : 's'} clean.`); - console.log(` (${allErrors.length} non-gated error${allErrors.length === 1 ? '' : 's'} in the wider tsc run, ignored — see SD-2863/SD-2833 follow-ups.)`); + console.log(`OK ${CHECKED_FILES.length} CHECKED_FILES clean; ratchet snapshot in sync.`); + console.log( + ` (${allErrors.length} non-gated error(s) in the wider tsc run, ignored — tracked by the debt snapshot or outside the public surface.)`, + ); process.exit(0); } -console.log(`FAIL ${checkedErrors.length} error${checkedErrors.length === 1 ? '' : 's'} in gated files:`); -for (const line of checkedErrors) { - console.log(` ${line}`); -} +console.log(`FAIL ${checkedErrors.length} error(s) in CHECKED_FILES:`); +for (const line of checkedErrors) console.log(` ${line}`); console.log(); -console.log('Each error means a public-contract JSDoc has drifted from the implementation.'); +console.log('Each error means a CHECKED_FILES entry has drifted from its JSDoc.'); console.log('Fix the type or the code so they match. Adding `// @ts-ignore` is not the answer.'); process.exit(1); diff --git a/packages/superdoc/scripts/jsdoc-allowlist.cjs b/packages/superdoc/scripts/jsdoc-allowlist.cjs new file mode 100644 index 0000000000..3c614fcc8c --- /dev/null +++ b/packages/superdoc/scripts/jsdoc-allowlist.cjs @@ -0,0 +1,23 @@ +/** + * Hand-maintained allowlist for the SuperDoc JSDoc ratchet + * (`packages/superdoc/scripts/check-jsdoc.cjs`). + * + * The ratchet fails when a NEW public-reachable .js file with JSDoc + * type annotations lands without `// @ts-check`. The expectation is + * that new public code opts into checkJs. This allowlist exists for + * the rare cases where that isn't appropriate: + * + * - Files vendored from third-party sources with their own JSDoc + * - Intentionally untyped boundary shims that exist for runtime + * reasons (e.g. lazy loader stubs) + * - Files where adding `// @ts-check` would force a downstream + * refactor that's tracked separately + * + * Each entry MUST carry a one-line reason. The key is the repo-relative + * path; the value is the reason. Empty today — every public JSDoc file + * is either auto-gated (has `// @ts-check`) or tracked in + * `jsdoc-debt-snapshot.json` for later opt-in. + */ +module.exports = { + // 'packages/.../some-vendored-file.js': 'reason this file is exempt', +}; diff --git a/packages/superdoc/scripts/jsdoc-debt-snapshot.json b/packages/superdoc/scripts/jsdoc-debt-snapshot.json new file mode 100644 index 0000000000..f8adff0215 --- /dev/null +++ b/packages/superdoc/scripts/jsdoc-debt-snapshot.json @@ -0,0 +1,108 @@ +{ + "$comment": "Auto-managed by packages/superdoc/scripts/check-jsdoc.cjs. Run with --write to refresh after intentionally adding/removing public JSDoc files. Each entry is a public-reachable .js file with JSDoc that does not yet have // @ts-check.", + "knownUngated": [ + "packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js", + "packages/super-editor/src/editors/v1/core/DocxZipper.js", + "packages/super-editor/src/editors/v1/core/Schema.js", + "packages/super-editor/src/editors/v1/core/helpers/annotator.js", + "packages/super-editor/src/editors/v1/core/helpers/chainableEditorState.js", + "packages/super-editor/src/editors/v1/core/helpers/cleanSchemaItem.js", + "packages/super-editor/src/editors/v1/core/helpers/createDocument.js", + "packages/super-editor/src/editors/v1/core/helpers/defaultBlockAt.js", + "packages/super-editor/src/editors/v1/core/helpers/findChildren.js", + "packages/super-editor/src/editors/v1/core/helpers/generateDocxRandomId.js", + "packages/super-editor/src/editors/v1/core/helpers/getActiveFormatting.js", + "packages/super-editor/src/editors/v1/core/helpers/getMarkType.js", + "packages/super-editor/src/editors/v1/core/helpers/getNodeType.js", + "packages/super-editor/src/editors/v1/core/helpers/getSchemaTypeByName.js", + "packages/super-editor/src/editors/v1/core/helpers/getSchemaTypeNameByName.js", + "packages/super-editor/src/editors/v1/core/helpers/isActive.js", + "packages/super-editor/src/editors/v1/core/helpers/isList.js", + "packages/super-editor/src/editors/v1/core/helpers/isMarkActive.js", + "packages/super-editor/src/editors/v1/core/helpers/isNodeActive.js", + "packages/super-editor/src/editors/v1/core/helpers/isTextSelection.js", + "packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js", + "packages/super-editor/src/editors/v1/core/super-converter/zipper.js", + "packages/super-editor/src/editors/v1/core/utilities/createStyleTag.js", + "packages/super-editor/src/editors/v1/core/utilities/cssColorToHex.js", + "packages/super-editor/src/editors/v1/core/utilities/deleteProps.js", + "packages/super-editor/src/editors/v1/core/utilities/isEmptyObject.js", + "packages/super-editor/src/editors/v1/core/utilities/objectIncludes.js", + "packages/super-editor/src/editors/v1/core/utilities/parseSizeUnit.js", + "packages/super-editor/src/editors/v1/extensions/ai/ai-plugin.js", + "packages/super-editor/src/editors/v1/extensions/block-node/block-node.js", + "packages/super-editor/src/editors/v1/extensions/bold/bold.js", + "packages/super-editor/src/editors/v1/extensions/bookmarks/bookmark-end.js", + "packages/super-editor/src/editors/v1/extensions/bookmarks/bookmark-start.js", + "packages/super-editor/src/editors/v1/extensions/collaboration/collaboration.js", + "packages/super-editor/src/editors/v1/extensions/color/color.js", + "packages/super-editor/src/editors/v1/extensions/comment/comments-plugin.js", + "packages/super-editor/src/editors/v1/extensions/custom-selection/custom-selection.js", + "packages/super-editor/src/editors/v1/extensions/diffing/diffing.js", + "packages/super-editor/src/editors/v1/extensions/document-index/document-index.js", + "packages/super-editor/src/editors/v1/extensions/field-annotation/field-annotation.js", + "packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/findFieldAnnotations.js", + "packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/findFieldAnnotationsBetween.js", + "packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/findFieldAnnotationsByFieldId.js", + "packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/findFirstFieldAnnotationByFieldId.js", + "packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/findHeaderFooterAnnotationsByFieldId.js", + "packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/findRemovedFieldAnnotations.js", + "packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/getAllFieldAnnotations.js", + "packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/getAllFieldAnnotationsWithRect.js", + "packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/getHeaderFooterAnnotations.js", + "packages/super-editor/src/editors/v1/extensions/field-annotation/fieldAnnotationHelpers/trackFieldAnnotationsDeletion.js", + "packages/super-editor/src/editors/v1/extensions/field-update/field-update.js", + "packages/super-editor/src/editors/v1/extensions/font-family/font-family.js", + "packages/super-editor/src/editors/v1/extensions/font-size/font-size.js", + "packages/super-editor/src/editors/v1/extensions/format-commands/format-commands.js", + "packages/super-editor/src/editors/v1/extensions/heading/heading.js", + "packages/super-editor/src/editors/v1/extensions/highlight/highlight.js", + "packages/super-editor/src/editors/v1/extensions/history/history.js", + "packages/super-editor/src/editors/v1/extensions/image/image.js", + "packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js", + "packages/super-editor/src/editors/v1/extensions/image/imageHelpers/processAndInsertImageFile.js", + "packages/super-editor/src/editors/v1/extensions/italic/italic.js", + "packages/super-editor/src/editors/v1/extensions/letter-spacing/letter-spacing.js", + "packages/super-editor/src/editors/v1/extensions/line-break/line-break.js", + "packages/super-editor/src/editors/v1/extensions/mention/mention.js", + "packages/super-editor/src/editors/v1/extensions/mixed-bidi-backspace/mixed-bidi-backspace.js", + "packages/super-editor/src/editors/v1/extensions/no-break-hyphen/no-break-hyphen.js", + "packages/super-editor/src/editors/v1/extensions/noderesizer/noderesizer.js", + "packages/super-editor/src/editors/v1/extensions/paragraph/paragraph.js", + "packages/super-editor/src/editors/v1/extensions/permission-ranges/permission-ranges.js", + "packages/super-editor/src/editors/v1/extensions/placeholder/placeholder.js", + "packages/super-editor/src/editors/v1/extensions/popover-plugin/popover-plugin.js", + "packages/super-editor/src/editors/v1/extensions/protection/protection.js", + "packages/super-editor/src/editors/v1/extensions/search/search.js", + "packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js", + "packages/super-editor/src/editors/v1/extensions/strike/strike.js", + "packages/super-editor/src/editors/v1/extensions/structured-content/document-section.js", + "packages/super-editor/src/editors/v1/extensions/structured-content/document-section/helpers.js", + "packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-block.js", + "packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-commands.js", + "packages/super-editor/src/editors/v1/extensions/structured-content/structured-content.js", + "packages/super-editor/src/editors/v1/extensions/tab/tab.js", + "packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js", + "packages/super-editor/src/editors/v1/extensions/table-header/table-header.js", + "packages/super-editor/src/editors/v1/extensions/table-of-contents/table-of-contents.js", + "packages/super-editor/src/editors/v1/extensions/table-row/table-row.js", + "packages/super-editor/src/editors/v1/extensions/table/table.js", + "packages/super-editor/src/editors/v1/extensions/table/tableHelpers/computeColumnWidths.js", + "packages/super-editor/src/editors/v1/extensions/text-align/text-align.js", + "packages/super-editor/src/editors/v1/extensions/text-style/text-style.js", + "packages/super-editor/src/editors/v1/extensions/text-transform/text-transform.js", + "packages/super-editor/src/editors/v1/extensions/text/text.js", + "packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/documentHelpers.js", + "packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/findTrackedMarkBetween.js", + "packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/getLiveInlineMarksInRange.js", + "packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/getTrackChanges.js", + "packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/markSnapshotHelpers.js", + "packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/parseFormatList.js", + "packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/removeMarkStep.js", + "packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceAroundStep.js", + "packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceStep.js", + "packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js", + "packages/super-editor/src/editors/v1/extensions/underline/underline.js", + "packages/superdoc/src/index.js" + ] +} From 2823dc82fbcc373170e961c8d5ddc5b6439d47db Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sun, 24 May 2026 06:37:25 -0300 Subject: [PATCH 2/3] fix(typecheck): fold jsdoc-ratchet into check:public + enforce allowlist contract The ratchet now runs as stage 3 of scripts/check-public-contract.mjs between tier-discipline and build:superdoc. One canonical public-interface validation path: pnpm check:public:superdoc runs the ratchet automatically for PR CI, preview release, and stable release. The standalone 'Public-contract checkJs' step in ci-superdoc.yml is removed as redundant. The allowlist contract is also enforced. Each entry in jsdoc-allowlist.cjs must carry a non-empty string reason, point at a file that exists on disk, and still resolve to a public-reachable JSDoc file. Empty reasons, typo paths, and dead entries all fail. --- .github/workflows/ci-superdoc.yml | 9 ++--- packages/superdoc/scripts/README.md | 2 +- packages/superdoc/scripts/check-jsdoc.cjs | 38 +++++++++++++++++++-- scripts/check-public-contract.mjs | 40 ++++++++++++++++------- 4 files changed, 67 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci-superdoc.yml b/.github/workflows/ci-superdoc.yml index cb307198c8..280f0b46f7 100644 --- a/.github/workflows/ci-superdoc.yml +++ b/.github/workflows/ci-superdoc.yml @@ -113,15 +113,10 @@ jobs: - name: Typecheck run: pnpm run type-check - - name: Public-contract checkJs (SD-2863) - # Gated subset of public-contract files run with `// @ts-check`. The - # script greps tsc output for errors in the curated file list and - # ignores the wider 1500+ errors from the broader super-editor source - # tree (those are tracked under SD-2863 follow-up tickets). - run: pnpm --filter superdoc run check:jsdoc - - name: SuperDoc public interface check # Single wrapper covering all SuperDoc public-surface gates: + # - tier-discipline (package.json#exports vs publicContract) + # - jsdoc-ratchet (checkJs gate + per-file ratchet) # - typecheck-matrix.mjs (packs superdoc, runs every scenario) # - deep-type-audit.mjs --strict-supported-root # - package-shape-gate.mjs (publint + attw) diff --git a/packages/superdoc/scripts/README.md b/packages/superdoc/scripts/README.md index a8f98b49f1..cd02ec6131 100644 --- a/packages/superdoc/scripts/README.md +++ b/packages/superdoc/scripts/README.md @@ -103,7 +103,7 @@ it stopped running. | `check-export-coverage.cjs` | postbuild | Every `package.json#exports` subpath carries a `types` field or is on the runtime-only allowlist. | `TS7016` returns for consumers on runtime-only subpaths. | | `verify-public-facade-emit.cjs` | postbuild | Per-facade expected symbol set + ESM/CJS parity + legacy command-signature compat. Has a hand-maintained `expectedNames` allowlist per facade (consolidation tracked separately). | Symbol set drift ships silently; CJS shims diverge from ESM. | | `report-declaration-reachability.cjs` | postbuild | Instrumentation (not a gate): per-bucket reachability ratio of emitted declarations. | Loses visibility into unreachable emit (the SD-2952 trim target). | -| `check-jsdoc.cjs` | CI step | Two gates: (a) per-file checkJs on the hand-curated `CHECKED_FILES` (currently 6 files; each must carry `// @ts-check` and stay clean against tsc); (b) ratchet over the public-reachable .js JSDoc surface — every file must be in `CHECKED_FILES`, carry `// @ts-check`, be on `jsdoc-allowlist.cjs` with a reason, or be in `jsdoc-debt-snapshot.json` as known pre-existing debt. New public JSDoc files that aren't accounted for fail with a clear "add @ts-check or allowlist" message. Stale snapshot entries (file gone, gained @ts-check, moved out of public surface) also fail. Refresh the snapshot with `pnpm --filter superdoc run check:jsdoc -- --write`. | New public-reachable JSDoc files could land without type coverage; existing ones could lose their `// @ts-check` directive without surfacing as a regression. | +| `check-jsdoc.cjs` | wrapper stage 3 (`jsdoc-ratchet`) | Two gates: (a) per-file checkJs on the hand-curated `CHECKED_FILES` (currently 6 files; each must carry `// @ts-check` and stay clean against tsc); (b) ratchet over the public-reachable .js JSDoc surface — every file must be in `CHECKED_FILES`, carry `// @ts-check`, be on `jsdoc-allowlist.cjs` with a reason, or be in `jsdoc-debt-snapshot.json` as known pre-existing debt. New public JSDoc files that aren't accounted for fail with a clear "add @ts-check or allowlist" message. Stale snapshot entries (file gone, gained @ts-check, moved out of public surface) also fail. The allowlist contract is enforced too: every entry must carry a non-empty reason, point at an existing file, and still resolve to a public-reachable JSDoc file. Refresh the snapshot with `pnpm --filter superdoc run check:jsdoc -- --write`. Runs as stage 3 of `check:public:superdoc`. | New public-reachable JSDoc files could land without type coverage; existing ones could lose their `// @ts-check` directive without surfacing as a regression; the allowlist could grow silent / typo-shaped exemptions. | The repo also has a top-level tier-discipline gate. One script, `scripts/report-public-contract.mjs`, with two modes: diff --git a/packages/superdoc/scripts/check-jsdoc.cjs b/packages/superdoc/scripts/check-jsdoc.cjs index c985cf1f48..475d00cc9b 100644 --- a/packages/superdoc/scripts/check-jsdoc.cjs +++ b/packages/superdoc/scripts/check-jsdoc.cjs @@ -57,7 +57,10 @@ * Adding to the allowlist (rare): * Edit `packages/superdoc/scripts/jsdoc-allowlist.cjs`. Each entry must * document WHY the file is exempt (e.g. third-party shim, vendored - * code, intentionally untyped boundary). + * code, intentionally untyped boundary). The script enforces the + * contract: every entry must carry a non-empty string reason, point at + * a file that exists on disk, and still resolve to a public-reachable + * JSDoc file. Empty reasons, typo paths, and dead entries all fail. */ const fs = require('fs'); @@ -301,6 +304,32 @@ const checkedFileSet = new Set(CHECKED_FILES); const allowlist = loadAllowlist(); const allowlistedSet = new Set(Object.keys(allowlist)); +// Validate the allowlist contract up-front. Every entry must: +// 1. Carry a non-empty string reason (the whole point of the allowlist +// is to leave an explanation). +// 2. Point at a file that exists on disk (a path-typo silently widening +// the exclusion set is exactly what this gate is meant to prevent). +// 3. Still resolve to a public-reachable JSDoc file (an allowlist entry +// for a file that left the public surface is dead weight that hides +// what's actually being excluded). +const allowlistFailures = []; +for (const [rel, reason] of Object.entries(allowlist)) { + if (typeof reason !== 'string' || reason.trim().length === 0) { + allowlistFailures.push(` - ${rel}: missing or empty reason (each entry must explain the exemption)`); + continue; + } + const abs = path.join(repoRoot, rel); + if (!fs.existsSync(abs)) { + allowlistFailures.push(` - ${rel}: file does not exist on disk`); + continue; + } + if (!publicJsdocSet.has(rel)) { + allowlistFailures.push( + ` - ${rel}: no longer a public-reachable JSDoc file (allowlist entry is dead; remove it)`, + ); + } +} + // A public JSDoc file is "accounted for" when it has `// @ts-check` // (we trust the per-file directive — broader checkJs catches drift), // or is on the allowlist, or is in CHECKED_FILES. The debt snapshot @@ -386,7 +415,7 @@ if (staleDebt.length > 0) { ); } -if (preflightFailures.length > 0 || ratchetFailures.length > 0) { +if (preflightFailures.length > 0 || allowlistFailures.length > 0 || ratchetFailures.length > 0) { console.log('[check-jsdoc] SuperDoc JSDoc ratchet'); console.log(HR); if (preflightFailures.length > 0) { @@ -394,6 +423,11 @@ if (preflightFailures.length > 0 || ratchetFailures.length > 0) { for (const line of preflightFailures) console.log(line); console.log(); } + if (allowlistFailures.length > 0) { + console.log('FAIL jsdoc-allowlist.cjs contract violations:'); + for (const line of allowlistFailures) console.log(line); + console.log(); + } if (ratchetFailures.length > 0) { console.log('FAIL ratchet drift detected:'); for (const line of ratchetFailures) console.log(line); diff --git a/scripts/check-public-contract.mjs b/scripts/check-public-contract.mjs index 3796990372..a43eb2e1ef 100755 --- a/scripts/check-public-contract.mjs +++ b/scripts/check-public-contract.mjs @@ -13,31 +13,37 @@ * allowlist. Cheap (~10ms); runs early so * tier drift fails fast before the slow * build/matrix work. - * 3. build:superdoc - vite build + the postbuild validator chain + * 3. jsdoc-ratchet - per-file checkJs gate for the curated + * CHECKED_FILES list + ratchet over public- + * reachable JSDoc files. Cheap; fails when + * new public JSDoc files land without + * `// @ts-check` or when the allowlist + * carries empty/stale entries. + * 4. build:superdoc - vite build + the postbuild validator chain * (check-tsconfig-type-surface, ensure-types, * audit-bundle, audit-declarations, * check-export-coverage, verify-public-facade-emit, * report-declaration-reachability). * Skipped when `--skip-build` is passed (CI calls * `pnpm run build` separately in its own step). - * 4. typecheck-matrix - packs superdoc + installs the tarball into + * 5. typecheck-matrix - packs superdoc + installs the tarball into * tests/consumer-typecheck/node_modules/, then * runs every consumer scenario. - * 5. deep-type-audit - strict gate on the supported-root public + * 6. deep-type-audit - strict gate on the supported-root public * surface (must be 0 findings). Reuses the - * install that stage 4 produced (no `--pack`). - * 6. package-shape - publint + attw against the packed manifest - * (reuses the tarball from stage 4). - * 7. snapshots - super-editor / legacy / root no-growth + * install that stage 5 produced (no `--pack`). + * 7. package-shape - publint + attw against the packed manifest + * (reuses the tarball from stage 5). + * 8. snapshots - super-editor / legacy / root no-growth * snapshots (reuses the install). - * 8. closure - root-classification closure gate: + * 9. closure - root-classification closure gate: * no supported-root/legacy-root export * references an internal-candidate type. * - * Matrix runs BEFORE stages 5-8 on purpose: it packs `superdoc.tgz` - * and installs the tarball into the consumer fixture once. Stages 5, - * 7, and 8 (deep-type-audit, snapshots, closure) reuse the installed - * fixture; stage 6 (package-shape-gate) reuses the packed tarball + * Matrix runs BEFORE stages 6-9 on purpose: it packs `superdoc.tgz` + * and installs the tarball into the consumer fixture once. Stages 6, + * 8, and 9 (deep-type-audit, snapshots, closure) reuse the installed + * fixture; stage 7 (package-shape-gate) reuses the packed tarball * directly. Without this ordering each downstream stage would * `--pack` separately and multiply the work. * @@ -84,6 +90,16 @@ const stages = [ '(tier coverage, routing, legacy-raw allowlist). Cheap; fast-fails before ' + 'the slow build/matrix stages.', }, + { + name: 'jsdoc-ratchet', + cwd: REPO_ROOT, + cmd: 'pnpm', + args: ['--filter', 'superdoc', 'run', 'check:jsdoc'], + blurb: + 'Per-file checkJs gate for the 6 hand-curated CHECKED_FILES + ratchet that ' + + 'fails when new public-reachable JSDoc files land without // @ts-check. ' + + 'Cheap; runs before the slow build so JSDoc drift fails fast.', + }, { name: 'build:superdoc', cwd: REPO_ROOT, From 61d10431e4089698fa78460e507b3d457d3d95b8 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sun, 24 May 2026 07:12:05 -0300 Subject: [PATCH 3/3] refactor(typecheck): normalize wrapper stage display names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Display-name only. No script files, package commands, or rerunnable commands changed. The wrapper prints the actual cwd + cmd + args on failure regardless of the display name. Old display name → New tier-discipline:test → contract-tiers-test tier-discipline → contract-tiers jsdoc-ratchet → jsdoc-ratchet build:superdoc → build typecheck-matrix → consumer-typecheck-matrix deep-type-audit --strict-supported-root → deep-type-audit-supported-root package-shape-gate → package-shape snapshot --all --check → export-snapshots check-root-classification-closure → root-classification-closure Why: the old set mixed colon-namespaced, kebab, check- prefixes, and flags-baked-into-names. Now all nine names are plain kebab, scope-only, no flags, no redundant check- prefix. CI logs are easier to scan back to source. Also documents the cheap-to-expensive ordering invariant in the wrapper docstring and refreshes AGENTS.md (eight stages → nine) and packages/superdoc/scripts/README.md (six → five wrapper stages, plus the three new policy gates at the head). --- .github/workflows/ci-superdoc.yml | 27 +++--- AGENTS.md | 6 +- packages/superdoc/scripts/README.md | 39 +++++---- scripts/check-public-contract.mjs | 124 ++++++++++++++++------------ 4 files changed, 113 insertions(+), 83 deletions(-) diff --git a/.github/workflows/ci-superdoc.yml b/.github/workflows/ci-superdoc.yml index 280f0b46f7..3860946284 100644 --- a/.github/workflows/ci-superdoc.yml +++ b/.github/workflows/ci-superdoc.yml @@ -114,20 +114,21 @@ jobs: run: pnpm run type-check - name: SuperDoc public interface check - # Single wrapper covering all SuperDoc public-surface gates: - # - tier-discipline (package.json#exports vs publicContract) + # Single wrapper covering all SuperDoc public-surface gates, + # ordered cheap-to-expensive: + # - contract-tiers-test (pure validator unit tests) + # - contract-tiers (package.json#exports vs publicContract) # - jsdoc-ratchet (checkJs gate + per-file ratchet) - # - typecheck-matrix.mjs (packs superdoc, runs every scenario) - # - deep-type-audit.mjs --strict-supported-root - # - package-shape-gate.mjs (publint + attw) - # - snapshot.mjs --all --check (super-editor / legacy / root) - # - check-root-classification-closure.mjs (SD-3212 A1b) - # All stages run from the same packed-and-installed fixture, so - # ordering matters: matrix produces the install; the rest reuse - # it. --skip-build because the Build step above already ran - # `pnpm run build` (which includes build:superdoc). - # Local equivalent: `pnpm check:public:superdoc` (with the build - # stage included). + # - build (skipped here; the Build step above already ran it) + # - consumer-typecheck-matrix (packs superdoc, runs every scenario) + # - deep-type-audit-supported-root (any-leak gate) + # - package-shape (publint + attw) + # - export-snapshots (super-editor / legacy / root no-growth) + # - root-classification-closure (SD-3212 A1b) + # Stages 5-9 share one packed-and-installed fixture; stage 5 + # produces it, the rest reuse it. --skip-build skips stage 4 + # because the Build step above already ran `pnpm run build`. + # Local equivalent: `pnpm check:public:superdoc` (with build). run: pnpm check:public:superdoc --skip-build unit-tests: diff --git a/AGENTS.md b/AGENTS.md index a5fc4cf567..444e912221 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,12 +72,12 @@ Do not hand-edit `COMMAND_CATALOG`, `OPERATION_MEMBER_PATH_MAP`, `OPERATION_REFE - `pnpm test` - unit tests - `pnpm dev` - dev server from `examples/` - `pnpm check:types` - raw TS compile across all referenced projects (`tsc -b tsconfig.references.json`). Does NOT run the public-interface chain. Legacy alias: `pnpm run type-check`. -- `pnpm check:public` - **canonical pre-merge command for typed public surfaces.** Validates both `superdoc` (tier discipline + vite build + postbuild chain + consumer typecheck matrix + deep-type audit + package-shape + snapshots + classification closure) and Document API (contract parity + output staleness + examples + overview). ~5 min. Non-mutating. Combines `check:public:superdoc` + `check:public:docapi`. -- `pnpm check:public:superdoc` - SuperDoc public package surface only. Wraps eight stages: tier-discipline:test + tier-discipline (fast-fail), build, matrix, deep-type audit, package-shape, snapshots, closure. Legacy alias: `pnpm run check:public-contract`. +- `pnpm check:public` - **canonical pre-merge command for typed public surfaces.** Validates both `superdoc` (tier discipline + jsdoc ratchet + vite build + postbuild chain + consumer typecheck matrix + deep-type audit + package-shape + snapshots + classification closure) and Document API (contract parity + output staleness + examples + overview). ~5 min. Non-mutating. Combines `check:public:superdoc` + `check:public:docapi`. +- `pnpm check:public:superdoc` - SuperDoc public package surface only. Wraps nine stages in cheap-to-expensive order: `contract-tiers-test`, `contract-tiers`, `jsdoc-ratchet`, `build`, `consumer-typecheck-matrix`, `deep-type-audit-supported-root`, `package-shape`, `export-snapshots`, `root-classification-closure`. Legacy alias: `pnpm run check:public-contract`. - `pnpm check:public:docapi` - Document API public surface only. Clean-checkout safe: gitignored generated artifacts are built in memory; tracked outputs (reference docs, overview block) are compared byte-for-byte. No mutation. Legacy alias: `pnpm run docapi:check`. - `pnpm generate:docapi` - regenerate Document API outputs after editing the contract (alias of `docapi:sync`). Writes gitignored Document API generated artifacts. Run only when you need the artifacts materialized locally (SDK builds, publishing); `check:public:docapi` does not require it. - `pnpm generate:all` - regenerate schemas, SDK clients, tool catalogs, reference docs. -- `pnpm report:public:superdoc` - print public-contract tier metadata (supported / legacy / legacy-raw / asset / deprecated). Read-only, not a gate. Use `check:public:superdoc` (or its `tier-discipline` stage) to enforce. Source of truth: `packages/superdoc/scripts/type-surface.config.cjs`. +- `pnpm report:public:superdoc` - print public-contract tier metadata (supported / legacy / legacy-raw / asset / deprecated). Read-only, not a gate. Use `check:public:superdoc` (or its `contract-tiers` stage) to enforce. Source of truth: `packages/superdoc/scripts/type-surface.config.cjs`. Full system reference (script catalog, dataflow, CI vs local): `packages/superdoc/scripts/README.md`. diff --git a/packages/superdoc/scripts/README.md b/packages/superdoc/scripts/README.md index cd02ec6131..7abfbc2b09 100644 --- a/packages/superdoc/scripts/README.md +++ b/packages/superdoc/scripts/README.md @@ -105,14 +105,15 @@ it stopped running. | `report-declaration-reachability.cjs` | postbuild | Instrumentation (not a gate): per-bucket reachability ratio of emitted declarations. | Loses visibility into unreachable emit (the SD-2952 trim target). | | `check-jsdoc.cjs` | wrapper stage 3 (`jsdoc-ratchet`) | Two gates: (a) per-file checkJs on the hand-curated `CHECKED_FILES` (currently 6 files; each must carry `// @ts-check` and stay clean against tsc); (b) ratchet over the public-reachable .js JSDoc surface — every file must be in `CHECKED_FILES`, carry `// @ts-check`, be on `jsdoc-allowlist.cjs` with a reason, or be in `jsdoc-debt-snapshot.json` as known pre-existing debt. New public JSDoc files that aren't accounted for fail with a clear "add @ts-check or allowlist" message. Stale snapshot entries (file gone, gained @ts-check, moved out of public surface) also fail. The allowlist contract is enforced too: every entry must carry a non-empty reason, point at an existing file, and still resolve to a public-reachable JSDoc file. Refresh the snapshot with `pnpm --filter superdoc run check:jsdoc -- --write`. Runs as stage 3 of `check:public:superdoc`. | New public-reachable JSDoc files could land without type coverage; existing ones could lose their `// @ts-check` directive without surfacing as a regression; the allowlist could grow silent / typo-shaped exemptions. | -The repo also has a top-level tier-discipline gate. One script, +The repo also has a top-level public-contract tier gate. One script, `scripts/report-public-contract.mjs`, with two modes: - default (read-only report) - what `pnpm report:public:superdoc` runs. - Prints the tiers + a validator status block. Exit 0 always. -- `--check` (gate) - runs as stage 2 of `check:public:superdoc` after - the validator's unit tests. Fails the build on any invariant - violation. + Prints the tiers + a validator status block. Report mode does not + fail on contract drift; load/runtime errors can still exit non-zero. +- `--check` (gate) - runs as stage `contract-tiers` of + `check:public:superdoc` after the validator's unit tests + (`contract-tiers-test`). Fails the build on any invariant violation. Both modes share the pure `validatePublicContract` exported from the same file (unit-tested in `scripts/report-public-contract.test.mjs`). @@ -144,17 +145,23 @@ what an actual consumer would see — not the workspace source. | `package-shape-gate.mjs` | External package-shape linters (publint + attw) against the packed tarball. | Catches condition ordering, masquerading exports, missing field declarations. | | `check-root-classification-closure.mjs` | Asserts no `supported-root` or `legacy-root` export references an `internal-candidate` symbol in its public declared type. | Closure rule from SD-3212. | -`check:public:superdoc` runs all six in order (after the cheap -tier-discipline stage at the top of the wrapper). `typecheck-matrix` packs -`superdoc.tgz` and installs it into the consumer fixture. The rest -reuse what matrix produced: `deep-type-audit`, `snapshot --all ---check`, and `check-root-classification-closure` read from the -installed fixture in `node_modules/superdoc/`; `package-shape-gate` -runs `publint` / `attw` against the packed tarball at -`packages/superdoc/superdoc.tgz` directly. CI (`ci-superdoc.yml`) and -release workflows (`release-superdoc.yml`, `release-stable.yml`) call -`pnpm check:public:superdoc --skip-build` directly — no duplicated step -lists. +Of these, five run as wrapper stages of `check:public:superdoc` +after the cheap policy gates (`contract-tiers-test`, +`contract-tiers`, `jsdoc-ratchet`) and `build`: +`consumer-typecheck-matrix`, `deep-type-audit-supported-root`, +`package-shape`, `export-snapshots`, `root-classification-closure`. +`consumer-typecheck-matrix` packs `superdoc.tgz` and installs it into +the consumer fixture. The rest reuse what matrix produced: +`deep-type-audit-supported-root`, `export-snapshots`, and +`root-classification-closure` read from the installed fixture in +`node_modules/superdoc/`; `package-shape` runs `publint` / `attw` +against the packed tarball at `packages/superdoc/superdoc.tgz` +directly. `check-all-public-types-fixture.mjs` is a fixture-build +helper, not a wrapper stage. + +CI (`ci-superdoc.yml`) and release workflows (`release-superdoc.yml`, +`release-stable.yml`) call `pnpm check:public:superdoc --skip-build` +directly - no duplicated step lists. --- diff --git a/scripts/check-public-contract.mjs b/scripts/check-public-contract.mjs index a43eb2e1ef..0ce7a465f4 100755 --- a/scripts/check-public-contract.mjs +++ b/scripts/check-public-contract.mjs @@ -3,49 +3,68 @@ * Single command to validate the published superdoc package's public * TypeScript surface end-to-end. * + * Ordering invariant: cheap policy/config gates first, then build, + * then packed-consumer gates, then gates that reuse the packed fixture. + * Contributor iteration cost stays low because tier or jsdoc drift + * fails in seconds instead of after a full build + install. + * + * Stage names are display labels for logs only; the rerunnable command + * (printed on failure) is the actual cwd + cmd + args from the stage + * definition. + * * Stages: - * 1. tier-discipline:test - unit tests for the pure tier validator. - * Cheap (~50ms). Verifies the validator - * catches every failure class before the - * next stage trusts its verdict. - * 2. tier-discipline - package.json#exports vs publicContract - * tier coverage, routing, and legacy-raw - * allowlist. Cheap (~10ms); runs early so - * tier drift fails fast before the slow - * build/matrix work. - * 3. jsdoc-ratchet - per-file checkJs gate for the curated - * CHECKED_FILES list + ratchet over public- - * reachable JSDoc files. Cheap; fails when - * new public JSDoc files land without - * `// @ts-check` or when the allowlist - * carries empty/stale entries. - * 4. build:superdoc - vite build + the postbuild validator chain - * (check-tsconfig-type-surface, ensure-types, - * audit-bundle, audit-declarations, - * check-export-coverage, verify-public-facade-emit, - * report-declaration-reachability). - * Skipped when `--skip-build` is passed (CI calls - * `pnpm run build` separately in its own step). - * 5. typecheck-matrix - packs superdoc + installs the tarball into - * tests/consumer-typecheck/node_modules/, then - * runs every consumer scenario. - * 6. deep-type-audit - strict gate on the supported-root public - * surface (must be 0 findings). Reuses the - * install that stage 5 produced (no `--pack`). - * 7. package-shape - publint + attw against the packed manifest - * (reuses the tarball from stage 5). - * 8. snapshots - super-editor / legacy / root no-growth - * snapshots (reuses the install). - * 9. closure - root-classification closure gate: - * no supported-root/legacy-root export - * references an internal-candidate type. + * 1. contract-tiers-test - unit tests for the pure tier + * validator. ~50ms. Verifies the + * validator catches every failure + * class before stage 2 trusts it. + * 2. contract-tiers - package.json#exports vs + * publicContract (tier coverage, + * routing, legacy-raw allowlist). + * ~10ms; fast-fails before the + * slow build/matrix work. + * 3. jsdoc-ratchet - per-file checkJs on the curated + * CHECKED_FILES list + ratchet over + * public-reachable JSDoc files. + * Fails when new public JSDoc files + * land without `// @ts-check` or + * when the allowlist carries + * empty/stale entries. + * 4. build - vite build + the postbuild + * validator chain + * (check-tsconfig-type-surface, + * ensure-types, audit-bundle, + * audit-declarations, + * check-export-coverage, + * verify-public-facade-emit, + * report-declaration-reachability). + * Skipped when `--skip-build` is + * passed (CI calls `pnpm run build` + * separately in its own step). + * 5. consumer-typecheck-matrix - packs superdoc + installs the + * tarball into + * tests/consumer-typecheck/ + * node_modules/, then runs every + * consumer scenario. + * 6. deep-type-audit-supported-root - strict gate on the supported- + * root public surface; fails on any + * `any` leak. Reuses the install + * from stage 5. + * 7. package-shape - publint + attw against the packed + * manifest. Reuses the tarball + * from stage 5. + * 8. export-snapshots - super-editor / legacy / root + * no-growth export snapshots. + * Reuses the install. + * 9. root-classification-closure - no supported-root or legacy-root + * export references an internal- + * candidate type in its public + * declared shape (SD-3212 A1b). * - * Matrix runs BEFORE stages 6-9 on purpose: it packs `superdoc.tgz` - * and installs the tarball into the consumer fixture once. Stages 6, - * 8, and 9 (deep-type-audit, snapshots, closure) reuse the installed - * fixture; stage 7 (package-shape-gate) reuses the packed tarball - * directly. Without this ordering each downstream stage would - * `--pack` separately and multiply the work. + * Why stage 5 runs before 6-9: stage 5 packs `superdoc.tgz` and + * installs the tarball into the consumer fixture once. Stages 6, 8, + * and 9 reuse the installed fixture; stage 7 reuses the packed tarball + * directly. Without this ordering each downstream stage would `--pack` + * separately and multiply the work. * * Local usage: * pnpm check:public (umbrella, runs SuperDoc + Document API) @@ -69,9 +88,12 @@ const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const flags = new Set(process.argv.slice(2)); const skipBuild = flags.has('--skip-build'); +// Stage names below are display labels for logs only; the actual rerun +// command on failure is reconstructed from `cmd` + `args`. Renaming a +// `name` is purely cosmetic. const stages = [ { - name: 'tier-discipline:test', + name: 'contract-tiers-test', cwd: REPO_ROOT, cmd: 'node', args: ['--test', 'scripts/report-public-contract.test.mjs'], @@ -81,7 +103,7 @@ const stages = [ 'trusts its verdict.', }, { - name: 'tier-discipline', + name: 'contract-tiers', cwd: REPO_ROOT, cmd: 'node', args: ['scripts/report-public-contract.mjs', '--check'], @@ -101,7 +123,7 @@ const stages = [ 'Cheap; runs before the slow build so JSDoc drift fails fast.', }, { - name: 'build:superdoc', + name: 'build', cwd: REPO_ROOT, cmd: 'pnpm', args: ['run', 'build:superdoc'], @@ -112,7 +134,7 @@ const stages = [ skipReason: '--skip-build passed; CI Build step already ran this', }, { - name: 'typecheck-matrix', + name: 'consumer-typecheck-matrix', cwd: resolve(REPO_ROOT, 'tests/consumer-typecheck'), cmd: 'node', args: ['typecheck-matrix.mjs'], @@ -121,25 +143,25 @@ const stages = [ 'then runs every typecheck scenario.', }, { - name: 'deep-type-audit --strict-supported-root', + name: 'deep-type-audit-supported-root', cwd: resolve(REPO_ROOT, 'tests/consumer-typecheck'), cmd: 'node', args: ['deep-type-audit.mjs', '--strict-supported-root'], blurb: 'Strict gate on the supported-root public surface (must be 0 findings). ' + - 'Reuses the install produced by typecheck-matrix.', + 'Reuses the install produced by consumer-typecheck-matrix.', }, { - name: 'package-shape-gate', + name: 'package-shape', cwd: resolve(REPO_ROOT, 'tests/consumer-typecheck'), cmd: 'node', args: ['package-shape-gate.mjs'], blurb: 'External npm-package linters (publint + attw) against the packed manifest. ' + - 'Reuses the tarball produced by typecheck-matrix (not the installed fixture).', + 'Reuses the tarball produced by consumer-typecheck-matrix (not the installed fixture).', }, { - name: 'snapshot --all --check', + name: 'export-snapshots', cwd: resolve(REPO_ROOT, 'tests/consumer-typecheck'), cmd: 'node', args: ['snapshot.mjs', '--all', '--check'], @@ -148,7 +170,7 @@ const stages = [ 'Run with `node snapshot.mjs --family --write` to regenerate intentionally.', }, { - name: 'check-root-classification-closure', + name: 'root-classification-closure', cwd: resolve(REPO_ROOT, 'tests/consumer-typecheck'), cmd: 'node', args: ['check-root-classification-closure.mjs'],