Refuse a stdlib-global snapshot read as a value

`const proc = globalThis.process` lowers to alias plumbing: the declaration
emits no storage and no code, because member and call reads resolve through
the global it names. A read that needs the VALUE therefore failed at the use
with the generic "binding form with no lowering", which names neither the
declaration nor the cause.

Register every stdlib-global snapshot declaration (TypeScript sources only,
matching the elision) and report the cause at the declaration (SC2020),
naming the global and, when it has a short spelling, the type the program
asked the compiler to hold. The use site keeps the inherited-blocker cascade
(SC2004) instead of the generic text.

An unread snapshot stays elided: reading a global has no side effects, so
dropping an unread `const p = process` is observationally identical to Node,
and refusing it would break programs that compile harmlessly today.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-28 17:58:04 -07:00
co-authored by Claude Opus 5.5
parent 83e26da25e
commit caf098eff9
10 changed files with 145 additions and 0 deletions
@@ -1023,6 +1023,33 @@ export function noLoweringDiag(
};
}
/** A stdlib-global snapshot reached a site that needs its VALUE (an
* argument, a return, a stored field). The declaration emitted no storage
* — alias plumbing only serves member and call reads — and the global's
* own value has no lowering, so the read cannot resolve. Reported at the
* declaration, where the real cause sits; the use site carries the SC2004
* cascade. */
export function stdlibSnapshotValueUseDiag(
name: string,
alias: string,
typeText: string | undefined,
loc: SrcLoc,
): ScrDiagnostic {
// Name the requested type when it has a readable spelling; a structural
// dump of the global's own object type says less than the global itself.
const unholdable =
typeText === undefined
? "the global it names is not a value scriptc can hold"
: `a '${typeText}' is not a value scriptc can hold`;
return {
code: "SC2020",
message: `the stdlib-global snapshot '${name}' ('${alias}') cannot be used as a value: the declaration lowers to alias plumbing (no storage, no code), and ${unholdable}`,
loc,
milestone: "later",
hint: `read the members through the global directly ('${alias}.member') instead of passing '${name}' around as a value`,
};
}
/** A REACHED use of a part of the WebAssembly API scriptc does not
* implement yet (SC2020): the streaming functions, Tag and Exception, the
* classes as values. compile, instantiate, validate, the Module, Instance,
@@ -286,6 +286,7 @@ import {
noLoweringDiag,
overloadedSignatureTypeDiag,
recordShapeMismatchDiag,
stdlibSnapshotValueUseDiag,
unsupportedNativeApiDiag,
unsupportedPackageValueDiag,
unionMismatchDiag,
@@ -3378,6 +3379,16 @@ export class Lowerer {
* checks resolve through this map (stdlibGlobalNameOf), declarations
* emit nothing. */
readonly stdlibGlobalAliases = new Map<ts.Symbol, string>();
/** The TypeScript declarations registered in stdlibGlobalAliases: the
* global each one names and its own name node. Their declarations emit
* nothing, so a read that needs the VALUE reports at the declaration
* (stdlibGlobalAliasDecl, rejectUnresolvedSymbol) instead of compiling
* it away. An unread snapshot stays elided: reading a global has no
* side effects. */
readonly stdlibGlobalAliasDecls = new Map<ts.Symbol, { alias: string; name: ts.Identifier }>();
/** Snapshots whose declaration-side refusal already reported (one
* declaration, one report). */
private readonly stdlibSnapshotReported = new Set<ts.Symbol>();
/** The stdlibGlobalAliases bound through a cast of the global object
* (`(globalThis as { Buffer?: B }).Buffer`). Their static type is the
* cast's, so their value reads are refused (identifiers.ts). */
@@ -4222,6 +4233,10 @@ export class Lowerer {
checkedTable("methodLambdasInProgress", () => this.methodLambdasInProgress.size),
checkedTable("classMethodValueSelections", () => this.classMethodValueSelections.size),
checkedTable("stdlibGlobalAliases", () => this.stdlibGlobalAliases.size),
// The snapshots' own declarations and their one-report memory: both
// grow with stdlibGlobalAliases, one entry per registered declaration.
checkedTable("stdlibGlobalAliasDecls", () => this.stdlibGlobalAliasDecls.size),
checkedTable("stdlibSnapshotReported", () => this.stdlibSnapshotReported.size),
checkedTable("cjsAccessorFns", () => this.cjsAccessorFns.size),
checkedTable("cjsAccessorSnapshots", () => this.cjsAccessorSnapshots.size),
checkedTable("cjsSnapshotReady", () => this.cjsSnapshotReady.size),
@@ -11358,6 +11373,24 @@ export class Lowerer {
throw new PoisonError();
}
/** The declaration-side refusal of a stdlib-global snapshot read as a
* VALUE (stdlibGlobalAliasDecl): report once, at the declaration's name,
* naming the type the program asked the compiler to hold when it has a
* short spelling. */
reportStdlibSnapshot(symbol: ts.Symbol): void {
const decl = this.stdlibGlobalAliasDecls.get(symbol);
if (decl === undefined || this.stdlibSnapshotReported.has(symbol)) return;
this.stdlibSnapshotReported.add(symbol);
const raw = panicSafe(
() => this.checker.typeToString(this.checker.getTypeOfSymbol(symbol)),
"",
);
const typeText = raw !== "" && raw.length <= 48 ? raw : undefined;
this.pushDiag(
stdlibSnapshotValueUseDiag(decl.name.text, decl.alias, typeText, locOf(decl.name)),
);
}
stdlibMemberFence(access: ts.PropertyAccessExpression): void {
return stdlibMemberFence(this, access);
}
@@ -15508,6 +15541,18 @@ export class Lowerer {
this.pushDiag(blockedBindingUseDiag(name, locOf(node)));
throw new PoisonError();
}
// A stdlib-global snapshot read as a VALUE (`return proc` for
// `const proc = globalThis.process`): the declaration emitted no
// storage — alias plumbing only serves member and call reads — and the
// global's own value has no lowering. The cause is the declaration, so
// report there (once) and cascade here, instead of the generic
// binding-form text that names neither.
if (symbol !== null && this.stdlibGlobalAliasDecls.has(symbol)) {
this.reportStdlibSnapshot(symbol);
this.blockedBindings.add(symbol);
this.pushDiag(blockedBindingUseDiag(name, locOf(node)));
throw new PoisonError();
}
// A binding whose TYPE keeps a generic call signature and whose
// declaration carries no initializer (`declare const o4: undefined |
// (<T>(f: (a: T) => T) => T)` — the optional-chained ambient shape):
@@ -3717,6 +3717,15 @@ export function stdlibGlobalAliasDecl(
lowerer.stdlibGlobalAliases.set(symbol, name);
if (init !== undefined && castGlobalAliasSource(lowerer, init))
lowerer.castGlobalAliases.add(symbol);
// The snapshot exists for reads, and its declaration emits nothing: a
// read that needs its VALUE (an argument, a return) cannot resolve, so
// register the declaration to report the cause there rather than at the
// use (rejectUnresolvedSymbol). TypeScript sources only: JavaScript
// bindings keep their checked-dynamic value paths. A snapshot no read
// resolves stays elided — reading a global has no side effects, so
// eliding an unread one is observationally identical to Node.
if (!isJsSourceFile(ts.getSourceFileOfNode(nameNode)))
lowerer.stdlibGlobalAliasDecls.set(symbol, { alias: name, name: nameNode });
return true;
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/stdlib-snapshot-value-use.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/unmappable-declaration-initializer.ts"
],
"diags": []
}
+7
View File
@@ -31,3 +31,10 @@ console.log(nonEmpty === process.argv.length);
const cwd = process.cwd();
console.log(cwd);
console.log(cwd === process.cwd(), cwd.startsWith("/"));
// A stdlib-global snapshot (`const proc = globalThis.process`) lowers to
// alias plumbing — no storage, no code — and reads through it resolve to
// the global itself.
const proc = globalThis.process;
console.log(proc.pid === process.pid, proc.platform === process.platform);
console.log(proc.argv === process.argv);
@@ -0,0 +1,10 @@
// The snapshot's declaration emits no storage: only reads that resolve
// THROUGH the global (members, calls) can exist. A read that needs the
// value reports the declaration's cause — the binding, the global it
// snapshots and why neither holds a value — and the use site carries the
// inherited-blocker cascade instead of "a binding form with no lowering".
function label(): unknown {
const proc = globalThis.process;
return proc;
}
console.log(label());
@@ -0,0 +1,10 @@
// An initializer is never dropped. This declaration's type has no lowering,
// so the declaration is refused where it is written — it cannot compile away
// with the call in its initializer, which would run under Node.
function touch(): number {
return 1;
}
function run(): void {
const ref: WeakRef<object> = touch() as unknown as WeakRef<object>;
}
run();
@@ -0,0 +1,17 @@
stdlib-snapshot-value-use.ts:7:9 - error SC2020: the stdlib-global snapshot 'proc' ('process') cannot be used as a value: the declaration lowers to alias plumbing (no storage, no code), and the global it names is not a value scriptc can hold
6 | function label(): unknown {
7 | const proc = globalThis.process;
| ^~~~
8 | return proc;
hint: read the members through the global directly ('process.member') instead of passing 'proc' around as a value
stdlib-snapshot-value-use.ts:8:10 - error SC2004: uses of 'proc' inherit the blocker on its declaration
7 | const proc = globalThis.process;
8 | return proc;
| ^~~~
9 | }
hint: the declaration of 'proc' did not compile — fix the diagnostic reported there and these sites clear with it
@@ -0,0 +1,8 @@
unmappable-declaration-initializer.ts:8:9 - error SC2020: 'WeakRef<object>' is part of the standard library types but has no scriptc lowering yet
7 | function run(): void {
8 | const ref: WeakRef<object> = touch() as unknown as WeakRef<object>;
| ^~~
9 | }
hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations)