Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/superdoc/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
193 changes: 193 additions & 0 deletions packages/superdoc/scripts/report-declaration-reachability.cjs
Original file line number Diff line number Diff line change
@@ -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/<subpath>'` 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 `<dir>/index.d.ts`.
const indexCand = path.join(base, 'index.d.ts');
if (fs.existsSync(indexCand)) return indexCand;
// Plain `<base>.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] || '<root>';
}
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).');
Loading