Skip to content

Commit e667344

Browse files
authored
feat(types): add declaration reachability report (SD-2952 step 3) (#3167)
Walks the public type graph from every typed package.json exports target and counts how many emitted .d.ts files are reachable from a consumer's TypeScript. The output is data for the SD-2952 trim slice (step 4); not a CI gate per the SD-2952 acceptance. Initial measurement on the post-SD-2953 dist: Reachable declarations: 442 / 2012 (22.0%) from 11 typed exports document-api 132 / 140 (94.3%) - reach-driven emit, healthy layout-engine 71 / 137 (51.8%) - moderate shared 0 / 3 (0.0%) - tsc-postbuild emit reachable only via currently-unreachable CommentsLayer/types.d.ts super-editor 221 / 1642 (13.5%) - 1421 unreachable, primary trim target for step 4 superdoc 18 / 90 (20.0%) - 72 internal helpers shipping to no consumer benefit Walker resolves relative imports + self-package `superdoc/<subpath>` via the package's exports map. External package specifiers (vue, prosemirror-*, @tiptap/*) are ignored - they don't live in dist. Private workspace specifiers (@superdoc/*) are ignored too; any surviving one is already a build failure via audit Rule 1. Script is a sibling to audit-declarations.cjs (gate) - this one is metric-only. Wired into postbuild after check-export-coverage.cjs. Failure semantics: exit 1 on missing dist, malformed package.json, unreadable type entry, or unreadable .d.ts files. Never exits non- zero on a low ratio. Verified end-to-end: clean run prints metrics + bucket breakdown; synthetic missing-dist exits 1; synthetic malformed `types` target exits 1; matrix stays 53/0/0.
1 parent ad0e5a9 commit e667344

2 files changed

Lines changed: 194 additions & 1 deletion

File tree

packages/superdoc/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@
121121
"word-benchmark-sidecar": "node ../../devtools/word-benchmark-sidecar/server.js",
122122
"build": "vite build && pnpm run build:cdn",
123123
"build:dev": "SUPERDOC_SKIP_DTS=1 vite build",
124-
"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",
124+
"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",
125125
"audit:declarations": "node ./scripts/audit-declarations.cjs",
126126
"audit:declarations:informational": "node ./scripts/audit-declarations.cjs --informational",
127127
"check:jsdoc": "node ./scripts/check-jsdoc.cjs",
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
#!/usr/bin/env node
2+
/**
3+
* SD-2952 step 3: report how many emitted `.d.ts` files in the published
4+
* dist are actually reachable from a public consumer's type graph.
5+
*
6+
* Walks every `exports[*].types` target in `package.json` and follows
7+
* relative-import / self-package edges through the emitted `.d.ts`
8+
* forest, counting how many files a consumer's TypeScript would
9+
* actually parse vs how many are shipped. The output is data for the
10+
* SD-2952 trim-emitted-types slice (step 4).
11+
*
12+
* This script is **instrumentation, not a gate**:
13+
* - It DOES exit 1 on script bugs, missing dist, malformed package
14+
* exports, or unreadable type entry files. "Informational" means
15+
* "metric only," not "broken script ignored."
16+
* - It does NOT exit 1 on a low ratio. There is no threshold yet;
17+
* we are establishing the measurement before deciding what
18+
* unreachable emit is harmless byproduct vs. avoidable noise.
19+
*
20+
* Walker semantics:
21+
* - Resolves relative `from '../foo.js'` and `import('../foo.js')`
22+
* specifiers to dist `.d.ts` siblings.
23+
* - Resolves self-package `from 'superdoc/<subpath>'` through the
24+
* package's own `exports` map.
25+
* - Ignores external package specifiers (vue, prosemirror-*,
26+
* @tiptap/*, etc.) - those don't live in dist.
27+
* - Ignores private workspace specifiers (`@superdoc/*`); they're
28+
* audited separately by `audit-declarations.cjs` Rule 1, and any
29+
* surviving one in dist is already a build failure.
30+
*/
31+
32+
const fs = require('node:fs');
33+
const path = require('node:path');
34+
35+
const packageRoot = path.resolve(__dirname, '..');
36+
const distRoot = path.join(packageRoot, 'dist');
37+
38+
if (!fs.existsSync(distRoot)) {
39+
console.error('[report-declaration-reachability] dist/ not found; run the build first.');
40+
process.exit(1);
41+
}
42+
43+
const packageJsonPath = path.join(packageRoot, 'package.json');
44+
let packageJson;
45+
try {
46+
packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
47+
} catch (err) {
48+
console.error(`[report-declaration-reachability] cannot read package.json: ${err.message}`);
49+
process.exit(1);
50+
}
51+
52+
const packageName = packageJson.name;
53+
const exportsMap = packageJson.exports || {};
54+
55+
// Build a self-package resolver: subpath like `./super-editor` → absolute
56+
// path of the `types` target in dist. Used when an emitted .d.ts contains
57+
// `from 'superdoc/super-editor'` (rare but legal).
58+
const selfPackageTypeMap = new Map();
59+
for (const [subpath, value] of Object.entries(exportsMap)) {
60+
if (typeof value !== 'object' || value === null) continue;
61+
if (typeof value.types !== 'string') continue;
62+
selfPackageTypeMap.set(subpath, path.resolve(packageRoot, value.types));
63+
}
64+
65+
// Build the seed set: every typed exports entry, resolved to a dist path.
66+
const typedExports = [];
67+
for (const [subpath, value] of Object.entries(exportsMap)) {
68+
if (typeof value !== 'object' || value === null) continue;
69+
if (typeof value.types !== 'string') continue;
70+
const target = path.resolve(packageRoot, value.types);
71+
if (!fs.existsSync(target)) {
72+
console.error(`[report-declaration-reachability] exports['${subpath}'].types target missing: ${value.types}`);
73+
process.exit(1);
74+
}
75+
typedExports.push({ subpath, target });
76+
}
77+
78+
if (typedExports.length === 0) {
79+
console.error('[report-declaration-reachability] package.json has no typed exports; nothing to walk.');
80+
process.exit(1);
81+
}
82+
83+
// Collect every .d.ts shipped in dist.
84+
function findDtsFiles(dir, acc = []) {
85+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
86+
const full = path.join(dir, entry.name);
87+
if (entry.isDirectory()) findDtsFiles(full, acc);
88+
else if (entry.name.endsWith('.d.ts')) acc.push(full);
89+
}
90+
return acc;
91+
}
92+
const allDtsFiles = findDtsFiles(distRoot);
93+
const allDtsSet = new Set(allDtsFiles);
94+
95+
// Match `from '...'` (top-level imports + re-exports) and `import('...')`
96+
// (type-position dynamic imports). Both contribute edges.
97+
const SPECIFIER_RE = /(?:from\s+|import\(\s*)['"]([^'"]+)['"]/g;
98+
99+
// Per-extension fallbacks the resolver tries when a relative specifier
100+
// has no extension. TypeScript itself accepts a wider set; keep these
101+
// minimal because the dist files we emit are all `.d.ts` (or directories
102+
// containing `index.d.ts`).
103+
function resolveRelative(spec, fromFile) {
104+
const base = path.resolve(path.dirname(fromFile), spec);
105+
// Spec already points at .d.ts? unusual but support it.
106+
if (base.endsWith('.d.ts') && fs.existsSync(base)) return base;
107+
// `.js` specifier → swap to `.d.ts`.
108+
if (base.endsWith('.js')) {
109+
const cand = base.slice(0, -3) + '.d.ts';
110+
if (fs.existsSync(cand)) return cand;
111+
}
112+
// `.ts` specifier (rare in emitted dist after ensure-types) → `.d.ts`.
113+
if (base.endsWith('.ts')) {
114+
const cand = base.slice(0, -3) + '.d.ts';
115+
if (fs.existsSync(cand)) return cand;
116+
}
117+
// Bare directory → look for `<dir>/index.d.ts`.
118+
const indexCand = path.join(base, 'index.d.ts');
119+
if (fs.existsSync(indexCand)) return indexCand;
120+
// Plain `<base>.d.ts`.
121+
const dtsCand = `${base}.d.ts`;
122+
if (fs.existsSync(dtsCand)) return dtsCand;
123+
return null;
124+
}
125+
126+
function resolveSelfPackage(spec) {
127+
// spec like `superdoc` or `superdoc/super-editor`.
128+
if (!spec.startsWith(packageName)) return null;
129+
const remainder = spec.slice(packageName.length);
130+
const subpath = remainder === '' ? '.' : `.${remainder}`;
131+
return selfPackageTypeMap.get(subpath) || null;
132+
}
133+
134+
function resolveSpecifier(spec, fromFile) {
135+
if (spec.startsWith('.')) return resolveRelative(spec, fromFile);
136+
if (spec.startsWith(packageName)) return resolveSelfPackage(spec);
137+
// External package or private workspace specifier — not in dist tree.
138+
return null;
139+
}
140+
141+
// BFS walk.
142+
const visited = new Set();
143+
const queue = typedExports.map((e) => e.target);
144+
for (const start of queue) visited.add(start);
145+
146+
while (queue.length > 0) {
147+
const file = queue.shift();
148+
let content;
149+
try {
150+
content = fs.readFileSync(file, 'utf8');
151+
} catch (err) {
152+
console.error(`[report-declaration-reachability] cannot read ${file}: ${err.message}`);
153+
process.exit(1);
154+
}
155+
for (const match of content.matchAll(SPECIFIER_RE)) {
156+
const resolved = resolveSpecifier(match[1], file);
157+
if (!resolved) continue;
158+
if (visited.has(resolved)) continue;
159+
visited.add(resolved);
160+
queue.push(resolved);
161+
}
162+
}
163+
164+
const reachableInDist = [...visited].filter((f) => allDtsSet.has(f));
165+
const total = allDtsFiles.length;
166+
const reachable = reachableInDist.length;
167+
const pct = total === 0 ? 0 : ((reachable / total) * 100).toFixed(1);
168+
169+
// Bucket reachable + total by top-level dist directory for the trim slice.
170+
function bucket(file) {
171+
const rel = path.relative(distRoot, file).split(path.sep);
172+
return rel[0] || '<root>';
173+
}
174+
const totalsByBucket = new Map();
175+
const reachableByBucket = new Map();
176+
for (const f of allDtsFiles) totalsByBucket.set(bucket(f), (totalsByBucket.get(bucket(f)) || 0) + 1);
177+
for (const f of reachableInDist) reachableByBucket.set(bucket(f), (reachableByBucket.get(bucket(f)) || 0) + 1);
178+
179+
console.log('[report-declaration-reachability] SD-2952 step 3: declaration reachability');
180+
console.log('='.repeat(72));
181+
console.log(`Reachable declarations: ${reachable} / ${total} (${pct}%) from ${typedExports.length} typed exports`);
182+
console.log();
183+
console.log('Per top-level dist bucket (reachable / total):');
184+
const buckets = [...new Set([...totalsByBucket.keys(), ...reachableByBucket.keys()])].sort();
185+
for (const b of buckets) {
186+
const r = reachableByBucket.get(b) || 0;
187+
const t = totalsByBucket.get(b) || 0;
188+
const bp = t === 0 ? '0.0' : ((r / t) * 100).toFixed(1);
189+
console.log(` ${b.padEnd(20)} ${String(r).padStart(5)} / ${String(t).padStart(5)} (${bp}%)`);
190+
}
191+
console.log();
192+
console.log('Note: instrumentation only. The ratio is not a CI gate (SD-2952 step 3).');
193+
console.log(' Use the bucket breakdown to inform SD-2952 step 4 (trim unreachable emit).');

0 commit comments

Comments
 (0)