diff --git a/packages/compiler/src/coverage/report.ts b/packages/compiler/src/coverage/report.ts index 99b484017..e0024a139 100644 --- a/packages/compiler/src/coverage/report.ts +++ b/packages/compiler/src/coverage/report.ts @@ -207,7 +207,9 @@ export function renderCoverage(input: CoverageInput, opts: { color?: boolean; so ); for (const b of builtins) { const status = b.shimmed - ? c(GREEN, "shimmed".padEnd(widestS)) + ? b.partial + ? c(YELLOW, "partial".padEnd(widestS)) + : c(GREEN, "shimmed".padEnd(widestS)) : b.lazy ? c(YELLOW, "not shimmed — lazy trap".padEnd(widestS)) : c(RED, "not shimmed".padEnd(widestS)); @@ -226,6 +228,11 @@ export function renderCoverage(input: CoverageInput, opts: { color?: boolean; so c(DIM, `(${t.via.join("/")} in ${t.packages.join(", ")})`), ); } + if (builtins.some((b) => b.partial)) { + out.push( + ` ${c(DIM, "(partial: the shim exists but covers only part of Node's surface; unsupported members throw at the call)")}`, + ); + } if (builtins.some((b) => b.lazy) || traps.length > 0) { out.push( ` ${c(DIM, "(lazy trap: only reachable through require()/import() boundaries — the build embeds Node's call-time error; the call throws at runtime)")}`, diff --git a/packages/compiler/src/frontend/npm.ts b/packages/compiler/src/frontend/npm.ts index eb9a85247..e8f7fdbb8 100644 --- a/packages/compiler/src/frontend/npm.ts +++ b/packages/compiler/src/frontend/npm.ts @@ -189,6 +189,11 @@ export interface NpmBuiltinUse { builtin: string; /** Whether the island ships a shim for it. */ shimmed: boolean; + /** Shimmed, but the island covers only PART of Node's surface: the + * implemented members work, the rest are throwing stubs. Coverage reports + * this so a partial shim is not indistinguishable from a complete one. + * Always false when not shimmed. */ + partial: boolean; /** Unshimmed AND only require()/import() edges reach it: the build * embeds the island's lazy throw at the call instead of failing — * Node's laziness for those edge kinds (Node itself would LOAD the @@ -270,6 +275,42 @@ const SHIMMED_BUILTINS = new Set([ "v8", ]); +/** Shimmed builtins whose island shim covers only PART of Node's surface: + * the members the shim carries work, the rest are honest throwing stubs + * (scr_island.c). `SHIMMED_BUILTINS` minus this set is the full-shim set, + * so coverage can tell a complete shim from a partial one without a + * per-function capability database. A partial shim still LOADS and runs + * programs that stay within its implemented slice, so it is not a blocker. */ +const PARTIAL_SHIM_BUILTINS = new Set([ + // Broad process plumbing with explicit fences: process.umask only supports + // its read form; module.register is unavailable; child_process is a + // load-only surface whose process-launching members all throw. + "process", "module", "child_process", + // Buffer is broadly implemented, but transcode remains unavailable. + "buffer", + // Whole-file reads/writes (readFile/writeFile/mkdir/stat/readdir/...); + // watch, open, and incremental read/write throw. + "fs", "fs/promises", + // The hashing/random/pbkdf2 slice; keys, ciphers, signing, and the rest + // throw at the call (the embedded runtime carries the hashing/random + // slice only). + "crypto", + // The stream classes and pipeline helpers work; the consumers submodule's + // Blob conversion remains an explicit fence. + "stream/consumers", + // deflate/gzip and the buffering stream classes; brotli and zstd throw. + "zlib", + // DNS has the loadable Node shape but every resolver call fences. The + // main-thread worker plumbing is real, while Worker construction throws. + "dns", "worker_threads", + // HTTP(S) implements the request/get client slice only. net/tls provide + // address/load plumbing for it, but their direct socket surfaces throw. + "http", "https", "net", "tls", + // Startup-snapshot/heap metadata is loadable; V8 serialization, heap + // snapshots, profiling, and promise hooks remain call-time fences. + "v8", +]); + /** Node builtins importable WITHOUT the "node:" prefix — used to tell * "missing builtin shim" apart from "missing package" for bare specifiers. * ("node:"-prefixed specifiers are always builtins: the prefix cannot name @@ -971,6 +1012,7 @@ export class NpmGraphBuilder { return { builtin, shimmed, + partial: shimmed && PARTIAL_SHIM_BUILTINS.has(builtin.slice(5)), lazy: !shimmed && !this.builtinsEager.has(builtin), packages: [...packages].sort(), }; diff --git a/tests/harness/__snapshots__/coverage-npm-lazy-builtin.txt b/tests/harness/__snapshots__/coverage-npm-lazy-builtin.txt index f3a9b4d7b..57f145b7b 100644 --- a/tests/harness/__snapshots__/coverage-npm-lazy-builtin.txt +++ b/tests/harness/__snapshots__/coverage-npm-lazy-builtin.txt @@ -6,9 +6,10 @@ scriptc coverage tests/fixtures/npm/cases/esbuild-require/main.ts embedded npm code imports Node builtins: node:http2 not shimmed — lazy trap (esbundled) - node:module shimmed (esbundled) + node:module partial (esbundled) node:os shimmed (esbundled) node:tty shimmed (esbundled) + (partial: the shim exists but covers only part of Node's surface; unsupported members throw at the call) (lazy trap: only reachable through require()/import() boundaries — the build embeds Node's call-time error; the call throws at runtime) builds with --dynamic — no remaining blockers (the island sites above run in the embedded engine). \ No newline at end of file diff --git a/tests/harness/__snapshots__/coverage-npm-partial-builtin.txt b/tests/harness/__snapshots__/coverage-npm-partial-builtin.txt new file mode 100644 index 000000000..78dd59ca6 --- /dev/null +++ b/tests/harness/__snapshots__/coverage-npm-partial-builtin.txt @@ -0,0 +1,11 @@ +scriptc coverage tests/fixtures/npm/cases/crypto-shims/main.ts + + statements analyzed 3 + compile statically 2 (66%) + compile dynamically 1 (33%) (island sites — the embedded engine runs them) + + embedded npm code imports Node builtins: + node:crypto partial (cryptozoo) + (partial: the shim exists but covers only part of Node's surface; unsupported members throw at the call) + + builds with --dynamic — no remaining blockers (the island sites above run in the embedded engine). \ No newline at end of file diff --git a/tests/harness/coverage.test.ts b/tests/harness/coverage.test.ts index 720a8ebc9..de84bc6df 100644 --- a/tests/harness/coverage.test.ts +++ b/tests/harness/coverage.test.ts @@ -93,14 +93,59 @@ test("lazy edges inventory: unresolvable require()/import() targets mark as lazy test("lazy builtin edges mark in the builtins table, __require sites included", async () => { // The esbuild-require fixture routes external requires through the // bundle's __require helper — its literal call sites collect as require - // edges, so the builtins table lists node:os/node:tty (shimmed) and - // node:stream as a lazy trap (unshimmed, reached only by the - // never-called require) without failing the build. + // edges, so the builtins table lists node:module (partial), node:os/ + // node:tty (shimmed), and node:http2 as a lazy trap (unshimmed, reached + // only by the never-called require) without failing the build. await expect( report(join(repoRoot, "tests/fixtures/npm/cases/esbuild-require/main.ts"), { dynamic: true }), ).toMatchFileSnapshot("__snapshots__/coverage-npm-lazy-builtin.txt"); }); +test("a partial dynamic shim is reported as partial, not fully shimmed", async () => { + // The crypto-shims fixture imports node:crypto through cryptozoo. The + // island ships a crypto shim, but only the hashing/random/pbkdf2 slice; + // keys, ciphers, signing, and the rest throw at the call. The builtins + // table marks it "partial" with an explanatory note, distinct from a + // fully implemented shim (the esbuild-require snapshot pins that side). + await expect( + report(join(repoRoot, "tests/fixtures/npm/cases/crypto-shims/main.ts"), { dynamic: true }), + ).toMatchFileSnapshot("__snapshots__/coverage-npm-partial-builtin.txt"); +}); + +test("known call-time-fenced builtin shims are never reported as complete", () => { + const cases = [ + [ + join(repoRoot, "tests/fixtures/commander-calc/calc.ts"), + ["node:child_process", "node:fs", "node:process"], + ], + [ + join(repoRoot, "tests/fixtures/npm/cases/island-web-plumbing/main.ts"), + ["node:buffer", "node:dns", "node:module", "node:worker_threads"], + ], + [ + join(repoRoot, "tests/fixtures/npm/cases/misc-shims/main.ts"), + ["node:v8"], + ], + [ + join(repoRoot, "tests/fixtures/npm/cases/stream-shims/main.ts"), + ["node:stream/consumers"], + ], + [ + join(repoRoot, "tests/fixtures/fetch/cases/island-http/main.ts"), + ["node:http"], + ], + ] as const; + + for (const [entry, builtins] of cases) { + const lines = report(entry, { dynamic: true }).split("\n"); + for (const builtin of builtins) { + const row = lines.find((line) => line.trimStart().startsWith(`${builtin} `)); + expect(row, `${builtin} coverage row for ${entry}`).toBeDefined(); + expect(row!.trim().split(/\s+/).slice(0, 2)).toEqual([builtin, "partial"]); + } + } +}); + test("import fences no longer stop analysis: percentage plus module blockers", async () => { // The fenced module reports ONE grouped blocker (the import line plus // every use of its bindings carry the same message); the rest of the