mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-06 08:23:19 +08:00
Alias held process.env values to the live environment; keep a returned arrow's result in value slots (#2554)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/held-env-value-writes.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/void-slot-returned-arrow.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/diagnostics/held-env-copy-hold.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user