Alias held process.env values to the live environment; keep a returned arrow's result in value slots (#2554)

This commit is contained in:
Chris Tate
2026-10-30 02:56:44 -05:00
committed by GitHub
21 changed files with 655 additions and 9 deletions
@@ -61,6 +61,7 @@ export const DIAGNOSTIC_CODES = {
SC2011: "'any'-typed values and their operations",
SC2012: "standard-library form without native lowering",
SC2013: "npm package import or value without native lowering",
SC2014: "write through a value held from process.env",
SC2020: "standard-library or @types/node surface with no lowering",
SC3001: "LLVM IR tier refusal",
SC3002: "target or capability refusal",
@@ -795,6 +795,10 @@ export const FENCE_CODES: Record<string, { name: string; status: "unsupported" }
name: "npm package imports and values without native lowering",
status: "unsupported",
},
SC2014: {
name: "writes through a value held from process.env (a snapshot, not the live table)",
status: "unsupported",
},
SC2020: {
name: "standard-library or @types/node surface with no lowering",
status: "unsupported",
@@ -1141,6 +1145,71 @@ export function computedRequireDiag(surface: string, loc: SrcLoc): ScrDiagnostic
};
}
/** A write through a value HELD from `process.env` (`const env =
* process.env; env.X = "1"`, `delete env[k]`): the held value is a snapshot
* record of the environment, not the live table, so the write would land on
* the copy and be lost while Node's write reaches setenv(3) through the same
* object. Refuse at the write instead of letting it disappear. */
export function heldEnvWriteDiag(loc: SrcLoc): ScrDiagnostic {
return {
code: "SC2014",
message:
"a write through a value held from process.env is not supported: the held value is a snapshot copy of the environment, so the write would be lost while Node writes the real environment",
loc,
milestone: "later",
hint: "read and write the live variable directly (process.env.NAME or process.env[key]), or write into a fresh copy: const env = { ...process.env, NAME: value }",
};
}
/** A BINDING that holds `process.env` under a type which is not the env
* record (`const env: Record<string, string> = process.env as any`, a
* fixed-field annotation, `const { env } = process` with one): the hold is
* a snapshot record taken at the initializer, so its member reads would
* observe the environment as it was then, while Node's hold IS the
* environment object and reads the live table. Refuse where the hold is
* created rather than letting its reads drift silently. */
export function heldEnvHoldDiag(loc: SrcLoc): ScrDiagnostic {
return {
code: "SC2014",
message:
"a value held from process.env under a type other than the environment record (a string-keyed record of string | undefined) is not supported: the hold is a snapshot copy taken where it is created, so its reads would see the environment as it was then while Node reads the live environment",
loc,
milestone: "later",
hint: "hold process.env without changing its type (const env = process.env), read the live table directly (process.env.NAME), or copy the entries you need: const env = { ...process.env, NAME: value }",
};
}
/** A binding initialized from `process.env` that the program reassigns.
* Node's binding holds the environment object until the reassignment; the
* hold here is a snapshot copy, so reads and writes before the reassignment
* would miss the live environment, and a live alias would keep writing it
* after the binding holds something else. */
export function heldEnvReassignedDiag(loc: SrcLoc): ScrDiagnostic {
return {
code: "SC2014",
message:
"a reassigned binding that holds process.env is not supported: Node's binding holds the environment object until it is reassigned, while the hold here is a snapshot copy of the environment",
loc,
milestone: "later",
hint: "hold process.env in a binding that is never reassigned (const env = process.env), or copy the entries you need: let env = { ...process.env }",
};
}
/** An identity comparison (`===`, `==` and their negations) between a value
* held from `process.env` and another object that is not the environment.
* Node compares the environment object itself; the hold here is a snapshot
* record, so the answer could differ from Node's. */
export function heldEnvIdentityDiag(loc: SrcLoc): ScrDiagnostic {
return {
code: "SC2014",
message:
"comparing process.env or a value held from it by identity with another object is not supported: the value here is a snapshot copy of the environment, not the environment object",
loc,
milestone: "later",
hint: "compare the entries you need (env.NAME === other.NAME), or compare two holds of process.env, which are the same object",
};
}
/** Records are monomorphic structs, so a value's shape must match the
* expected shape exactly OR narrow through the width-copy family
* (recordWidthPlan / the overflow capture: target fields copied off the
@@ -1,9 +1,18 @@
import { dynUndefinedExpr } from "../../../ir/build.js";
import * as ts from "../../ts7/adapter.js";
import { type Lowerer } from "../lowerer.js";
import { type Lowerer, PoisonError } from "../lowerer.js";
import { locOf } from "../../program.js";
import {
heldEnvHoldDiag,
heldEnvIdentityDiag,
heldEnvReassignedDiag,
heldEnvWriteDiag,
} from "../../../diagnostics/diagnostic.js";
import { bindingNeverReassigned } from "../binding-analysis.js";
import { constituentTypes } from "../../ts7/checker.js";
import { pairsSnapshotHelper } from "../pairs-snapshot.js";
import {
BOOL,
DYN,
type IrExpr,
type IrType,
@@ -49,6 +58,240 @@ export function isProcessEnv(lowerer: Lowerer, node: ts.Expression): boolean {
);
}
/** Whether `node` is a value HELD from `process.env` through a chain of
* bindings: `const env = process.env`, `const alias = env`, or the
* destructured `const { env } = process`. Node's held value IS the
* environment object. Here the binding holds a snapshot record of it:
* MEMBER accesses (`env.X`, `env[k]`, their writes, `delete env.X`) reroute
* to the live table (see heldEnvValueType), and every BARE use re-snapshots
* the environment at that use (see lowerIdentifierExpr), so both observe
* the environment as Node's single object would.
*
* The ambient `process.env` itself is NOT a hold (depth === 0): it keeps
* its own direct lowering (envGet/envSet intrinsics, its existing snapshot
* when it escapes as a value), which is what Node's reads and writes see. */
export function holdsEnvSnapshot(
lowerer: Lowerer,
node: ts.Expression | undefined,
depth = 0,
): boolean {
if (node === undefined) return false;
let inner: ts.Expression = node;
for (;;) {
if (
ts.isParenthesizedExpression(inner) ||
ts.isAsExpression(inner) ||
ts.isNonNullExpression(inner) ||
ts.isSatisfiesExpression(inner)
) {
inner = inner.expression;
continue;
}
break;
}
// At depth 0 this IS the ambient object, not a value held from it; as a
// binding's initializer (depth >= 1) it is the hold's source.
if (isProcessEnv(lowerer, inner)) return depth > 0;
if (!ts.isIdentifier(inner) || depth >= 4) return false;
const symbol = lowerer.checker.getSymbolAtLocation(inner);
const decl = symbol?.valueDeclaration ?? symbol?.declarations[0];
if (decl === undefined) return false;
// `const env = process.env` (or an identifier alias of one). The symbol's
// value declaration is the `const` itself; the written identifier here is
// a different node than the declared name, so no node identity check.
// A binding the program reassigns holds the environment only until the
// reassignment, so it is not a hold (refuseHeldEnvCopyHold refuses it).
if (ts.isVariableDeclaration(decl)) {
return (
holdsEnvSnapshot(lowerer, decl.initializer, depth + 1) &&
!reassignedBinding(lowerer, symbol!, decl)
);
}
// `const { env } = process` (also `{ env: held }`).
if (ts.isBindingElement(decl) && ts.isObjectBindingPattern(decl.parent)) {
const declaration = decl.parent.parent;
const named = decl.propertyName ?? decl.name;
if (
ts.isVariableDeclaration(declaration) &&
ts.isIdentifier(named) &&
named.text === "env" &&
declaration.initializer !== undefined
) {
return (
lowerer.isStdlibGlobal(declaration.initializer, "process") &&
!reassignedBinding(lowerer, symbol!, declaration)
);
}
}
return false;
}
/** Whether something writes the binding after its declaration: a `let` or
* `var` the file assigns (bindingNeverReassigned). A `const` never is. */
function reassignedBinding(
lowerer: Lowerer,
symbol: ts.Symbol,
declaration: ts.VariableDeclaration,
): boolean {
const list = declaration.parent;
if (ts.isVariableDeclarationList(list) && (list.flags & ts.NodeFlags.Const) !== 0) return false;
return !bindingNeverReassigned(lowerer, symbol, declaration);
}
/** Whether an expression is the environment object to Node: the ambient
* `process.env` or a value held from it (holdsEnvSnapshot). */
function denotesEnvironment(lowerer: Lowerer, node: ts.Expression): boolean {
return isProcessEnv(lowerer, unwrapped(node)) || holdsEnvSnapshot(lowerer, node);
}
function unwrapped(node: ts.Expression): ts.Expression {
let inner = node;
while (
ts.isParenthesizedExpression(inner) ||
ts.isAsExpression(inner) ||
ts.isNonNullExpression(inner) ||
ts.isSatisfiesExpression(inner)
)
inner = inner.expression;
return inner;
}
/** `a === b`, `a == b` and their negations where an operand is the
* environment object (denotesEnvironment). Every use of a held value reads
* a snapshot, so the snapshots would never be identical, while Node
* compares the one environment object:
* - two operands that both denote it compare equal, folded (neither
* operand has an effect: each is a binding read or `process.env`);
* - one compared with a primitive, `null` or `undefined` keeps the
* ordinary lowering, whose answer (never equal) is Node's;
* - one compared with another object is refused (heldEnvIdentityDiag).
* Null when no operand denotes the environment. */
export function lowerEnvironmentIdentity(
lowerer: Lowerer,
expr: ts.BinaryExpression,
loc: SrcLoc,
): IrExpr | null {
const op = expr.operatorToken.kind;
const negated =
op === ts.SyntaxKind.ExclamationEqualsEqualsToken ||
op === ts.SyntaxKind.ExclamationEqualsToken;
if (
!negated &&
op !== ts.SyntaxKind.EqualsEqualsEqualsToken &&
op !== ts.SyntaxKind.EqualsEqualsToken
)
return null;
const left = denotesEnvironment(lowerer, expr.left);
const right = denotesEnvironment(lowerer, expr.right);
if (!left && !right) return null;
if (left && right) return { kind: "boolLit", value: !negated, type: BOOL, loc };
if (isPrimitiveOperand(lowerer.typeOf(left ? expr.right : expr.left))) return null;
lowerer.pushDiag(heldEnvIdentityDiag(loc));
throw new PoisonError();
}
/** Whether a checker type has only primitive values (`null` and `undefined`
* included), which are never the environment object. */
function isPrimitiveOperand(type: ts.Type): boolean {
if ((type.flags & ts.TypeFlags.Union) !== 0)
return constituentTypes(type).every(isPrimitiveOperand);
return (
(type.flags &
(ts.TypeFlags.Null |
ts.TypeFlags.Undefined |
ts.TypeFlags.Void |
ts.TypeFlags.StringLike |
ts.TypeFlags.NumberLike |
ts.TypeFlags.BooleanLike |
ts.TypeFlags.BigIntLike |
ts.TypeFlags.ESSymbolLike)) !==
0
);
}
/** The receiver expression of a write target — the assignment's or delete's
* access (`env.X`, `env[k]`), through any compound/update wrapping. Null for
* targets that write no entry of a receiver. */
function writeTargetReceiver(node: ts.Node): ts.Expression | null {
if (ts.isBinaryExpression(node)) return writeTargetReceiver(node.left);
if (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) {
return writeTargetReceiver(node.operand);
}
if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
return node.expression;
}
return null;
}
/** Whether a MEMBER access (`env.X`, `env[k]`, and their writes) through a
* value HELD from `process.env` can go to the live environment table: the
* held binding's type must be exactly the env snapshot record (a pure
* index-signature shape whose value slot is `string | undefined`, what
* `process.env` maps to). Returns that value type, or null — a hold whose
* type differs keeps the snapshot record for reads and refuses writes (see
* refuseHeldEnvWrite). Member accesses alias the live table the way Node's
* do. A BARE use of the held value (a spread source, Object.keys, an
* argument, a return) is not a member access and takes a fresh snapshot of
* the environment at that use site (lowerIdentifierExpr). */
export function heldEnvValueType(lowerer: Lowerer, receiver: ts.Expression): IrType | null {
if (!holdsEnvSnapshot(lowerer, receiver)) return null;
return envRecordValueType(lowerer, lowerer.mapTypeOf(lowerer.typeOf(receiver)));
}
/** The env-record value type a hold must have to alias the live table —
* a pure index-signature record (no fields, not a tuple) whose value slot
* is the `string | undefined` union, which is what `process.env` maps to.
* Null for every other type. */
function envRecordValueType(lowerer: Lowerer, mapped: IrType | null): IrType | null {
if (mapped?.kind !== "record") return null;
const shape = lowerer.shapes.get(mapped.shapeId);
if (!shape || shape.tuple || shape.fields.length > 0 || shape.indexValue === undefined)
return null;
const value = lowerer.envValueType();
return typeEquals(shape.indexValue, value) ? value : null;
}
/** Refuse a BINDING that holds `process.env` under a type which is not the
* env record — `const env: Record<string, string> = process.env as any`, a
* fixed-field annotation, `const e: any = process.env`. Node's hold IS the
* environment object, so member reads through it see the live table;
* here the hold is a snapshot record taken at the initializer, so reads
* would silently observe the environment as it was then (writes are
* already refused — refuseHeldEnvWrite). Refuse the hold where it is
* created instead of letting its later reads drift. `name` is the bound
* identifier, `blame` the declaration the diagnostic points at. */
export function refuseHeldEnvCopyHold(lowerer: Lowerer, name: ts.Identifier, blame: ts.Node): void {
if (
ts.isVariableDeclaration(blame) &&
holdsEnvSnapshot(lowerer, blame.initializer, 1) &&
!holdsEnvSnapshot(lowerer, name)
) {
// Initialized from process.env but reassigned later (holdsEnvSnapshot).
lowerer.pushDiag(heldEnvReassignedDiag(locOf(blame)));
throw new PoisonError();
}
if (!holdsEnvSnapshot(lowerer, name)) return;
if (envRecordValueType(lowerer, lowerer.mapTypeOf(lowerer.typeOf(name))) !== null) return;
lowerer.pushDiag(heldEnvHoldDiag(locOf(blame)));
throw new PoisonError();
}
/** Refuse a write through a value HELD from `process.env` that cannot take
* the live path: the compound and update forms (`env.X += v`, `env.X++`)
* reach this wrapper, and so does any write through a hold whose type is
* not the env record. The held value is a snapshot record, so such a write
* would land on the copy while the real environment kept its old value;
* Node writes setenv(3) through the very same object, so a program relying
* on it would silently diverge. Plain `=`, computed-key and `delete` writes
* through the env record alias the live table instead (lower-stmts.ts,
* element-access.ts); `target` is the write's or delete's access node. */
export function refuseHeldEnvWrite(lowerer: Lowerer, target: ts.Node): void {
const receiver = writeTargetReceiver(target);
if (receiver === null || !holdsEnvSnapshot(lowerer, receiver)) return;
lowerer.pushDiag(heldEnvWriteDiag(locOf(target)));
throw new PoisonError();
}
/** The interned `string | undefined` union — the type every env read
* produces (withUndefinedArm interns [string, undefined] in canonical
* order, so the string arm's tag is 0 and the undefined arm's is 1,
@@ -1200,6 +1200,15 @@ export function lambdaSignature(
// is the one the adoption block below takes for mappable inferred
// returns, so this only changes programs that do not compile today.
//
// A lambda RETURNED from the function being lowered flows into that
// function's result type, which OBSERVES its value — the checker's void
// contextual type at such a site comes from an outer assignment target
// that would discard the enclosing function's own result. So the void
// adoption below must not fire there: `{ on: (h) => () => seen.push("y") }`
// against a `(h) => void` field types the inner arrow `() => number`
// (its own type) while its contextual type is the discarded `() => void`,
// and the outer arrow's result is the `() => number` that must return it.
const returnedValue = returnedIntoValueSlot(lowerer, node);
// The contextual signature is looked up at most once per lambda: both
// this decision and the adoption block below need it, and the checker
// call is not free in a compile this hot.
@@ -1217,6 +1226,7 @@ export function lambdaSignature(
ts.isArrowFunction(node) && !ts.isBlock(node.body) && isStreamUndefCallExpr(lowerer, node.body)
? VOID
: (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) &&
!returnedValue &&
voidContextualSlot(lowerer, node, contextualSigs)
? VOID
: lowerer.declaredReturnType(node, node);
@@ -1344,6 +1354,7 @@ export function lambdaSignature(
if (
!literalMethod &&
!isAsyncLike &&
!returnedValue &&
!node.type &&
!inferredLiteral &&
ret.kind !== "void" &&
@@ -1404,6 +1415,20 @@ function voidContextualSlot(
);
}
/** Whether `node` is the expression of a `return` in the function being
* lowered, and that function's result is a function type promising a
* value (`() => number`, not `() => void`). Such a lambda's value reaches
* the caller of the enclosing function, so its own return type is kept
* even when the checker's contextual type for it is `void` (see the ABI
* decision in lambdaSignature). */
function returnedIntoValueSlot(lowerer: Lowerer, node: ts.Node): boolean {
let parent: ts.Node | undefined = node.parent;
while (parent !== undefined && ts.isParenthesizedExpression(parent)) parent = parent.parent;
if (parent === undefined || !ts.isReturnStatement(parent) || !parent.expression) return false;
const result = lowerer.ctx.returnType;
return result.kind === "func" && result.ret.kind !== "void";
}
/** The type of the property an object-literal method fills on the
* literal's contextual type; undefined when the literal has no contextual
* type or the method has a computed name. */
@@ -87,6 +87,7 @@ import { lowerUnionEquality, sameNamespace, tagEqualityMayMissAlias } from "../s
import { withUnitArm } from "../../type-mapper.js";
import { npmStaticPackageOfPath } from "../../npm-static.js";
import { lowerWebAssemblyTypeof } from "../builtins/webassembly.js";
import { lowerEnvironmentIdentity } from "../builtins/environment.js";
/** `v === undefined` / `v !== null` — the narrowing tests for unit-armed
* unions. A union operand compared with a unit literal lowers to a
@@ -1495,6 +1496,12 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr
: "compound assignment as an expression (write `x op= e` as a statement, or spell out `x = x op e`)",
);
}
// An identity comparison with process.env or a value held from it
// compares the one environment object, which no snapshot can stand for.
{
const identity = lowerEnvironmentIdentity(lowerer, expr, loc);
if (identity) return identity;
}
if (op === ts.SyntaxKind.EqualsEqualsToken || op === ts.SyntaxKind.ExclamationEqualsToken) {
const nullTest = lowerLooseNullCompare(lowerer, expr, loc);
if (nullTest) return nullTest;
@@ -32,6 +32,7 @@ import {
unionContainerArmsOk,
} from "../../../ir/ir.js";
import { importMetaField, isImportMeta } from "../import-meta.js";
import { heldEnvValueType } from "../builtins/environment.js";
import { hasOptionalChainGuard, isOptionalChainTail } from "./optional-chains.js";
import { lowerEnumAccess } from "../lower-enums.js";
import { fenceNodeModuleMutation, lowerRequireCacheElement } from "../lower-node-module.js";
@@ -240,6 +241,25 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre
};
return lowerer.maybeNarrow(get, expr);
}
// `held[expr]` for a value HELD from process.env (`const env =
// process.env`): the hold aliases the live table, so the read is the same
// getenv the direct spelling uses.
{
const live = heldEnvValueType(lowerer, expr.expression);
if (live !== null) {
const key = lowerEnvironmentKey(lowerer, expr.argumentExpression);
if (key.type.kind === "string") {
const get: IrExpr = {
kind: "libCall",
fn: "process.envGet",
args: [key],
type: live,
loc: locOf(expr),
};
return lowerer.maybeNarrow(get, expr);
}
}
}
// Expando function members in element clothing (`foo[strMem]`,
// `foo[_private]` where foo is a module-level function/callable const
// and the key folds or is a unique-symbol const): the member's module
@@ -2267,8 +2287,14 @@ export function lowerElementWrite(lowerer: Lowerer, expr: ts.BinaryExpression):
};
}
// `process.env[key] = v` — the computed twin of the dotted env write:
// setenv(3), string keys and string values only.
if (lowerer.isProcessEnv(target.expression) && !target.questionDotToken) {
// setenv(3), string keys and string values only. A key write through a
// value HELD from process.env takes the same path: the hold aliases the
// live table.
if (
!target.questionDotToken &&
(lowerer.isProcessEnv(target.expression) ||
heldEnvValueType(lowerer, target.expression) !== null)
) {
const loc = locOf(expr);
const key = lowerEnvironmentKey(lowerer, target.argumentExpression);
if (key.type.kind !== "string") {
@@ -96,6 +96,33 @@ import { fenceEnumObjectValue } from "../lower-enums.js";
import { mixinFnOfCallee } from "../lower-mixins.js";
import { isNavigatorGlobal, navigatorValue } from "../builtins/navigator.js";
import { moduleFileName } from "../import-meta.js";
import { envSnapshotHelper, heldEnvValueType } from "../builtins/environment.js";
/** Whether this identifier is the RECEIVER of a member access — the dotted
* or bracketed `held.X` / `held[k]`, read or written, through any erasure
* wrapper. Those uses answer through the live environment table themselves
* (heldEnvValueType); every other use of a held value is a bare one and
* reads the environment as it is at that site. */
function memberReceiverUse(expr: ts.Identifier): boolean {
let node: ts.Node = expr;
for (;;) {
const parent: ts.Node | undefined = node.parent;
if (parent === undefined) return false;
if (
ts.isParenthesizedExpression(parent) ||
ts.isAsExpression(parent) ||
ts.isNonNullExpression(parent) ||
ts.isSatisfiesExpression(parent)
) {
node = parent;
continue;
}
return (
(ts.isPropertyAccessExpression(parent) || ts.isElementAccessExpression(parent)) &&
parent.expression === node
);
}
}
/** A JavaScript binding with checked-dynamic storage because the program
* stores values outside its declared type (analyzeJsWidenedBindings): the
@@ -193,6 +220,24 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc:
// when the checker types this USE as a single arm, maybeNarrow
// bridges the tagged representation with a unionNarrow.
const local = lowerer.resolveLocal(expr);
// A BARE use of a value HELD from process.env (`{ ...env }`, Object.keys,
// an argument, a return) reads the environment as it is HERE: Node's hold
// IS the environment object, so its contents at use time are the live
// table's. Member accesses (`env.X`, `env[k]`, their writes) reroute to
// getenv/setenv directly (heldEnvValueType) and never come through a bare
// read, so they materialize no snapshot; every other use takes a fresh
// one at this site instead of the copy the hold was given.
{
const mapped = lowerer.mapTypeOf(lowerer.typeOf(expr));
if (
mapped?.kind === "record" &&
!memberReceiverUse(expr) &&
heldEnvValueType(lowerer, expr) !== null
) {
const helper = envSnapshotHelper(lowerer, mapped.shapeId, loc);
if (helper !== null) return { kind: "call", callee: helper, args: [], type: mapped, loc };
}
}
{
const slot = local ?? lowerer.globalOf(expr);
const closureRead = slot ? closureDynRead(lowerer, expr, slot.id, slot.type, loc) : null;
@@ -211,6 +211,7 @@ import { isZlibStreamMember, lowerZlibStreamBytesWritten } from "../builtins/com
import { isWebCryptoReceiver } from "../builtins/crypto.js";
import { isFsStreamMember, lowerFsStreamProperty } from "../builtins/fs-streams.js";
import { keyedFieldRead } from "./keyed-field-targets.js";
import { heldEnvValueType } from "../builtins/environment.js";
import { promisePrototypeValue } from "../thenables.js";
import { lowerChildPropertyRead } from "../handle-properties.js";
@@ -4814,6 +4815,21 @@ export function fieldGetExpr(
// construction), typed as the index value armed with undefined
// (mirroring lowerRecordKeyRead).
if (target.container === "recordOvf") {
// A dot read through a value HELD from process.env (`const env =
// process.env; env.X`): the hold aliases the live table, so the read is
// the same getenv the direct spelling lowers to.
if (ts.isPropertyAccessExpression(blame)) {
const live = heldEnvValueType(lowerer, blame.expression);
if (live !== null) {
return {
kind: "libCall",
fn: "process.envGet",
args: [{ kind: "strLit", value: target.field, type: STRING, loc }],
type: live,
loc,
};
}
}
let obj = target.obj;
if (obj.type.kind === "union" && ts.isPropertyAccessExpression(blame)) {
const present = lowerer.stripUndefinedArm(obj.type);
@@ -110,6 +110,7 @@ import {
} from "./expressions/property-access.js";
import { lowerNullishPair } from "./expressions/binary.js";
import { lowerElementCompoundParts, recordKeyLiteralText } from "./expressions/element-access.js";
import { heldEnvValueType } from "./builtins/environment.js";
import { OBJECT_PROTOTYPE_MEMBERS } from "../shape-inference/domain.js";
export {
DEFERRED_READ_MESSAGE,
@@ -2517,6 +2518,12 @@ export function lowerAbsenceProbe(lowerer: Lowerer, node: ts.Expression): IrExpr
const header = lowerer.lowerHttpHeadersElement(expr);
if (header) return header;
// A keyed read through a value HELD from process.env (`const env =
// process.env; env[k] ?? fallback`) aliases the live table: the ordinary
// lowering is getenv, whose `string | undefined` result already carries
// the missing arm this probe reconstructs for record keys.
if (heldEnvValueType(lowerer, expr.expression) !== null) return null;
const receiverT = lowerer.mapTypeOf(lowerer.typeOf(expr.expression));
if (receiverT?.kind === "record") {
const shape = lowerer.shapes.get(receiverT.shapeId);
@@ -54,6 +54,7 @@ import { isWebStreamValueInJavaScript, typeInvolvesWebStream } from "./builtins/
import * as ts from "../ts7/adapter.js";
import { lowerOptionalIndex } from "./undefined-index.js";
import { processDataMemberValue, programReadsExitCode } from "./builtins/process.js";
import { heldEnvValueType, refuseHeldEnvCopyHold } from "./builtins/environment.js";
import { requireInitFlagStmt } from "./require-initialization.js";
import { isJsonRequireDecl } from "./json-require.js";
import { isTimeValueDateDeclaration } from "./date-values.js";
@@ -6171,6 +6172,10 @@ export function lowerVarDecl(
}
}
if (!ts.isIdentifier(decl.name)) lowerer.unsupported("SC1031", decl.name);
// A binding that holds `process.env` under any type but the env record
// is a snapshot copy whose reads would drift from Node's live table
// (refuseHeldEnvCopyHold); refuse it here, at the hold itself.
refuseHeldEnvCopyHold(lowerer, decl.name, decl);
{
const alias = lowerTupleAliasDecl(lowerer, decl);
if (alias) return alias;
@@ -7958,9 +7963,11 @@ function lowerDeleteStatement(lowerer: Lowerer, expr: ts.DeleteExpression): IrSt
return { kind: "strLit", value: target.name.text, type: STRING, loc: locOf(target.name) };
}
const keyNode = (target as ts.ElementAccessExpression).argumentExpression;
let key = lowerer.isProcessEnv(target.expression)
? lowerEnvironmentKey(lowerer, keyNode)
: lowerer.lowerExpr(keyNode);
let key =
lowerer.isProcessEnv(target.expression) ||
heldEnvValueType(lowerer, target.expression) !== null
? lowerEnvironmentKey(lowerer, keyNode)
: lowerer.lowerExpr(keyNode);
if (key.type.kind === "dyn") {
key = { kind: "libCall", fn: "dyn.toStringCoerce", args: [key], type: STRING, loc: key.loc };
}
@@ -7976,7 +7983,10 @@ function lowerDeleteStatement(lowerer: Lowerer, expr: ts.DeleteExpression): IrSt
}
return key;
};
if (lowerer.isProcessEnv(target.expression)) {
if (
lowerer.isProcessEnv(target.expression) ||
heldEnvValueType(lowerer, target.expression) !== null
) {
return {
kind: "exprStmt",
expr: { kind: "libCall", fn: "process.envUnset", args: [lowerKey()], type: VOID, loc },
@@ -8907,7 +8917,14 @@ export function lowerExprStatement(lowerer: Lowerer, expr: ts.Expression): IrStm
// (Node stringifies everything; a non-string RHS fences instead
// of diverging from Node). The computed-key form lands in
// lowerElementWrite.
if (lowerer.isProcessEnv(expr.left.expression) && !expr.left.questionDotToken) {
// The same setenv for a value HELD from process.env (`const env =
// process.env; env.X = v`): the hold aliases the live table, as
// Node's does.
if (
!expr.left.questionDotToken &&
(lowerer.isProcessEnv(expr.left.expression) ||
heldEnvValueType(lowerer, expr.left.expression) !== null)
) {
const loc = locOf(expr);
const key: IrExpr = {
kind: "strLit",
@@ -593,7 +593,12 @@ import {
} from "./builtins/filesystem.js";
import { lowerSpawnSyncCall, lowerSpawnCall, lowerExecFileCall } from "./builtins/child-process.js";
import { lowerExecSyncCall } from "./builtins/child-process-sync.js";
import { isProcessEnv, envValueType, lowerProcessEnvGet } from "./builtins/environment.js";
import {
isProcessEnv,
envValueType,
lowerProcessEnvGet,
refuseHeldEnvWrite,
} from "./builtins/environment.js";
import { lowerJsonMethodCall, lowerJsonProperty } from "./builtins/json.js";
import { lowerCryptoComposedCall, lowerCryptoModuleCall } from "./builtins/crypto.js";
import { lowerUrlMethodCall, lowerSearchParamsMethodCall } from "./builtins/url.js";
@@ -16917,6 +16922,7 @@ export class Lowerer {
}
fieldSetStmt(target: FieldTarget, value: IrExpr, loc: SrcLoc, blame: ts.Node): IrStmt {
refuseHeldEnvWrite(this, blame);
return markSloppyRecordWrites(this, fieldSetStmt(this, target, value, loc, blame), blame);
}
+7
View File
@@ -293,6 +293,13 @@
"status": "unsupported",
"code": "SC2013"
},
{
"id": "diagnostic.sc2014",
"kind": "diagnostic-fence",
"name": "writes through a value held from process.env (a snapshot, not the live table)",
"status": "unsupported",
"code": "SC2014"
},
{
"id": "diagnostic.sc2020",
"kind": "diagnostic-fence",
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/held-env-value-writes.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/void-slot-returned-arrow.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/held-env-copy-hold.ts"
],
"diags": []
}
+70
View File
@@ -0,0 +1,70 @@
// 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"'),
);
// Holds and process.env are the one environment object: identity
// comparisons between them are equal, and with a primitive never.
const alias = env;
console.log(
"identity:",
env === env,
env === alias,
alias == process.env,
env !== process.env,
env === undefined,
env != null,
);
console.log("done");
+26
View File
@@ -0,0 +1,26 @@
// A value-returning arrow RETURNED through a `() => void` interface member.
// TypeScript types the inner arrow `() => number` (its own type) while the
// checker contextually types it `() => void`; the enclosing arrow's result
// is `() => number`, so the value is observed and must be produced. Node
// runs this; the closure is called for its effect only.
interface Subscriber {
on(handler: (data: string) => void): () => void;
}
function makeSubscriber(): Subscriber {
const seen: string[] = [];
return {
on: (handler) => {
seen.push("registered");
handler("first");
// Concise arrow whose body returns a number: the result is discarded
// at the `() => void` slot but the arrow's own type keeps it.
return () => seen.push("unsubscribed");
},
};
}
const off = makeSubscriber().on((data) => console.log("got", data));
console.log("off is", typeof off);
off();
console.log("done");
+26
View File
@@ -0,0 +1,26 @@
// Values HELD from process.env under a type that is not the environment
// record (tsc-clean, outside the live-alias subset). Node's hold IS the
// environment object, so its reads see the live table; the hold here is a
// snapshot copy taken where it is created, so its reads would silently
// drift once the environment changes.
// a fixed-field annotation: tsc admits it through the index signature
const home: { HOME?: string } = process.env;
console.log(home.HOME);
// an `any` hold: no record shape to alias through
const anyEnv: any = process.env;
console.log(anyEnv.PATH);
// a reassigned hold: Node's binding is the environment until the
// reassignment, the hold here is a copy
let held = process.env;
held = { NAME: "replacement" };
held.NAME = "written";
console.log(held.NAME);
// identity with another object: the hold is a snapshot record, not the
// environment object
const live = process.env;
const other: Record<string, string | undefined> = {};
console.log(live === other);
@@ -0,0 +1,35 @@
held-env-copy-hold.ts:8:7 - error SC2014: a value held from process.env under a type other than the environment record (a string-keyed record of string | undefined) is not supported: the hold is a snapshot copy taken where it is created, so its reads would see the environment as it was then while Node reads the live environment
7 | // a fixed-field annotation: tsc admits it through the index signature
8 | const home: { HOME?: string } = process.env;
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
9 | console.log(home.HOME);
hint: hold process.env without changing its type (const env = process.env), read the live table directly (process.env.NAME), or copy the entries you need: const env = { ...process.env, NAME: value }
held-env-copy-hold.ts:12:7 - error SC2014: a value held from process.env under a type other than the environment record (a string-keyed record of string | undefined) is not supported: the hold is a snapshot copy taken where it is created, so its reads would see the environment as it was then while Node reads the live environment
11 | // an `any` hold: no record shape to alias through
12 | const anyEnv: any = process.env;
| ^~~~~~~~~~~~~~~~~~~~~~~~~
13 | console.log(anyEnv.PATH);
hint: hold process.env without changing its type (const env = process.env), read the live table directly (process.env.NAME), or copy the entries you need: const env = { ...process.env, NAME: value }
held-env-copy-hold.ts:17:5 - error SC2014: a reassigned binding that holds process.env is not supported: Node's binding holds the environment object until it is reassigned, while the hold here is a snapshot copy of the environment
16 | // reassignment, the hold here is a copy
17 | let held = process.env;
| ^~~~~~~~~~~~~~~~~~
18 | held = { NAME: "replacement" };
hint: hold process.env in a binding that is never reassigned (const env = process.env), or copy the entries you need: let env = { ...process.env }
held-env-copy-hold.ts:26:13 - error SC2014: comparing process.env or a value held from it by identity with another object is not supported: the value here is a snapshot copy of the environment, not the environment object
25 | const other: Record<string, string | undefined> = {};
26 | console.log(live === other);
| ^~~~~~~~~~~~~~
27 |
hint: compare the entries you need (env.NAME === other.NAME), or compare two holds of process.env, which are the same object
@@ -0,0 +1 @@
{ "plain": 1700, "san": 3300 }
@@ -0,0 +1 @@
{ "plain": 1600, "san": 2400 }