From 7eadda4d8b8cb8f6760dbf771a4d8be2f4e38160 Mon Sep 17 00:00:00 2001 From: filipeforattini Date: Tue, 1 Sep 2026 19:32:13 -0300 Subject: [PATCH 1/6] fix: classify the URL pathname setter as static MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit c6aef3e3 gave url.pathname a mutation lowering and flipped its census row to static, but left the operations table untouched — so the url profile declared 31 static inventory rows against 30 supported operations and the conformance tripwire fired on main. The setter now carries its own operation row with the corpus evidence that guards it (2854-url-pathname-setter) and a scope that says what the assignment does: re-run the WHATWG path parse, re-serialize href. The other nine writable components were probed by COMPILING one assignment each rather than by reading the lowering; all nine are still refused, so their refusal rows stand unchanged. The prose that claimed URL values have no setters at all — in the profile header, the ambient declaration, and the manifest's URL coverage note — is corrected to name the one exception. Claude-Session: https://claude.ai/code/session_0197JoEpMBBqqkSiBb2vNX5A --- .../ambient/scriptc-node-fallback.d.ts | 7 ++++--- packages/compiler/src/compat/url-profile.ts | 21 ++++++++++++++++--- .../compiler/src/coverage/surface-manifest.ts | 2 +- packages/compiler/surface-manifest.json | 7 +++---- 4 files changed, 26 insertions(+), 11 deletions(-) diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 989444ae7..4792ae952 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -1318,9 +1318,10 @@ declare module "node:os" { * LIVE query view — mutations through it re-serialize into the URL, so * href reflects immediately; every read answers the same object, Node's * caching), and toString() (the href serialization). - * URL values have no SETTERS — the component fields are read-only (the - * one supported mutation path is searchParams) — and participate in - * unions (URL | undefined). The parser covers the common CLI schemes + * pathname is the one WRITABLE component — assignment re-runs the WHATWG + * path parse and re-serializes href; every other component field is + * read-only (the other mutation path is searchParams). URL values + * participate in unions (URL | undefined). The parser covers the common CLI schemes * exactly (http/https/ws/wss/ftp/file authority URLs, opaque paths like * data: and mailto:) — divergences from the full WHATWG algorithm are * documented in SEMANTICS.md. */ diff --git a/packages/compiler/src/compat/url-profile.ts b/packages/compiler/src/compat/url-profile.ts index 499f0b5dc..54243c22e 100644 --- a/packages/compiler/src/compat/url-profile.ts +++ b/packages/compiler/src/compat/url-profile.ts @@ -16,8 +16,10 @@ * class for island/npm JS, but a compiled URL value never exposes the * extra members through it — so members without a lowering are * `unsupported`, not `dynamic-only`. - * - URL components are read-only here. Reads and writes are separate - * claims, so each writable component carries its own setter row. + * - Reads and writes are separate claims, so each writable component + * carries its own setter row. Exactly one of them — pathname — has a + * mutation lowering; the other nine are refused. That split is probed + * by compilation, not asserted: one assignment per component, compiled. */ import { @@ -76,7 +78,8 @@ const iteratorHelpers = "the ECMAScript iterator-helper protocol has no static lowering, and the search-params iterator object it would operate on is not a first-class handle either"; /** Every writable URL component under the pinned runtime. Reflected as - * accessor set functions by the conformance census. */ + * accessor set functions by the conformance census. `pathname` is the one + * with a mutation lowering; the rest carry refusal rows. */ const URL_SETTERS = [ "href", "protocol", @@ -251,6 +254,18 @@ export const NODE24_URL_COMPAT_PROFILE = { [corpus("1355-url-parse")], "the zero-argument call, equal to href; the method value itself is not a first-class handle", ), + { + // The one writable component. Every other setter is still refused, + // verified by COMPILING one assignment per component rather than by + // reading the lowering (see URL_SETTERS below). + id: "stdlib.url.setter.pathname", + name: "URL.pathname (setter)", + kind: "property", + facets: ["mutation", "parsing", "serialization"], + scope: + "assignment of a string to url.pathname, re-running the WHATWG path parse (percent-encoding, '.'/'..' segment resolution) and re-serializing href; compound assignment and destructuring writes are refused, and the component stays read-only on every other URL member", + evidence: [corpus("2854-url-pathname-setter")], + }, { id: "stdlib.url.static.canParse", name: "URL.canParse", diff --git a/packages/compiler/src/coverage/surface-manifest.ts b/packages/compiler/src/coverage/surface-manifest.ts index ea314cecf..0ba07971b 100644 --- a/packages/compiler/src/coverage/surface-manifest.ts +++ b/packages/compiler/src/coverage/surface-manifest.ts @@ -101,7 +101,7 @@ const COVERAGE_NOTES: string[] = [ "Entries with status 'dynamic-only' compile when the build embeds the dynamic engine (--dynamic); without the flag each use site is refused with the entry's code.", `The engine-free fetch projection targets Node ${NODE24_FETCH_COMPAT_PROFILE.target.node} with bundled Undici ${NODE24_FETCH_COMPAT_PROFILE.target.undici}. Each projected row names the differential evidence that guards it; changing the pinned Node or Undici version is an explicit profile update.`, "The fetch profile also contains a runtime-reflected census of the selected fetch, abort, Headers, and readable-stream interfaces plus RequestInit/ResponseInit dictionary reads. Static, dynamic-only, and unsupported census rows are projected here; its explicitly out-of-scope metadata rows and adjacent-interface exclusions remain in the profile so absence is deliberate rather than ambiguous.", - `The WHATWG URL projection targets Node ${NODE24_URL_COMPAT_PROFILE.targets.primary.node}. Its reflected census covers URL, URLSearchParams, and the search-params iterator: component READS and query operations are projected as static rows with their differential corpus evidence, while component WRITES (setters), the members served only by the dynamic engine's own emulated URL class (origin, port, hash, username, password, toJSON), the URL statics, and the iterator-helper protocol are projected as their fenced rows.`, + `The WHATWG URL projection targets Node ${NODE24_URL_COMPAT_PROFILE.targets.primary.node}. Its reflected census covers URL, URLSearchParams, and the search-params iterator: component READS, query operations, and the one writable component (the pathname setter) are projected as static rows with their differential corpus evidence, while the other nine component WRITES (setters), the members served only by the dynamic engine's own emulated URL class (origin, port, hash, username, password, toJSON), the URL statics, and the iterator-helper protocol are projected as their fenced rows.`, "Process-level diagnostic codes are not surface entries: SC0001-SC0004 are preflight gates, SC1110 is a comptime evaluation failure, SC3001/SC3002 are backend/target tier refusals, SC9001/SC9002 are internal errors.", "Entry statuses are projected for the desktop targets. The mobile targets (aarch64-apple-ios, aarch64-apple-ios-simulator, aarch64-linux-android) compile library-mode archives only: the library-admissible surface (what SC4005's async_free requirement and the library link set admit) is supported there, the executable lane refuses those triples with SC3002, and no entry outside the library-admissible surface carries a mobile support claim. iOS archives build for iOS 15.0 on darwin hosts; Android archives build against NDK API level 26.", "No scheduling metadata is published; entry ids are the stable diff keys across releases.", diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index 26b00c01b..8520cb7e9 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -10,7 +10,7 @@ "Entries with status 'dynamic-only' compile when the build embeds the dynamic engine (--dynamic); without the flag each use site is refused with the entry's code.", "The engine-free fetch projection targets Node 24.15.0 with bundled Undici 7.24.4. Each projected row names the differential evidence that guards it; changing the pinned Node or Undici version is an explicit profile update.", "The fetch profile also contains a runtime-reflected census of the selected fetch, abort, Headers, and readable-stream interfaces plus RequestInit/ResponseInit dictionary reads. Static, dynamic-only, and unsupported census rows are projected here; its explicitly out-of-scope metadata rows and adjacent-interface exclusions remain in the profile so absence is deliberate rather than ambiguous.", - "The WHATWG URL projection targets Node 24.15.0. Its reflected census covers URL, URLSearchParams, and the search-params iterator: component READS and query operations are projected as static rows with their differential corpus evidence, while component WRITES (setters), the members served only by the dynamic engine's own emulated URL class (origin, port, hash, username, password, toJSON), the URL statics, and the iterator-helper protocol are projected as their fenced rows.", + "The WHATWG URL projection targets Node 24.15.0. Its reflected census covers URL, URLSearchParams, and the search-params iterator: component READS, query operations, and the one writable component (the pathname setter) are projected as static rows with their differential corpus evidence, while the other nine component WRITES (setters), the members served only by the dynamic engine's own emulated URL class (origin, port, hash, username, password, toJSON), the URL statics, and the iterator-helper protocol are projected as their fenced rows.", "Process-level diagnostic codes are not surface entries: SC0001-SC0004 are preflight gates, SC1110 is a comptime evaluation failure, SC3001/SC3002 are backend/target tier refusals, SC9001/SC9002 are internal errors.", "Entry statuses are projected for the desktop targets. The mobile targets (aarch64-apple-ios, aarch64-apple-ios-simulator, aarch64-linux-android) compile library-mode archives only: the library-admissible surface (what SC4005's async_free requirement and the library link set admit) is supported there, the executable lane refuses those triples with SC3002, and no entry outside the library-admissible surface carries a mobile support claim. iOS archives build for iOS 15.0 on darwin hosts; Android archives build against NDK API level 26.", "No scheduling metadata is published; entry ids are the stable diff keys across releases." @@ -4199,9 +4199,8 @@ "id": "stdlib.url.setter.pathname", "kind": "stdlib", "name": "URL.pathname (setter)", - "status": "unsupported", - "code": "SC1090", - "note": "Node 24.15.0; URL components are read-only in the static tier: there is no component-assignment lowering and no native mutation path behind it" + "status": "static", + "note": "Node 24.15.0; facets: mutation, parsing, serialization; supported scope: assignment of a string to url.pathname, re-running the WHATWG path parse (percent-encoding, '.'/'..' segment resolution) and re-serializing href; compound assignment and destructuring writes are refused, and the component stays read-only on every other URL member; differential evidence: corpus:2854-url-pathname-setter" }, { "id": "stdlib.url.setter.port", From fb9d123d0f4d2fc1d20bbd9065e311ec82591d31 Mon Sep 17 00:00:00 2001 From: filipeforattini Date: Tue, 1 Sep 2026 19:37:12 -0300 Subject: [PATCH 2/6] feat: census the compat profiles across the Node 24/26 matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The version axis in profile-schema.ts existed but every profile's `candidates` list was empty, so the "matrix" was a single pin wearing a list's clothes. This fills it, and only with what a reflection actually found. The probe: reflect every interface each of the three profiles censuses — statics, own prototype members, inherited members, public symbols, setters, and the own properties of a constructed instance — under 24.15.0 and under 26.8.1, then diff. The result, which is the answer the mission wanted written down: - URL / URLSearchParams / the search-params iterator: IDENTICAL across the two majors. Every row is shared. - EventEmitter, including the three pre-private-field internals and the instance census: IDENTICAL. Every row is shared. - fetch: NOT identical. Node 26 (Undici 8.10.0) grows textStream() on Request.prototype and Response.prototype — a decoded-text ReadableStream the body mixin did not have under 24 (Undici 7.24.4). Two rows, and only those two, carry a version qualifier. So the schema addition is deliberately small: an inventory row may name the target ids whose census contains it, and OMITTING that field means "every target". A shared row cannot be narrowed by forgetting an id, and the common case stays unannotated. The manifest stamps a shared row with the primary's label as before and a qualified row with exactly the runtimes it exists on, so "Node 26 only" is readable off the shipped manifest. node-matrix.ts is the single place the runtimes are named — the profiles, the conformance suites, and the matrix gate all read it rather than repeating version literals. The fetch profile's bespoke {node, undici} tuple is folded into the same CompatTargets shape the other two use, with Undici as a pinned component of each target. Claude-Session: https://claude.ai/code/session_0197JoEpMBBqqkSiBb2vNX5A --- .../compiler/src/compat/events-profile.ts | 12 ++- packages/compiler/src/compat/fetch-profile.ts | 74 +++++++++++++---- packages/compiler/src/compat/node-matrix.ts | 47 +++++++++++ .../compiler/src/compat/profile-schema.ts | 81 +++++++++++++++++-- packages/compiler/src/compat/url-profile.ts | 12 ++- .../compiler/src/coverage/surface-manifest.ts | 15 +++- packages/compiler/src/index.ts | 12 +++ packages/compiler/surface-manifest.json | 21 ++++- 8 files changed, 237 insertions(+), 37 deletions(-) create mode 100644 packages/compiler/src/compat/node-matrix.ts diff --git a/packages/compiler/src/compat/events-profile.ts b/packages/compiler/src/compat/events-profile.ts index 4b8820bd6..8f2837b40 100644 --- a/packages/compiler/src/compat/events-profile.ts +++ b/packages/compiler/src/compat/events-profile.ts @@ -62,6 +62,7 @@ import { type CompatProfileProjection, type CompatTargets, } from "./profile-schema.js"; +import { NODE_COMPAT_MATRIX } from "./node-matrix.js"; export type EventsCompatFacet = | "argument-tuple" @@ -169,10 +170,13 @@ const LITERAL_NAME = export const NODE24_EVENTS_COMPAT_PROFILE = { schemaVersion: 1, targets: { - // The census below was reflected under this runtime. A second entry - // arrives only with its own reflected census, never as an assumption. - primary: { node: "24.15.0" }, - candidates: [], + // The census below was reflected under BOTH runtimes, and the two + // reflections were identical: EventEmitter's statics, prototype + // members, the three public internals, and the own properties of a + // constructed instance are the same on Node 24 and Node 26. So no row + // here carries a version qualifier — the shared census IS the Node 26 + // census, not an assumption that it carries over. + ...NODE_COMPAT_MATRIX, }, operations: [ emitterOperation( diff --git a/packages/compiler/src/compat/fetch-profile.ts b/packages/compiler/src/compat/fetch-profile.ts index 9e1321ade..bf48aca98 100644 --- a/packages/compiler/src/compat/fetch-profile.ts +++ b/packages/compiler/src/compat/fetch-profile.ts @@ -19,6 +19,7 @@ import { compatEntries, compatFixture, compatGenerated, + compatOnTargets, compatTargetLabel, type CompatEvidence, type CompatInventory, @@ -29,7 +30,16 @@ import { type CompatOperation, type CompatOption, type CompatProfileProjection, + type CompatTargets, } from "./profile-schema.js"; +import { + NODE24_TARGET_ID, + NODE24_UNDICI_VERSION, + NODE24_VERSION, + NODE26_TARGET_ID, + NODE26_UNDICI_VERSION, + NODE26_VERSION, +} from "./node-matrix.js"; export type FetchCompatFacet = | "argument-evaluation" @@ -62,10 +72,7 @@ export type FetchCompatInventory = CompatInventory; export interface FetchCompatProfile { schemaVersion: 1; - target: { - node: string; - undici: string; - }; + targets: CompatTargets; requestInit: readonly FetchCompatOption[]; responseInit: readonly FetchCompatOption[]; members: { @@ -95,9 +102,24 @@ const metadataExclusion = COMPAT_METADATA_EXCLUSION; export const NODE24_FETCH_COMPAT_PROFILE = { schemaVersion: 1, - target: { - node: "24.15.0", - undici: "7.24.4", + targets: { + // Both runtimes were reflected. Unlike URL and EventEmitter, this + // slice is NOT identical across the matrix: Node 26 ships a newer + // Undici, and its body mixin grew textStream() on Request and + // Response. Those two rows carry a `node26` qualifier below; every + // other row is shared because the reflection said so. + primary: { + id: NODE24_TARGET_ID, + node: NODE24_VERSION, + components: { undici: NODE24_UNDICI_VERSION }, + }, + candidates: [ + { + id: NODE26_TARGET_ID, + node: NODE26_VERSION, + components: { undici: NODE26_UNDICI_VERSION }, + }, + ], }, requestInit: [ { @@ -592,6 +614,19 @@ export const NODE24_FETCH_COMPAT_PROFILE = { typedInterfaceUnsupported, ) ), + // Node 26's body mixin only. Reflected, not read off a changelog: + // the census probe found textStream on Request.prototype and + // Response.prototype under 26.8.1 and not under 24.15.0. + ...compatOnTargets( + [NODE26_TARGET_ID], + unsupportedEntry( + "stdlib.request.textStream", + "Request", + "textStream", + "prototype", + typedInterfaceUnsupported, + ), + ), outOfScopeEntry( "stdlib.request.symbol.toStringTag", "Request", @@ -645,6 +680,19 @@ export const NODE24_FETCH_COMPAT_PROFILE = { staticEntry(`stdlib.response.${member}`, "Response", member, "prototype") ), staticEntry("stdlib.response.bytes", "Response", "bytes", "prototype"), + // Node 26's body mixin only — the Response half of the same delta. + // A decoded-text ReadableStream, so it fences for the same reason + // the other stream-shaped Response members do. + ...compatOnTargets( + [NODE26_TARGET_ID], + unsupportedEntry( + "stdlib.response.textStream", + "Response", + "textStream", + "prototype", + "the dynamic fetch bridge does not implement this Response operation", + ), + ), outOfScopeEntry( "stdlib.response.symbol.toStringTag", "Response", @@ -847,18 +895,10 @@ export const NODE24_FETCH_COMPAT_PROFILE = { }, } satisfies FetchCompatProfile; -/** The registry view of this profile. The version axis is derived from the - * single pinned tuple this profile already carries — Node plus the - * bundled Undici build — with no candidate runtime censused yet. */ +/** The registry view of this profile. */ export const FETCH_COMPAT_PROJECTION: CompatProfileProjection = { id: "fetch", - targets: { - primary: { - node: NODE24_FETCH_COMPAT_PROFILE.target.node, - components: { undici: NODE24_FETCH_COMPAT_PROFILE.target.undici }, - }, - candidates: [], - }, + targets: NODE24_FETCH_COMPAT_PROFILE.targets, operations: NODE24_FETCH_COMPAT_PROFILE.operations, options: [ ...NODE24_FETCH_COMPAT_PROFILE.requestInit, diff --git a/packages/compiler/src/compat/node-matrix.ts b/packages/compiler/src/compat/node-matrix.ts new file mode 100644 index 000000000..54d060fe7 --- /dev/null +++ b/packages/compiler/src/compat/node-matrix.ts @@ -0,0 +1,47 @@ +/** + * The Node runtime matrix every compatibility profile is censused against. + * + * scriptc targets two Node majors as first-class runtimes, so the version + * axis of a compat profile is a LIST, not a pin. This module is the one + * place the list lives: the profiles reference these ids and versions, the + * conformance suites select one of them from the running runtime, and the + * `gate:node-matrix` runner reads them to decide which interpreters to + * spawn. + * + * The rule that keeps the matrix honest: a target is in this list only + * because its reflection has actually been run. Every member the two + * majors disagree on is written down as a version-qualified inventory row + * (`targets: [...]`) in the profile that owns it, so the delta is data a + * reader can inspect rather than a claim to be trusted. Adding a third + * runtime means running the probes again and reconciling every profile — + * nothing here may be filled in from a changelog. + * + * The primary is the runtime .node-version pins and the one whose label + * stamps every shared manifest row; a candidate is equally supported, just + * not the label the manifest prose is written against. + */ + +import type { CompatTargets } from "./profile-schema.js"; + +/** The stable target ids inventory rows use as version qualifiers. */ +export const NODE24_TARGET_ID = "node24"; +export const NODE26_TARGET_ID = "node26"; + +/** The exact Node builds censused, matching .node-version (primary) and + * the candidate the matrix gate runs alongside it. */ +export const NODE24_VERSION = "24.15.0"; +export const NODE26_VERSION = "26.8.1"; + +/** The bundled Undici build behind each major's fetch surface — the + * fetch profile's second observable component. */ +export const NODE24_UNDICI_VERSION = "7.24.4"; +export const NODE26_UNDICI_VERSION = "8.10.0"; + +/** The matrix as a version axis, for profiles whose surface has no + * observable component beyond the Node build itself. The fetch profile + * builds its own from the same constants because its rows also depend on + * the bundled Undici. */ +export const NODE_COMPAT_MATRIX: CompatTargets = { + primary: { id: NODE24_TARGET_ID, node: NODE24_VERSION }, + candidates: [{ id: NODE26_TARGET_ID, node: NODE26_VERSION }], +}; diff --git a/packages/compiler/src/compat/profile-schema.ts b/packages/compiler/src/compat/profile-schema.ts index eb72ad6bc..e3ff07a0e 100644 --- a/packages/compiler/src/compat/profile-schema.ts +++ b/packages/compiler/src/compat/profile-schema.ts @@ -105,6 +105,13 @@ export interface CompatInventoryEntry { * different compiler claims behind each; without an override the two * rows would publish under one name and opposite statuses. */ publishAs?: string; + /** The version qualifier: the target ids whose reflected census contains + * this member. OMITTED means every target in the matrix — the common + * case, and deliberately the default, so a shared row cannot be silently + * narrowed by forgetting a target id. A row lists targets only where the + * majors genuinely disagree (Node 26's Request/Response.textStream), and + * then the value is a census result, never a prediction. */ + targets?: readonly string[]; } export interface CompatInventoryExclusion { @@ -140,23 +147,66 @@ export interface CompatInventory { excludedInterfaces: readonly CompatInventoryExclusion[]; } -/** One pinned runtime the profile can be censused against. Node's version - * is the axis; `components` pins whatever else is behaviorally observable +/** One pinned runtime the profile is censused against. Node's version is + * the axis; `components` pins whatever else is behaviorally observable * (the bundled Undici build, an ICU level). */ export interface CompatRuntimeTarget { + /** Stable target id, the qualifier an inventory row names ("node24"). */ + id: string; node: string; components?: Readonly>; } -/** The version axis. `primary` is the runtime this profile's inventory was - * reflected under and the only one its conformance suite asserts; - * `candidates` names runtimes a future census must cover, so adding - * Node 26 is a filled-in slot rather than a new schema. */ +/** The version axis — a MATRIX, not a pin. `primary` is the runtime whose + * label stamps every shared manifest row and whose version the repository + * pins in .node-version; `candidates` are the further runtimes the profile + * also claims. The rule that keeps the matrix honest is unchanged: a + * runtime enters this list only once its own reflection has been run and + * every disagreement with the primary census is written down as a + * version-qualified row. */ export interface CompatTargets { primary: CompatRuntimeTarget; candidates: readonly CompatRuntimeTarget[]; } +/** Every declared target, primary first. */ +export function compatTargetList( + targets: CompatTargets, +): readonly CompatRuntimeTarget[] { + return [targets.primary, ...targets.candidates]; +} + +/** The declared target a running runtime IS, or null when the runtime is + * outside the matrix. This is the oracle-selection primitive: a + * conformance suite asks the running Node which target it is instead of + * demanding it be one particular pin, so a red only ever means "this host + * is in no declared target", never "this host is not the pinned host". */ +export function compatTargetFor( + targets: CompatTargets, + nodeVersion: string, +): CompatRuntimeTarget | null { + return compatTargetList(targets).find((target) => target.node === nodeVersion) ?? null; +} + +/** Whether a census row exists on one target. An unqualified row — the + * common case — exists on every target in the matrix. */ +export function compatRowOnTarget( + row: Pick, + targetId: string, +): boolean { + return row.targets === undefined || row.targets.includes(targetId); +} + +/** Attach a version qualifier to census rows built by the shared entry + * constructors, so a matrix delta reads as data at the row rather than as + * a second constructor family. */ +export function compatOnTargets( + targetIds: readonly string[], + ...entries: readonly CompatInventoryEntry[] +): CompatInventoryEntry[] { + return entries.map((entry) => ({ ...entry, targets: targetIds })); +} + /** Human-readable target stamp for manifest notes: "Node X / Undici Y". */ export function compatTargetLabel(target: CompatRuntimeTarget): string { const components = Object.entries(target.components ?? {}).map( @@ -165,6 +215,25 @@ export function compatTargetLabel(target: CompatRuntimeTarget): string { return [`Node ${target.node}`, ...components].join(" / "); } +/** The stamp a census row publishes under. A row that exists across the + * whole matrix stamps the primary — the manifest's one canonical runtime + * label — while a version-qualified row stamps exactly the targets whose + * census contains it, so a reader can tell "Node 26 only" from "both" + * without cross-referencing the profile. */ +export function compatRowTargetLabel( + targets: CompatTargets, + row: Pick, +): string { + if (row.targets === undefined) return compatTargetLabel(targets.primary); + const qualified = compatTargetList(targets).filter((target) => + row.targets!.includes(target.id), + ); + if (qualified.length === 0) { + throw new Error("compat inventory row names no declared target"); + } + return qualified.map(compatTargetLabel).join(" + "); +} + /** The profile shape the manifest projection and the registry consume. * Individual profiles keep their own extra tables (member allowlists, * dictionary groups) and expose this view of themselves. */ diff --git a/packages/compiler/src/compat/url-profile.ts b/packages/compiler/src/compat/url-profile.ts index 54243c22e..23a9662b5 100644 --- a/packages/compiler/src/compat/url-profile.ts +++ b/packages/compiler/src/compat/url-profile.ts @@ -34,6 +34,7 @@ import { type CompatProfileProjection, type CompatTargets, } from "./profile-schema.js"; +import { NODE_COMPAT_MATRIX } from "./node-matrix.js"; export type UrlCompatFacet = | "argument-evaluation" @@ -154,10 +155,13 @@ const paramsStatic = (member: string): CompatInventoryEntry => export const NODE24_URL_COMPAT_PROFILE = { schemaVersion: 1, targets: { - // The census below was reflected under this runtime. A second entry - // arrives only with its own reflected census, never as an assumption. - primary: { node: "24.15.0" }, - candidates: [], + // The census below was reflected under BOTH runtimes, and the two + // reflections were identical: URL, URLSearchParams, and the + // search-params iterator expose the same members, the same setters, + // and the same symbols on Node 24 and Node 26. So no row here carries + // a version qualifier — the shared census IS the Node 26 census, not + // an assumption that it carries over. + ...NODE_COMPAT_MATRIX, }, operations: [ urlOperation( diff --git a/packages/compiler/src/coverage/surface-manifest.ts b/packages/compiler/src/coverage/surface-manifest.ts index 0ba07971b..60f1b79e5 100644 --- a/packages/compiler/src/coverage/surface-manifest.ts +++ b/packages/compiler/src/coverage/surface-manifest.ts @@ -39,12 +39,15 @@ import { InternalCompilerError } from "../errors.js"; * byte-deterministic: entries sort by id, keys are emitted in one fixed * order, and the output carries no timestamps or absolute paths. */ import { FENCE_CODES, UNSUPPORTED } from "../diagnostics/diagnostic.js"; -import { NODE24_FETCH_COMPAT_PROFILE } from "../compat/fetch-profile.js"; +import { FETCH_COMPAT_PROJECTION } from "../compat/fetch-profile.js"; +import { NODE_COMPAT_MATRIX } from "../compat/node-matrix.js"; import { COMPAT_PROFILES } from "../compat/registry.js"; import { compatEvidenceKey, compatRowName, + compatRowTargetLabel, compatTargetLabel, + compatTargetList, } from "../compat/profile-schema.js"; import { NODE24_URL_COMPAT_PROFILE } from "../compat/url-profile.js"; import { SUPPORTED_BUILTIN_MODULES, SUPPORTED_NODE_MODULES } from "../frontend/shared.js"; @@ -99,9 +102,10 @@ const COVERAGE_NOTES: string[] = [ "stdlib and node-builtin member entries name surface whose LOWERED call forms are constrained (arity, argument shapes); declared call forms outside the lowered set are refused per site, with code SC2020 for standard-library and node-builtin surface.", "Entries with status 'unsupported' or 'dynamic-only' describe where the named code is raised: forms of the construct outside the supported subset are refused with that code — not that every form of the named feature is refused. Supported forms appear as their own static entries where a table projects them.", "Entries with status 'dynamic-only' compile when the build embeds the dynamic engine (--dynamic); without the flag each use site is refused with the entry's code.", - `The engine-free fetch projection targets Node ${NODE24_FETCH_COMPAT_PROFILE.target.node} with bundled Undici ${NODE24_FETCH_COMPAT_PROFILE.target.undici}. Each projected row names the differential evidence that guards it; changing the pinned Node or Undici version is an explicit profile update.`, + `The builtin-class compat profiles are censused against a MATRIX of Node runtimes, not one pin: ${compatTargetList(NODE_COMPAT_MATRIX).map((target) => `Node ${target.node}`).join(" and ")}, each reflected in its own right. A row's note names the runtimes whose reflected census contains it, so a row stamped with only one of them exists on that major alone. Adding a runtime to the matrix means running the reflection under it, never reading a changelog.`, + `The engine-free fetch projection targets ${compatTargetList(FETCH_COMPAT_PROJECTION.targets).map(compatTargetLabel).join(" and ")}. Each projected row names the differential evidence that guards it; changing a pinned Node or Undici version is an explicit profile update.`, "The fetch profile also contains a runtime-reflected census of the selected fetch, abort, Headers, and readable-stream interfaces plus RequestInit/ResponseInit dictionary reads. Static, dynamic-only, and unsupported census rows are projected here; its explicitly out-of-scope metadata rows and adjacent-interface exclusions remain in the profile so absence is deliberate rather than ambiguous.", - `The WHATWG URL projection targets Node ${NODE24_URL_COMPAT_PROFILE.targets.primary.node}. Its reflected census covers URL, URLSearchParams, and the search-params iterator: component READS, query operations, and the one writable component (the pathname setter) are projected as static rows with their differential corpus evidence, while the other nine component WRITES (setters), the members served only by the dynamic engine's own emulated URL class (origin, port, hash, username, password, toJSON), the URL statics, and the iterator-helper protocol are projected as their fenced rows.`, + `The WHATWG URL projection targets ${compatTargetList(NODE24_URL_COMPAT_PROFILE.targets).map((target) => `Node ${target.node}`).join(" and ")}, whose reflections of these interfaces are identical. Its reflected census covers URL, URLSearchParams, and the search-params iterator: component READS, query operations, and the one writable component (the pathname setter) are projected as static rows with their differential corpus evidence, while the other nine component WRITES (setters), the members served only by the dynamic engine's own emulated URL class (origin, port, hash, username, password, toJSON), the URL statics, and the iterator-helper protocol are projected as their fenced rows.`, "Process-level diagnostic codes are not surface entries: SC0001-SC0004 are preflight gates, SC1110 is a comptime evaluation failure, SC3001/SC3002 are backend/target tier refusals, SC9001/SC9002 are internal errors.", "Entry statuses are projected for the desktop targets. The mobile targets (aarch64-apple-ios, aarch64-apple-ios-simulator, aarch64-linux-android) compile library-mode archives only: the library-admissible surface (what SC4005's async_free requirement and the library link set admit) is supported there, the executable lane refuses those triples with SC3002, and no entry outside the library-admissible surface carries a mobile support claim. iOS archives build for iOS 15.0 on darwin hosts; Android archives build against NDK API level 26.", "No scheduling metadata is published; entry ids are the stable diff keys across releases.", @@ -384,7 +388,10 @@ export function generateSurfaceManifest(compilerVersion: string): SurfaceManifes name: compatRowName(row), status: row.status, code: row.code, - note: `${target}; ${row.reason}`, + // Shared rows stamp the primary; a row the matrix disagrees on + // stamps exactly the runtimes whose census contains it, so + // "Node 26 only" is readable off the manifest itself. + note: `${compatRowTargetLabel(profile.targets, row)}; ${row.reason}`, }); } } diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 631f0149d..34cbeae2c 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -97,10 +97,22 @@ export { type UrlCompatOperation, type UrlCompatProfile, } from "./compat/url-profile.js"; +export { + NODE_COMPAT_MATRIX, + NODE24_TARGET_ID, + NODE24_VERSION, + NODE26_TARGET_ID, + NODE26_VERSION, +} from "./compat/node-matrix.js"; export { compatEvidenceKey, + compatOnTargets, compatRowName, + compatRowOnTarget, + compatRowTargetLabel, + compatTargetFor, compatTargetLabel, + compatTargetList, type CompatEvidence, type CompatFenceCode, type CompatInterfaceSource, diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index 8520cb7e9..826e451cc 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -8,9 +8,10 @@ "stdlib and node-builtin member entries name surface whose LOWERED call forms are constrained (arity, argument shapes); declared call forms outside the lowered set are refused per site, with code SC2020 for standard-library and node-builtin surface.", "Entries with status 'unsupported' or 'dynamic-only' describe where the named code is raised: forms of the construct outside the supported subset are refused with that code — not that every form of the named feature is refused. Supported forms appear as their own static entries where a table projects them.", "Entries with status 'dynamic-only' compile when the build embeds the dynamic engine (--dynamic); without the flag each use site is refused with the entry's code.", - "The engine-free fetch projection targets Node 24.15.0 with bundled Undici 7.24.4. Each projected row names the differential evidence that guards it; changing the pinned Node or Undici version is an explicit profile update.", + "The builtin-class compat profiles are censused against a MATRIX of Node runtimes, not one pin: Node 24.15.0 and Node 26.8.1, each reflected in its own right. A row's note names the runtimes whose reflected census contains it, so a row stamped with only one of them exists on that major alone. Adding a runtime to the matrix means running the reflection under it, never reading a changelog.", + "The engine-free fetch projection targets Node 24.15.0 / Undici 7.24.4 and Node 26.8.1 / Undici 8.10.0. Each projected row names the differential evidence that guards it; changing a pinned Node or Undici version is an explicit profile update.", "The fetch profile also contains a runtime-reflected census of the selected fetch, abort, Headers, and readable-stream interfaces plus RequestInit/ResponseInit dictionary reads. Static, dynamic-only, and unsupported census rows are projected here; its explicitly out-of-scope metadata rows and adjacent-interface exclusions remain in the profile so absence is deliberate rather than ambiguous.", - "The WHATWG URL projection targets Node 24.15.0. Its reflected census covers URL, URLSearchParams, and the search-params iterator: component READS, query operations, and the one writable component (the pathname setter) are projected as static rows with their differential corpus evidence, while the other nine component WRITES (setters), the members served only by the dynamic engine's own emulated URL class (origin, port, hash, username, password, toJSON), the URL statics, and the iterator-helper protocol are projected as their fenced rows.", + "The WHATWG URL projection targets Node 24.15.0 and Node 26.8.1, whose reflections of these interfaces are identical. Its reflected census covers URL, URLSearchParams, and the search-params iterator: component READS, query operations, and the one writable component (the pathname setter) are projected as static rows with their differential corpus evidence, while the other nine component WRITES (setters), the members served only by the dynamic engine's own emulated URL class (origin, port, hash, username, password, toJSON), the URL statics, and the iterator-helper protocol are projected as their fenced rows.", "Process-level diagnostic codes are not surface entries: SC0001-SC0004 are preflight gates, SC1110 is a comptime evaluation failure, SC3001/SC3002 are backend/target tier refusals, SC9001/SC9002 are internal errors.", "Entry statuses are projected for the desktop targets. The mobile targets (aarch64-apple-ios, aarch64-apple-ios-simulator, aarch64-linux-android) compile library-mode archives only: the library-admissible surface (what SC4005's async_free requirement and the library link set admit) is supported there, the executable lane refuses those triples with SC3002, and no entry outside the library-admissible surface carries a mobile support claim. iOS archives build for iOS 15.0 on darwin hosts; Android archives build against NDK API level 26.", "No scheduling metadata is published; entry ids are the stable diff keys across releases." @@ -3430,6 +3431,14 @@ "code": "SC2020", "note": "Node 24.15.0 / Undici 7.24.4; typed source has no compiler bridge for this interface in either tier" }, + { + "id": "stdlib.request.textStream", + "kind": "stdlib", + "name": "Request.textStream", + "status": "unsupported", + "code": "SC2020", + "note": "Node 26.8.1 / Undici 8.10.0; typed source has no compiler bridge for this interface in either tier" + }, { "id": "stdlib.request.url", "kind": "stdlib", @@ -3592,6 +3601,14 @@ "status": "static", "note": "Node 24.15.0 / Undici 7.24.4; facets: body-consumption, promise-settlement, state-machine, error-shape; differential evidence: fixture:static, fixture:static-stream" }, + { + "id": "stdlib.response.textStream", + "kind": "stdlib", + "name": "Response.textStream", + "status": "unsupported", + "code": "SC2020", + "note": "Node 26.8.1 / Undici 8.10.0; the dynamic fetch bridge does not implement this Response operation" + }, { "id": "stdlib.response.type", "kind": "stdlib", From a00138ae78213f399bfabf258ef60c05a7e01686 Mon Sep 17 00:00:00 2001 From: filipeforattini Date: Tue, 1 Sep 2026 19:42:17 -0300 Subject: [PATCH 3/6] test: select the compat target from the running runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three conformance suites opened with the same assertion: expect(process.versions.node).toBe(profile.targets.primary.node) which turns every host that is not one exact build into a red — including Node 26, a first-class target. That is a false red by construction, and it is the papercut that bit four times in one day. The suites now SELECT instead of demand: each asks the running runtime which declared target it is, compares the reflection against that target's rows, and fails only when the host is in no declared target at all — the one condition that genuinely is a contract violation. The failure message names the host and lists the declared runtimes, so the fix is obvious from the output. Selection is what makes the fetch profile's version-qualified rows work: Request/Response.textStream is compared only under Node 26, and its absence under Node 24 is not a mismatch. Verified in both directions — flipping the qualifier to node24 makes the Node 26 census fail on exactly those two members, so the tripwire is still a tripwire. Two things deliberately stay matrix-wide rather than per-target: row SHAPE validation (a malformed Node 26 row must fail on Node 24 too) and the shipped-manifest comparison, since the manifest is one artifact carrying the whole matrix whatever runtime generated it. Claude-Session: https://claude.ai/code/session_0197JoEpMBBqqkSiBb2vNX5A --- tests/harness/compat-census.ts | 49 +++++++++++++++++- tests/harness/events-conformance.test.ts | 66 +++++++++++++++++------- tests/harness/fetch-conformance.test.ts | 64 +++++++++++++++++------ tests/harness/url-conformance.test.ts | 66 +++++++++++++++++------- 4 files changed, 191 insertions(+), 54 deletions(-) diff --git a/tests/harness/compat-census.ts b/tests/harness/compat-census.ts index 557091ec0..1ed0fb2d6 100644 --- a/tests/harness/compat-census.ts +++ b/tests/harness/compat-census.ts @@ -9,7 +9,54 @@ * makes a member writable fails loudly instead of widening an unaudited * surface. */ -import type { CompatInterfaceSource } from "@scriptc/compiler"; +import { + compatRowOnTarget, + compatTargetFor, + compatTargetList, + type CompatInterfaceSource, + type CompatInventoryEntry, + type CompatRuntimeTarget, + type CompatTargets, +} from "@scriptc/compiler"; + +/** + * The matrix target the RUNNING runtime is, selected by asking the runtime + * its version rather than by demanding it be one particular pin. + * + * This is the structural fix for a whole class of false red: a conformance + * suite that asserts `process.versions.node === ` + * fails on every host that is not that exact build, including the other + * first-class target. Selecting instead means the suite reflects under + * whichever declared runtime it finds itself on, compares against that + * target's rows, and reds only when the host is in NO declared target — + * which is the one thing that genuinely is a contract violation. + * + * Returns null for an undeclared host; callers name that in their own + * assertion so the failure message says which runtime was unexpected. + */ +export function activeCompatTarget( + targets: CompatTargets, + nodeVersion: string = process.versions.node, +): CompatRuntimeTarget | null { + return compatTargetFor(targets, nodeVersion); +} + +/** The declared targets, as the human-readable list a failure message + * needs ("24.15.0, 26.8.1"). */ +export function compatTargetVersions(targets: CompatTargets): string { + return compatTargetList(targets).map((target) => target.node).join(", "); +} + +/** The census rows that exist on one target: unqualified rows plus the + * rows that name it. A suite compares the reflection against THIS, never + * against the whole inventory, or a member that exists on one major only + * would look like a mismatch on the other. */ +export function rowsForTarget( + entries: readonly CompatInventoryEntry[], + target: CompatRuntimeTarget, +): CompatInventoryEntry[] { + return entries.filter((entry) => compatRowOnTarget(entry, target.id)); +} export interface InterfaceCensus { /** Own properties of the interface object, minus length/name/prototype. */ diff --git a/tests/harness/events-conformance.test.ts b/tests/harness/events-conformance.test.ts index 30f7c2780..7af49ab86 100644 --- a/tests/harness/events-conformance.test.ts +++ b/tests/harness/events-conformance.test.ts @@ -1,11 +1,14 @@ /** - * Version-pinned conformance for the node:events / EventEmitter slice. + * Matrix conformance for the node:events / EventEmitter slice. * * The compatibility profile is compiler input, not test-only metadata: its * rows project into the shipped surface manifest. This suite holds the * profile to the same four things the URL suite holds its own profile to: * - * - the running oracle is the exact Node the census was reflected under; + * - the running runtime IS one of the profile's declared targets — the + * suite selects the target from the host rather than demanding one + * particular pin, so it is green under every first-class Node and red + * only on a runtime the matrix does not declare at all; * - the REFLECTED census of EventEmitter — statics, prototype, and the * own properties of a constructed instance — equals the declared * inventory member for member, the tripwire that makes a Node upgrade @@ -26,14 +29,18 @@ import { describe, expect, test } from "vitest"; import { compatEvidenceKey, compatRowName, + compatRowTargetLabel, compatTargetLabel, NODE24_EVENTS_COMPAT_PROFILE, type CompatInventoryPlacement, type SurfaceManifest, } from "@scriptc/compiler"; import { + activeCompatTarget, + compatTargetVersions, publicSymbolName, reflectInterface, + rowsForTarget, wellKnownSymbolNames, } from "./compat-census.js"; @@ -45,8 +52,16 @@ const manifest = JSON.parse( readFileSync(join(repoRoot, "packages/compiler/surface-manifest.json"), "utf8"), ) as SurfaceManifest; +/** The matrix target this run IS. Every comparison below is made against + * this target's rows, so the suite is green on Node 24 and on Node 26 and + * red only on a runtime the profile does not declare at all. */ +const target = activeCompatTarget(profile.targets); +const entries = target === null + ? inventory.entries + : rowsForTarget(inventory.entries, target); + const membersAt = (owner: string, placement: CompatInventoryPlacement): string[] => - inventory.entries + entries .filter((entry) => entry.owner === owner && entry.placement === placement) .map((entry) => entry.member) .sort(); @@ -58,15 +73,22 @@ function corpusExists(name: string): boolean { ); } -describe("Node 24 EventEmitter compatibility profile", () => { - test("the running oracle is the exact pinned Node", () => { +describe("EventEmitter compatibility profile", () => { + test("the running runtime is one of the declared matrix targets", () => { + // The primary is what .node-version pins; the candidates are equally + // supported runtimes, each with its own reflected census. const pinnedNode = readFileSync(join(repoRoot, ".node-version"), "utf8").trim(); expect(profile.targets.primary.node).toBe(pinnedNode); - expect(process.versions.node).toBe(profile.targets.primary.node); - // The version axis exists so a second runtime is a filled slot rather - // than a schema change; a candidate is only ever added with its own - // reflected census, so the pinned profile declares none. - expect(profile.targets.candidates).toEqual([]); + expect(profile.targets.candidates.length).toBeGreaterThan(0); + + // Selection, not equality: the only failure this can produce is a host + // outside the whole matrix. Running on any declared target is green. + expect( + target, + `Node ${process.versions.node} is not a declared target of the events profile ` + + `(declared: ${compatTargetVersions(profile.targets)}) — add it to the ` + + `matrix with its own reflected census, or run the suite under one of them`, + ).not.toBeNull(); }); test("the inventory classifies every supported row and every gap", () => { @@ -74,7 +96,7 @@ describe("Node 24 EventEmitter compatibility profile", () => { const ids = inventory.entries.map((entry) => entry.id); expect(new Set(ids).size).toBe(ids.length); expect( - inventory.entries + entries .filter((entry) => entry.status === "static") .map((entry) => entry.id) .sort(), @@ -116,7 +138,7 @@ describe("Node 24 EventEmitter compatibility profile", () => { for (const owner of inventory.interfaces) { const source = inventory.sources?.[owner]; const actual = reflectInterface(owner, source); - const declaredConstructors = inventory.entries.filter( + const declaredConstructors = entries.filter( (entry) => entry.owner === owner && entry.placement === "constructor", ).length; expect(declaredConstructors, `${owner}: constructor classification`).toBe( @@ -244,24 +266,28 @@ describe("Node 24 EventEmitter compatibility profile", () => { } }); + // The manifest is a shipped artifact, not a per-host one: it carries + // every row of the matrix whatever runtime generated it, so this test + // compares against the WHOLE inventory rather than the active target's + // slice, and each row against the label its own targets produce. test("the shipped manifest carries every projected row", () => { - const entries = new Map(manifest.entries.map((entry) => [entry.id, entry])); - const target = compatTargetLabel(profile.targets.primary); - expect(target).toBe(`Node ${profile.targets.primary.node}`); + const published = new Map(manifest.entries.map((entry) => [entry.id, entry])); + const primaryLabel = compatTargetLabel(profile.targets.primary); + expect(primaryLabel).toBe(`Node ${profile.targets.primary.node}`); for (const operation of profile.operations) { - const entry = entries.get(operation.id); + const entry = published.get(operation.id); expect(entry, `${operation.id} is missing from the manifest`).toBeDefined(); expect(entry!.status, `${operation.id}: manifest status`).toBe("static"); expect(entry!.name).toBe(operation.name); - expect(entry!.note).toContain(target); + expect(entry!.note).toContain(primaryLabel); for (const evidence of operation.evidence) { expect(entry!.note).toContain(compatEvidenceKey(evidence)); } } for (const row of inventory.entries) { - const entry = entries.get(row.id); + const entry = published.get(row.id); if (row.status === "out-of-scope") { // Exclusions stay in the profile: the manifest publishes claims, // not the deliberate silences behind them. @@ -273,7 +299,9 @@ describe("Node 24 EventEmitter compatibility profile", () => { expect(entry!.status, `${row.id}: manifest status`).toBe(row.status); expect(entry!.code, `${row.id}: manifest fence`).toBe(row.code); expect(entry!.name).toBe(compatRowName(row)); - expect(entry!.note).toBe(`${target}; ${row.reason}`); + expect(entry!.note).toBe( + `${compatRowTargetLabel(profile.targets, row)}; ${row.reason}`, + ); } }); }); diff --git a/tests/harness/fetch-conformance.test.ts b/tests/harness/fetch-conformance.test.ts index d31806cba..676fe2c0b 100644 --- a/tests/harness/fetch-conformance.test.ts +++ b/tests/harness/fetch-conformance.test.ts @@ -1,10 +1,13 @@ /** - * Generated, version-pinned conformance for the engine-free fetch slice. + * Generated, matrix conformance for the engine-free fetch slice. * * The compatibility profile is compiler input, not test-only metadata: its * member allowlists drive lowering and its entries project into the shipped * surface manifest. The reflected inventory supplies the denominator. This - * suite checks that the pinned Node executable is the intended oracle, every + * suite selects the profile target the running runtime IS — this is the one + * profile whose census genuinely differs across the matrix, so a row that + * exists on Node 26 alone is compared only against Node 26 — and checks + * that the host is a declared target at all, that every * profile row names differential evidence, and the generated * WebIDL/state-machine program agrees through both native backends. */ @@ -22,6 +25,9 @@ import { join } from "node:path"; import { promisify } from "node:util"; import { beforeAll, describe, expect, test } from "vitest"; import { + compatTargetFor, + compatTargetList, + compatRowOnTarget, compile, NODE24_FETCH_COMPAT_PROFILE, renderAll, @@ -39,6 +45,15 @@ const repoRoot = join(import.meta.dirname, "../.."); const fixturesRoot = join(repoRoot, "tests/fixtures/fetch"); const sanitize = process.env["SCRIPTC_SAN"] === "1"; const profile = NODE24_FETCH_COMPAT_PROFILE; +/** The matrix target this run IS — selected from the running runtime + * rather than demanded of it, so the suite is green under every declared + * Node and red only on one the profile does not declare at all. */ +const target = compatTargetFor(profile.targets, process.versions.node); +/** The census rows that exist on the active target: unqualified rows plus + * the rows that name it (Node 26's Request/Response.textStream). */ +const targetEntries = target === null + ? profile.inventory.entries + : profile.inventory.entries.filter((entry) => compatRowOnTarget(entry, target.id)); function configuredInteger( name: string, fallback: number, @@ -231,26 +246,44 @@ beforeAll(() => { writeFileSync(entry, generatedSource); }); -describe("Node 24 fetch compatibility profile", () => { - test("the running oracle is the exact pinned Node/Undici tuple", () => { +describe("fetch compatibility profile", () => { + test("the running runtime is one of the declared matrix targets", () => { + // The primary is what .node-version pins; the candidates are equally + // supported runtimes, each with its own reflected census. const pinnedNode = readFileSync(join(repoRoot, ".node-version"), "utf8").trim(); - expect(profile.target.node).toBe(pinnedNode); - expect(process.versions.node).toBe(profile.target.node); - expect(process.versions.undici).toBe(profile.target.undici); + expect(profile.targets.primary.node).toBe(pinnedNode); + expect(profile.targets.candidates.length).toBeGreaterThan(0); + + // Selection, not equality: the only failure this can produce is a host + // outside the whole matrix. Running on any declared target is green. + const declared = compatTargetList(profile.targets); + expect( + target, + `Node ${process.versions.node} is not a declared target of the fetch profile ` + + `(declared: ${declared.map((row) => row.node).join(", ")}) — add it to the ` + + `matrix with its own reflected census, or run the suite under one of them`, + ).not.toBeNull(); + + // Undici is the profile's second observable component, so the target + // that claims to be this runtime must agree about it too. A Node build + // carrying an unexpected Undici is a real census divergence. + expect(target!.components?.["undici"]).toBe(process.versions.undici); }); test("the inventory classifies every supported row and every gap", () => { const supported = [...profile.operations, ...profile.requestInit, ...profile.responseInit] .map((row) => row.id) .sort(); - const entries = profile.inventory.entries; - const ids = entries.map((entry) => entry.id); + const entries = targetEntries; + const ids = profile.inventory.entries.map((entry) => entry.id); expect(new Set(ids).size).toBe(ids.length); expect( entries.filter((entry) => entry.status === "static").map((entry) => entry.id).sort(), ).toEqual(supported); - for (const entry of entries) { + // Row SHAPE is validated across the whole matrix, not just the active + // target: a malformed Node 26 row must fail on Node 24 too. + for (const entry of profile.inventory.entries) { if (entry.status === "static") { expect(entry.code, `${entry.id}: static rows have no refusal code`).toBeUndefined(); expect(entry.reason, `${entry.id}: static rows are explained by evidence`).toBeUndefined(); @@ -263,9 +296,10 @@ describe("Node 24 fetch compatibility profile", () => { } } - expect(entries.some((entry) => entry.status === "dynamic-only")).toBe(true); - expect(entries.some((entry) => entry.status === "unsupported")).toBe(true); - expect(entries.some((entry) => entry.status === "out-of-scope")).toBe(true); + const all = profile.inventory.entries; + expect(all.some((entry) => entry.status === "dynamic-only")).toBe(true); + expect(all.some((entry) => entry.status === "unsupported")).toBe(true); + expect(all.some((entry) => entry.status === "out-of-scope")).toBe(true); expect(profile.inventory.excludedInterfaces.length).toBeGreaterThan(0); for (const exclusion of profile.inventory.excludedInterfaces) { expect(exclusion.name.length).toBeGreaterThan(0); @@ -274,7 +308,7 @@ describe("Node 24 fetch compatibility profile", () => { }); test("the selected runtime interfaces match the complete public census", () => { - const entries = profile.inventory.entries; + const entries = targetEntries; const expected = (owner: string, placement: string): string[] => entries .filter((entry) => entry.owner === owner && entry.placement === placement) @@ -313,7 +347,7 @@ describe("Node 24 fetch compatibility profile", () => { test("the WebIDL dictionary census matches Node's conversion reads", () => { const expected = (owner: string): string[] => - profile.inventory.entries + targetEntries .filter((entry) => entry.owner === owner && entry.placement === "dictionary") .map((entry) => entry.member); expect(dictionaryReads((init) => diff --git a/tests/harness/url-conformance.test.ts b/tests/harness/url-conformance.test.ts index e73cf65e6..591d9ac2c 100644 --- a/tests/harness/url-conformance.test.ts +++ b/tests/harness/url-conformance.test.ts @@ -1,11 +1,14 @@ /** - * Version-pinned conformance for the WHATWG URL / URLSearchParams slice. + * Matrix conformance for the WHATWG URL / URLSearchParams slice. * * The compatibility profile is compiler input, not test-only metadata: its * rows project into the shipped surface manifest. This suite holds the * profile to four things: * - * - the running oracle is the exact Node the census was reflected under; + * - the running runtime IS one of the profile's declared targets — the + * suite selects the target from the host rather than demanding one + * particular pin, so it is green under every first-class Node and red + * only on a runtime the matrix does not declare at all; * - the REFLECTED census of URL, URLSearchParams, and the search-params * iterator equals the declared inventory, member for member, setter for * setter — the tripwire that makes a Node upgrade a deliberate profile @@ -20,14 +23,18 @@ import { describe, expect, test } from "vitest"; import { compatEvidenceKey, compatRowName, + compatRowTargetLabel, compatTargetLabel, NODE24_URL_COMPAT_PROFILE, type CompatInventoryPlacement, type SurfaceManifest, } from "@scriptc/compiler"; import { + activeCompatTarget, + compatTargetVersions, publicSymbolName, reflectInterface, + rowsForTarget, wellKnownSymbolNames, } from "./compat-census.js"; @@ -39,8 +46,16 @@ const manifest = JSON.parse( readFileSync(join(repoRoot, "packages/compiler/surface-manifest.json"), "utf8"), ) as SurfaceManifest; +/** The matrix target this run IS. Every comparison below is made against + * this target's rows, so the suite is green on Node 24 and on Node 26 and + * red only on a runtime the profile does not declare at all. */ +const target = activeCompatTarget(profile.targets); +const entries = target === null + ? inventory.entries + : rowsForTarget(inventory.entries, target); + const membersAt = (owner: string, placement: CompatInventoryPlacement): string[] => - inventory.entries + entries .filter((entry) => entry.owner === owner && entry.placement === placement) .map((entry) => entry.member) .sort(); @@ -52,15 +67,22 @@ function corpusExists(name: string): boolean { ); } -describe("Node 24 URL compatibility profile", () => { - test("the running oracle is the exact pinned Node", () => { +describe("URL compatibility profile", () => { + test("the running runtime is one of the declared matrix targets", () => { + // The primary is what .node-version pins; the candidates are equally + // supported runtimes, each with its own reflected census. const pinnedNode = readFileSync(join(repoRoot, ".node-version"), "utf8").trim(); expect(profile.targets.primary.node).toBe(pinnedNode); - expect(process.versions.node).toBe(profile.targets.primary.node); - // The version axis exists so a second runtime is a filled slot rather - // than a schema change; a candidate is only ever added with its own - // reflected census, so the pinned profile declares none. - expect(profile.targets.candidates).toEqual([]); + expect(profile.targets.candidates.length).toBeGreaterThan(0); + + // Selection, not equality: the only failure this can produce is a host + // outside the whole matrix. Running on any declared target is green. + expect( + target, + `Node ${process.versions.node} is not a declared target of the URL profile ` + + `(declared: ${compatTargetVersions(profile.targets)}) — add it to the ` + + `matrix with its own reflected census, or run the suite under one of them`, + ).not.toBeNull(); }); test("the inventory classifies every supported row and every gap", () => { @@ -68,7 +90,7 @@ describe("Node 24 URL compatibility profile", () => { const ids = inventory.entries.map((entry) => entry.id); expect(new Set(ids).size).toBe(ids.length); expect( - inventory.entries + entries .filter((entry) => entry.status === "static") .map((entry) => entry.id) .sort(), @@ -103,7 +125,7 @@ describe("Node 24 URL compatibility profile", () => { for (const owner of inventory.interfaces) { const source = inventory.sources?.[owner]; const actual = reflectInterface(owner, source); - const declaredConstructors = inventory.entries.filter( + const declaredConstructors = entries.filter( (entry) => entry.owner === owner && entry.placement === "constructor", ).length; // An interface with no reachable constructor object claims neither a @@ -170,24 +192,28 @@ describe("Node 24 URL compatibility profile", () => { } }); + // The manifest is a shipped artifact, not a per-host one: it carries + // every row of the matrix whatever runtime generated it, so this test + // compares against the WHOLE inventory rather than the active target's + // slice, and each row against the label its own targets produce. test("the shipped manifest carries every projected row", () => { - const entries = new Map(manifest.entries.map((entry) => [entry.id, entry])); - const target = compatTargetLabel(profile.targets.primary); - expect(target).toBe(`Node ${profile.targets.primary.node}`); + const published = new Map(manifest.entries.map((entry) => [entry.id, entry])); + const primaryLabel = compatTargetLabel(profile.targets.primary); + expect(primaryLabel).toBe(`Node ${profile.targets.primary.node}`); for (const operation of profile.operations) { - const entry = entries.get(operation.id); + const entry = published.get(operation.id); expect(entry, `${operation.id} is missing from the manifest`).toBeDefined(); expect(entry!.status, `${operation.id}: manifest status`).toBe("static"); expect(entry!.name).toBe(operation.name); - expect(entry!.note).toContain(target); + expect(entry!.note).toContain(primaryLabel); for (const evidence of operation.evidence) { expect(entry!.note).toContain(compatEvidenceKey(evidence)); } } for (const row of inventory.entries) { - const entry = entries.get(row.id); + const entry = published.get(row.id); if (row.status === "out-of-scope") { // Exclusions stay in the profile: the manifest publishes claims, // not the deliberate silences behind them. @@ -199,7 +225,9 @@ describe("Node 24 URL compatibility profile", () => { expect(entry!.status, `${row.id}: manifest status`).toBe(row.status); expect(entry!.code, `${row.id}: manifest fence`).toBe(row.code); expect(entry!.name).toBe(compatRowName(row)); - expect(entry!.note).toBe(`${target}; ${row.reason}`); + expect(entry!.note).toBe( + `${compatRowTargetLabel(profile.targets, row)}; ${row.reason}`, + ); } }); }); From 370f49ac8c6f536586bbf64edd27a71f5b9d036d Mon Sep 17 00:00:00 2001 From: filipeforattini Date: Tue, 1 Sep 2026 19:48:39 -0300 Subject: [PATCH 4/6] chore: add the Node matrix conformance gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pnpm test:*-conformance` inherits whatever `node` the shell resolves, so "run the conformance suites" silently meant "run them under one runtime, whichever one that is" — useless as evidence for a two-runtime contract. scripts/node-matrix.mjs names the interpreter explicitly, one lane per declared target, and VERIFIES the binary it found by asking its --version: a moved mise symlink must not let the node26 lane run Node 24 and report PASS. Resolution order is override, running interpreter, mise install tree, `mise which`. The runtime list is read from node-matrix.ts, so a runtime cannot be in the gate without being in the contract. pnpm gate:node-matrix both runtimes, in sequence pnpm test:conformance:node24 one runtime by target id pnpm test:conformance:node26 pnpm test:conformance the three suites, host runtime Running it turned up a REAL Node 24 → 26 semantic divergence that had to be resolved before the gate could be green: Node 26 rewords AbortSignal.any's ERR_INVALID_ARG_TYPE from "signals can not be converted to sequence." to "signals cannot be converted to sequence.", and the generated fetch differential compared native stdout against process.execPath — so under Node 26 it red on Node's own typo fix, twice, saying nothing about either backend. That forced the distinction the single-pin world never had to make, now stated once in tests/harness/node-matrix.ts and enforced by both callers: the CENSUS follows the HOST — what members a runtime exposes is a question about the runtime you are on, and asking both majors is the entire point; the SEMANTIC ORACLE stays PINNED to the primary — a compiled binary reproduces one Node's observable behavior, message text included, and cannot reproduce two. SCRIPTC_NODE_ORACLE is how you go looking for divergences deliberately rather than tripping over them. The harness README documents the gate as the enforcement point, since CI does not run on the fork. Claude-Session: https://claude.ai/code/session_0197JoEpMBBqqkSiBb2vNX5A --- package.json | 4 + scripts/node-matrix.mjs | 126 ++++++++++++++++++++++ tests/harness/README.md | 59 ++++++++++- tests/harness/fetch-conformance.test.ts | 14 ++- tests/harness/node-matrix.ts | 133 ++++++++++++++++++++++++ 5 files changed, 330 insertions(+), 6 deletions(-) create mode 100644 scripts/node-matrix.mjs create mode 100644 tests/harness/node-matrix.ts diff --git a/package.json b/package.json index 82315558f..6d422a23a 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,10 @@ "test:fetch-conformance": "vitest run tests/harness/fetch-conformance.test.ts", "test:url-conformance": "vitest run tests/harness/url-conformance.test.ts", "test:events-conformance": "vitest run tests/harness/events-conformance.test.ts", + "test:conformance": "vitest run tests/harness/fetch-conformance.test.ts tests/harness/url-conformance.test.ts tests/harness/events-conformance.test.ts", + "test:conformance:node24": "tsx scripts/node-matrix.mjs --target node24", + "test:conformance:node26": "tsx scripts/node-matrix.mjs --target node26", + "gate:node-matrix": "tsx scripts/node-matrix.mjs", "test:cache-identity": "node tests/harness/cache-identity.mjs", "test:sandbox": "node scripts/sandbox-test.mjs", "test:sandbox:image": "node scripts/sandbox-image.mjs", diff --git a/scripts/node-matrix.mjs b/scripts/node-matrix.mjs new file mode 100644 index 000000000..df0859c5f --- /dev/null +++ b/scripts/node-matrix.mjs @@ -0,0 +1,126 @@ +#!/usr/bin/env tsx +// Runs the builtin-class conformance suites under every Node runtime the +// compat matrix declares — the local enforcement of the dual-target +// contract. +// +// Why a script rather than three package.json lines: `vitest run` inherits +// whatever `node` the shell happens to resolve, so "run the conformance +// suites" silently means "run them under one runtime, whichever one that +// is". The whole point of the matrix is that BOTH majors are first-class, +// and a gate that can only ever exercise one of them proves nothing about +// the other. This script names the interpreter explicitly, verifies the +// binary it found really is the version it claims to be, and runs the +// suites under each in turn. +// +// The runtime list is not duplicated here: it is read from +// packages/compiler/src/compat/node-matrix.ts, the same module the profiles +// and the suites read, so a runtime cannot be in the gate without being in +// the contract or vice versa. (Hence tsx: the matrix is TypeScript source, +// and the suites run against source too — no build step in between.) +// +// Usage: +// pnpm gate:node-matrix every declared runtime, in sequence +// pnpm test:conformance:node24 one runtime by target id +// pnpm test:conformance:node26 +// +// Locating an interpreter, in order: +// 1. SCRIPTC_NODE_, e.g. SCRIPTC_NODE_NODE26=/path/to/node — +// the escape hatch for a host that manages Node some other way; +// 2. the interpreter running this script, when its version matches; +// 3. the mise install tree ($MISE_DATA_DIR, else ~/.local/share/mise); +// 4. `mise which node@`. +// Whatever is found is then asked for its --version and REJECTED if it +// disagrees, because a moved symlink otherwise turns the gate into a lie. +import { execFileSync } from "node:child_process"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { NODE_COMPAT_MATRIX } from "../packages/compiler/src/compat/node-matrix.ts"; +import { compatTargetList } from "../packages/compiler/src/compat/profile-schema.ts"; +import { + primaryOracleExecutable, + resolveMatrixExecutable, +} from "../tests/harness/node-matrix.ts"; + +const repoRoot = fileURLToPath(new URL("..", import.meta.url)); + +/** The suites the matrix gate is FOR: the three profiles whose census is + * runtime-reflected, so running them under a second major is the only way + * to learn whether the second major agrees. */ +const CONFORMANCE_SUITES = [ + "tests/harness/fetch-conformance.test.ts", + "tests/harness/url-conformance.test.ts", + "tests/harness/events-conformance.test.ts", +]; + +const targets = compatTargetList(NODE_COMPAT_MATRIX); + +function usage(message) { + console.error(message); + console.error( + `usage: node-matrix.mjs [--target <${targets.map((t) => t.id).join("|")}>] [-- ]`, + ); + process.exit(2); +} + +const argv = process.argv.slice(2); +let selected = null; +const passthrough = []; +for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--") { + passthrough.push(...argv.slice(i + 1)); + break; + } + if (arg === "--target") { + selected = argv[i + 1]; + i += 1; + continue; + } + usage(`unexpected argument '${arg}'`); +} + +const selectedTargets = + selected === null ? targets : targets.filter((target) => target.id === selected); +if (selectedTargets.length === 0) { + usage(`unknown target '${selected}'`); +} + +// The semantic oracle is pinned to the primary and passed down explicitly, +// so the Node 26 lane reflects Node 26's SURFACE while still comparing +// compiled output against the one Node whose observable behavior the native +// runtime reproduces. Without this the lane reds on message rewording +// (Node 26 says "cannot" where Node 24 says "can not") and tells you +// nothing about the compiler. An oracle the caller already chose wins. +const oracle = primaryOracleExecutable(NODE_COMPAT_MATRIX); + +const failures = []; +for (const target of selectedTargets) { + const executable = resolveMatrixExecutable(target); + console.log( + `\n─── ${target.id}: Node ${target.node} — ${executable}\n` + + ` semantic oracle: ${oracle}`, + ); + try { + execFileSync( + executable, + [join(repoRoot, "node_modules/vitest/vitest.mjs"), "run", ...CONFORMANCE_SUITES, ...passthrough], + { cwd: repoRoot, stdio: "inherit", env: { ...process.env, SCRIPTC_NODE_ORACLE: oracle } }, + ); + console.log(`─── ${target.id}: PASS`); + } catch { + console.error(`─── ${target.id}: FAIL`); + failures.push(target.id); + } +} + +if (failures.length > 0) { + console.error( + `\nnode matrix gate FAILED under: ${failures.join(", ")} ` + + `(of ${selectedTargets.length} runtime(s))`, + ); + process.exit(1); +} +console.log( + `\nnode matrix gate PASSED under ${selectedTargets.length} runtime(s): ` + + `${selectedTargets.map((target) => `Node ${target.node}`).join(", ")}`, +); diff --git a/tests/harness/README.md b/tests/harness/README.md index cb2c178dd..4f65d6ad0 100644 --- a/tests/harness/README.md +++ b/tests/harness/README.md @@ -34,8 +34,9 @@ Each engine-free builtin-class slice has one versioned, data-only profile under `profile-schema.ts` — statuses, placements, entry constructors, evidence keys, the version axis — and are listed in `registry.ts`, which is what the surface manifest iterates: adding a profile is one registry line plus its data module -and its conformance suite. Notes in the manifest are stamped with the runtime -the profile was censused under, so two profiles can pin different targets. +and its conformance suite. Notes in the manifest are stamped with the runtimes +whose reflected census contains the row, so a row stamped with one major exists +on that major alone. Schema pieces worth knowing before adding rows: @@ -49,12 +50,60 @@ Schema pieces worth knowing before adding rows: - `inventory.sources` names how to reach an interface that is not a global constructor (a module export, or a prototype with no constructor object at all, such as an iterator result); -- `targets` is `{ primary, candidates }`: `primary` is the runtime the census - was reflected under, and a candidate runtime is added only with its own - reflected census. +- `targets` is `{ primary, candidates }` — a **matrix**, not a pin (see below); + `primary` is the runtime `.node-version` pins and whose label stamps shared + manifest rows, and a candidate is added only with its own reflected census. +- an inventory row may carry `targets: ["node26"]`, the **version qualifier**, + naming the target ids whose census contains that member. Omitting it means + *every* target, so a shared row cannot be narrowed by forgetting an id. The shared reflection probes live in `compat-census.ts`. +### The Node matrix gate + +scriptc targets **Node 24 and Node 26 as first-class runtimes**. The matrix +lives in `packages/compiler/src/compat/node-matrix.ts` — one module the +profiles, the conformance suites, and the gate runner all read, so a runtime +cannot be in the gate without being in the contract. + +``` +pnpm gate:node-matrix # every declared runtime, in sequence +pnpm test:conformance:node24 # one runtime by target id +pnpm test:conformance:node26 +pnpm test:conformance # the three suites under whatever `node` resolves +``` + +**This local gate IS the enforcement.** CI does not run on the fork, so +`pnpm gate:node-matrix` green under both runtimes is the evidence that the +dual-target claim holds; run it before committing anything that touches a +compat profile, the census probes, or the conformance suites. The runner +resolves each interpreter explicitly (an `SCRIPTC_NODE_NODE24` / +`SCRIPTC_NODE_NODE26` override, else the running interpreter, else the mise +install tree, else `mise which`) and asks each candidate its `--version`, +rejecting a mismatch — a moved symlink must not let the "Node 26 lane" run +Node 24 and report success. + +Two rules keep the lanes meaningful, and they pull in opposite directions: + +- **The census follows the HOST.** "What members does `URL` expose?" is a + question about the runtime the suite runs on. The suites *select* their + target from `process.versions.node` rather than asserting equality with one + pin, so they are green under any declared runtime and red only when the host + is in **no** declared target — which is the one thing that is genuinely a + contract violation. +- **The semantic oracle stays PINNED to the primary.** A compiled binary + reproduces one Node's observable behavior, error-message text included, and + cannot reproduce two: Node 26 rewords messages Node 24 emits (AbortSignal.any's + `ERR_INVALID_ARG_TYPE` is "signals cannot be converted to sequence." there and + "signals can not be converted to sequence." here). So differential checks call + `primaryOracleExecutable` from `node-matrix.ts` rather than using + `process.execPath`, and `SCRIPTC_NODE_ORACLE` is how you deliberately go + looking for divergences instead of tripping over them. + +Adding a runtime to the matrix means **running the reflection under it** and +writing every disagreement down as a version-qualified row. Nothing in the +matrix may be filled in from a changelog. + ### Fetch compatibility profile The engine-free fetch/Web Streams slice has one versioned source of truth in diff --git a/tests/harness/fetch-conformance.test.ts b/tests/harness/fetch-conformance.test.ts index 676fe2c0b..2b835c111 100644 --- a/tests/harness/fetch-conformance.test.ts +++ b/tests/harness/fetch-conformance.test.ts @@ -39,6 +39,7 @@ import { generateFetchConformanceProgram, generatedScenarioIds, } from "./fetch-conformance-program.js"; +import { primaryOracleExecutable } from "./node-matrix.js"; const execFileAsync = promisify(execFile); const repoRoot = join(import.meta.dirname, "../.."); @@ -54,6 +55,9 @@ const target = compatTargetFor(profile.targets, process.versions.node); const targetEntries = target === null ? profile.inventory.entries : profile.inventory.entries.filter((entry) => compatRowOnTarget(entry, target.id)); +/** The Node the generated differential compares native output against — + * the matrix primary, not the host. See tests/harness/node-matrix.ts. */ +const oracleExecutable = primaryOracleExecutable(profile.targets); function configuredInteger( name: string, fallback: number, @@ -432,8 +436,16 @@ describe( "%s backend matches the pinned Node oracle", async (backend) => { const binary = await build(backend); + // NOT process.execPath: the census above follows the host, but a + // compiled binary reproduces ONE Node's observable behavior, so + // the differential compares against the matrix primary (or an + // explicit SCRIPTC_NODE_ORACLE). Node 26 rewords error messages + // Node 24 emits — AbortSignal.any's ERR_INVALID_ARG_TYPE is + // "cannot" there and "can not" here — and comparing against + // whichever Node happened to launch vitest would red on spelling + // while saying nothing about either backend. const [nodeResult, nativeResult] = await Promise.all([ - run(process.execPath, [entry]), + run(oracleExecutable, [entry]), run(binary, []), ]); if (!nativeResult.stdout.equals(nodeResult.stdout)) { diff --git a/tests/harness/node-matrix.ts b/tests/harness/node-matrix.ts new file mode 100644 index 000000000..e44b91067 --- /dev/null +++ b/tests/harness/node-matrix.ts @@ -0,0 +1,133 @@ +/** + * Locating the interpreters of the compat matrix, and choosing which one a + * given check should talk to. + * + * The matrix forces a distinction the single-pin world never had to make, + * and getting it wrong is how a dual-target suite turns into a coin flip: + * + * - The CENSUS follows the HOST. "What members does URL expose?" is a + * question about the runtime the suite is running on, and the whole + * point of running the suite twice is to ask it of both majors. + * + * - The SEMANTIC ORACLE stays PINNED to the primary. "What exactly does + * Node print here?" is a question a compiled binary answers with one + * fixed answer: the native runtime reproduces one Node's observable + * behavior, error-message text included, and it cannot reproduce two. + * Node 26 rewords messages Node 24 emits — AbortSignal.any's + * ERR_INVALID_ARG_TYPE went from "signals can not be converted to + * sequence." to "signals cannot be converted to sequence." — so a + * differential that compared native output against whichever Node + * happened to be running would red on message spelling and say nothing + * about the compiler. + * + * So a differential check asks for `primaryOracleExecutable`, which is the + * primary target's interpreter unless SCRIPTC_NODE_ORACLE names another — + * the override is how you deliberately go looking for divergences instead + * of tripping over them. + */ +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { compatTargetList, type CompatRuntimeTarget, type CompatTargets } from "@scriptc/compiler"; + +/** Ask a candidate interpreter its version, without the leading "v". + * Returns null when it cannot be executed, so a stale path falls through + * to the next candidate instead of aborting the search. */ +export function interpreterVersion(executable: string): string | null { + try { + return execFileSync(executable, ["--version"], { + encoding: "utf8", + timeout: 10_000, + }).trim().replace(/^v/, ""); + } catch { + return null; + } +} + +function miseWhich(version: string): string | null { + try { + return execFileSync("mise", ["which", `node@${version}`], { + encoding: "utf8", + timeout: 30_000, + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return null; + } +} + +/** The environment variable that pins one target's interpreter explicitly: + * the escape hatch for a host that manages Node some way this module does + * not know about. */ +export function matrixExecutableVariable(target: CompatRuntimeTarget): string { + return `SCRIPTC_NODE_${target.id.toUpperCase()}`; +} + +/** + * The interpreter for one matrix target. Candidates are tried in order — + * explicit override, the running interpreter, the mise install tree, `mise + * which` — and every one of them is asked for its --version and rejected + * on disagreement, because a moved symlink would otherwise let the "Node + * 26 lane" quietly run Node 24 and report success. + */ +export function resolveMatrixExecutable( + target: CompatRuntimeTarget, + env: NodeJS.ProcessEnv = process.env, + hostExecutable: string = process.execPath, +): string { + const variable = matrixExecutableVariable(target); + const override = env[variable]; + const miseData = env["MISE_DATA_DIR"] ?? join(homedir(), ".local/share/mise"); + const candidates = [ + override, + hostExecutable, + join(miseData, "installs/node", target.node, "bin/node"), + miseWhich(target.node), + ].filter((candidate): candidate is string => + candidate !== undefined && candidate !== null && candidate !== "", + ); + + const rejected: string[] = []; + for (const candidate of candidates) { + if (candidate !== hostExecutable && !existsSync(candidate)) continue; + const version = interpreterVersion(candidate); + if (version === target.node) return candidate; + // A wrong-version explicit override is an error worth surfacing, not a + // candidate to skip past silently. + if (candidate === override) { + throw new Error( + `${variable} points at ${candidate}, which reports Node ${version ?? "nothing"} — ` + + `the ${target.id} target is Node ${target.node}`, + ); + } + if (version !== null) rejected.push(`${candidate} (Node ${version})`); + } + + throw new Error( + `no Node ${target.node} interpreter found for target '${target.id}'. ` + + `Install it (mise install node@${target.node}) or point ${variable} at one.` + + (rejected.length > 0 ? ` Rejected: ${rejected.join(", ")}.` : ""), + ); +} + +/** + * The interpreter a DIFFERENTIAL check should compare native output + * against: SCRIPTC_NODE_ORACLE when set, otherwise the matrix primary — + * never "whatever is running". See this module's header for why. + */ +export function primaryOracleExecutable( + targets: CompatTargets, + env: NodeJS.ProcessEnv = process.env, + hostExecutable: string = process.execPath, +): string { + const override = env["SCRIPTC_NODE_ORACLE"]; + if (override !== undefined && override !== "") return override; + return resolveMatrixExecutable(targets.primary, env, hostExecutable); +} + +/** Every declared target, primary first — re-exported so a caller needs + * one import for the matrix rather than two. */ +export function matrixTargets(targets: CompatTargets): readonly CompatRuntimeTarget[] { + return compatTargetList(targets); +} From 7ae706620d59e3d1dce06ec73a44789bea94fcea Mon Sep 17 00:00:00 2001 From: filipeforattini Date: Tue, 1 Sep 2026 19:51:23 -0300 Subject: [PATCH 5/6] feat: inventory the Node API surface from all.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compat profiles classify three slices member by member, which answers "is this member supported?" but never "how much of Node is that?" — a numerator with nothing under the line. This derives the denominator mechanically from Node's own generated API database, for both matrix runtimes. Nothing here is classified, deliberately. An added class is not a support claim in either direction; establishing what THERE IS is the deliverable, and deciding what each class means for the compiler stays profile work. The result, and the artifact a coverage dashboard can consume: Node 24.15.0: 262 documented classes across 43 modules Node 26.8.1: 279 documented classes across 44 modules delta: +20 / -3 added async_context.RunScope, diagnostics_channel.{BoundedChannel, BoundedChannelScope,RunStoresScope}, ffi.DynamicLibrary, globals.QuotaExceededError, net.BoundSocket, perf_hooks.ELDHistogram, stream_iter.{Share,SyncShare}, v8.SyncHeapProfileHandle, vfs.{MemoryProvider,RealFSProvider, VirtualFileSystem,VirtualProvider}, webcrypto.{KangarooTwelveParams, TurboShakeParams}, zlib.{ZipBuffer,ZipEntry,ZipFile} removed assert.CallTracker, buffer.SlowBuffer, perf_hooks.IntervalHistogram The URLs move, so the source is pinned by EXACT version — never /latest/, never a major alias — and each artifact records the byte length and SHA-256 of the all.json it was derived from, which makes a silently republished upstream detectable instead of assumed away. The raw downloads are ~8 MB each and are cached rather than committed: 16 MB of generated JSON in-tree is a permanent cost every clone pays, and the derived inventories are what the dashboard actually reads and what is legible in a diff. `--vendor-raw` writes the upstream bytes in-tree for anyone who wants that trade instead. One normalization is load-bearing: Node's class `name` is prose, not an identifier — bare, already module-qualified, or carrying an `extends` clause. Normalizing to a bare identifier owned by its defining document is what keeps a docs edit from showing up as an add AND a remove; before it, the same run reported +24/-6 with three phantom pairs. Claude-Session: https://claude.ai/code/session_0197JoEpMBBqqkSiBb2vNX5A --- package.json | 2 + .../compiler/compat/inventories/README.md | 45 +++ .../inventories/node-24.15.0-classes.json | 321 +++++++++++++++++ .../inventories/node-26.8.1-classes.json | 341 ++++++++++++++++++ .../compat/inventories/node-class-diff.json | 39 ++ scripts/node-surface-inventory.mjs | 221 ++++++++++++ 6 files changed, 969 insertions(+) create mode 100644 packages/compiler/compat/inventories/README.md create mode 100644 packages/compiler/compat/inventories/node-24.15.0-classes.json create mode 100644 packages/compiler/compat/inventories/node-26.8.1-classes.json create mode 100644 packages/compiler/compat/inventories/node-class-diff.json create mode 100644 scripts/node-surface-inventory.mjs diff --git a/package.json b/package.json index 6d422a23a..3816d415a 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "test:conformance:node24": "tsx scripts/node-matrix.mjs --target node24", "test:conformance:node26": "tsx scripts/node-matrix.mjs --target node26", "gate:node-matrix": "tsx scripts/node-matrix.mjs", + "inventory:node": "node scripts/node-surface-inventory.mjs", + "inventory:node:check": "node scripts/node-surface-inventory.mjs --check --offline", "test:cache-identity": "node tests/harness/cache-identity.mjs", "test:sandbox": "node scripts/sandbox-test.mjs", "test:sandbox:image": "node scripts/sandbox-image.mjs", diff --git a/packages/compiler/compat/inventories/README.md b/packages/compiler/compat/inventories/README.md new file mode 100644 index 000000000..3db2e0b5b --- /dev/null +++ b/packages/compiler/compat/inventories/README.md @@ -0,0 +1,45 @@ +# Node surface inventories + +The **denominator** behind the compat profiles, derived mechanically from +Node's own generated API database (`all.json`) for every runtime in the +matrix — see `packages/compiler/src/compat/node-matrix.ts`. + +The profiles under `packages/compiler/src/compat/` classify three slices +member by member, which answers *"is this member supported?"* but never *"how +much of Node is that?"*. These files answer the second question: what +documented classes exist, per module, per version, and what changed between +the two majors. Nothing here is classified — an added class is **not** a +support claim in either direction, and a class the compiler will never +implement counts toward the denominator exactly like one it already does. + +| File | What it is | +| --- | --- | +| `node--classes.json` | Module-qualified class list for one runtime, with counts and the pinned source | +| `node-class-diff.json` | The mechanical delta between the matrix runtimes | + +Regenerate with `pnpm inventory:node`; `pnpm inventory:node:check` fails on +drift without touching the network. The generator is +`scripts/node-surface-inventory.mjs`. + +## What is pinned, and what is not committed + +The source URL is pinned by **exact version** — +`https://nodejs.org/docs/v/api/all.json`, never `/latest/` and never +a major alias — because those URLs move under you. Each artifact records the +byte length and SHA-256 of the `all.json` it was derived from, so a silently +republished upstream is detectable rather than assumed away. + +The raw `all.json` downloads are ~8 MB each and are **cached, not committed** +(`node_modules/.cache/node-api/`). The derived inventories are the artifact +that matters — they are what a coverage dashboard consumes, and they are small +enough to read in a diff. Pass `--vendor-raw` if you want the upstream bytes +in-tree as well. + +## Normalization worth knowing + +Node's `name` for a class is prose, not an identifier: bare +(`EventEmitter`), already qualified (`events.EventEmitterAsyncResource`), or +carrying a superclause (`BroadcastChannel extends EventTarget`). The generator +normalizes to one bare identifier owned by the document that defines it. +Without that, a docs edit adding or removing an `extends` clause makes the same +class appear as both an addition and a removal. diff --git a/packages/compiler/compat/inventories/node-24.15.0-classes.json b/packages/compiler/compat/inventories/node-24.15.0-classes.json new file mode 100644 index 000000000..8211ca2ee --- /dev/null +++ b/packages/compiler/compat/inventories/node-24.15.0-classes.json @@ -0,0 +1,321 @@ +{ + "schemaVersion": 1, + "node": "24.15.0", + "source": { + "url": "https://nodejs.org/docs/v24.15.0/api/all.json", + "bytes": 7977263, + "digest": "sha256:ad66cf85ee273e20ede86b6864cc6ecd1ae6d9ea44f2011e75f4427223f7d16c", + "note": "Pinned by EXACT version. The digest is what makes the pin verifiable: a silently republished all.json changes it." + }, + "counts": { + "modules": 41, + "classes": 262 + }, + "modules": [ + "assert", + "async_context", + "async_hooks", + "buffer", + "child_process", + "cluster", + "console", + "crypto", + "dgram", + "diagnostics_channel", + "dns", + "domain", + "errors", + "events", + "fs", + "globals", + "http", + "http2", + "https", + "inspector", + "module", + "net", + "perf_hooks", + "readline", + "repl", + "sqlite", + "stream", + "string_decoder", + "test", + "timers", + "tls", + "tty", + "url", + "util", + "v8", + "vm", + "wasi", + "webcrypto", + "webstreams", + "worker_threads", + "zlib" + ], + "classes": [ + "assert.Assert", + "assert.AssertionError", + "assert.CallTracker", + "async_context.AsyncLocalStorage", + "async_context.AsyncResource", + "async_hooks.AsyncHook", + "async_hooks.AsyncLocalStorage", + "async_hooks.AsyncResource", + "buffer.Blob", + "buffer.Buffer", + "buffer.File", + "buffer.SlowBuffer", + "child_process.ChildProcess", + "cluster.Worker", + "console.Console", + "crypto.Certificate", + "crypto.Cipheriv", + "crypto.Decipheriv", + "crypto.DiffieHellman", + "crypto.DiffieHellmanGroup", + "crypto.ECDH", + "crypto.Hash", + "crypto.Hmac", + "crypto.KeyObject", + "crypto.Sign", + "crypto.Verify", + "crypto.X509Certificate", + "dgram.Socket", + "diagnostics_channel.Channel", + "diagnostics_channel.TracingChannel", + "dns.Resolver", + "domain.Domain", + "errors.AssertionError", + "errors.Error", + "errors.RangeError", + "errors.ReferenceError", + "errors.SyntaxError", + "errors.SystemError", + "errors.TypeError", + "events.CustomEvent", + "events.Event", + "events.EventEmitter", + "events.EventEmitterAsyncResource", + "events.EventTarget", + "events.NodeEventTarget", + "fs.Dir", + "fs.Dirent", + "fs.FSWatcher", + "fs.FileHandle", + "fs.ReadStream", + "fs.StatFs", + "fs.StatWatcher", + "fs.Stats", + "fs.Utf8Stream", + "fs.WriteStream", + "globals.AbortController", + "globals.AbortSignal", + "globals.Blob", + "globals.BroadcastChannel", + "globals.Buffer", + "globals.ByteLengthQueuingStrategy", + "globals.CloseEvent", + "globals.CompressionStream", + "globals.CountQueuingStrategy", + "globals.Crypto", + "globals.CryptoKey", + "globals.CustomEvent", + "globals.DOMException", + "globals.DecompressionStream", + "globals.Event", + "globals.EventSource", + "globals.EventTarget", + "globals.File", + "globals.FormData", + "globals.Headers", + "globals.MessageChannel", + "globals.MessageEvent", + "globals.MessagePort", + "globals.Navigator", + "globals.PerformanceEntry", + "globals.PerformanceMark", + "globals.PerformanceMeasure", + "globals.PerformanceObserver", + "globals.PerformanceObserverEntryList", + "globals.PerformanceResourceTiming", + "globals.ReadableByteStreamController", + "globals.ReadableStream", + "globals.ReadableStreamBYOBReader", + "globals.ReadableStreamBYOBRequest", + "globals.ReadableStreamDefaultController", + "globals.ReadableStreamDefaultReader", + "globals.Request", + "globals.Response", + "globals.Storage", + "globals.SubtleCrypto", + "globals.TextDecoder", + "globals.TextDecoderStream", + "globals.TextEncoder", + "globals.TextEncoderStream", + "globals.TransformStream", + "globals.TransformStreamDefaultController", + "globals.URL", + "globals.URLPattern", + "globals.URLSearchParams", + "globals.WebAssembly", + "globals.WebSocket", + "globals.WritableStream", + "globals.WritableStreamDefaultController", + "globals.WritableStreamDefaultWriter", + "http.Agent", + "http.ClientRequest", + "http.IncomingMessage", + "http.OutgoingMessage", + "http.Server", + "http.ServerResponse", + "http.WebSocket", + "http2.ClientHttp2Session", + "http2.ClientHttp2Stream", + "http2.Http2SecureServer", + "http2.Http2Server", + "http2.Http2ServerRequest", + "http2.Http2ServerResponse", + "http2.Http2Session", + "http2.Http2Stream", + "http2.ServerHttp2Session", + "http2.ServerHttp2Stream", + "https.Agent", + "https.Server", + "inspector.Session", + "module.SourceMap", + "net.BlockList", + "net.Server", + "net.Socket", + "net.SocketAddress", + "perf_hooks.Histogram", + "perf_hooks.IntervalHistogram", + "perf_hooks.PerformanceEntry", + "perf_hooks.PerformanceMark", + "perf_hooks.PerformanceMeasure", + "perf_hooks.PerformanceNodeEntry", + "perf_hooks.PerformanceNodeTiming", + "perf_hooks.PerformanceObserver", + "perf_hooks.PerformanceObserverEntryList", + "perf_hooks.PerformanceResourceTiming", + "perf_hooks.RecordableHistogram", + "readline.Interface", + "readline.InterfaceConstructor", + "readline.Readline", + "repl.REPLServer", + "sqlite.DatabaseSync", + "sqlite.SQLTagStore", + "sqlite.Session", + "sqlite.StatementSync", + "stream.Duplex", + "stream.PassThrough", + "stream.Readable", + "stream.Transform", + "stream.Writable", + "string_decoder.StringDecoder", + "test.MockFunctionContext", + "test.MockModuleContext", + "test.MockPropertyContext", + "test.MockTimers", + "test.MockTracker", + "test.SuiteContext", + "test.TestContext", + "test.TestsStream", + "timers.Immediate", + "timers.Timeout", + "tls.Server", + "tls.TLSSocket", + "tty.ReadStream", + "tty.WriteStream", + "url.URL", + "url.URLPattern", + "url.URLSearchParams", + "util.MIMEParams", + "util.MIMEType", + "util.TextDecoder", + "util.TextEncoder", + "v8.CPUProfileHandle", + "v8.DefaultDeserializer", + "v8.DefaultSerializer", + "v8.Deserializer", + "v8.GCProfiler", + "v8.HeapProfileHandle", + "v8.Serializer", + "v8.SyncCPUProfileHandle", + "vm.Module", + "vm.Script", + "vm.SourceTextModule", + "vm.SyntheticModule", + "wasi.WASI", + "webcrypto.AeadParams", + "webcrypto.AesCbcParams", + "webcrypto.AesCtrParams", + "webcrypto.AesDerivedKeyParams", + "webcrypto.AesKeyAlgorithm", + "webcrypto.AesKeyGenParams", + "webcrypto.Algorithm", + "webcrypto.Argon2Params", + "webcrypto.CShakeParams", + "webcrypto.ContextParams", + "webcrypto.Crypto", + "webcrypto.CryptoKey", + "webcrypto.CryptoKeyPair", + "webcrypto.EcKeyAlgorithm", + "webcrypto.EcKeyGenParams", + "webcrypto.EcKeyImportParams", + "webcrypto.EcdhKeyDeriveParams", + "webcrypto.EcdsaParams", + "webcrypto.EncapsulatedBits", + "webcrypto.EncapsulatedKey", + "webcrypto.HkdfParams", + "webcrypto.HmacImportParams", + "webcrypto.HmacKeyAlgorithm", + "webcrypto.HmacKeyGenParams", + "webcrypto.KeyAlgorithm", + "webcrypto.KmacImportParams", + "webcrypto.KmacKeyAlgorithm", + "webcrypto.KmacKeyGenParams", + "webcrypto.KmacParams", + "webcrypto.Pbkdf2Params", + "webcrypto.RsaHashedImportParams", + "webcrypto.RsaHashedKeyAlgorithm", + "webcrypto.RsaHashedKeyGenParams", + "webcrypto.RsaOaepParams", + "webcrypto.RsaPssParams", + "webcrypto.SubtleCrypto", + "webstreams.ByteLengthQueuingStrategy", + "webstreams.CompressionStream", + "webstreams.CountQueuingStrategy", + "webstreams.DecompressionStream", + "webstreams.ReadableByteStreamController", + "webstreams.ReadableStream", + "webstreams.ReadableStreamBYOBReader", + "webstreams.ReadableStreamBYOBRequest", + "webstreams.ReadableStreamDefaultController", + "webstreams.ReadableStreamDefaultReader", + "webstreams.TextDecoderStream", + "webstreams.TextEncoderStream", + "webstreams.TransformStream", + "webstreams.TransformStreamDefaultController", + "webstreams.WritableStream", + "webstreams.WritableStreamDefaultController", + "webstreams.WritableStreamDefaultWriter", + "worker_threads.BroadcastChannel", + "worker_threads.MessageChannel", + "worker_threads.MessagePort", + "worker_threads.Worker", + "zlib.BrotliCompress", + "zlib.BrotliDecompress", + "zlib.Deflate", + "zlib.DeflateRaw", + "zlib.Gunzip", + "zlib.Gzip", + "zlib.Inflate", + "zlib.InflateRaw", + "zlib.Unzip", + "zlib.ZlibBase", + "zlib.ZstdCompress", + "zlib.ZstdDecompress" + ] +} diff --git a/packages/compiler/compat/inventories/node-26.8.1-classes.json b/packages/compiler/compat/inventories/node-26.8.1-classes.json new file mode 100644 index 000000000..b69e3e44f --- /dev/null +++ b/packages/compiler/compat/inventories/node-26.8.1-classes.json @@ -0,0 +1,341 @@ +{ + "schemaVersion": 1, + "node": "26.8.1", + "source": { + "url": "https://nodejs.org/docs/v26.8.1/api/all.json", + "bytes": 8693267, + "digest": "sha256:2c3f985ebc2c0bee9a83776491b01a2975e41684589025ec2d9d8da76d9ab40c", + "note": "Pinned by EXACT version. The digest is what makes the pin verifiable: a silently republished all.json changes it." + }, + "counts": { + "modules": 44, + "classes": 279 + }, + "modules": [ + "assert", + "async_context", + "async_hooks", + "buffer", + "child_process", + "cluster", + "console", + "crypto", + "dgram", + "diagnostics_channel", + "dns", + "domain", + "errors", + "events", + "ffi", + "fs", + "globals", + "http", + "http2", + "https", + "inspector", + "module", + "net", + "perf_hooks", + "readline", + "repl", + "sqlite", + "stream", + "stream_iter", + "string_decoder", + "test", + "timers", + "tls", + "tty", + "url", + "util", + "v8", + "vfs", + "vm", + "wasi", + "webcrypto", + "webstreams", + "worker_threads", + "zlib" + ], + "classes": [ + "assert.Assert", + "assert.AssertionError", + "async_context.AsyncLocalStorage", + "async_context.AsyncResource", + "async_context.RunScope", + "async_hooks.AsyncHook", + "async_hooks.AsyncLocalStorage", + "async_hooks.AsyncResource", + "buffer.Blob", + "buffer.Buffer", + "buffer.File", + "child_process.ChildProcess", + "cluster.Worker", + "console.Console", + "crypto.Certificate", + "crypto.Cipheriv", + "crypto.Decipheriv", + "crypto.DiffieHellman", + "crypto.DiffieHellmanGroup", + "crypto.ECDH", + "crypto.Hash", + "crypto.Hmac", + "crypto.KeyObject", + "crypto.Sign", + "crypto.Verify", + "crypto.X509Certificate", + "dgram.Socket", + "diagnostics_channel.BoundedChannel", + "diagnostics_channel.BoundedChannelScope", + "diagnostics_channel.Channel", + "diagnostics_channel.RunStoresScope", + "diagnostics_channel.TracingChannel", + "dns.Resolver", + "domain.Domain", + "errors.AssertionError", + "errors.Error", + "errors.RangeError", + "errors.ReferenceError", + "errors.SyntaxError", + "errors.SystemError", + "errors.TypeError", + "events.CustomEvent", + "events.Event", + "events.EventEmitter", + "events.EventEmitterAsyncResource", + "events.EventTarget", + "events.NodeEventTarget", + "ffi.DynamicLibrary", + "fs.Dir", + "fs.Dirent", + "fs.FSWatcher", + "fs.FileHandle", + "fs.ReadStream", + "fs.StatFs", + "fs.StatWatcher", + "fs.Stats", + "fs.Utf8Stream", + "fs.WriteStream", + "globals.AbortController", + "globals.AbortSignal", + "globals.Blob", + "globals.BroadcastChannel", + "globals.Buffer", + "globals.ByteLengthQueuingStrategy", + "globals.CloseEvent", + "globals.CompressionStream", + "globals.CountQueuingStrategy", + "globals.Crypto", + "globals.CryptoKey", + "globals.CustomEvent", + "globals.DOMException", + "globals.DecompressionStream", + "globals.Event", + "globals.EventSource", + "globals.EventTarget", + "globals.File", + "globals.FormData", + "globals.Headers", + "globals.MessageChannel", + "globals.MessageEvent", + "globals.MessagePort", + "globals.Navigator", + "globals.PerformanceEntry", + "globals.PerformanceMark", + "globals.PerformanceMeasure", + "globals.PerformanceObserver", + "globals.PerformanceObserverEntryList", + "globals.PerformanceResourceTiming", + "globals.QuotaExceededError", + "globals.ReadableByteStreamController", + "globals.ReadableStream", + "globals.ReadableStreamBYOBReader", + "globals.ReadableStreamBYOBRequest", + "globals.ReadableStreamDefaultController", + "globals.ReadableStreamDefaultReader", + "globals.Request", + "globals.Response", + "globals.Storage", + "globals.SubtleCrypto", + "globals.TextDecoder", + "globals.TextDecoderStream", + "globals.TextEncoder", + "globals.TextEncoderStream", + "globals.TransformStream", + "globals.TransformStreamDefaultController", + "globals.URL", + "globals.URLPattern", + "globals.URLSearchParams", + "globals.WebAssembly", + "globals.WebSocket", + "globals.WritableStream", + "globals.WritableStreamDefaultController", + "globals.WritableStreamDefaultWriter", + "http.Agent", + "http.ClientRequest", + "http.IncomingMessage", + "http.OutgoingMessage", + "http.Server", + "http.ServerResponse", + "http.WebSocket", + "http2.ClientHttp2Session", + "http2.ClientHttp2Stream", + "http2.Http2SecureServer", + "http2.Http2Server", + "http2.Http2ServerRequest", + "http2.Http2ServerResponse", + "http2.Http2Session", + "http2.Http2Stream", + "http2.ServerHttp2Session", + "http2.ServerHttp2Stream", + "https.Agent", + "https.Server", + "inspector.Session", + "module.SourceMap", + "net.BlockList", + "net.BoundSocket", + "net.Server", + "net.Socket", + "net.SocketAddress", + "perf_hooks.ELDHistogram", + "perf_hooks.Histogram", + "perf_hooks.PerformanceEntry", + "perf_hooks.PerformanceMark", + "perf_hooks.PerformanceMeasure", + "perf_hooks.PerformanceNodeEntry", + "perf_hooks.PerformanceNodeTiming", + "perf_hooks.PerformanceObserver", + "perf_hooks.PerformanceObserverEntryList", + "perf_hooks.PerformanceResourceTiming", + "perf_hooks.RecordableHistogram", + "readline.Interface", + "readline.InterfaceConstructor", + "readline.Readline", + "repl.REPLServer", + "sqlite.DatabaseSync", + "sqlite.SQLTagStore", + "sqlite.Session", + "sqlite.StatementSync", + "stream.Duplex", + "stream.PassThrough", + "stream.Readable", + "stream.Transform", + "stream.Writable", + "stream_iter.Share", + "stream_iter.SyncShare", + "string_decoder.StringDecoder", + "test.MockFunctionContext", + "test.MockModuleContext", + "test.MockPropertyContext", + "test.MockTimers", + "test.MockTracker", + "test.SuiteContext", + "test.TestContext", + "test.TestsStream", + "timers.Immediate", + "timers.Timeout", + "tls.Server", + "tls.TLSSocket", + "tty.ReadStream", + "tty.WriteStream", + "url.URL", + "url.URLPattern", + "url.URLSearchParams", + "util.MIMEParams", + "util.MIMEType", + "util.TextDecoder", + "util.TextEncoder", + "v8.CPUProfileHandle", + "v8.DefaultDeserializer", + "v8.DefaultSerializer", + "v8.Deserializer", + "v8.GCProfiler", + "v8.HeapProfileHandle", + "v8.Serializer", + "v8.SyncCPUProfileHandle", + "v8.SyncHeapProfileHandle", + "vfs.MemoryProvider", + "vfs.RealFSProvider", + "vfs.VirtualFileSystem", + "vfs.VirtualProvider", + "vm.Module", + "vm.Script", + "vm.SourceTextModule", + "vm.SyntheticModule", + "wasi.WASI", + "webcrypto.AeadParams", + "webcrypto.AesCbcParams", + "webcrypto.AesCtrParams", + "webcrypto.AesDerivedKeyParams", + "webcrypto.AesKeyAlgorithm", + "webcrypto.AesKeyGenParams", + "webcrypto.Algorithm", + "webcrypto.Argon2Params", + "webcrypto.CShakeParams", + "webcrypto.ContextParams", + "webcrypto.Crypto", + "webcrypto.CryptoKey", + "webcrypto.CryptoKeyPair", + "webcrypto.EcKeyAlgorithm", + "webcrypto.EcKeyGenParams", + "webcrypto.EcKeyImportParams", + "webcrypto.EcdhKeyDeriveParams", + "webcrypto.EcdsaParams", + "webcrypto.EncapsulatedBits", + "webcrypto.EncapsulatedKey", + "webcrypto.HkdfParams", + "webcrypto.HmacImportParams", + "webcrypto.HmacKeyAlgorithm", + "webcrypto.HmacKeyGenParams", + "webcrypto.KangarooTwelveParams", + "webcrypto.KeyAlgorithm", + "webcrypto.KmacImportParams", + "webcrypto.KmacKeyAlgorithm", + "webcrypto.KmacKeyGenParams", + "webcrypto.KmacParams", + "webcrypto.Pbkdf2Params", + "webcrypto.RsaHashedImportParams", + "webcrypto.RsaHashedKeyAlgorithm", + "webcrypto.RsaHashedKeyGenParams", + "webcrypto.RsaOaepParams", + "webcrypto.RsaPssParams", + "webcrypto.SubtleCrypto", + "webcrypto.TurboShakeParams", + "webstreams.ByteLengthQueuingStrategy", + "webstreams.CompressionStream", + "webstreams.CountQueuingStrategy", + "webstreams.DecompressionStream", + "webstreams.ReadableByteStreamController", + "webstreams.ReadableStream", + "webstreams.ReadableStreamBYOBReader", + "webstreams.ReadableStreamBYOBRequest", + "webstreams.ReadableStreamDefaultController", + "webstreams.ReadableStreamDefaultReader", + "webstreams.TextDecoderStream", + "webstreams.TextEncoderStream", + "webstreams.TransformStream", + "webstreams.TransformStreamDefaultController", + "webstreams.WritableStream", + "webstreams.WritableStreamDefaultController", + "webstreams.WritableStreamDefaultWriter", + "worker_threads.BroadcastChannel", + "worker_threads.MessageChannel", + "worker_threads.MessagePort", + "worker_threads.Worker", + "zlib.BrotliCompress", + "zlib.BrotliDecompress", + "zlib.Deflate", + "zlib.DeflateRaw", + "zlib.Gunzip", + "zlib.Gzip", + "zlib.Inflate", + "zlib.InflateRaw", + "zlib.Unzip", + "zlib.ZipBuffer", + "zlib.ZipEntry", + "zlib.ZipFile", + "zlib.ZlibBase", + "zlib.ZstdCompress", + "zlib.ZstdDecompress" + ] +} diff --git a/packages/compiler/compat/inventories/node-class-diff.json b/packages/compiler/compat/inventories/node-class-diff.json new file mode 100644 index 000000000..b8157a540 --- /dev/null +++ b/packages/compiler/compat/inventories/node-class-diff.json @@ -0,0 +1,39 @@ +{ + "schemaVersion": 1, + "from": "24.15.0", + "to": "26.8.1", + "note": "The mechanical delta between the two matrix runtimes' documented class surfaces, derived from Node's own all.json. It is a DENOMINATOR artifact: nothing here is classified, and an added class is not a support claim in either direction. A class that moved modules appears once in each list.", + "counts": { + "24.15.0": 262, + "26.8.1": 279, + "added": 20, + "removed": 3 + }, + "added": [ + "async_context.RunScope", + "diagnostics_channel.BoundedChannel", + "diagnostics_channel.BoundedChannelScope", + "diagnostics_channel.RunStoresScope", + "ffi.DynamicLibrary", + "globals.QuotaExceededError", + "net.BoundSocket", + "perf_hooks.ELDHistogram", + "stream_iter.Share", + "stream_iter.SyncShare", + "v8.SyncHeapProfileHandle", + "vfs.MemoryProvider", + "vfs.RealFSProvider", + "vfs.VirtualFileSystem", + "vfs.VirtualProvider", + "webcrypto.KangarooTwelveParams", + "webcrypto.TurboShakeParams", + "zlib.ZipBuffer", + "zlib.ZipEntry", + "zlib.ZipFile" + ], + "removed": [ + "assert.CallTracker", + "buffer.SlowBuffer", + "perf_hooks.IntervalHistogram" + ] +} diff --git a/scripts/node-surface-inventory.mjs b/scripts/node-surface-inventory.mjs new file mode 100644 index 000000000..5fce7379b --- /dev/null +++ b/scripts/node-surface-inventory.mjs @@ -0,0 +1,221 @@ +#!/usr/bin/env node +// Ingests Node's own API documentation database (all.json) for every runtime +// in the compat matrix and emits the DENOMINATOR: the module-qualified class +// list per version, plus the mechanical 24 → 26 diff. +// +// Why this exists: the compat profiles classify three slices (fetch, URL, +// EventEmitter) member by member, which answers "is this member supported?" +// but never "how much of Node is that?". Without a denominator the coverage +// story is a numerator with nothing under the line. all.json is Node's own +// generated inventory of its documented surface, so deriving the denominator +// from it is mechanical rather than a judgement call — and re-deriving it for +// a second major turns "Node 26 support" into a countable claim. +// +// This does NOT classify anything. Deciding what each class means for the +// compiler is profile work; this script only establishes what there is. +// +// The URLs move. nodejs.org/docs/v/api/all.json is pinned by +// EXACT version (never /latest/, never a major alias), and both the byte +// length and the SHA-256 of what was fetched are recorded in the emitted +// artifact, so a silently changed upstream is detectable rather than assumed +// away. The raw downloads are ~8 MB each and are cached under +// node_modules/.cache/node-api/ rather than committed; pass --vendor-raw to +// write them into the inventory directory too if you want the bytes in-tree. +// +// Usage: +// node scripts/node-surface-inventory.mjs regenerate the artifacts +// node scripts/node-surface-inventory.mjs --check fail on drift, write nothing +// node scripts/node-surface-inventory.mjs --offline use only the cache +// node scripts/node-surface-inventory.mjs --vendor-raw also vendor all.json +// +// Emits, under packages/compiler/compat/inventories/: +// node--classes.json the per-version denominator +// node-class-diff.json the mechanical delta between the two majors +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = fileURLToPath(new URL("..", import.meta.url)); +const inventoryDir = join(repoRoot, "packages/compiler/compat/inventories"); +const cacheDir = join(repoRoot, "node_modules/.cache/node-api"); + +const flags = new Set(process.argv.slice(2)); +const check = flags.has("--check"); +const offline = flags.has("--offline"); +const vendorRaw = flags.has("--vendor-raw"); +for (const flag of flags) { + if (!["--check", "--offline", "--vendor-raw"].includes(flag)) { + console.error(`unknown flag '${flag}'`); + process.exit(2); + } +} + +// The matrix, read as source text rather than imported: this script runs +// under a bare `node` with no loader, and duplicating two version strings is +// a worse trade than a build step. The parse is asserted, so a rename in +// node-matrix.ts fails loudly here instead of silently inventorying the +// wrong runtimes. +const matrixSource = readFileSync( + join(repoRoot, "packages/compiler/src/compat/node-matrix.ts"), + "utf8", +); +function matrixVersion(constant) { + const match = new RegExp(`${constant}\\s*=\\s*"([^"]+)"`).exec(matrixSource); + if (match === null) { + throw new Error( + `${constant} not found in compat/node-matrix.ts — the matrix moved and this script must follow it`, + ); + } + return match[1]; +} +const versions = [matrixVersion("NODE24_VERSION"), matrixVersion("NODE26_VERSION")]; + +/** + * Node's `name` for a class is prose, not an identifier: it may be bare + * ("EventEmitter"), already module-qualified + * ("events.EventEmitterAsyncResource"), or carry a superclause + * ("BroadcastChannel extends EventTarget"). Normalizing to one bare + * identifier is what makes the two versions comparable — without it the same + * class appears as an add AND a remove whenever the docs gain or lose an + * `extends` clause, which is a documentation edit rather than API surface. + */ +function bareClassName(name) { + const withoutSuper = name.split(/\s+extends\s+/)[0].trim(); + return withoutSuper.includes(".") + ? withoutSuper.slice(withoutSuper.lastIndexOf(".") + 1) + : withoutSuper; +} + +/** Node's doc database nests classes inside modules inside modules; the + * `source` field ("doc/api/events.md") is the stable module qualifier. An + * entry carries its own where it has one and inherits its parent's + * otherwise, so a class is attributed to the document that defines it. */ +function collectClasses(node, source, out) { + const currentSource = typeof node.source === "string" ? node.source : source; + for (const entry of node.classes ?? []) { + const entrySource = typeof entry.source === "string" ? entry.source : currentSource; + const owner = entrySource === undefined + ? "(unattributed)" + : entrySource.replace(/^doc\/api\//, "").replace(/\.md$/, ""); + out.add(`${owner}.${bareClassName(entry.name)}`); + collectClasses(entry, entrySource, out); + } + for (const entry of node.modules ?? []) collectClasses(entry, currentSource, out); + for (const entry of node.miscs ?? []) collectClasses(entry, currentSource, out); +} + +async function loadAllJson(version) { + const url = `https://nodejs.org/docs/v${version}/api/all.json`; + const cached = join(cacheDir, `v${version}-all.json`); + let bytes; + if (existsSync(cached)) { + bytes = readFileSync(cached); + } else { + if (offline) throw new Error(`--offline but ${cached} is not cached`); + process.stderr.write(`fetching ${url}\n`); + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `${url} responded ${response.status} — the docs URL scheme may have moved; ` + + `pin the new one here rather than falling back to a major alias`, + ); + } + bytes = Buffer.from(await response.arrayBuffer()); + mkdirSync(cacheDir, { recursive: true }); + writeFileSync(cached, bytes); + } + return { + url, + bytes, + digest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`, + document: JSON.parse(bytes.toString("utf8")), + }; +} + +/** Byte-deterministic rendering: sorted keys, two-space indent, trailing + * newline — so --check is a plain string comparison. */ +const render = (value) => `${JSON.stringify(value, null, 2)}\n`; + +const written = []; +function emit(name, value) { + const path = join(inventoryDir, name); + const rendered = render(value); + if (check) { + const current = existsSync(path) ? readFileSync(path, "utf8") : null; + if (current !== rendered) { + console.error( + `${name} is stale — run 'node scripts/node-surface-inventory.mjs' and commit the result`, + ); + process.exitCode = 1; + } + return; + } + mkdirSync(inventoryDir, { recursive: true }); + writeFileSync(path, rendered); + written.push(name); +} + +const inventories = new Map(); +for (const version of versions) { + const { url, bytes, digest, document } = await loadAllJson(version); + const classes = new Set(); + collectClasses(document, undefined, classes); + const sorted = [...classes].sort(); + const modules = [...new Set(sorted.map((name) => name.slice(0, name.lastIndexOf("."))))].sort(); + inventories.set(version, sorted); + + emit(`node-${version}-classes.json`, { + schemaVersion: 1, + node: version, + source: { + url, + bytes: bytes.length, + digest, + note: + "Pinned by EXACT version. The digest is what makes the pin verifiable: " + + "a silently republished all.json changes it.", + }, + counts: { modules: modules.length, classes: sorted.length }, + modules, + classes: sorted, + }); + + if (vendorRaw) emit(`node-${version}-all.json`, document); +} + +const [older, newer] = versions; +const before = new Set(inventories.get(older)); +const after = new Set(inventories.get(newer)); +const added = [...after].filter((name) => !before.has(name)).sort(); +const removed = [...before].filter((name) => !after.has(name)).sort(); + +emit("node-class-diff.json", { + schemaVersion: 1, + from: older, + to: newer, + note: + "The mechanical delta between the two matrix runtimes' documented class " + + "surfaces, derived from Node's own all.json. It is a DENOMINATOR artifact: " + + "nothing here is classified, and an added class is not a support claim in " + + "either direction. A class that moved modules appears once in each list.", + counts: { + [older]: before.size, + [newer]: after.size, + added: added.length, + removed: removed.length, + }, + added, + removed, +}); + +if (check) { + if (process.exitCode === 1) process.exit(1); + console.log("node surface inventories are up to date"); +} else { + console.log(`wrote ${written.join(", ")}`); + console.log( + `Node ${older}: ${before.size} classes; Node ${newer}: ${after.size} classes ` + + `(+${added.length} / -${removed.length})`, + ); +} From 7b0077e058315cfca71cdd2125d255cfbd0ada38 Mon Sep 17 00:00:00 2001 From: filipeforattini Date: Tue, 1 Sep 2026 19:56:30 -0300 Subject: [PATCH 6/6] test: pin the oracle cache key and interpreter resolution to the matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit oracle-environment.ts already asked the oracle for its own version rather than trusting process.version, so the cache key was correct across majors — but nothing tested it, and "correct by inspection" is what the matrix work exists to stop accepting. Four regressions now pin the host/oracle split: - the cache key separates the two matrix majors (and two patches of the same major): a colliding key would serve one runtime's recorded stdout to the other and call it parity; - every declared target resolves to an interpreter that REPORTS that exact version — the check that catches a moved mise symlink turning the node26 lane into a second node24 lane; - a wrong-version SCRIPTC_NODE_ override raises instead of silently falling through to another candidate; - the differential oracle defaults to the matrix primary, not the host, with SCRIPTC_NODE_ORACLE still winning. Sampled the differential lanes against a Node 26 oracle across both native backends — 1355-url-parse and 2854-url-pathname-setter (rust), 1654-ee-namespace (rust), 1644-ee-basics and 1794-searchparams-url-live (c/llvm) — all green. The only semantic divergence found anywhere in this work remains AbortSignal.any's reworded ERR_INVALID_ARG_TYPE. Claude-Session: https://claude.ai/code/session_0197JoEpMBBqqkSiBb2vNX5A --- tests/harness/oracle-environment.test.ts | 71 ++++++++++++++++++++++++ tests/harness/oracle-environment.ts | 11 +++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/tests/harness/oracle-environment.test.ts b/tests/harness/oracle-environment.test.ts index 0eb47e1fc..dab3251c4 100644 --- a/tests/harness/oracle-environment.test.ts +++ b/tests/harness/oracle-environment.test.ts @@ -1,9 +1,16 @@ import { expect, test } from "vitest"; +import { NODE_COMPAT_MATRIX, compatTargetList } from "@scriptc/compiler"; import { nodeOracleExecutable, oracleCacheKeyBase, oracleEnvironmentFingerprint, } from "./oracle-environment.js"; +import { + interpreterVersion, + matrixExecutableVariable, + primaryOracleExecutable, + resolveMatrixExecutable, +} from "./node-matrix.js"; test("Node oracle defaults to the test host executable", () => { expect(nodeOracleExecutable({}, "/opt/node-host/bin/node")).toBe("/opt/node-host/bin/node"); @@ -82,3 +89,67 @@ test("oracle cache key invalidates when the transform-types hook changes", () => oracleCacheKeyBase({ ...inputs, transformTypesHook: "second" }), ); }); + +// ── the compat matrix: host vs oracle ─────────────────────────────────── +// The matrix makes the host/oracle split load-bearing rather than +// theoretical. These pin the two halves of it. + +test("oracle cache key separates the two matrix majors", () => { + // The cached verdict is Node's answer for a program, and the two majors + // do not always give the same answer (Node 26 rewords AbortSignal.any's + // ERR_INVALID_ARG_TYPE). A key that collided across majors would serve + // one major's recorded stdout to the other and call it parity. + const inputs = { + typescriptVersion: "5.9.0", + comptimeShim: "comptime", + islandShim: "island", + transformTypesHook: "transform-types", + environment: {}, + cwd: "/repo", + }; + const [primary, candidate] = compatTargetList(NODE_COMPAT_MATRIX); + expect(primary!.node).not.toBe(candidate!.node); + expect(oracleCacheKeyBase({ ...inputs, nodeVersion: `v${primary!.node}` })).not.toBe( + oracleCacheKeyBase({ ...inputs, nodeVersion: `v${candidate!.node}` }), + ); + // Down to a patch, not just a major: a rewording can land in either. + expect(oracleCacheKeyBase({ ...inputs, nodeVersion: "v26.8.1" })).not.toBe( + oracleCacheKeyBase({ ...inputs, nodeVersion: "v26.8.0" }), + ); +}); + +test("every matrix target resolves to an interpreter of that exact version", () => { + // The gate's whole claim is that each lane ran under the runtime it says + // it did. Resolution therefore VERIFIES rather than trusts a path — this + // is what catches a moved mise symlink. + for (const target of compatTargetList(NODE_COMPAT_MATRIX)) { + const executable = resolveMatrixExecutable(target); + expect(interpreterVersion(executable), `${target.id}: ${executable}`).toBe(target.node); + } +}); + +test("a wrong-version target override is an error, not a silent fallback", () => { + const [primary, candidate] = compatTargetList(NODE_COMPAT_MATRIX); + const wrong = resolveMatrixExecutable(candidate!); + expect(() => + resolveMatrixExecutable(primary!, { [matrixExecutableVariable(primary!)]: wrong }), + ).toThrow(/reports Node/); +}); + +test("the differential oracle is the matrix primary, not the host", () => { + // The census follows the host; the semantic oracle does not. Under the + // Node 26 lane this is the line that keeps the fetch differential + // comparing against the one Node whose behavior the native runtime + // reproduces. + const [primary] = compatTargetList(NODE_COMPAT_MATRIX); + expect(interpreterVersion(primaryOracleExecutable(NODE_COMPAT_MATRIX, {}))).toBe( + primary!.node, + ); + // An explicit override still wins: that is how you go looking for a + // divergence on purpose. + expect( + primaryOracleExecutable(NODE_COMPAT_MATRIX, { + SCRIPTC_NODE_ORACLE: "/opt/node26/bin/node", + }), + ).toBe("/opt/node26/bin/node"); +}); diff --git a/tests/harness/oracle-environment.ts b/tests/harness/oracle-environment.ts index 5d41fe890..ff590a5d0 100644 --- a/tests/harness/oracle-environment.ts +++ b/tests/harness/oracle-environment.ts @@ -3,7 +3,16 @@ import { createHash } from "node:crypto"; const nativeTransformTypes = new Map(); -/** The Node executable used as the differential oracle. */ +/** + * The Node executable used as the differential oracle, defaulting to the + * host. + * + * A check whose answer is version-dependent — anything that compares + * compiled output against Node's, message text included — should call + * `primaryOracleExecutable` in node-matrix.ts instead, which pins the + * oracle to the compat matrix's primary. This function's host default is + * correct only where the host IS the intended oracle. + */ export function nodeOracleExecutable( env: NodeJS.ProcessEnv = process.env, hostExecutable: string = process.execPath,