diff --git a/tests/harness/differential.test.ts b/tests/harness/differential.test.ts index 0cc7ef5f2..b46e6bebe 100644 --- a/tests/harness/differential.test.ts +++ b/tests/harness/differential.test.ts @@ -17,6 +17,7 @@ import { promisify } from "node:util"; import { describe, expect, test } from "vitest"; import ts5 from "typescript"; import { compile } from "@scriptc/compiler"; +import { oracleCacheKeyBase } from "./oracle-environment.js"; import { shardSelect, shardSuffix } from "./shard.js"; const execFileAsync = promisify(execFile); @@ -223,8 +224,8 @@ function programInputs(file: string): string[] { // Node's verdict for a corpus program is a pure function of the program bytes, // the shims, and the Node build (corpus stdout is deterministic by // construction — it must match a non-Node native binary byte-for-byte). So -// cache it, keyed by all of those plus the invocation shape (SCRIPTC_TEST_ENV -// and the cwd). Only the SPAWN is skipped: the native side always runs live +// cache it, keyed by all of those plus the invocation shape (the complete +// inherited environment and the cwd). Only the SPAWN is skipped: the native side always runs live // and the comparison itself never changes. SCRIPTC_NO_CACHE=1 (or an unset // SCRIPTC_CACHE_DIR) disables the cache in both directions — no reads, no writes. // Storage shares the compile cache's root and its LRU sweep (see cc.ts). @@ -239,17 +240,16 @@ function oracleKeyBase(): Promise { // The spawned `node` comes from PATH, so ask IT for its version rather than // trusting process.version (vitest's own node could differ). oracleKeyBaseMemo ??= execFileAsync("node", ["--version"]).then(({ stdout }) => - createHash("sha256") - .update("oracle-v1\0") - .update(stdout.trim()).update("\0") + oracleCacheKeyBase({ + nodeVersion: stdout.trim(), // Decorator programs run tsc's downlevel on the Node side — its // emitter version is part of the verdict. - .update(ts5.version).update("\0") - .update(readFileSync(fileURLToPath(comptimeShim))).update("\0") - .update(readFileSync(fileURLToPath(islandShim))).update("\0") - .update(process.env["SCRIPTC_TEST_ENV"] ?? "").update("\0") - .update(process.cwd()).update("\0") - .digest("hex"), + typescriptVersion: ts5.version, + comptimeShim: readFileSync(fileURLToPath(comptimeShim), "utf8"), + islandShim: readFileSync(fileURLToPath(islandShim), "utf8"), + environment: process.env, + cwd: process.cwd(), + }), ); return oracleKeyBaseMemo; } diff --git a/tests/harness/oracle-environment.test.ts b/tests/harness/oracle-environment.test.ts new file mode 100644 index 000000000..92327f774 --- /dev/null +++ b/tests/harness/oracle-environment.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "vitest"; +import { oracleCacheKeyBase, oracleEnvironmentFingerprint } from "./oracle-environment.js"; + +test("oracle environment fingerprint covers arbitrary output-affecting variables", () => { + const base = oracleEnvironmentFingerprint({ NODE_ENV: "development", SCRIPTC_NEVER: "no" }); + + expect(oracleEnvironmentFingerprint({ NODE_ENV: "production", SCRIPTC_NEVER: "no" })).not.toBe(base); + expect(oracleEnvironmentFingerprint({ NODE_ENV: "development", SCRIPTC_NEVER: "yes" })).not.toBe(base); + expect(oracleEnvironmentFingerprint({ NODE_ENV: "development", SCRIPTC_NEVER: "no", EXTRA: "value" })).not.toBe(base); +}); + +test("oracle environment fingerprint is independent of insertion order", () => { + expect(oracleEnvironmentFingerprint({ NODE_ENV: "production", PATH: "/bin", EMPTY: "" })).toBe( + oracleEnvironmentFingerprint({ EMPTY: "", PATH: "/bin", NODE_ENV: "production" }), + ); +}); + +test("oracle environment fingerprint distinguishes missing, unset, and empty variables", () => { + expect(oracleEnvironmentFingerprint({})).not.toBe(oracleEnvironmentFingerprint({ VALUE: undefined })); + expect(oracleEnvironmentFingerprint({ VALUE: undefined })).not.toBe( + oracleEnvironmentFingerprint({ VALUE: "" }), + ); +}); + +test("oracle environment fingerprint length-frames keys and values", () => { + expect(oracleEnvironmentFingerprint({ "A:B": "C;D" })).not.toBe( + oracleEnvironmentFingerprint({ A: "B:C;D" }), + ); +}); + +test("oracle cache key invalidates when corpus output-affecting variables change", () => { + const inputs = { + nodeVersion: "v24.0.0", + typescriptVersion: "5.9.0", + comptimeShim: "comptime", + islandShim: "island", + cwd: "/repo", + }; + const base = oracleCacheKeyBase({ + ...inputs, + environment: { NODE_ENV: "development", SCRIPTC_NEVER: "no" }, + }); + + expect(oracleCacheKeyBase({ ...inputs, environment: { NODE_ENV: "production", SCRIPTC_NEVER: "no" } })).not.toBe(base); + expect(oracleCacheKeyBase({ ...inputs, environment: { NODE_ENV: "development", SCRIPTC_NEVER: "yes" } })).not.toBe(base); +}); diff --git a/tests/harness/oracle-environment.ts b/tests/harness/oracle-environment.ts new file mode 100644 index 000000000..2486ab58e --- /dev/null +++ b/tests/harness/oracle-environment.ts @@ -0,0 +1,42 @@ +import { createHash } from "node:crypto"; + +/** + * The complete inherited environment visible to the Node oracle. Corpus + * programs may read arbitrary process.env keys directly or through imported + * modules, so an allowlist cannot soundly describe this input. Keys sort by + * UTF-16 code unit for a deterministic order; names and values are + * length-framed so missing, empty, and delimiter-containing entries remain + * distinct. + */ +export function oracleEnvironmentFingerprint(env: NodeJS.ProcessEnv): string { + return Object.keys(env) + .sort((a, b) => a < b ? -1 : a > b ? 1 : 0) + .map((key) => { + const value = env[key]; + const framedValue = value === undefined ? "unset" : `${value.length}:${value}`; + return `${key.length}:${key}:${framedValue};`; + }) + .join(""); +} + +interface OracleCacheKeyBaseInputs { + nodeVersion: string; + typescriptVersion: string; + comptimeShim: string; + islandShim: string; + environment: NodeJS.ProcessEnv; + cwd: string; +} + +/** The shared, testable base of every per-program Node oracle cache key. */ +export function oracleCacheKeyBase(inputs: OracleCacheKeyBaseInputs): string { + return createHash("sha256") + .update("oracle-v3\0") + .update(inputs.nodeVersion).update("\0") + .update(inputs.typescriptVersion).update("\0") + .update(inputs.comptimeShim).update("\0") + .update(inputs.islandShim).update("\0") + .update(oracleEnvironmentFingerprint(inputs.environment)).update("\0") + .update(inputs.cwd).update("\0") + .digest("hex"); +}