Alias a value held from process.env to the live environment

`const env = process.env` compiled the hold to a snapshot record copy of
the environment, so `env.X = "1"` wrote the copy and was lost while Node's
write reached the real environment table (undefined natively, "1" in Node;
`repros/d3-env-write.ts`). Node's held value IS the environment object.

A hold whose type is the env record now aliases the live table for every
member access: dotted and computed reads lower to process.envGet, writes
to process.envSet, `delete` to process.envUnset, and the absence probe
(`env[k] ?? fallback`, `||`, `if`) reads getenv instead of reconstructing
presence over the snapshot. A BARE use of the hold (spread, Object.keys,
argument, return) takes a fresh %env.snapshot at that site, so it observes
the environment as it is there rather than as it was when the binding was
initialized.

Two holds keep copy semantics because they cannot alias safely, and a
write through either is refused with SC2014 rather than lost: a hold that
escapes into another function, and a hold whose declared type is not the
env record. Compound and update writes through a hold (`env.X += v`,
`env.X++`) are refused the same way.

tests/corpus/held-env-value-writes.ts covers writes, computed writes,
deletes, child inheritance, direct writes seen through the hold, a copy
staying a copy, and bare-use re-snapshotting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-29 05:39:20 -07:00
co-authored by Claude Opus 5.5
parent 02d7230c66
commit 3fac2e5766
12 changed files with 314 additions and 9 deletions
+58
View File
@@ -0,0 +1,58 @@
// A value HELD from process.env aliases the live environment, exactly as
// Node's one process.env object does: writes, deletes and reads through the
// hold reach the real environment table (setenv/unsetenv/getenv), so a
// later direct read, `in` test, and a spawned child's inherited environment
// all observe them. A COPY made from the hold stays a copy.
import { spawnSync } from "node:child_process";
const env: Record<string, string | undefined> = process.env;
env.SCRIPTC_HELD_WRITE = "one";
console.log("direct:", process.env.SCRIPTC_HELD_WRITE ?? "(unset)");
console.log("held:", env.SCRIPTC_HELD_WRITE ?? "(unset)");
// A computed key writes the same variable.
const key = "SCRIPTC_HELD_" + "computed";
env[key] = "two";
console.log("computed:", process.env[key] ?? "(unset)", env[key] ?? "(unset)");
// The write reaches children through the inherited environment.
const child = spawnSync("sh", ["-c", 'printf %s "$SCRIPTC_HELD_WRITE"'], { encoding: "utf8" });
console.log("child sees:", child.stdout);
// A write made directly is visible through the hold (one object, not a copy).
process.env.SCRIPTC_HELD_LATE = "late";
console.log("late via hold:", env.SCRIPTC_HELD_LATE ?? "(unset)");
// Deletes through the hold unset the real variable.
delete env.SCRIPTC_HELD_WRITE;
console.log(
"deleted:",
process.env.SCRIPTC_HELD_WRITE === undefined,
env.SCRIPTC_HELD_WRITE === undefined,
"SCRIPTC_HELD_WRITE" in process.env,
);
delete env[key];
console.log("computed deleted:", process.env[key] === undefined, key in process.env);
// An absent key deletes as a no-op, like JS.
delete env.SCRIPTC_HELD_NEVER;
console.log("absent delete:", process.env.SCRIPTC_HELD_NEVER === undefined);
// A COPY of the hold keeps copy semantics: its writes never touch the
// environment, and its own entries stay. The copy sees the environment as
// it is at the spread, including writes made after the hold.
const copy = { ...env };
copy.SCRIPTC_HELD_COPY = "copy-only";
console.log(
"copy:",
copy.SCRIPTC_HELD_COPY,
process.env.SCRIPTC_HELD_COPY === undefined,
copy.SCRIPTC_HELD_LATE ?? "(unset)",
);
// Every other bare use of the hold reads the environment at that point too.
console.log(
"bare:",
Object.keys(env).includes("SCRIPTC_HELD_LATE"),
JSON.stringify({ ...env }).includes('"SCRIPTC_HELD_LATE":"late"'),
);
console.log("done");
@@ -0,0 +1 @@
{ "plain": 1700, "san": 3300 }