// Compare an observed API surface (tests/fixtures/guests/surface-walk.cjs run in the // kernel) against a Node manifest (compat/node-.json). Shared by // tests/surface.spec.ts and scripts/surface.mjs. /** `path -> kind` as surface-walk.cjs prints it, plus `#^ -> ` * links: a class's own prototype members are listed, inherited ones are reached * through the link. */ export type Surface = Record; const LINK = "#^"; export interface Manifest { node: string; platform: string; arch: string; surface: Surface; } /** Paths we knowingly don't match, each with why. A path also covers everything under * it: "http2" covers "http2.connect" and "http2.Http2ServerRequest#url". */ export type Divergences = Record; export interface Gap { path: string; /** Node's kind. */ expected: string; /** The kernel's kind, or undefined when the path doesn't exist. */ actual: string | undefined; } export interface Comparison { /** Gaps no divergence covers, outermost only (a missing module, not each export). */ unexplained: Gap[]; /** Divergences that no longer cover any gap: the kernel caught up, delete them. */ stale: string[]; } function parent(path: string): string | undefined { const at = Math.max(path.lastIndexOf("."), path.lastIndexOf("#")); return at === -1 ? undefined : path.slice(0, at); } export function covers(entry: string, path: string): boolean { return path === entry || path.startsWith(`${entry}.`) || path.startsWith(`${entry}#`); } function diff(want: Surface, have: Surface): Gap[] { const out: Gap[] = []; for (const [path, expected] of Object.entries(want)) { const actual = have[path]; if (actual !== expected) out.push({ path, expected, actual }); } return out; } /** Own instance members per class path, and each class's base link. */ function index(surface: Surface): { own: Map>; base: Map } { const own = new Map>(); const base = new Map(); for (const [path, kind] of Object.entries(surface)) { if (path.endsWith(LINK)) base.set(path.slice(0, -LINK.length), kind); else if (path.includes("#")) { const at = path.lastIndexOf("#"); const owner = path.slice(0, at); (own.get(owner) ?? own.set(owner, new Map()).get(owner)!).set(path.slice(at + 1), kind); } } return { own, base }; } /** The surface as code sees it: every class lists its inherited members too. */ export function flatten(surface: Surface): Surface { const { own, base } = index(surface); const memo = new Map>(); const membersOf = (path: string): Map => { let m = memo.get(path); if (m) return m; m = new Map(own.get(path)); memo.set(path, m); const b = base.get(path); if (b !== undefined) for (const [k, kind] of membersOf(b)) if (!m.has(k)) m.set(k, kind); return m; }; const out: Surface = {}; for (const [path, kind] of Object.entries(surface)) if (!path.includes("#")) out[path] = kind; for (const path of new Set([...own.keys(), ...base.keys()])) { for (const [k, kind] of membersOf(path)) out[`${path}#${k}`] = kind; } return out; } /** Every Node path the kernel lacks or gives a different kind, compared as code sees * them (flattened). An inherited member is blamed where Node defines it: * - missing there too: reported there only, no `zlib.Gzip#map` when the gap is * `stream.Readable#map`; * - present there: the class doesn't inherit like Node's, one `#^` gap. * Extras are ignored: exporting more than Node is not a compat bug. */ export function gaps(node: Surface, observed: Surface): Gap[] { const { own, base } = index(node); const want = flatten(node); const have = flatten(observed); const definer = (path: string, name: string): string | undefined => { for (let p: string | undefined = path; p !== undefined; p = base.get(p)) if (own.get(p)?.has(name)) return p; return undefined; }; const out = new Map(); for (const g of diff(want, have)) { const at = g.path.lastIndexOf("#"); const owner = g.path.slice(0, at); const d = at === -1 ? undefined : definer(owner, g.path.slice(at + 1)); if (d === undefined || d === owner) out.set(g.path, g); else if (have[`${d}${g.path.slice(at)}`] === want[`${d}${g.path.slice(at)}`]) { const wiring = `${owner}${LINK}`; if (!out.has(wiring)) out.set(wiring, { path: wiring, expected: `inherits ${base.get(owner)}`, actual: `lacks ${d}${g.path.slice(at)}` }); } } return [...out.values()]; } /** Drop gaps whose ancestor is itself a gap, so one missing class reads as one line. */ export function outermost(list: Gap[]): Gap[] { const paths = new Set(list.map((g) => g.path)); return list.filter((g) => { for (let p = parent(g.path); p !== undefined; p = parent(p)) if (paths.has(p)) return false; return true; }); } export function compare(node: Surface, observed: Surface, divergences: Divergences): Comparison { const all = gaps(node, observed); const entries = Object.keys(divergences); const unexplained = outermost(all.filter((g) => !entries.some((d) => covers(d, g.path)))); const stale = entries.filter((d) => !all.some((g) => covers(d, g.path))); return { unexplained, stale }; } /** What changed between two Node manifests: the work a version bump asks for. */ export function delta(from: Surface, to: Surface): { added: Gap[]; removed: Gap[] } { return { added: outermost(diff(to, from)), removed: outermost(diff(from, to).filter((g) => g.actual === undefined)), }; } /** Fold gaps into the shallowest path whose Node subtree is entirely gaps, so a class * missing every member is one divergence entry, not seventy. */ export function compact(node: Surface, list: Gap[]): string[] { const missing = new Set(list.map((g) => g.path)); const flat = flatten(node); const children = new Map(); for (const path of Object.keys(flat)) { const p = parent(path); if (p !== undefined) (children.get(p) ?? children.set(p, []).get(p)!).push(path); } const allMissing = (path: string): boolean => missing.has(path) || ((children.get(path)?.length ?? 0) > 1 && children.get(path)!.every(allMissing)); // Wiring gaps (`#^`) aren't surface paths; they pass through as they are. const folded = [...Object.keys(flat).filter(allMissing), ...[...missing].filter((p) => !(p in flat))]; const set = new Set(folded); return folded.filter((path) => { for (let p = parent(path); p !== undefined; p = parent(p)) if (set.has(p)) return false; return true; }); }