diff --git a/packages/superdoc/package.json b/packages/superdoc/package.json index 772c54cbec..5b7fdce1ad 100644 --- a/packages/superdoc/package.json +++ b/packages/superdoc/package.json @@ -121,7 +121,7 @@ "word-benchmark-sidecar": "node ../../devtools/word-benchmark-sidecar/server.js", "build": "vite build && pnpm run build:cdn", "build:dev": "SUPERDOC_SKIP_DTS=1 vite build", - "postbuild": "node ./scripts/check-tsconfig-type-surface.cjs && node ./scripts/ensure-types.cjs && node ./scripts/audit-bundle.cjs && node ./scripts/audit-declarations.cjs && node ./scripts/check-export-coverage.cjs", + "postbuild": "node ./scripts/check-tsconfig-type-surface.cjs && node ./scripts/ensure-types.cjs && node ./scripts/audit-bundle.cjs && node ./scripts/audit-declarations.cjs && node ./scripts/check-export-coverage.cjs && node ./scripts/report-declaration-reachability.cjs", "audit:declarations": "node ./scripts/audit-declarations.cjs", "audit:declarations:informational": "node ./scripts/audit-declarations.cjs --informational", "check:jsdoc": "node ./scripts/check-jsdoc.cjs", diff --git a/packages/superdoc/scripts/report-declaration-reachability.cjs b/packages/superdoc/scripts/report-declaration-reachability.cjs new file mode 100644 index 0000000000..bb73f8937d --- /dev/null +++ b/packages/superdoc/scripts/report-declaration-reachability.cjs @@ -0,0 +1,193 @@ +#!/usr/bin/env node +/** + * SD-2952 step 3: report how many emitted `.d.ts` files in the published + * dist are actually reachable from a public consumer's type graph. + * + * Walks every `exports[*].types` target in `package.json` and follows + * relative-import / self-package edges through the emitted `.d.ts` + * forest, counting how many files a consumer's TypeScript would + * actually parse vs how many are shipped. The output is data for the + * SD-2952 trim-emitted-types slice (step 4). + * + * This script is **instrumentation, not a gate**: + * - It DOES exit 1 on script bugs, missing dist, malformed package + * exports, or unreadable type entry files. "Informational" means + * "metric only," not "broken script ignored." + * - It does NOT exit 1 on a low ratio. There is no threshold yet; + * we are establishing the measurement before deciding what + * unreachable emit is harmless byproduct vs. avoidable noise. + * + * Walker semantics: + * - Resolves relative `from '../foo.js'` and `import('../foo.js')` + * specifiers to dist `.d.ts` siblings. + * - Resolves self-package `from 'superdoc/'` through the + * package's own `exports` map. + * - Ignores external package specifiers (vue, prosemirror-*, + * @tiptap/*, etc.) - those don't live in dist. + * - Ignores private workspace specifiers (`@superdoc/*`); they're + * audited separately by `audit-declarations.cjs` Rule 1, and any + * surviving one in dist is already a build failure. + */ + +const fs = require('node:fs'); +const path = require('node:path'); + +const packageRoot = path.resolve(__dirname, '..'); +const distRoot = path.join(packageRoot, 'dist'); + +if (!fs.existsSync(distRoot)) { + console.error('[report-declaration-reachability] dist/ not found; run the build first.'); + process.exit(1); +} + +const packageJsonPath = path.join(packageRoot, 'package.json'); +let packageJson; +try { + packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); +} catch (err) { + console.error(`[report-declaration-reachability] cannot read package.json: ${err.message}`); + process.exit(1); +} + +const packageName = packageJson.name; +const exportsMap = packageJson.exports || {}; + +// Build a self-package resolver: subpath like `./super-editor` → absolute +// path of the `types` target in dist. Used when an emitted .d.ts contains +// `from 'superdoc/super-editor'` (rare but legal). +const selfPackageTypeMap = new Map(); +for (const [subpath, value] of Object.entries(exportsMap)) { + if (typeof value !== 'object' || value === null) continue; + if (typeof value.types !== 'string') continue; + selfPackageTypeMap.set(subpath, path.resolve(packageRoot, value.types)); +} + +// Build the seed set: every typed exports entry, resolved to a dist path. +const typedExports = []; +for (const [subpath, value] of Object.entries(exportsMap)) { + if (typeof value !== 'object' || value === null) continue; + if (typeof value.types !== 'string') continue; + const target = path.resolve(packageRoot, value.types); + if (!fs.existsSync(target)) { + console.error(`[report-declaration-reachability] exports['${subpath}'].types target missing: ${value.types}`); + process.exit(1); + } + typedExports.push({ subpath, target }); +} + +if (typedExports.length === 0) { + console.error('[report-declaration-reachability] package.json has no typed exports; nothing to walk.'); + process.exit(1); +} + +// Collect every .d.ts shipped in dist. +function findDtsFiles(dir, acc = []) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) findDtsFiles(full, acc); + else if (entry.name.endsWith('.d.ts')) acc.push(full); + } + return acc; +} +const allDtsFiles = findDtsFiles(distRoot); +const allDtsSet = new Set(allDtsFiles); + +// Match `from '...'` (top-level imports + re-exports) and `import('...')` +// (type-position dynamic imports). Both contribute edges. +const SPECIFIER_RE = /(?:from\s+|import\(\s*)['"]([^'"]+)['"]/g; + +// Per-extension fallbacks the resolver tries when a relative specifier +// has no extension. TypeScript itself accepts a wider set; keep these +// minimal because the dist files we emit are all `.d.ts` (or directories +// containing `index.d.ts`). +function resolveRelative(spec, fromFile) { + const base = path.resolve(path.dirname(fromFile), spec); + // Spec already points at .d.ts? unusual but support it. + if (base.endsWith('.d.ts') && fs.existsSync(base)) return base; + // `.js` specifier → swap to `.d.ts`. + if (base.endsWith('.js')) { + const cand = base.slice(0, -3) + '.d.ts'; + if (fs.existsSync(cand)) return cand; + } + // `.ts` specifier (rare in emitted dist after ensure-types) → `.d.ts`. + if (base.endsWith('.ts')) { + const cand = base.slice(0, -3) + '.d.ts'; + if (fs.existsSync(cand)) return cand; + } + // Bare directory → look for `/index.d.ts`. + const indexCand = path.join(base, 'index.d.ts'); + if (fs.existsSync(indexCand)) return indexCand; + // Plain `.d.ts`. + const dtsCand = `${base}.d.ts`; + if (fs.existsSync(dtsCand)) return dtsCand; + return null; +} + +function resolveSelfPackage(spec) { + // spec like `superdoc` or `superdoc/super-editor`. + if (!spec.startsWith(packageName)) return null; + const remainder = spec.slice(packageName.length); + const subpath = remainder === '' ? '.' : `.${remainder}`; + return selfPackageTypeMap.get(subpath) || null; +} + +function resolveSpecifier(spec, fromFile) { + if (spec.startsWith('.')) return resolveRelative(spec, fromFile); + if (spec.startsWith(packageName)) return resolveSelfPackage(spec); + // External package or private workspace specifier — not in dist tree. + return null; +} + +// BFS walk. +const visited = new Set(); +const queue = typedExports.map((e) => e.target); +for (const start of queue) visited.add(start); + +while (queue.length > 0) { + const file = queue.shift(); + let content; + try { + content = fs.readFileSync(file, 'utf8'); + } catch (err) { + console.error(`[report-declaration-reachability] cannot read ${file}: ${err.message}`); + process.exit(1); + } + for (const match of content.matchAll(SPECIFIER_RE)) { + const resolved = resolveSpecifier(match[1], file); + if (!resolved) continue; + if (visited.has(resolved)) continue; + visited.add(resolved); + queue.push(resolved); + } +} + +const reachableInDist = [...visited].filter((f) => allDtsSet.has(f)); +const total = allDtsFiles.length; +const reachable = reachableInDist.length; +const pct = total === 0 ? 0 : ((reachable / total) * 100).toFixed(1); + +// Bucket reachable + total by top-level dist directory for the trim slice. +function bucket(file) { + const rel = path.relative(distRoot, file).split(path.sep); + return rel[0] || ''; +} +const totalsByBucket = new Map(); +const reachableByBucket = new Map(); +for (const f of allDtsFiles) totalsByBucket.set(bucket(f), (totalsByBucket.get(bucket(f)) || 0) + 1); +for (const f of reachableInDist) reachableByBucket.set(bucket(f), (reachableByBucket.get(bucket(f)) || 0) + 1); + +console.log('[report-declaration-reachability] SD-2952 step 3: declaration reachability'); +console.log('='.repeat(72)); +console.log(`Reachable declarations: ${reachable} / ${total} (${pct}%) from ${typedExports.length} typed exports`); +console.log(); +console.log('Per top-level dist bucket (reachable / total):'); +const buckets = [...new Set([...totalsByBucket.keys(), ...reachableByBucket.keys()])].sort(); +for (const b of buckets) { + const r = reachableByBucket.get(b) || 0; + const t = totalsByBucket.get(b) || 0; + const bp = t === 0 ? '0.0' : ((r / t) * 100).toFixed(1); + console.log(` ${b.padEnd(20)} ${String(r).padStart(5)} / ${String(t).padStart(5)} (${bp}%)`); +} +console.log(); +console.log('Note: instrumentation only. The ratio is not a CI gate (SD-2952 step 3).'); +console.log(' Use the bucket breakdown to inform SD-2952 step 4 (trim unreachable emit).');