From caf098eff9acdd1b006bb9079538c280346b655b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 10:22:50 -0700 Subject: [PATCH] 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 --- .../compiler/src/diagnostics/diagnostic.ts | 27 +++++++++++ .../compiler/src/frontend/lowering/lowerer.ts | 45 +++++++++++++++++++ .../src/frontend/lowering/surfaces.ts | 9 ++++ .../stdlib-snapshot-value-use.ts.json | 6 +++ ...unmappable-declaration-initializer.ts.json | 6 +++ tests/corpus/990-process-basics.ts | 7 +++ .../diagnostics/stdlib-snapshot-value-use.ts | 10 +++++ .../unmappable-declaration-initializer.ts | 10 +++++ .../stdlib-snapshot-value-use.ts.txt | 17 +++++++ .../unmappable-declaration-initializer.ts.txt | 8 ++++ 10 files changed, 145 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/stdlib-snapshot-value-use.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/unmappable-declaration-initializer.ts.json create mode 100644 tests/diagnostics/stdlib-snapshot-value-use.ts create mode 100644 tests/diagnostics/unmappable-declaration-initializer.ts create mode 100644 tests/harness/__snapshots__/stdlib-snapshot-value-use.ts.txt create mode 100644 tests/harness/__snapshots__/unmappable-declaration-initializer.ts.txt diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index 21c0b00f8..e63ab750c 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -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, diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index a16d2f396..df4498846 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -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(); + /** 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(); + /** Snapshots whose declaration-side refusal already reported (one + * declaration, one report). */ + private readonly stdlibSnapshotReported = new Set(); /** 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 | // ((f: (a: T) => T) => T)` — the optional-chained ambient shape): diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 03b65bd54..ded666ab2 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -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; } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/stdlib-snapshot-value-use.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/stdlib-snapshot-value-use.ts.json new file mode 100644 index 000000000..4cf28c2c3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/stdlib-snapshot-value-use.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/stdlib-snapshot-value-use.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/unmappable-declaration-initializer.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/unmappable-declaration-initializer.ts.json new file mode 100644 index 000000000..dc30afdbc --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/unmappable-declaration-initializer.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/unmappable-declaration-initializer.ts" + ], + "diags": [] +} diff --git a/tests/corpus/990-process-basics.ts b/tests/corpus/990-process-basics.ts index c09e19b74..c902d0c78 100644 --- a/tests/corpus/990-process-basics.ts +++ b/tests/corpus/990-process-basics.ts @@ -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); diff --git a/tests/diagnostics/stdlib-snapshot-value-use.ts b/tests/diagnostics/stdlib-snapshot-value-use.ts new file mode 100644 index 000000000..563dd88d8 --- /dev/null +++ b/tests/diagnostics/stdlib-snapshot-value-use.ts @@ -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()); diff --git a/tests/diagnostics/unmappable-declaration-initializer.ts b/tests/diagnostics/unmappable-declaration-initializer.ts new file mode 100644 index 000000000..823f430e6 --- /dev/null +++ b/tests/diagnostics/unmappable-declaration-initializer.ts @@ -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 = touch() as unknown as WeakRef; +} +run(); diff --git a/tests/harness/__snapshots__/stdlib-snapshot-value-use.ts.txt b/tests/harness/__snapshots__/stdlib-snapshot-value-use.ts.txt new file mode 100644 index 000000000..f4dce44f0 --- /dev/null +++ b/tests/harness/__snapshots__/stdlib-snapshot-value-use.ts.txt @@ -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 \ No newline at end of file diff --git a/tests/harness/__snapshots__/unmappable-declaration-initializer.ts.txt b/tests/harness/__snapshots__/unmappable-declaration-initializer.ts.txt new file mode 100644 index 000000000..c4dc441ca --- /dev/null +++ b/tests/harness/__snapshots__/unmappable-declaration-initializer.ts.txt @@ -0,0 +1,8 @@ +unmappable-declaration-initializer.ts:8:9 - error SC2020: 'WeakRef' is part of the standard library types but has no scriptc lowering yet + + 7 | function run(): void { + 8 | const ref: WeakRef = touch() as unknown as WeakRef; + | ^~~ + 9 | } + + hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) \ No newline at end of file