From 41e7950ea91bfcdb70e55e1aa7976b0666dd8f08 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 27 Oct 2026 23:07:13 -0500 Subject: [PATCH 1/4] Keep frozen checked records frozen, and give sloppy-mode this the global object Typed records checked from frozen untyped values stay frozen, as the objects they copy are. A sloppy-mode function called with undefined or null as this receives the global object that globalThis reads. The record check reads the source's integrity with a query that answers for every value: a proxy answers as its target, and values without a modeled integrity level, which Object.freeze refuses, are not frozen. Object.isFrozen keeps its refusal of those values. --- .../builtin-apis/object-freezing.mdx | 1 + .../language-features/function-values.mdx | 2 + packages/compiler/src/backend/llvm/dyn.ts | 15 ++++++ .../src/backend/llvm/reference-writes.ts | 1 + .../src/frontend/lowering/frozen-records.ts | 29 +++++++---- .../src/frontend/lowering/lower-functions.ts | 28 +++++++--- .../frontend/lowering/lower-global-value.ts | 26 ++++++++-- .../compiler/src/ir/builtin-signatures.ts | 2 + .../frozen-checked-records-sloppy.cjs.json | 6 +++ .../corpus/frozen-checked-records.ts.json | 6 +++ .../tests/corpus/sloppy-this-global.cjs.json | 6 +++ packages/runtime/src/scr_dyn.h | 5 ++ packages/runtime/src/scr_json.c | 35 ++++++++++--- .../corpus/frozen-checked-records-sloppy.cjs | 37 +++++++++++++ tests/corpus/frozen-checked-records.ts | 29 +++++++++++ tests/corpus/sloppy-this-global.cjs | 52 +++++++++++++++++++ .../frozen-checked-records-sloppy.cjs.json | 1 + .../frozen-checked-records.ts.json | 1 + .../corpus-costs/sloppy-this-global.cjs.json | 1 + 19 files changed, 256 insertions(+), 27 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records-sloppy.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/sloppy-this-global.cjs.json create mode 100644 tests/corpus/frozen-checked-records-sloppy.cjs create mode 100644 tests/corpus/frozen-checked-records.ts create mode 100644 tests/corpus/sloppy-this-global.cjs create mode 100644 tests/harness/corpus-costs/frozen-checked-records-sloppy.cjs.json create mode 100644 tests/harness/corpus-costs/frozen-checked-records.ts.json create mode 100644 tests/harness/corpus-costs/sloppy-this-global.cjs.json diff --git a/docs/content/limitations/builtin-apis/object-freezing.mdx b/docs/content/limitations/builtin-apis/object-freezing.mdx index 030b03b4b..80281bb2d 100644 --- a/docs/content/limitations/builtin-apis/object-freezing.mdx +++ b/docs/content/limitations/builtin-apis/object-freezing.mdx @@ -5,6 +5,7 @@ A write to a read-only field throws Node's `TypeError` in strict mode code and leaves the field unchanged in sloppy mode code. `Object.defineProperty` with a string-literal key changes the value, `writable`, `enumerable`, and `configurable` attributes of a declared field; an object-literal accessor descriptor (`get` or `set`) for a declared field is refused at compile time with `SC2020`, and another accessor descriptor for a declared field throws `SC2020` when the call runs. `Object.freeze`, `Object.seal`, and `Object.preventExtensions` of a class instance held in an untyped value change the instance itself, so writes through its typed references are checked the same way. + A typed record checked from a frozen untyped object (an object literal frozen in a JavaScript file, or a frozen `JSON.parse` result) is frozen too, and writes through its typed references are checked the same way. The `TypeError` for a write to a read-only property that was added to a class instance through an untyped value names `#` where Node names the class. Functions support `Object.freeze`, `Object.seal`, `Object.preventExtensions`, `Object.isFrozen`, `Object.isSealed`, and `Object.isExtensible`; the operations apply to the function's own properties (`name`, `length`, `prototype`, and the ones a program assigns). Class constructors are not supported. diff --git a/docs/content/limitations/language-features/function-values.mdx b/docs/content/limitations/language-features/function-values.mdx index 7083f865d..32af01186 100644 --- a/docs/content/limitations/language-features/function-values.mdx +++ b/docs/content/limitations/language-features/function-values.mdx @@ -5,3 +5,5 @@ - **JavaScript parameters:** In a JavaScript file TypeScript does not check, a prototype method (`C.prototype.m = function (...) {}`) takes whatever values its callers pass; its JSDoc types document the usual values without constraining them. A sloppy-mode function with named parameters may read `arguments` only where the aliasing of its parameters cannot show: it reads `arguments.length`, or reads elements and never writes a parameter or an element. Other uses of `arguments` in such a function are refused with `SC1090`; ES modules, `'use strict'` code, classes, and functions with default, rest, or destructured parameters have no such restriction. + A sloppy-mode function called with `undefined` or `null` as its receiver gets the global object as `this`, as in Node. + Called with a primitive receiver, its `this` is the primitive itself, where Node passes the primitive's wrapper object (`typeof this` is `"object"` there). diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index dfeffdecb..58bad60af 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -2889,6 +2889,21 @@ export class LlDyn { B.line(`call void @scr_str_release(ptr ${ek})`); }); } + // A record built from a frozen object is frozen too, as the object it + // copies (frozen-records.ts makes such shapes freezable). + if (shape.freezable) { + host.declare(`declare zeroext i1 @scr_dyn_checked_record_frozen(ptr)`); + host.declare(`declare void @scr_record_freeze(ptr)`); + const frozen = B.tmp(); + const lFreeze = B.newLabel("dcr.freeze"); + const lDone = B.newLabel("dcr.frozen"); + B.line(`${frozen} = call i1 @scr_dyn_checked_record_frozen(ptr %d)`); + B.condBr(frozen, lFreeze, lDone); + B.startBlock(lFreeze); + B.line(`call void @scr_record_freeze(ptr %r0)`); + B.br(lDone); + B.startBlock(lDone); + } B.terminate(`ret ptr %r0`); break; } diff --git a/packages/compiler/src/backend/llvm/reference-writes.ts b/packages/compiler/src/backend/llvm/reference-writes.ts index 56379a443..c352b5eb4 100644 --- a/packages/compiler/src/backend/llvm/reference-writes.ts +++ b/packages/compiler/src/backend/llvm/reference-writes.ts @@ -94,6 +94,7 @@ const INERT_LIB_CALLS = new Set([ "dyn.compare", // Reads the ambient receiver (a fresh reference to the bound value). "dyn.this", + // A sloppy-mode function's receiver from it (a fresh reference). "error.new", "error.nodeThrow", "dyn.typedRefIs", diff --git a/packages/compiler/src/frontend/lowering/frozen-records.ts b/packages/compiler/src/frontend/lowering/frozen-records.ts index 3700b57ff..75056b3cc 100644 --- a/packages/compiler/src/frontend/lowering/frozen-records.ts +++ b/packages/compiler/src/frontend/lowering/frozen-records.ts @@ -121,6 +121,8 @@ function freezableTypes( const shapes = new Set(); const arrays = new Set(); const live: IrType[] = []; + const checked: IrType[] = []; + let freezesUntyped = false; for (const fn of functions) everyStmtList(fn.body, { expr: (e) => { @@ -130,19 +132,28 @@ function freezableTypes( } else if (e.kind === "libCall" && e.fn === "array.freeze") { const target = e.args[0]; if (target && target.type.kind === "array") arrays.add(typeKey(target.type)); - } else if (e.kind === "dynFrom") live.push(e.value.type); + } else if (e.kind === "libCall" && e.fn === "dyn.freeze") freezesUntyped = true; + else if (e.kind === "dynFrom") live.push(e.value.type); + else if (e.kind === "dynCheck") checked.push(e.type); return true; }, stmt: () => true, }); - if (!lowerer.untypedIntegrity) return { shapes, arrays }; - // An untyped reference to a class instance, or to a record or array - // boxed live, reaches the values its fields and elements hold as further - // live references. Class instances also cross into untyped code through - // compiled callbacks, so every class is a root. - const stack = [...live]; - for (const info of lowerer.classes.values()) - for (const field of info.def.fields) stack.push(field.type); + if (!lowerer.untypedIntegrity && !freezesUntyped) return { shapes, arrays }; + const stack: IrType[] = []; + // A record checked from an untyped value copies it, frozen when the value + // is (a fresh literal frozen in a JavaScript file included), so the shapes + // a check builds are freezable in a program that freezes untyped values. + if (freezesUntyped) stack.push(...checked); + if (lowerer.untypedIntegrity) { + // An untyped reference to a class instance, or to a record or array + // boxed live, reaches the values its fields and elements hold as further + // live references. Class instances also cross into untyped code through + // compiled callbacks, so every class is a root. + stack.push(...live); + for (const info of lowerer.classes.values()) + for (const field of info.def.fields) stack.push(field.type); + } const seen = new Set(); while (stack.length > 0) { const type = stack.pop()!; diff --git a/packages/compiler/src/frontend/lowering/lower-functions.ts b/packages/compiler/src/frontend/lowering/lower-functions.ts index 7f4c8dfb7..263cbcb5f 100644 --- a/packages/compiler/src/frontend/lowering/lower-functions.ts +++ b/packages/compiler/src/frontend/lowering/lower-functions.ts @@ -38,6 +38,8 @@ import { blockBodyOf, } from "./function-returns.js"; import { generatorMeta, isThisParameter, type ParamShape } from "./call-signatures.js"; +import { isStrictCode } from "./strict-mode.js"; +import { lowerSloppyGlobalReceiver } from "./lower-global-value.js"; /** Save the call-site receiver before defaults or body effects. Escaping * arrows capture this binding instead of reading a later call's receiver. */ @@ -48,16 +50,28 @@ function declareCallReceiver( ): IrStmt { const receiver = lowerer.declareThis(DYN); const loc = locOf(node); + const bound: IrExpr = { + kind: "libCall", + fn: generator ? "dyn.generatorThis" : "dyn.this", + args: [], + type: DYN, + loc, + }; + // A sloppy-mode function's `this` is the global object when it is called + // with undefined or null. The function's own body decides its mode. + const sloppy = !ts.isArrowFunction(node) && node.body !== undefined && !isStrictCode(node.body); return { kind: "varDecl", localId: receiver.id, - init: { - kind: "libCall", - fn: generator ? "dyn.generatorThis" : "dyn.this", - args: [], - type: DYN, - loc, - }, + init: sloppy + ? { + kind: "nullish", + left: bound, + right: lowerSloppyGlobalReceiver(lowerer, node, loc), + type: DYN, + loc, + } + : bound, loc, }; } diff --git a/packages/compiler/src/frontend/lowering/lower-global-value.ts b/packages/compiler/src/frontend/lowering/lower-global-value.ts index e7ab6b0f0..53e538298 100644 --- a/packages/compiler/src/frontend/lowering/lower-global-value.ts +++ b/packages/compiler/src/frontend/lowering/lower-global-value.ts @@ -1,5 +1,5 @@ import * as ts from "../ts7/adapter.js"; -import { DYN, STRING, arrayOf, type IrExpr } from "../../ir/ir.js"; +import { DYN, STRING, arrayOf, type IrExpr, type SrcLoc } from "../../ir/ir.js"; import { locOf } from "../program.js"; import type { Lowerer } from "./lowerer.js"; import { TEMPORAL_GLOBAL } from "../builtin-modules.js"; @@ -8,11 +8,29 @@ import { TEMPORAL_GLOBAL } from "../builtin-modules.js"; * Known globals without a value implementation refuse at runtime; absent host * capabilities answer undefined. The checker supplies names, never values. */ export function lowerGlobalValue(lowerer: Lowerer, expr: ts.Expression): IrExpr { - const loc = locOf(expr); + return lowerGlobalValueOfType(lowerer, lowerer.typeOf(expr), locOf(expr)); +} + +/** The global object a sloppy-mode function receives as `this` when it is + * called with undefined or null: the value `globalThis` reads. */ +export function lowerSloppyGlobalReceiver(lowerer: Lowerer, node: ts.Node, loc: SrcLoc): IrExpr { + const symbol = lowerer.checker.resolveName( + "globalThis", + ts.SymbolFlags.Value, + ts.getSourceFileOfNode(node), + false, + ); + return lowerGlobalValueOfType( + lowerer, + symbol === undefined ? undefined : lowerer.checker.getTypeOfSymbol(symbol), + loc, + ); +} + +function lowerGlobalValueOfType(lowerer: Lowerer, type: ts.Type | undefined, loc: SrcLoc): IrExpr { // The temporal global is typed for the programs that name it (TS 7's // lib slice) but absent in Node 24: reads answer undefined. - const names = lowerer.checker - .getPropertiesOfType(lowerer.typeOf(expr)) + const names = (type === undefined ? [] : lowerer.checker.getPropertiesOfType(type)) .map((property) => ts.symbolName(property)) .filter((name) => name !== TEMPORAL_GLOBAL); return { diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 65b560a22..e9d6f04c6 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3556,6 +3556,8 @@ function buildLibFnSignatures() { "process.pid": { argTypes: [], result: F64 }, "dyn.this": { argTypes: [], result: DYN }, "dyn.generatorThis": { argTypes: [], result: DYN }, + /** A sloppy-mode function's `this` from its receiver: the global object + * for undefined and null, else the receiver. Never throws. */ "process.getuid": { argTypes: [], result: F64 }, "process.getgid": { argTypes: [], result: F64 }, /** geteuid(2) / getegid(2) / getgroups(2) (the effective gid appended diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records-sloppy.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records-sloppy.cjs.json new file mode 100644 index 000000000..7f5478af9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records-sloppy.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/frozen-checked-records-sloppy.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records.ts.json new file mode 100644 index 000000000..a0771e0cf --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/frozen-checked-records.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/frozen-checked-records.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/sloppy-this-global.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/sloppy-this-global.cjs.json new file mode 100644 index 000000000..0bc118bd0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/sloppy-this-global.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/sloppy-this-global.cjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index fafa16eda..039187426 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1095,6 +1095,11 @@ ScrDyn *scr_dyn_freeze(ScrDyn *value); * the programs that link it. */ ScrDyn *scr_dyn_freeze_data(ScrDyn *value); bool scr_dyn_is_frozen(const ScrDyn *value); +/* Whether a record checked from `value` is frozen: `value` is. It answers + * for every value, where scr_dyn_is_frozen refuses values without a modeled + * integrity level: those are not frozen, as Object.freeze refuses them. A + * proxy is frozen exactly when its target is (the proxy invariants). */ +bool scr_dyn_checked_record_frozen(const ScrDyn *value); ScrDyn *scr_dyn_new_bigint(ScrBigInt *value); /* borrows and retains value */ ScrDyn *scr_dyn_new_symbol(ScrSym *value); /* borrows and retains value */ ScrSym *scr_sym_well_known(ScrStr *name); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 1177231ee..ce637eed9 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -19210,26 +19210,29 @@ ScrDyn *scr_dyn_freeze_data(ScrDyn *value) { return NULL; } -bool scr_dyn_is_frozen(const ScrDyn *value) { - if (scr_dyn_class_reflection_fence(value)) return false; +/* Object.isFrozen of `value` with `self` for the values it contains. A value + * without an integrity level the runtime models refuses with SC2020 when + * `refuse` is set, and otherwise answers false: Object.freeze refuses such + * a value too, so it is never frozen. */ +static bool scr_dyn_frozen_answer(const ScrDyn *value, bool (*self)(const ScrDyn *), bool refuse) { if (scr_dyn_published_object(value)) return true; bool answer; - if (scr_dyn_typed_ref_view_answer((ScrDyn *)value, scr_dyn_is_frozen, &answer)) return answer; + if (scr_dyn_typed_ref_view_answer((ScrDyn *)value, self, &answer)) return answer; ScrMap *collection = scr_dyn_native_collection(value); if (collection) return !scr_map_is_extensible(collection); - if (value->symbol_properties && !scr_dyn_is_frozen(value->symbol_properties)) return false; + if (value->symbol_properties && !self(value->symbol_properties)) return false; if (scr_dyn_plain_fn(value)) { ScrDyn *table = scr_dyn_fn_existing_table(value); - answer = table && scr_dyn_is_frozen(table); + answer = table && self(table); scr_dyn_release(table); return answer; } if (value->kind == SCR_DYN_ARR) return scr_dyn_is_sealed(value) && (value->v.arr.len == 0 || value->v.arr.frozen) && - (!value->v.arr.properties || scr_dyn_is_frozen(value->v.arr.properties)); + (!value->v.arr.properties || self(value->v.arr.properties)); if (value->kind == SCR_DYN_BYTES) return (value->v.bytes->is_data_view || value->v.bytes->len == 0) && value->v.bytes->properties && - scr_dyn_is_frozen(value->v.bytes->properties); + self(value->v.bytes->properties); if (value->kind == SCR_DYN_OBJ && !value->copied_from_native) { if (!value->non_extensible) return false; for (size_t i = 0; i < value->v.obj.len; i++) { @@ -19241,11 +19244,29 @@ bool scr_dyn_is_frozen(const ScrDyn *value) { } if (value->kind == SCR_DYN_NULL || value->kind == SCR_DYN_UNDEF || value->kind == SCR_DYN_NUM || value->kind == SCR_DYN_BOOL || value->kind == SCR_DYN_STR || value->kind == SCR_DYN_BIGINT || value->kind == SCR_DYN_SYMBOL) return true; + if (!refuse) return false; static const char message[] = "Object.isFrozen of this native value has no lowering"; scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020"); return false; } +bool scr_dyn_is_frozen(const ScrDyn *value) { + if (scr_dyn_class_reflection_fence(value)) return false; + return scr_dyn_frozen_answer(value, scr_dyn_is_frozen, true); +} + +/* A record checked from `value` copies its integrity: whether `value` is + * frozen, answered for every value the check can meet. A proxy is frozen + * exactly when its target is (the proxy invariants). Object.prototype and + * class constructors, which Object.isFrozen fences, and native values + * without a modeled integrity level are not frozen. */ +bool scr_dyn_checked_record_frozen(const ScrDyn *value) { + while (value && value->kind == SCR_DYN_PROXY) value = value->v.proxy.target; + if (!value || value == scr_builtin_object_prototype) return false; + if (value->kind == SCR_DYN_FUNC && value->v.fn.class_obj && !value->v.fn.class_obj->static_data) return false; + return scr_dyn_frozen_answer(value, scr_dyn_checked_record_frozen, false); +} + double scr_buffer_byte_length_dyn(ScrDyn *value, ScrStr *encoding) { if (value->kind == SCR_DYN_STR) return scr_bytes_byte_length_str(value->v.str, encoding); if (value->kind == SCR_DYN_BYTES) return scr_bytes_byte_len(value->v.bytes); diff --git a/tests/corpus/frozen-checked-records-sloppy.cjs b/tests/corpus/frozen-checked-records-sloppy.cjs new file mode 100644 index 000000000..f6edab231 --- /dev/null +++ b/tests/corpus/frozen-checked-records-sloppy.cjs @@ -0,0 +1,37 @@ +// Untyped values frozen in sloppy-mode code and then held as typed records +// stay frozen: writes through typed parameters and a typed `this` leave them +// unchanged, as Node does, and Object.isFrozen answers true. An unfrozen value +// checked into the same record type stays writable. + +/** @typedef {{ count: number, label: string }} Counter */ + +/** @param {Counter} c @param {number} by */ +function bump(c, by) { + c.count += by; + c.label = c.label + "!"; + return c.count; +} + +/** @this {Counter} @param {number} by */ +function bumpThis(by) { + this.count += by; + this.label = "this"; + return this.count; +} + +/** @type {Counter} */ +const literal = Object.freeze({ count: 1, label: "a" }); +console.log(bump(literal, 2), bumpThis.call(literal, 3), JSON.stringify(literal), Object.isFrozen(literal)); + +/** @type {Counter} */ +const parsed = Object.freeze(JSON.parse('{"count":10,"label":"b"}')); +console.log(bump(parsed, 2), bumpThis.call(parsed, 3), JSON.stringify(parsed), Object.isFrozen(parsed)); + +/** @type {Counter} */ +const open = JSON.parse('{"count":100,"label":"c"}'); +console.log(bump(open, 2), bumpThis.call(open, 3), JSON.stringify(open), Object.isFrozen(open)); + +/** @type {Counter[]} */ +const list = [Object.freeze({ count: 5, label: "l" }), { count: 6, label: "m" }]; +for (const item of list) bump(item, 1); +console.log(JSON.stringify(list), Object.isFrozen(list[0]), Object.isFrozen(list[1])); diff --git a/tests/corpus/frozen-checked-records.ts b/tests/corpus/frozen-checked-records.ts new file mode 100644 index 000000000..1c205079e --- /dev/null +++ b/tests/corpus/frozen-checked-records.ts @@ -0,0 +1,29 @@ +// An untyped value frozen and then held as a typed record stays frozen: +// writes to it in strict mode code throw Node's TypeError and leave it +// unchanged. An unfrozen value checked into the same record type stays +// writable. + +type Counter = { count: number; label: string }; + +function bump(c: Counter, by: number): string { + try { + c.count += by; + return "wrote " + c.count; + } catch (e) { + return e instanceof TypeError ? "TypeError" : "other"; + } +} + +const frozen = Object.freeze(JSON.parse('{"count":1,"label":"a"}')) as Counter; +console.log(bump(frozen, 2), frozen.count, Object.isFrozen(frozen)); + +const open = JSON.parse('{"count":10,"label":"b"}') as Counter; +console.log(bump(open, 2), open.count, Object.isFrozen(open)); + +const raw: unknown = Object.freeze(JSON.parse('{"count":20,"label":"c"}')); +const viaUnknown = raw as Counter; +console.log(bump(viaUnknown, 2), viaUnknown.count, Object.isFrozen(viaUnknown)); + +// A proxy checked into the record type stays writable. +const proxied = new Proxy({ count: 40, label: "q" }, {}) as Counter; +console.log(bump(proxied, 2), proxied.count); diff --git a/tests/corpus/sloppy-this-global.cjs b/tests/corpus/sloppy-this-global.cjs new file mode 100644 index 000000000..1fef1859a --- /dev/null +++ b/tests/corpus/sloppy-this-global.cjs @@ -0,0 +1,52 @@ +// In sloppy-mode functions, `this` is the global object when the function is +// called with undefined or null as its receiver: by a plain call, by a +// detached method, and through call and apply. Strict functions and arrows +// keep the receiver they get. + +// Before anything else reads the global object. +function readConsole() { + return typeof this.console.log === "function" && this.console === console; +} +console.log(readConsole(), readConsole.call(null), readConsole.call({ console: 1 })); + +function receiver() { + return this; +} +function kind() { + return typeof this; +} +function strictReceiver() { + "use strict"; + return this; +} + +console.log(receiver() === globalThis, kind()); +console.log(receiver.call(undefined) === globalThis, receiver.call(null) === globalThis); +console.log(receiver.apply(undefined, []) === globalThis, receiver.apply(null) === globalThis); +console.log(strictReceiver() === undefined, strictReceiver.call(null) === null); + +const holder = { + name: "holder", + self() { + return this; + }, +}; +const detached = holder.self; +console.log(holder.self() === holder, detached() === globalThis); + +function outer() { + function inner() { + return this; + } + return inner() === globalThis; +} +console.log(outer(), outer.call({ name: "o" })); + +globalThis.sloppyMarker = 42; +function readMarker() { + return this.sloppyMarker; +} +console.log(readMarker(), readMarker.call(undefined), readMarker.call({ sloppyMarker: 7 })); + +const arrow = () => this; +console.log(arrow() === module.exports, arrow.call(null) === module.exports); diff --git a/tests/harness/corpus-costs/frozen-checked-records-sloppy.cjs.json b/tests/harness/corpus-costs/frozen-checked-records-sloppy.cjs.json new file mode 100644 index 000000000..6289d836d --- /dev/null +++ b/tests/harness/corpus-costs/frozen-checked-records-sloppy.cjs.json @@ -0,0 +1 @@ +{ "plain": 3590, "san": 12400 } diff --git a/tests/harness/corpus-costs/frozen-checked-records.ts.json b/tests/harness/corpus-costs/frozen-checked-records.ts.json new file mode 100644 index 000000000..1086c8461 --- /dev/null +++ b/tests/harness/corpus-costs/frozen-checked-records.ts.json @@ -0,0 +1 @@ +{ "plain": 2610, "san": 12260 } diff --git a/tests/harness/corpus-costs/sloppy-this-global.cjs.json b/tests/harness/corpus-costs/sloppy-this-global.cjs.json new file mode 100644 index 000000000..66f469d88 --- /dev/null +++ b/tests/harness/corpus-costs/sloppy-this-global.cjs.json @@ -0,0 +1 @@ +{ "plain": 3030, "san": 3970 } From efdd53912d88fdaee00c21e3d33c328abb24926f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 27 Oct 2026 23:43:34 -0500 Subject: [PATCH 2/4] Answer builtin globals read through a sloppy-mode function's this A sloppy-mode function called with undefined or null as this reads the global object, which answers builtin globals through the program's lookup. Modules whose sloppy-mode functions read builtin globals through this, or through a variable it initializes, now install that lookup; it answers those names, or every declared global when a module reads the global object by computed names. --- .../src/frontend/lowering/global-values.ts | 116 ++++++++++++++++-- .../src/frontend/lowering/lower-modules.ts | 10 +- .../compiler/src/frontend/lowering/lowerer.ts | 8 ++ tests/corpus/sloppy-this-global.cjs | 10 ++ 4 files changed, 128 insertions(+), 16 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/global-values.ts b/packages/compiler/src/frontend/lowering/global-values.ts index 21593203d..6fc3cb694 100644 --- a/packages/compiler/src/frontend/lowering/global-values.ts +++ b/packages/compiler/src/frontend/lowering/global-values.ts @@ -24,6 +24,7 @@ import { type SrcLoc, } from "../../ir/ir.js"; import { isJsSourceFile } from "../program.js"; +import { isStrictCode } from "./strict-mode.js"; import type { Lowerer } from "./lowerer.js"; import { TEMPORAL_GLOBAL } from "../builtin-modules.js"; import { stdlibGlobalNameOf } from "./surfaces.js"; @@ -166,22 +167,115 @@ const URI_FUNCTIONS = new Set([ "decodeURIComponent", ]); +/** The builtin globals that sloppy-mode functions of the JavaScript source + * `sf` read through their `this` (`this.Array`) or through a variable it + * initializes (`var root = this; root.Array`). Called with undefined or null + * as `this`, such a function reads them on the global object, which answers + * them through the program's lookup. */ +export function sloppyThisGlobalReadsOf(lowerer: Lowerer, sf: ts.SourceFile): ReadonlySet { + const cached = lowerer.sloppyThisGlobalReads.get(sf); + if (cached !== undefined) return cached; + const reads = new Set(); + if (isJsSourceFile(sf)) { + const declared = new Set(declaredGlobalNames(lowerer, sf)); + ts.walkPreorder(sf, (node) => { + if ( + ts.isPropertyAccessExpression(node) && + ts.isIdentifier(node.name) && + declared.has(node.name.text) && + readsSloppyThis(lowerer, node.expression) + ) + reads.add(node.name.text); + return undefined; + }); + } + lowerer.sloppyThisGlobalReads.set(sf, reads); + return reads; +} + +/** Whether `expr` is the `this` of a sloppy-mode function, or a variable + * whose only declaration initializes it with that `this`. */ +function readsSloppyThis(lowerer: Lowerer, expr: ts.Expression): boolean { + let inner = expr; + while (ts.isParenthesizedExpression(inner)) inner = inner.expression; + if (inner.kind === ts.SyntaxKind.ThisKeyword) return sloppyReceiverOf(inner); + if (!ts.isIdentifier(inner)) return false; + const declarations = lowerer.checker.getSymbolAtLocation(inner)?.declarations ?? []; + const declaration = declarations[0]; + if ( + declarations.length !== 1 || + declaration === undefined || + !ts.isVariableDeclaration(declaration) + ) + return false; + let init = declaration.initializer; + while (init !== undefined && ts.isParenthesizedExpression(init)) init = init.expression; + return init !== undefined && init.kind === ts.SyntaxKind.ThisKeyword && sloppyReceiverOf(init); +} + +/** Whether the `this` at `node` is a sloppy-mode function's receiver + * (lower-functions.ts declareCallReceiver): arrows take the enclosing one. */ +function sloppyReceiverOf(node: ts.Node): boolean { + for (let parent = node.parent; parent !== undefined; parent = parent.parent) { + if (ts.isArrowFunction(parent)) continue; + if (ts.isFunctionLike(parent)) return parent.body !== undefined && !isStrictCode(parent.body); + if ( + ts.isSourceFile(parent) || + ts.isClassDeclaration(parent) || + ts.isClassExpression(parent) || + ts.isClassStaticBlockDeclaration(parent) + ) + return false; + } + return false; +} + +/** The names of the global object's declared properties that the lookup can + * answer. */ +function declaredGlobalNames(lowerer: Lowerer, at: ts.Node): string[] { + const symbol = lowerer.checker.resolveName( + "globalThis", + ts.SymbolFlags.Value, + ts.getSourceFileOfNode(at), + false, + ); + if (symbol === undefined) return []; + return lowerer.checker + .getPropertiesOfType(lowerer.checker.getTypeOfSymbol(symbol)) + .map((property) => ts.symbolName(property)) + .filter((name) => name !== TEMPORAL_GLOBAL && !RUNTIME_ANSWERED.has(name)); +} + +/** The names the program's lookup answers: every declared global when a + * module reads the global object by computed names, else the ones its + * sloppy-mode functions read through `this`. */ +function lookupNames(lowerer: Lowerer): readonly string[] { + if (lowerer.globalLookupNames !== undefined) return lowerer.globalLookupNames; + const modules = lowerer.moduleOrder.length > 0 ? lowerer.moduleOrder : [lowerer.entry]; + const names = modules.some((sf) => computedGlobalReadOf(lowerer, sf) !== null) + ? declaredGlobalNames(lowerer, lowerer.entry) + : modules.flatMap((sf) => [...sloppyThisGlobalReadsOf(lowerer, sf)]); + lowerer.globalLookupNames = [...new Set(names)].sort(); + return lowerer.globalLookupNames; +} + +/** Whether the JavaScript source `sf` installs the program's lookup when its + * body starts: it reads builtin globals through the global object by + * computed names, or through a sloppy-mode function's `this`. */ +export function installsGlobalValues(lowerer: Lowerer, sf: ts.SourceFile): boolean { + return ( + computedGlobalReadOf(lowerer, sf) !== null || sloppyThisGlobalReadsOf(lowerer, sf).size > 0 + ); +} + /** The statement that installs the program's lookup, built on first use - * from the names the checker declares on `receiver` (a global object). */ -export function globalValuesInstall( - lowerer: Lowerer, - receiver: ts.Expression, - loc: SrcLoc, -): IrStmt { + * from the names it answers (lookupNames). */ +export function globalValuesInstall(lowerer: Lowerer, loc: SrcLoc): IrStmt { const fnType: IrType & { kind: "func" } = { kind: "func", params: [STRING], ret: DYN }; if (!lowerer.liftedFnNamed(LOOKUP_FN)) { - const names = lowerer.checker - .getPropertiesOfType(lowerer.typeOf(receiver)) - .map((property) => ts.symbolName(property)) - .filter((name) => name !== TEMPORAL_GLOBAL && !RUNTIME_ANSWERED.has(name)); const name: IrExpr = { kind: "varRef", localId: "name.0", type: STRING, loc }; const body: IrStmt[] = []; - for (const global of [...new Set(names)].sort()) { + for (const global of lookupNames(lowerer)) { const webStream = (WEB_STREAM_CLASSES as readonly string[]).indexOf(global); const value: IrExpr | null = REFUSED.has(global) ? { kind: "unitLit", unit: "null", type: { kind: "nullT" }, loc } diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 9804aebdf..466f80ac8 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -1,4 +1,4 @@ -import { computedGlobalReadOf, globalValuesInstall } from "./global-values.js"; +import { globalValuesInstall, installsGlobalValues } from "./global-values.js"; import type { TypeFailureSlot } from "../../diagnostics/diagnostic.js"; import { THREADS_MODULE } from "../threads-import.js"; import { collectIslandImport } from "./lower-node-island.js"; @@ -3120,10 +3120,10 @@ export function lowerFileInit( }); } if (sf === lowerer.entry) header.push(...lowerer.punycodeLinkWarning()); - // A module that reads builtin globals through the global object by - // computed names installs the program's lookup before its body runs. - const computedGlobalRead = computedGlobalReadOf(lowerer, sf); - if (computedGlobalRead) header.push(globalValuesInstall(lowerer, computedGlobalRead, loc0)); + // A module that reads builtin globals through the global object, by + // computed names or through a sloppy-mode function's `this`, installs + // the program's lookup before its body runs. + if (installsGlobalValues(lowerer, sf)) header.push(globalValuesInstall(lowerer, loc0)); // Top-level `this` of a CommonJS module: its object exists before the // module's first statement (lowerCjsTopLevelThis). const exportsThis = lowerer.cjsExportsThis.get(sf); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 58e5d2ba5..a00d152b0 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -3752,6 +3752,12 @@ export class Lowerer { /** global-values.ts's answer per JavaScript source: the receiver of its * first read of the global object by a computed name, or null. */ readonly computedGlobalReads = new Map(); + /** global-values.ts's answer per JavaScript source: the builtin globals + * its sloppy-mode functions read through their `this`. */ + readonly sloppyThisGlobalReads = new Map>(); + /** global-values.ts's answer: the names the program's global lookup + * answers; undefined until asked. */ + globalLookupNames: readonly string[] | undefined = undefined; /** web-streams-dyn.ts's answer: whether a JavaScript source names a web * stream class; undefined until asked. */ namesWebStreamClass: boolean | undefined = undefined; @@ -4378,6 +4384,8 @@ export class Lowerer { "createRequireBindingTargets", "createRequireElementTargets", "computedGlobalReads", + "sloppyThisGlobalReads", + "globalLookupNames", "namesWebStreamClass", "timeValueDateDecisions", "timeValueDateWrites", diff --git a/tests/corpus/sloppy-this-global.cjs b/tests/corpus/sloppy-this-global.cjs index 1fef1859a..dfca51b30 100644 --- a/tests/corpus/sloppy-this-global.cjs +++ b/tests/corpus/sloppy-this-global.cjs @@ -9,6 +9,16 @@ function readConsole() { } console.log(readConsole(), readConsole.call(null), readConsole.call({ console: 1 })); +// Builtins read through the global `this`, directly and through a variable. +function readBuiltins() { + return [this.Array === Array, this.JSON === JSON, typeof this.setTimeout]; +} +function aliasedRoot() { + var root = this; + return root.Object === Object && typeof root.Promise; +} +console.log(readBuiltins(), aliasedRoot()); + function receiver() { return this; } From 0f47b9cbabe1bd0a6a40a27ae4795604bb5fac4f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 06:05:39 -0500 Subject: [PATCH 3/4] Answer builtin globals read through a sloppy-mode function's this by key A sloppy-mode function called with undefined or null as this reads the global object. Element reads of that this, or of a variable it initializes, now reach the program's lookup too: a literal key (`this["Map"]`) adds that name, and a computed key (`this[name]`) installs the lookup for every declared global, as `globalThis[name]` does. Before, a literal key read the opaque identity token and a computed key threw SC2020. --- .../src/frontend/lowering/global-values.ts | 51 ++++++++++++++++--- .../sloppy-this-global-computed.cjs.json | 6 +++ tests/corpus/sloppy-this-global-computed.cjs | 26 ++++++++++ tests/corpus/sloppy-this-global.cjs | 7 +++ .../sloppy-this-global-computed.cjs.json | 1 + 5 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/sloppy-this-global-computed.cjs.json create mode 100644 tests/corpus/sloppy-this-global-computed.cjs create mode 100644 tests/harness/corpus-costs/sloppy-this-global-computed.cjs.json diff --git a/packages/compiler/src/frontend/lowering/global-values.ts b/packages/compiler/src/frontend/lowering/global-values.ts index 6fc3cb694..af52fe94e 100644 --- a/packages/compiler/src/frontend/lowering/global-values.ts +++ b/packages/compiler/src/frontend/lowering/global-values.ts @@ -168,16 +168,22 @@ const URI_FUNCTIONS = new Set([ ]); /** The builtin globals that sloppy-mode functions of the JavaScript source - * `sf` read through their `this` (`this.Array`) or through a variable it - * initializes (`var root = this; root.Array`). Called with undefined or null - * as `this`, such a function reads them on the global object, which answers - * them through the program's lookup. */ + * `sf` read through their `this` (`this.Array`, `this["Array"]`) or through + * a variable it initializes (`var root = this; root.Array`); every declared + * global when such a read takes a computed key (`this[name]`). Called with + * undefined or null as `this`, such a function reads them on the global + * object, which answers them through the program's lookup. */ export function sloppyThisGlobalReadsOf(lowerer: Lowerer, sf: ts.SourceFile): ReadonlySet { const cached = lowerer.sloppyThisGlobalReads.get(sf); if (cached !== undefined) return cached; const reads = new Set(); if (isJsSourceFile(sf)) { const declared = new Set(declaredGlobalNames(lowerer, sf)); + // Element reads of `this` or of a variable, and the names of the + // variables initialized with `this`: the checker resolves only the + // element reads of `this` and of those variables. + const elementReads: ts.ElementAccessExpression[] = []; + const thisAliases = new Set(); ts.walkPreorder(sf, (node) => { if ( ts.isPropertyAccessExpression(node) && @@ -186,8 +192,32 @@ export function sloppyThisGlobalReadsOf(lowerer: Lowerer, sf: ts.SourceFile): Re readsSloppyThis(lowerer, node.expression) ) reads.add(node.name.text); + else if (ts.isElementAccessExpression(node)) { + const target = withoutParentheses(node.expression); + if (target.kind === ts.SyntaxKind.ThisKeyword || ts.isIdentifier(target)) + elementReads.push(node); + } else if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.initializer !== undefined && + withoutParentheses(node.initializer).kind === ts.SyntaxKind.ThisKeyword + ) + thisAliases.add(node.name.text); return undefined; }); + for (const node of elementReads) { + const target = withoutParentheses(node.expression); + if (ts.isIdentifier(target) && !thisAliases.has(target.text)) continue; + if (!readsSloppyThis(lowerer, node.expression) || !readsBuiltinByName(lowerer, node)) + continue; + const key = withoutParentheses(node.argumentExpression); + if (ts.isStringLiteralLike(key)) { + if (declared.has(key.text)) reads.add(key.text); + } else if (!ts.isNumericLiteral(key)) { + for (const name of declared) reads.add(name); + break; + } + } } lowerer.sloppyThisGlobalReads.set(sf, reads); return reads; @@ -196,8 +226,7 @@ export function sloppyThisGlobalReadsOf(lowerer: Lowerer, sf: ts.SourceFile): Re /** Whether `expr` is the `this` of a sloppy-mode function, or a variable * whose only declaration initializes it with that `this`. */ function readsSloppyThis(lowerer: Lowerer, expr: ts.Expression): boolean { - let inner = expr; - while (ts.isParenthesizedExpression(inner)) inner = inner.expression; + const inner = withoutParentheses(expr); if (inner.kind === ts.SyntaxKind.ThisKeyword) return sloppyReceiverOf(inner); if (!ts.isIdentifier(inner)) return false; const declarations = lowerer.checker.getSymbolAtLocation(inner)?.declarations ?? []; @@ -208,11 +237,17 @@ function readsSloppyThis(lowerer: Lowerer, expr: ts.Expression): boolean { !ts.isVariableDeclaration(declaration) ) return false; - let init = declaration.initializer; - while (init !== undefined && ts.isParenthesizedExpression(init)) init = init.expression; + const init = + declaration.initializer === undefined ? undefined : withoutParentheses(declaration.initializer); return init !== undefined && init.kind === ts.SyntaxKind.ThisKeyword && sloppyReceiverOf(init); } +function withoutParentheses(expr: ts.Expression): ts.Expression { + let inner = expr; + while (ts.isParenthesizedExpression(inner)) inner = inner.expression; + return inner; +} + /** Whether the `this` at `node` is a sloppy-mode function's receiver * (lower-functions.ts declareCallReceiver): arrows take the enclosing one. */ function sloppyReceiverOf(node: ts.Node): boolean { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/sloppy-this-global-computed.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/sloppy-this-global-computed.cjs.json new file mode 100644 index 000000000..ba45eb398 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/sloppy-this-global-computed.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/sloppy-this-global-computed.cjs" + ], + "diags": [] +} diff --git a/tests/corpus/sloppy-this-global-computed.cjs b/tests/corpus/sloppy-this-global-computed.cjs new file mode 100644 index 000000000..ac526e086 --- /dev/null +++ b/tests/corpus/sloppy-this-global-computed.cjs @@ -0,0 +1,26 @@ +// A sloppy-mode function called with undefined or null as its receiver reads +// builtin globals on the global object by computed keys, directly and through +// a variable its `this` initializes. + +function readByKey(key) { + return this[key]; +} +function aliasedRead(key) { + var root = this; + return root[key]; +} + +console.log(readByKey("Array") === Array, readByKey("JSON") === JSON); +console.log(aliasedRead("Map") === Map, typeof aliasedRead("setTimeout")); +console.log(readByKey.call(null, "Promise") === Promise, readByKey.call({ Promise: 1 }, "Promise")); +console.log(readByKey("globalThis") === globalThis, readByKey("missingGlobal")); + +const names = ["Object", "Symbol", "Uint8Array"]; +const values = [Object, Symbol, Uint8Array]; +console.log(names.map((name, i) => readByKey(name) === values[i])); + +function strictRead(key) { + "use strict"; + return this === undefined ? "no receiver" : this[key]; +} +console.log(strictRead("Array"), strictRead.call({ Array: 2 }, "Array")); diff --git a/tests/corpus/sloppy-this-global.cjs b/tests/corpus/sloppy-this-global.cjs index dfca51b30..ca7f212db 100644 --- a/tests/corpus/sloppy-this-global.cjs +++ b/tests/corpus/sloppy-this-global.cjs @@ -19,6 +19,13 @@ function aliasedRoot() { } console.log(readBuiltins(), aliasedRoot()); +// Builtins read through the global `this` by literal keys. +function readByLiteral() { + var root = this; + return [this["Map"] === Map, root["Math"] === Math, typeof this[`Set`], this[0]]; +} +console.log(readByLiteral(), readByLiteral.call({ Map: 1 })[0]); + function receiver() { return this; } diff --git a/tests/harness/corpus-costs/sloppy-this-global-computed.cjs.json b/tests/harness/corpus-costs/sloppy-this-global-computed.cjs.json new file mode 100644 index 000000000..2249e0825 --- /dev/null +++ b/tests/harness/corpus-costs/sloppy-this-global-computed.cjs.json @@ -0,0 +1 @@ +{ "plain": 3300, "san": 4550 } From 6f65b58432e455fd38a95bbaec849345a10846bf Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 07:14:31 -0500 Subject: [PATCH 4/4] Report a namespace's member fences where the program names it The program's builtin lookup holds every declared global when a module reads the global object, or a sloppy-mode function's this, by a computed key. Building the Reflect and JSON namespaces for it reported the SC1090 fence of each member without a function value (Reflect.apply, JSON.rawJSON, ...), so a program that never names those namespaces reported them. A namespace's builder now only creates the functions that throw those fences when called, and builtinNamespaceValue reports the fences, once per program, when a site that names the namespace reads it. The lookup reads the namespaces without reporting; its members still throw their fences when called. --- .../frontend/lowering/builtin-namespaces.ts | 44 ++++++++++++++----- .../src/frontend/lowering/global-values.ts | 14 ++++-- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/builtin-namespaces.ts b/packages/compiler/src/frontend/lowering/builtin-namespaces.ts index c1e40d2e6..07c6bda5b 100644 --- a/packages/compiler/src/frontend/lowering/builtin-namespaces.ts +++ b/packages/compiler/src/frontend/lowering/builtin-namespaces.ts @@ -19,7 +19,7 @@ import { funcOf, } from "../../ir/ir.js"; import { helperLoc, varRef } from "../../ir/build.js"; -import { unsupportedDiag } from "../../diagnostics/diagnostic.js"; +import { unsupportedDiag, type ScrDiagnostic } from "../../diagnostics/diagnostic.js"; import type { Lowerer } from "./lowerer.js"; import { InternalCompilerError } from "../../errors.js"; import { mathMemberValue } from "./lower-stdlib.js"; @@ -117,21 +117,46 @@ const WRITABLE = 1; const CONFIGURABLE = 4; /** The namespace object the stdlib global `canonical` reads as in - * JavaScript, or null when it is not a namespace. */ + * JavaScript, or null when it is not a namespace. The fences of its members + * without a function value are reported unless `reportFences` is false: the + * program's builtin lookup (global-values.ts) holds every namespace without + * the program naming it, and its members still throw their fences when + * called. */ export function builtinNamespaceValue( lowerer: Lowerer, canonical: string, loc: SrcLoc, + reportFences = true, ): IrExpr | null { const members = namespaceMembers(canonical); if (members === null) return null; const name = `%builtin.namespace.${canonical}`; if (!lowerer.liftedFns.some((fn) => fn.name === name)) defineNamespaceBuilder(lowerer, canonical, members, name, helperLoc(loc)); + if (reportFences) reportMemberFences(lowerer, canonical, members, helperLoc(loc)); lowerer.noteEdge(name); return { kind: "call", callee: name, args: [], type: DYN, loc }; } +/** Reports the SC1090 fence of each member of `canonical` whose function + * throws it (fencedMember), once per program. */ +function reportMemberFences( + lowerer: Lowerer, + canonical: string, + members: readonly NamespaceMember[], + loc: SrcLoc, +): void { + for (const { key, length } of members) { + if (length < 0) continue; + const display = `${canonical}.${key}`; + if (!lowerer.liftedFns.some((fn) => fn.name === fenceName(display))) continue; + const diag = fenceDiag(display, loc); + if (lowerer.runtimeFences.some((d) => d.code === diag.code && d.message === diag.message)) + continue; + lowerer.runtimeFences.push(diag); + } +} + /** The lifted function that builds the namespace object once and answers * the same object afterwards. */ function defineNamespaceBuilder( @@ -253,18 +278,17 @@ function functionValue(lowerer: Lowerer, canonical: string, key: string, loc: Sr return fencedMember(lowerer, `${canonical}.${key}`, key, loc); } +const fenceName = (display: string): string => `%builtin.fence.${display}`; +const fenceDiag = (display: string, loc: SrcLoc): ScrDiagnostic => + unsupportedDiag("SC1090", loc, `'${display}' as a function value (call it directly)`); + /** One function per member without a function value: calling it throws the * SC1090 fence, while `typeof`, identity and the property's presence stay - * as in Node. */ + * as in Node. builtinNamespaceValue reports the fence. */ function fencedMember(lowerer: Lowerer, display: string, key: string, loc: SrcLoc): IrExpr { - const name = `%builtin.fence.${display}`; + const name = fenceName(display); if (!lowerer.liftedFns.some((fn) => fn.name === name)) { - const diag = unsupportedDiag( - "SC1090", - loc, - `'${display}' as a function value (call it directly)`, - ); - lowerer.runtimeFences.push(diag); + const diag = fenceDiag(display, loc); lowerer.liftedFns.push({ name, params: [], diff --git a/packages/compiler/src/frontend/lowering/global-values.ts b/packages/compiler/src/frontend/lowering/global-values.ts index af52fe94e..84bb587d1 100644 --- a/packages/compiler/src/frontend/lowering/global-values.ts +++ b/packages/compiler/src/frontend/lowering/global-values.ts @@ -122,11 +122,17 @@ function readsBuiltinByName(lowerer: Lowerer, node: ts.ElementAccessExpression): /** The value a JavaScript source reads for the declared global `name` * taken as a value, whatever its spelling (`name`, `globalThis.name`, * `const { name } = globalThis`, `globalThis[name]`), or null when it - * reads the opaque identity token. */ -export function jsGlobalValueByName(lowerer: Lowerer, name: string, loc: SrcLoc): IrExpr | null { + * reads the opaque identity token. `reportFences` (builtinNamespaceValue) + * is false for the program's lookup. */ +export function jsGlobalValueByName( + lowerer: Lowerer, + name: string, + loc: SrcLoc, + reportFences = true, +): IrExpr | null { return ( jsSpecialGlobalValue(lowerer, name, loc) ?? - builtinNamespaceValue(lowerer, name, loc) ?? + builtinNamespaceValue(lowerer, name, loc, reportFences) ?? builtinGlobalValue(name, loc, true) ); } @@ -318,7 +324,7 @@ export function globalValuesInstall(lowerer: Lowerer, loc: SrcLoc): IrStmt { ? programNamesWebStreamClass(lowerer) ? webStreamClassValue(webStream, loc) : null - : jsGlobalValueByName(lowerer, global, loc); + : jsGlobalValueByName(lowerer, global, loc, false); if (value === null) continue; body.push({ kind: "if",