From a4c19ae4b4ba4b38749713635b6b498d62759f28 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 13:04:21 -0700 Subject: [PATCH 1/8] Host the bare compareArray from the upstream assert.js harness harness/assert.js defines `compareArray(a, b)` beside `assert`: a plain function that RETURNS a boolean, whose throwing counterpart is `assert.compareArray` (already hosted). The adapter now injects the bare name into every prepared test and implements the upstream comparison: read both `.length`s, so a nullish operand throws the same TypeError, return false on a length mismatch, otherwise compare elements by SameValue. That also admits the compareArray.js include when the only use is a direct call. A value reference or property read stays excluded, because it would need the upstream function object and its `format` expando. Co-Authored-By: Claude Opus 5.5 --- tests/harness/test262-runner.test.ts | 12 ++++++++++-- tests/harness/test262.test.ts | 20 ++++++++++++++++++++ tests/test262/harness.ts | 22 ++++++++++++++++++++++ tests/test262/support.mjs | 8 +++++++- 4 files changed, 59 insertions(+), 3 deletions(-) diff --git a/tests/harness/test262-runner.test.ts b/tests/harness/test262-runner.test.ts index 3ddf3ca84..3873d1c34 100644 --- a/tests/harness/test262-runner.test.ts +++ b/tests/harness/test262-runner.test.ts @@ -250,14 +250,22 @@ test("built-in error assertions are admitted without permitting constructor alia expect(exclusion(alias, metadata(alias), "strict")).toBe("harness:assert-surface"); }); -test("the compareArray include only admits the implemented assertion form", () => { +test("the compareArray include admits the assertion and the bare call form only", () => { const accepted = source( "includes: [compareArray.js]", "assert.compareArray([1, NaN], [1, NaN]);", ); expect(exclusion(accepted, metadata(accepted), "strict")).toBeUndefined(); const globalHelper = source("includes: [compareArray.js]", "compareArray([1], [1]);"); - expect(exclusion(globalHelper, metadata(globalHelper), "strict")).toBe( + expect(exclusion(globalHelper, metadata(globalHelper), "strict")).toBeUndefined(); + // assert.js supplies compareArray, so a test needs no include to call it. + const withoutInclude = source("description: intl", "assert(!compareArray([1], [2]));"); + expect(exclusion(withoutInclude, metadata(withoutInclude), "strict")).toBeUndefined(); + const valueReference = source( + "includes: [compareArray.js]", + "const compare = compareArray; assert(compare([1], [1]));", + ); + expect(exclusion(valueReference, metadata(valueReference), "strict")).toBe( "harness:compareArray-surface", ); }); diff --git a/tests/harness/test262.test.ts b/tests/harness/test262.test.ts index 678685103..948c016bb 100644 --- a/tests/harness/test262.test.ts +++ b/tests/harness/test262.test.ts @@ -189,6 +189,26 @@ assert.notSameValue(1, "1"); source: "assert.compareArray([1, 2], [1, 3]);", }, { name: "array signed zero mismatch", status: "fail", source: "assert.compareArray([0], [-0]);" }, + { + name: "bare compareArray returns true for matching contents", + status: "pass", + source: "assert(compareArray([1, NaN, -0], [1, NaN, -0]));", + }, + { + name: "bare compareArray returns false for a length or element mismatch", + status: "pass", + source: "assert(!compareArray([1], [1, 2])); assert(!compareArray([1, 2], [1, 3]));", + }, + { + name: "bare compareArray compares operands without a length as equal", + status: "pass", + source: "assert(compareArray(1, 2)); assert(compareArray({}, {}));", + }, + { + name: "bare compareArray does not throw on a mismatch", + status: "fail", + source: "assert(compareArray([1], [2]));", + }, { name: "assert requires true, not truthiness", status: "fail", source: "assert(1);" }, { name: "exact built-in exception", diff --git a/tests/test262/harness.ts b/tests/test262/harness.ts index dce1be99c..8a0f7bd1a 100644 --- a/tests/test262/harness.ts +++ b/tests/test262/harness.ts @@ -60,6 +60,28 @@ export function assert(value: unknown, message?: unknown): void { if (value !== true) throw new Test262Error(text(message, "Expected true")); } +function lengthOf(value: unknown): unknown { + return (value as { length?: unknown }).length; +} + +function elementAt(value: unknown, index: number): unknown { + return (value as Record)[index]; +} + +/** The bare `compareArray(a, b)` the upstream `harness/assert.js` defines + * alongside `assert`. It RETURNS a boolean (the throwing assertion is + * `assert.compareArray`), and it has no shape checks of its own: it reads + * `.length` off both operands, so a null operand throws TypeError there and + * here, and two operands without a length of their own compare equal. */ +export function compareArray(actual: unknown, expected: unknown): boolean { + const length = lengthOf(actual); + if (lengthOf(expected) !== length) return false; + for (let i = 0; typeof length === "number" && i < length; i++) { + if (!same(elementAt(expected, i), elementAt(actual, i))) return false; + } + return true; +} + export namespace assert { export function sameValue(actual: unknown, expected: unknown, message?: unknown): void { if (!same(actual, expected)) throw new Test262Error(text(message, "Expected SameValue")); diff --git a/tests/test262/support.mjs b/tests/test262/support.mjs index 52739f91a..ecb5d45c4 100644 --- a/tests/test262/support.mjs +++ b/tests/test262/support.mjs @@ -169,7 +169,11 @@ export function exclusion(source, meta, variant) { // Supported assertion calls; aliases, mutations, and reflection stay out. } else reason = "harness:assert-surface"; } else if (meta.includes.includes("compareArray.js") && ts.isIdentifier(node) && node.text === "compareArray" && + !(ts.isCallExpression(node.parent) && node.parent.expression === node) && !(ts.isPropertyAccessExpression(node.parent) && node.parent.name === node && ts.isIdentifier(node.parent.expression) && node.parent.expression.text === "assert")) { + // assert.js defines the bare compareArray; only its direct call form is + // hosted (a value reference or property read would need the upstream + // function object and its format expando). reason = "harness:compareArray-surface"; } else if (ts.isIdentifier(node) && node.text === "Test262Error") { if (!ts.isNewExpression(node.parent) || node.parent.expression !== node) reason = "harness:Test262Error-surface"; @@ -192,7 +196,9 @@ export function exclusion(source, meta, variant) { } export function prepare(source, asyncTest = false, marker = completion, variant = "strict", includes = []) { - const names = asyncTest ? "assert, Test262Error, $DONE" : "assert, Test262Error"; + const names = asyncTest + ? "assert, compareArray, Test262Error, $DONE" + : "assert, compareArray, Test262Error"; const end = asyncTest ? "" : `;console.log(${JSON.stringify(marker)});\n`; const helpers = (includes.includes("propertyHelper.js") ? `${propertyHelperSource}\n` : "") + From c6db09c8e4f1c3d6bb4e15f372e2758f52af13b8 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 13:07:02 -0700 Subject: [PATCH 2/8] Relax the async-iterator protocol demand in untyped JavaScript TS2504 demands a `[Symbol.asyncIterator]()` method that returns an async iterator. In untyped JavaScript, whether a value is async-iterable is a runtime fact of the value rather than a static one, exactly like the sync demand (2488) relaxed just above. `for await` over a non-async-iterable and `yield*` in an async generator both lower through the runtime protocol openers, which read the method, call it, and throw the same TypeError Node throws when it is missing, uncallable, or returns a non-object. Only diagnostics change, so only variants that previously held a 2504 refusal can move: the relaxed set is consulted only when JavaScript strictness is suppressed, and 2504 is the sole code this adds. The sync sibling 2505 stays enforced on the typed lanes, and TypeScript sources keep 2504 because the relaxation applies to `.js`/`.mjs`/`.cjs`/`.jsx` only. Corpus 8423-async-iterator-protocol-shapes.js pins the seven protocol shapes Node rejects and the two it accepts. Co-Authored-By: Claude Opus 5.5 --- .../frontend/inferred-js-diagnostics.test.ts | 20 ++++ packages/compiler/src/frontend/tsc-codes.ts | 10 ++ .../8423-async-iterator-protocol-shapes.js | 96 +++++++++++++++++++ ...423-async-iterator-protocol-shapes.js.json | 1 + 4 files changed, 127 insertions(+) create mode 100644 tests/corpus/8423-async-iterator-protocol-shapes.js create mode 100644 tests/harness/corpus-costs/8423-async-iterator-protocol-shapes.js.json diff --git a/packages/compiler/src/frontend/inferred-js-diagnostics.test.ts b/packages/compiler/src/frontend/inferred-js-diagnostics.test.ts index 96d285f03..49eab16b5 100644 --- a/packages/compiler/src/frontend/inferred-js-diagnostics.test.ts +++ b/packages/compiler/src/frontend/inferred-js-diagnostics.test.ts @@ -94,3 +94,23 @@ test.for([ invalid, ); }); + +test("JavaScript async generators may delegate over a runtime async-iterator shape", () => { + expect( + diagnostics( + `export async function* delegated() { yield* { [Symbol.asyncIterator]: 42 }; } +export async function* looped() { for await (const value of { [Symbol.asyncIterator]() { return "text"; } }) console.dir(value); }`, + 'import { delegated, looped } from "./library.js"; delegated(); looped();', + ), + ).toEqual([]); +}); + +test("TypeScript keeps the async-iterator demand", () => { + expect( + diagnostics( + "export async function* delegated() { yield* { [Symbol.asyncIterator]: 42 }; }", + 'import { delegated } from "./library.js"; delegated();', + "ts", + ).some((diagnostic) => diagnostic.message.includes("Symbol.asyncIterator")), + ).toBe(true); +}); diff --git a/packages/compiler/src/frontend/tsc-codes.ts b/packages/compiler/src/frontend/tsc-codes.ts index a30945be7..1f9ce923e 100644 --- a/packages/compiler/src/frontend/tsc-codes.ts +++ b/packages/compiler/src/frontend/tsc-codes.ts @@ -109,6 +109,16 @@ export const JS_RELAXED_TSC_CODES: ReadonlySet = new Set([ 2488, 2721, 2722, + // the async-iterator protocol demand (2504: "must have a + // '[Symbol.asyncIterator]()' method that returns an async iterator"). + // Whether a value is async-iterable is a runtime fact of untyped JS, exactly + // like the sync demand (2488) relaxed above: `for await` over a non-iterable + // value and `yield*` in an async generator both lower through the runtime + // protocol openers, which read the method, call it, and throw TypeError at + // the site when it is missing, uncallable, or returns a non-object — the + // same protocol failure Node reports. 2505 is the sync sibling on the typed + // lowering lanes and stays enforced. + 2504, // an UNUSED @ts-expect-error: the directive annotated an error visible // under the project's OWN tsconfig world — scriptc's world deliberately // differs (adopted options, hidden declaration twins), and an unused diff --git a/tests/corpus/8423-async-iterator-protocol-shapes.js b/tests/corpus/8423-async-iterator-protocol-shapes.js new file mode 100644 index 000000000..f27d3acb6 --- /dev/null +++ b/tests/corpus/8423-async-iterator-protocol-shapes.js @@ -0,0 +1,96 @@ +// Async iteration over values whose [Symbol.asyncIterator] shape only exists +// at run time. JavaScript puts no static demand on the receiver: the protocol +// reads the method, calls it, and throws TypeError when the method is missing, +// is not callable, or returns a non-object. The checker must not refuse the +// source for the shapes inference cannot prove. +function report(label, fn) { + return Promise.resolve() + .then(fn) + .then( + (value) => console.log(label, "ok", value), + (error) => console.log(label, error instanceof TypeError, error.name), + ); +} + +async function* delegate(value) { + yield* value; +} + +async function drain(value) { + const values = []; + for await (const item of value) values.push(item); + return values.join(","); +} + +async function main() { + // A well-formed async iterator written inline still delegates. + await report("inline", () => + drain({ + async *[Symbol.asyncIterator]() { + yield "a"; + yield "b"; + }, + }), + ); + + // The method is a data property, not a function. + await report("method-not-callable", () => drain({ [Symbol.asyncIterator]: 42 })); + + // The method returns a number instead of an iterator object. + await report("result-not-object", () => drain({ [Symbol.asyncIterator]() { return 42; } })); + + // The returned iterator's next is not callable. + await report("next-not-callable", () => + drain({ + [Symbol.asyncIterator]() { + return { next: "nope" }; + }, + }), + ); + + // No async iterator at all and no sync fallback either. + await report("no-protocol", () => drain({ value: 1 })); + + // yield* delegates through the same protocol, so it throws the same way. + await report("yield-star-not-callable", async () => { + let seen = ""; + for await (const value of delegate({ [Symbol.asyncIterator]: true })) seen += value; + return seen; + }); + + await report("yield-star-result-not-object", async () => { + let seen = ""; + for await (const value of delegate({ [Symbol.asyncIterator]() { return "text"; } })) seen += value; + return seen; + }); + + // A non-callable async iterator wins over a working sync iterator: the + // protocol only falls back when the method is undefined. + await report("async-shadows-sync", () => + drain({ + [Symbol.asyncIterator]: 0, + *[Symbol.iterator]() { + yield "never"; + }, + }), + ); + + // A value with only a sync iterable iterates through the sync fallback. + await report("sync-only", () => + drain({ + *[Symbol.iterator]() { + yield "s1"; + yield "s2"; + }, + }), + ); + + // yield* over arrays and strings keeps working. + await report("yield-star-array", async () => { + let seen = ""; + for await (const value of delegate([1, 2])) seen += value; + return seen; + }); +} + +main(); diff --git a/tests/harness/corpus-costs/8423-async-iterator-protocol-shapes.js.json b/tests/harness/corpus-costs/8423-async-iterator-protocol-shapes.js.json new file mode 100644 index 000000000..35e276558 --- /dev/null +++ b/tests/harness/corpus-costs/8423-async-iterator-protocol-shapes.js.json @@ -0,0 +1 @@ +{ "plain": 8004 } From b234e06f2055a7daeeb2b26336c94614652ff006 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 15:10:30 -0700 Subject: [PATCH 3/8] Enforce the ECMAScript early errors the checker does not model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Static semantics reject a program before it runs. Until now the compiler borrowed that rejection from whatever tsc happened to report, and Test262's negative-parse suites show where the borrowing stops: programs the spec makes SyntaxErrors compile and run. This adds the two largest families the grab-bag misses (plan §7.7 D-4, option c), each under its own preflight code, so a checker relaxation can never remove the only rejection a parse-negative program had: SC0005 a statement list declares one lexical name twice (`{ class f {} async function f() {} }`) SC0006 a lexical name is `var`-declared in the same statement list (`switch (1) { case 1: let x; case 2: var x; }`) SC0007 a declaration where the grammar admits only a Statement (`while (false) function f() {}`, `l: class c {}`, `if (x) async function f() {}`) frontend/early-errors.ts is a preflight walk over JavaScript sources, so a rejected program fails where a tsc parse error fails, before any lowering. A case block is one lexical scope across its clauses; a list's VarDeclaredNames reach into nested blocks but never into a function or a class body; and Annex B's allowances are honored exactly: two plain function declarations may repeat in a sloppy block (B.3.3.1), and a plain function may be an if/else body or a labelled statement in sloppy code (B.3.2) — an async function, a generator or a class never gets either allowance, and neither does any function as a loop or 'with' body. Strictness comes from Node's own module classification (isNodeEsmFile7), not from the checker's external-module bit: moduleDetection=force gives every file module scope, CommonJS included, and taking that bit as strictness cost the Annex B exemption on the annexB function-redeclaration tests. Typed sources are untouched — their checker diagnostics (TS2451 and friends) remain the gate, and re-reporting a collision under a second code would only duplicate them. tests/test262/support.mjs counts one of these codes at a location inside the test's own source as the parse-negative match, the same standard the helper already applies to tsc's early errors. The diagnostics fixtures pin both families, including the strict half of SC0007 where Node refuses the two Annex B shapes as well. The pass only reads the AST and only refuses programs, so no typed program's IR changes. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/coverage/surface-manifest.ts | 2 +- packages/compiler/src/diagnostics/codes.ts | 3 + .../compiler/src/diagnostics/diagnostic.ts | 8 +- .../compiler/src/frontend/early-errors.ts | 364 ++++++++++++++++++ packages/compiler/src/frontend/program.ts | 6 + packages/compiler/src/frontend/tsc-codes.ts | 15 + packages/compiler/surface-manifest.json | 2 +- tests/diagnostics/js-block-redeclaration.js | 18 + .../js-single-statement-declaration.js | 10 + .../diagnostics/js-single-statement-strict.js | 8 + .../js-block-redeclaration.js.txt | 41 ++ .../js-single-statement-declaration.js.txt | 48 +++ .../js-single-statement-strict.js.txt | 20 + tests/test262/support.mjs | 21 + 14 files changed, 562 insertions(+), 4 deletions(-) create mode 100644 packages/compiler/src/frontend/early-errors.ts create mode 100644 tests/diagnostics/js-block-redeclaration.js create mode 100644 tests/diagnostics/js-single-statement-declaration.js create mode 100644 tests/diagnostics/js-single-statement-strict.js create mode 100644 tests/harness/__snapshots__/js-block-redeclaration.js.txt create mode 100644 tests/harness/__snapshots__/js-single-statement-declaration.js.txt create mode 100644 tests/harness/__snapshots__/js-single-statement-strict.js.txt diff --git a/packages/compiler/src/coverage/surface-manifest.ts b/packages/compiler/src/coverage/surface-manifest.ts index b87a8065d..d0cff1eb2 100644 --- a/packages/compiler/src/coverage/surface-manifest.ts +++ b/packages/compiler/src/coverage/surface-manifest.ts @@ -98,7 +98,7 @@ function coverageNotes(): string[] { "Entries with status 'unsupported' describe where the named code is raised: forms of the construct outside the supported subset are refused with that code — not that every form of the named feature is refused. Supported forms appear as their own static entries where a table projects them.", `The engine-free fetch projection targets Node ${fetchTarget.node} with bundled Undici ${fetchTarget.undici}. Each projected row names the differential evidence that guards it; changing the pinned Node or Undici version is an explicit profile update.`, "The fetch profile also contains a runtime-reflected census of the selected fetch, abort, Headers, and readable-stream interfaces plus RequestInit/ResponseInit dictionary reads. Static and unsupported census rows are projected here; its explicitly out-of-scope metadata rows and adjacent-interface exclusions remain in the profile so absence is deliberate rather than ambiguous.", - "Process-level diagnostic codes are not surface entries: SC0001-SC0004 are preflight gates, SC1110 is a comptime evaluation failure, SC3001/SC3002 are backend/target tier refusals, SC9001/SC9002 are internal errors.", + "Process-level diagnostic codes are not surface entries: SC0001-SC0007 are preflight gates (SC0005-SC0007 are the ECMAScript early errors), SC1110 is a comptime evaluation failure, SC3001/SC3002 are backend/target tier refusals, SC9001/SC9002 are internal errors.", "Entry statuses are projected for the desktop targets. The mobile targets (aarch64-apple-ios, aarch64-apple-ios-simulator, aarch64-linux-android) compile library-mode archives only: the library-admissible surface (what SC4005's async_free requirement and the library link set admit) is supported there, the executable lane refuses those triples with SC3002, and no entry outside the library-admissible surface carries a mobile support claim. iOS archives build for iOS 15.0 on darwin hosts; Android archives build against NDK API level 26.", "No scheduling metadata is published; entry ids are the stable diff keys across releases.", ]; diff --git a/packages/compiler/src/diagnostics/codes.ts b/packages/compiler/src/diagnostics/codes.ts index 1e35903cd..bd597cb01 100644 --- a/packages/compiler/src/diagnostics/codes.ts +++ b/packages/compiler/src/diagnostics/codes.ts @@ -19,6 +19,9 @@ export const DIAGNOSTIC_CODES = { SC0002: "project configuration incompatible with scriptc", SC0003: "malformed imported JSON module", SC0004: "TypeScript checker panic", + SC0005: "duplicate lexical declaration in a block or switch statement list", + SC0006: "lexical declaration colliding with a var in the same block", + SC0007: "declaration in a single-statement position", SC1010: "package imports outside the supported set", SC1012: "default exports/imports (residual edges)", SC1013: "namespace imports (residual edges)", diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index 0cabb6ddb..de377441f 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -7,8 +7,12 @@ * Code bands: * SC0xxx preflight gates: TypeScript errors passed through (SC0001), * project-config incompatibilities (SC0002), malformed imported - * JSON modules (SC0003), and upstream tsgo checker panics - * surfaced as source-anchored diagnostics (SC0004) + * JSON modules (SC0003), upstream tsgo checker panics surfaced + * as source-anchored diagnostics (SC0004), and the ECMAScript + * early errors the checker does not model: a duplicated or + * `var`-colliding lexical declaration in a block or switch + * statement list (SC0005/SC0006) and a declaration where the + * grammar admits only a Statement (SC0007) * SC1xxx supported-TypeScript-not-yet: valid TS outside the current subset * SC2xxx scriptc type rules (types we cannot compile yet) * SC3xxx backend/target coverage: the program is valid, but the selected diff --git a/packages/compiler/src/frontend/early-errors.ts b/packages/compiler/src/frontend/early-errors.ts new file mode 100644 index 000000000..37dd71b48 --- /dev/null +++ b/packages/compiler/src/frontend/early-errors.ts @@ -0,0 +1,364 @@ +/* ECMAScript early errors the TypeScript checker does not model. + * + * Static semantics reject a program before it runs, and until this pass + * existed scriptc borrowed that rejection from whatever tsc happened to + * report (`Let x` declarations at an odd position, a redeclared block name + * TypeScript refuses). Test262's negative-parse suites show where the + * borrowing stops: 1,198 programs the spec makes SyntaxErrors compile — the + * constructs below are the ones the corpus covers, in descending variant + * count (plan §7.7 D-4 option (c)). + * + * One code per construct, so C1's checker relaxation stays safe: relaxing a + * tsc code that doubles as an early error removes the ONLY rejection some + * parse-negative test had, which is why these codes live beside + * JS_RELAXED_TSC_CODES in tsc-codes.ts and never depend on it. + * + * Scope: JavaScript sources only. A TypeScript source is type-checked, and + * its checker diagnostics (TS2451 and friends) are the gate; re-reporting + * the same collision under a second code would only duplicate them. + * + * The pass runs at preflight, before any lowering, and reports through + * SC0xxx — the preflight band — so a rejected program fails the same way a + * tsc error does. */ + +import * as ts from "./ts7/adapter.js"; +import type { ScrDiagnostic } from "../diagnostics/diagnostic.js"; +import type { SrcLoc } from "../ir/ir.js"; +import { isJsSourceFileName } from "./tsc-codes.js"; + +/** The early-error codes. SC0005 and SC0006 are the Block/CaseBlock static + * semantics (a statement list may not declare one lexical name twice, and a + * lexical name may not be `var`-declared in the same list); SC0007 is a + * declaration where the grammar admits only a Statement. */ +export const EARLY_ERROR_CODES = ["SC0005", "SC0006", "SC0007"] as const; + +const NO_DUPLICATE_LEXICAL = "SC0005"; +const LEXICAL_VAR_COLLISION = "SC0006"; +const NOT_A_STATEMENT = "SC0007"; + +/** Whether the code around a node is strict — the one input this pass needs + * from outside: Node's own module classification (program.ts's + * isNodeEsmFile7: fixed extensions, package type, then syntax markers). The + * checker's external-module bit cannot answer it, because + * moduleDetection=force gives every file module scope, CommonJS included. */ +export type IsStrictCode = (sf: ts.SourceFile, node: ts.Node) => boolean; + +export function earlyErrorDiagnostics7( + files: readonly ts.SourceFile[], + isModuleCode: (sf: ts.SourceFile) => boolean, +): ScrDiagnostic[] { + const diags: ScrDiagnostic[] = []; + const isStrict: IsStrictCode = (sf, node) => isStrictCode(sf, node, isModuleCode); + for (const sf of files) { + if (!isJsSourceFileName(sf.fileName)) continue; + // Iterative walk: a deeply nested program must not overflow the stack + // before the lowering's own nesting fence runs. + const stack: ts.Node[] = [sf]; + while (stack.length > 0) { + const node = stack.pop()!; + checkStatementListScope(sf, node, diags, isStrict); + checkStatementSlot(node, diags, isStrict); + ts.forEachChild(node, (child) => { + stack.push(child); + return undefined; + }); + } + } + return diags; +} + +function locOf(node: ts.Node): SrcLoc { + const sf = ts.getSourceFileOfNode(node); + return { file: sf.fileName, start: node.getStart(sf), end: node.end }; +} + +function report(diags: ScrDiagnostic[], code: EarlyErrorCode, node: ts.Node, message: string) { + diags.push({ code, message, loc: locOf(node) }); +} + +type EarlyErrorCode = (typeof EARLY_ERROR_CODES)[number]; + +/* ── SC0005 / SC0006: Block and CaseBlock static semantics ─────────────── + * + * Block : { StatementList } + * CaseBlock : { CaseClauses_opt DefaultClause CaseClauses_opt } + * + * * It is a Syntax Error if the LexicallyDeclaredNames of StatementList + * contains any duplicate entries. + * * It is a Syntax Error if any element of the LexicallyDeclaredNames of + * StatementList also occurs in the VarDeclaredNames of StatementList. + * + * A case block is ONE lexical scope across its clauses, and VarDeclaredNames + * reach into nested blocks (a `var` anywhere below the block belongs to it) + * but never into a function or class body. The duplicate rule's one carve-out + * is Annex B's B.3.3.1: two plain function declarations of the same name in + * the same block are legal in non-strict code. */ + +function checkStatementListScope( + sf: ts.SourceFile, + node: ts.Node, + diags: ScrDiagnostic[], + isStrict: IsStrictCode, +): void { + let statements: readonly ts.Statement[] | null = null; + if (ts.isBlock(node)) statements = node.statements.nodes; + else if (ts.isCaseBlock(node)) { + statements = node.clauses.nodes.flatMap((clause) => clause.statements.nodes); + } + if (statements === null) return; + if (statements.length < 2) return; + + const lexical = new Map(); + for (const stmt of statements) { + for (const [name, decl] of lexicalDeclarationsOf(stmt)) { + const list = lexical.get(name); + if (list === undefined) lexical.set(name, [decl]); + else list.push(decl); + } + } + if (lexical.size === 0) return; + + const varNames = new Set(); + for (const stmt of statements) collectVarDeclaredNames(stmt, varNames); + + for (const [name, decls] of lexical) { + if (varNames.has(name)) { + report( + diags, + LEXICAL_VAR_COLLISION, + decls[0]!, + `the lexical declaration '${name}' collides with a 'var' declaration of the same name in this block`, + ); + continue; + } + if (decls.length < 2) continue; + // Annex B B.3.3.1: duplicate plain function declarations are the web's + // legacy, and only non-strict code keeps them. + if (decls.every((d) => ts.isFunctionDeclaration(d) && !isEnhancedFunction(d))) { + if (!isStrict(sf, decls[0]!)) continue; + } + report( + diags, + NO_DUPLICATE_LEXICAL, + decls[1]!, + `the declaration '${name}' is already declared in this block's statement list`, + ); + } +} + +/** The names a DIRECT statement of a statement list declares lexically + * (let/const/using, class, and every function declaration form), paired with + * the node to point the diagnostic at. */ +function lexicalDeclarationsOf(stmt: ts.Statement): Array<[string, ts.Node]> { + const names: Array<[string, ts.Node]> = []; + if (ts.isVariableStatement(stmt)) { + if ((stmt.declarationList.flags & ts.NodeFlags.BlockScoped) === 0) return names; + for (const decl of stmt.declarationList.declarations.nodes) + collectBindingNames(decl.name, names); + return names; + } + if (ts.isClassDeclaration(stmt) || ts.isFunctionDeclaration(stmt)) { + const name = stmt.name; + if (name !== undefined && ts.isIdentifier(name)) names.push([name.text, stmt]); + } + return names; +} + +function collectBindingNames(name: ts.BindingName, into: Array<[string, ts.Node]>): void { + if (ts.isIdentifier(name)) { + into.push([name.text, name]); + return; + } + for (const element of name.elements.nodes) { + if (ts.isOmittedExpression(element)) continue; + if (ts.isBindingElement(element) && element.name !== undefined) + collectBindingNames(element.name, into); + } +} + +/** VarDeclaredNames of a statement: every `var`-declared name below it, + * stopping at function and class boundaries (their declarations are not + * var-scoped here). */ +function collectVarDeclaredNames(root: ts.Node, into: Set): void { + const stack: ts.Node[] = [root]; + while (stack.length > 0) { + const node = stack.pop()!; + if (ts.isFunctionLike(node) || ts.isClassDeclaration(node) || ts.isClassExpression(node)) + continue; + if (ts.isVariableDeclarationList(node) && (node.flags & ts.NodeFlags.BlockScoped) === 0) { + const names: Array<[string, ts.Node]> = []; + for (const decl of node.declarations.nodes) collectBindingNames(decl.name, names); + for (const [name] of names) into.add(name); + } + ts.forEachChild(node, (child) => { + stack.push(child); + return undefined; + }); + } +} + +/** `async function` and `function*` are Annex B's allowance never — only the + * plain form is. */ +function isEnhancedFunction(decl: ts.FunctionDeclaration): boolean { + if (decl.asteriskToken !== undefined) return true; + return (ts.getCombinedModifierFlags(decl) & ts.ModifierFlags.Async) !== 0; +} + +/* ── SC0007: a declaration where the grammar admits only a Statement ───── + * + * Every `Statement` slot below admits Statement productions only, and only + * Annex B widens three of them to a plain FunctionDeclaration in non-strict + * code: the body of an `if`/`else` and a labelled statement. Async functions, + * generators, classes and lexical declarations are never admitted, and a + * LABELLED function is never admitted as an if/else body (`IsLabelledFunction` + * is an explicit early error there, so `if (x) l: function f(){}` fails even + * in sloppy code). */ +type StatementSlot = "if" | "loop" | "with" | "label"; + +function checkStatementSlot(node: ts.Node, diags: ScrDiagnostic[], isStrict: IsStrictCode): void { + if (ts.isIfStatement(node)) { + checkStatementPosition(node.thenStatement, "if", diags, isStrict); + if (node.elseStatement !== undefined) + checkStatementPosition(node.elseStatement, "if", diags, isStrict); + return; + } + if ( + ts.isWhileStatement(node) || + ts.isDoStatement(node) || + ts.isForStatement(node) || + ts.isForInStatement(node) || + ts.isForOfStatement(node) + ) { + checkStatementPosition(node.statement, "loop", diags, isStrict); + return; + } + if (ts.isWithStatement(node)) { + checkStatementPosition(node.statement, "with", diags, isStrict); + return; + } + if (ts.isLabeledStatement(node)) checkStatementPosition(node.statement, "label", diags, isStrict); +} + +function checkStatementPosition( + stmt: ts.Statement, + slot: StatementSlot, + diags: ScrDiagnostic[], + isStrict: IsStrictCode, +): void { + const sf = ts.getSourceFileOfNode(stmt); + // A labelled function is an early error as an if-clause, loop, or 'with' + // body in BOTH modes (Node agrees); only a plain labelled STATEMENT is + // Annex B's, and that is the `slot === "label"` case, whose terminal + // function the function branches below handle. + const labelTerminal = + slot === "label" || !ts.isLabeledStatement(stmt) ? null : labelledFunctionOf(stmt); + if (labelTerminal !== null) { + report( + diags, + NOT_A_STATEMENT, + labelTerminal, + "a labelled function declaration cannot be the body of a loop, a 'with' statement, or an 'if' clause", + ); + return; + } + if (ts.isVariableStatement(stmt)) { + if ((stmt.declarationList.flags & ts.NodeFlags.BlockScoped) !== 0) + report( + diags, + NOT_A_STATEMENT, + stmt, + "a lexical declaration cannot appear in a single-statement position", + ); + return; + } + if (ts.isClassDeclaration(stmt)) { + report( + diags, + NOT_A_STATEMENT, + stmt, + "a class declaration cannot appear in a single-statement position", + ); + return; + } + if (!ts.isFunctionDeclaration(stmt)) return; + if (isEnhancedFunction(stmt)) { + report( + diags, + NOT_A_STATEMENT, + stmt, + "an async function or generator declaration can only appear at the top level or inside a block", + ); + return; + } + // A plain function declaration: Annex B admits it as an if/else body and as + // a labelled statement, in non-strict code only, and never as a loop's or a + // 'with' statement's body. + if (slot === "loop" || slot === "with") { + report( + diags, + NOT_A_STATEMENT, + stmt, + "a function declaration can only appear at the top level, inside a block, or as the body of an if statement", + ); + return; + } + if (isStrict(sf, stmt)) + report( + diags, + NOT_A_STATEMENT, + stmt, + "strict mode code may not declare a function in a single-statement position", + ); +} + +/** The function declaration at the end of a `label:` chain, when `stmt` is a + * labelled statement around one. */ +function labelledFunctionOf(stmt: ts.Statement): ts.FunctionDeclaration | null { + let cur: ts.Statement = stmt; + while (ts.isLabeledStatement(cur)) cur = cur.statement; + return ts.isFunctionDeclaration(cur) ? cur : null; +} + +/* ── strictness of the code around a node ─────────────────────────────── + * + * A directive prologue's "use strict" applies to the function (or script) it + * opens, arrows cannot carry one, and a class body is strict by + * construction. Module sources are strict whether they say so or not, and + * Node's classification — not the checker's forced external-module scope — + * decides which sources are modules. */ +function isStrictCode( + sf: ts.SourceFile, + node: ts.Node, + isModuleCode: (sf: ts.SourceFile) => boolean, +): boolean { + if (isModuleCode(sf)) return true; + for (let n: ts.Node | undefined = node; n !== undefined; n = n.parent) { + if (ts.isSourceFile(n)) return hasUseStrictPrologue(n.statements.nodes); + if ( + ts.isClassStaticBlockDeclaration(n) || + ts.isMethodDeclaration(n) || + ts.isConstructorDeclaration(n) || + ts.isGetAccessorDeclaration(n) || + ts.isSetAccessorDeclaration(n) || + ts.isPropertyDeclaration(n) + ) + return true; + if (ts.isArrowFunction(n)) continue; + if (ts.isFunctionLike(n)) { + const body = n.body; + if (body !== undefined && ts.isBlock(body) && hasUseStrictPrologue(body.statements.nodes)) + return true; + } + } + return false; +} + +function hasUseStrictPrologue(statements: readonly ts.Statement[]): boolean { + for (const stmt of statements) { + if (!ts.isExpressionStatement(stmt)) return false; + const expr = stmt.expression; + if (!ts.isStringLiteral(expr)) return false; + if (expr.text === "use strict") return true; + } + return false; +} diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 348ca658e..0d3269b3c 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -136,6 +136,7 @@ import { JS_RELAXED_TSC_CODES, } from "./tsc-codes.js"; import { trackedFileExists, trackedReadFile, trackedRealpath } from "./input-tracker.js"; +import { earlyErrorDiagnostics7 } from "./early-errors.js"; import { forkTargetPaths } from "./fork-target.js"; import { workerTargetPaths } from "./worker-target.js"; import { @@ -6673,6 +6674,11 @@ function preflight7(load: LoadResult): { esmNamedImportLinkCheck(program, entry, order, diags), ); + // ECMAScript early errors the TypeScript checker does not model (plan + // §7.7 D-4): a JavaScript source the spec makes a SyntaxError fails here, + // before any lowering, exactly where a tsc parse error fails. + diags.push(...earlyErrorDiagnostics7(order, (sf) => isNodeEsmFile7(sf, program))); + return { diags, moduleOrder: order, diff --git a/packages/compiler/src/frontend/tsc-codes.ts b/packages/compiler/src/frontend/tsc-codes.ts index 1f9ce923e..79a95c2b4 100644 --- a/packages/compiler/src/frontend/tsc-codes.ts +++ b/packages/compiler/src/frontend/tsc-codes.ts @@ -36,6 +36,21 @@ export const ADOPTED_OPTIONS = [ "reactNamespace", ] as const; +/* ── The early-error boundary (read this before relaxing a code) ──────── + * + * Some of the codes below do double duty: TypeScript reports them for a + * JavaScript construct that ECMAScript ALSO makes a SyntaxError (a + * redeclared block binding, a declaration in a statement-only position). + * Relaxing such a code removes that construct's only rejection, so a + * parse-negative program would start compiling and running. + * + * frontend/early-errors.ts owns those rejections now, under their own + * preflight codes — SC0005 (duplicate lexical declaration in a block or + * switch statement list), SC0006 (lexical declaration colliding with a + * `var` in the same block), SC0007 (declaration in a single-statement + * position). A relaxation that needs one of those rules to keep firing must + * land with the matching rule in that pass, not rely on this table. */ + export const JS_RELAXED_TSC_CODES: ReadonlySet = new Set([ // implicit any (params, variables, elements, this, returns, rest, // construct signatures, index reads over shapes with no index diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index ea8295406..34914a034 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -9,7 +9,7 @@ "Entries with status 'unsupported' describe where the named code is raised: forms of the construct outside the supported subset are refused with that code — not that every form of the named feature is refused. Supported forms appear as their own static entries where a table projects them.", "The engine-free fetch projection targets Node 24.22.0 with bundled Undici 7.29.1. Each projected row names the differential evidence that guards it; changing the pinned Node or Undici version is an explicit profile update.", "The fetch profile also contains a runtime-reflected census of the selected fetch, abort, Headers, and readable-stream interfaces plus RequestInit/ResponseInit dictionary reads. Static and unsupported census rows are projected here; its explicitly out-of-scope metadata rows and adjacent-interface exclusions remain in the profile so absence is deliberate rather than ambiguous.", - "Process-level diagnostic codes are not surface entries: SC0001-SC0004 are preflight gates, SC1110 is a comptime evaluation failure, SC3001/SC3002 are backend/target tier refusals, SC9001/SC9002 are internal errors.", + "Process-level diagnostic codes are not surface entries: SC0001-SC0007 are preflight gates (SC0005-SC0007 are the ECMAScript early errors), SC1110 is a comptime evaluation failure, SC3001/SC3002 are backend/target tier refusals, SC9001/SC9002 are internal errors.", "Entry statuses are projected for the desktop targets. The mobile targets (aarch64-apple-ios, aarch64-apple-ios-simulator, aarch64-linux-android) compile library-mode archives only: the library-admissible surface (what SC4005's async_free requirement and the library link set admit) is supported there, the executable lane refuses those triples with SC3002, and no entry outside the library-admissible surface carries a mobile support claim. iOS archives build for iOS 15.0 on darwin hosts; Android archives build against NDK API level 26.", "No scheduling metadata is published; entry ids are the stable diff keys across releases." ], diff --git a/tests/diagnostics/js-block-redeclaration.js b/tests/diagnostics/js-block-redeclaration.js new file mode 100644 index 000000000..c6af1944b --- /dev/null +++ b/tests/diagnostics/js-block-redeclaration.js @@ -0,0 +1,18 @@ +// ECMAScript early errors the TypeScript checker does not model: a statement +// list may not declare one lexical name twice, and a lexical name may not be +// `var`-declared in the same list (Block : { StatementList }, and a switch's +// case block is one such list). Both are SC0005/SC0006, reported at +// preflight — the constructs come from Test262's negative-parse suites. +{ + class collide {} + async function collide() {} +} + +switch (1) { + case 1: + let scoped = 1; + break; + case 2: + var scoped = 2; + break; +} diff --git a/tests/diagnostics/js-single-statement-declaration.js b/tests/diagnostics/js-single-statement-declaration.js new file mode 100644 index 000000000..21a07b54a --- /dev/null +++ b/tests/diagnostics/js-single-statement-declaration.js @@ -0,0 +1,10 @@ +// ECMAScript early errors the TypeScript checker does not model: the body of +// a loop, a `with` statement, or an if clause admits only a Statement, and +// Annex B's function-declaration allowance never covers an async function, a +// generator, a class, or a lexical declaration. SC0007, reported at +// preflight — the shapes come from Test262's negative-parse suites. +while (false) function inLoop() {} +l: class inLabel {} +if (true) async function inIf() {} +if (false) il: function labelledInIf() {} +do function* inDoWhile() {} while (false); diff --git a/tests/diagnostics/js-single-statement-strict.js b/tests/diagnostics/js-single-statement-strict.js new file mode 100644 index 000000000..d13cddbcf --- /dev/null +++ b/tests/diagnostics/js-single-statement-strict.js @@ -0,0 +1,8 @@ +// The strict half of SC0007: Annex B's allowance for a plain function +// declaration in an if/else body or a labelled statement is non-strict code +// only, so a "use strict" prologue takes it away — Node agrees on both +// shapes. The sloppy fixture beside this one pins the refusals that hold in +// both modes. +"use strict"; +if (true) function inIf() {} +l: function inLabel() {} diff --git a/tests/harness/__snapshots__/js-block-redeclaration.js.txt b/tests/harness/__snapshots__/js-block-redeclaration.js.txt new file mode 100644 index 000000000..4e73a842c --- /dev/null +++ b/tests/harness/__snapshots__/js-block-redeclaration.js.txt @@ -0,0 +1,41 @@ +js-block-redeclaration.js:7:9 - error SC0001: Class declaration cannot implement overload list for 'collide'. + + 6 | { + 7 | class collide {} + | ^~~~~~~ + 8 | async function collide() {} + +js-block-redeclaration.js:8:3 - error SC0005: the declaration 'collide' is already declared in this block's statement list + + 7 | class collide {} + 8 | async function collide() {} + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~ + 9 | } + +js-block-redeclaration.js:8:18 - error SC0001: Function with bodies can only merge with classes that are ambient. + + 7 | class collide {} + 8 | async function collide() {} + | ^~~~~~~ + 9 | } + +js-block-redeclaration.js:13:9 - error SC0006: the lexical declaration 'scoped' collides with a 'var' declaration of the same name in this block + + 12 | case 1: + 13 | let scoped = 1; + | ^~~~~~ + 14 | break; + +js-block-redeclaration.js:15:8 - error SC0001: Type '2' is not comparable to type '1'. + + 14 | break; + 15 | case 2: + | ^ + 16 | var scoped = 2; + +js-block-redeclaration.js:16:9 - error SC0001: Cannot initialize outer scoped variable 'scoped' in the same scope as block scoped declaration 'scoped'. + + 15 | case 2: + 16 | var scoped = 2; + | ^~~~~~ + 17 | break; \ No newline at end of file diff --git a/tests/harness/__snapshots__/js-single-statement-declaration.js.txt b/tests/harness/__snapshots__/js-single-statement-declaration.js.txt new file mode 100644 index 000000000..892b49ce3 --- /dev/null +++ b/tests/harness/__snapshots__/js-single-statement-declaration.js.txt @@ -0,0 +1,48 @@ +js-single-statement-declaration.js:6:15 - error SC0007: a function declaration can only appear at the top level, inside a block, or as the body of an if statement + + 5 | // preflight — the shapes come from Test262's negative-parse suites. + 6 | while (false) function inLoop() {} + | ^~~~~~~~~~~~~~~~~~~~ + 7 | l: class inLabel {} + +js-single-statement-declaration.js:7:1 - error SC0001: A label is not allowed here. + + 6 | while (false) function inLoop() {} + 7 | l: class inLabel {} + | ^ + 8 | if (true) async function inIf() {} + +js-single-statement-declaration.js:7:4 - error SC0007: a class declaration cannot appear in a single-statement position + + 6 | while (false) function inLoop() {} + 7 | l: class inLabel {} + | ^~~~~~~~~~~~~~~~ + 8 | if (true) async function inIf() {} + +js-single-statement-declaration.js:8:11 - error SC0007: an async function or generator declaration can only appear at the top level or inside a block + + 7 | l: class inLabel {} + 8 | if (true) async function inIf() {} + | ^~~~~~~~~~~~~~~~~~~~~~~~ + 9 | if (false) il: function labelledInIf() {} + +js-single-statement-declaration.js:9:12 - error SC0001: A label is not allowed here. + + 8 | if (true) async function inIf() {} + 9 | if (false) il: function labelledInIf() {} + | ^~ + 10 | do function* inDoWhile() {} while (false); + +js-single-statement-declaration.js:9:16 - error SC0007: a labelled function declaration cannot be the body of a loop, a 'with' statement, or an 'if' clause + + 8 | if (true) async function inIf() {} + 9 | if (false) il: function labelledInIf() {} + | ^~~~~~~~~~~~~~~~~~~~~~~~~~ + 10 | do function* inDoWhile() {} while (false); + +js-single-statement-declaration.js:10:4 - error SC0007: an async function or generator declaration can only appear at the top level or inside a block + + 9 | if (false) il: function labelledInIf() {} + 10 | do function* inDoWhile() {} while (false); + | ^~~~~~~~~~~~~~~~~~~~~~~~ + 11 | \ No newline at end of file diff --git a/tests/harness/__snapshots__/js-single-statement-strict.js.txt b/tests/harness/__snapshots__/js-single-statement-strict.js.txt new file mode 100644 index 000000000..0fdfcfe6d --- /dev/null +++ b/tests/harness/__snapshots__/js-single-statement-strict.js.txt @@ -0,0 +1,20 @@ +js-single-statement-strict.js:7:11 - error SC0007: strict mode code may not declare a function in a single-statement position + + 6 | "use strict"; + 7 | if (true) function inIf() {} + | ^~~~~~~~~~~~~~~~~~ + 8 | l: function inLabel() {} + +js-single-statement-strict.js:8:1 - error SC0001: A label is not allowed here. + + 7 | if (true) function inIf() {} + 8 | l: function inLabel() {} + | ^ + 9 | + +js-single-statement-strict.js:8:4 - error SC0007: strict mode code may not declare a function in a single-statement position + + 7 | if (true) function inIf() {} + 8 | l: function inLabel() {} + | ^~~~~~~~~~~~~~~~~~~~~ + 9 | \ No newline at end of file diff --git a/tests/test262/support.mjs b/tests/test262/support.mjs index ecb5d45c4..46c601b7c 100644 --- a/tests/test262/support.mjs +++ b/tests/test262/support.mjs @@ -261,8 +261,29 @@ function earlyErrorDiagnostics(source, variant) { !(variant === "sloppy" && (diagnostic.code === 1214 || diagnostic.code === 1215))); } +// The compiler's OWN early errors (frontend/early-errors.ts): static +// semantics tsc does not model, reported at preflight under their own codes. +// A negative-parse test only asks for SOME SyntaxError, so one of these +// inside the test's source is a match — the same standard as the tsc +// diagnostics above, minus tsc's ability to tell us where it points. +const ownEarlyErrorCodes = new Set(["SC0005", "SC0006", "SC0007"]); + export function matchesParseNegative(outcome, source, variant = "strict") { if (outcome.status !== "compile-refusal") return false; + { + const prepared = prepare(source, false, completion, variant); + const start = prepared.indexOf(source); + const end = start + source.length; + const suffix = variant === "sloppy" ? "/main.cjs" : "/main.js"; + const own = outcome.diagnostics?.some( + (reported) => + ownEarlyErrorCodes.has(reported.code) && + reported.loc?.file?.endsWith(suffix) && + reported.loc.start >= start && + reported.loc.start < end, + ); + if (own) return true; + } const matches = (parsed) => outcome.diagnostics?.some((reported) => reported.code === "SC0001" && reported.loc?.file?.endsWith(variant === "sloppy" ? "/main.cjs" : "/main.js") && From ff93a5a4de2e123f72a7e4cf8c48daaacadb6c96 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 15:03:49 -0700 Subject: [PATCH 4/8] Refuse sloppy implicit-global writes by name, and relax undeclared JS names Node resolves a name nothing declares at run time. In sloppy mode a WRITE creates a property on the process-wide global object, which a native binary has no object for, so those programs belong on the Node island route (plan D-2/D-5). Until that routing exists, an undeclared write in a `.js` source is refused at build time under a dedicated code, SC1091, whose text names the island route instead of failing with a generic no-lowering message. Reaching that refusal at all means the checker has to stop treating an undeclared name in a JavaScript source as a compile error, so 2304, its did-you-mean spelling 2552, and shorthand position's 18004 join the JS relaxation set. The relaxation stays `isJsSourceFileName`-gated: TypeScript input keeps the compile error, where an undeclared name is a bug rather than a runtime path. The refusal is a preflight walk over every JavaScript file, so it reports once per write, before any code is emitted, at the span the checker used. `isJsWriteTarget7` is shared with the lowerer's unresolved-reference fence, which supplies the same refusal for a write that reaches lowering by a path preflight does not model. An empty identifier is skipped: it is a parse recovery artifact, never a name a program writes. The JS-read half of D-2 (the name lowers to Node's catchable `ReferenceError`) is the next commit; this one only changes what the build says about a write. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/diagnostics/codes.ts | 1 + .../compiler/src/diagnostics/diagnostic.ts | 5 + .../compiler/src/frontend/lowering/lowerer.ts | 13 +++ packages/compiler/src/frontend/program.ts | 106 ++++++++++++++++++ packages/compiler/src/frontend/tsc-codes.ts | 14 +++ packages/compiler/surface-manifest.json | 8 ++ .../sloppy-implicit-global-write.js | 6 + .../sloppy-implicit-global-write.js.txt | 8 ++ 8 files changed, 161 insertions(+) create mode 100644 tests/diagnostics/sloppy-implicit-global-write.js create mode 100644 tests/harness/__snapshots__/sloppy-implicit-global-write.js.txt diff --git a/packages/compiler/src/diagnostics/codes.ts b/packages/compiler/src/diagnostics/codes.ts index bd597cb01..3ec5e6c87 100644 --- a/packages/compiler/src/diagnostics/codes.ts +++ b/packages/compiler/src/diagnostics/codes.ts @@ -43,6 +43,7 @@ export const DIAGNOSTIC_CODES = { SC1071: "generators", SC1080: "'this' outside a class method", SC1090: "this syntax (generic construct fence)", + SC1091: "write to an undeclared name in a JavaScript source", SC1100: "operations on 'unknown' values", SC1101: "converting typed values to 'unknown'", SC1110: "comptime evaluation failure", diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index de377441f..6b5973418 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -674,6 +674,11 @@ export const UNSUPPORTED: Record = { // remains is `this` outside any method (top level, plain functions). SC1080: { feature: "'this' outside a class method", milestone: "later" }, SC1090: { feature: "this syntax", milestone: "later" }, // generic fallback; message names the construct + SC1091: { + feature: "writing an undeclared name in a JavaScript source", + milestone: "later", + hint: "a write to an undeclared name has no native lowering: sloppy mode makes it a property on Node's global object, which a native binary has none of, and strict mode makes it throw; declare the name, or keep the module in a Node island (sloppy-mode programs route there)", + }, SC1100: { feature: "operations on 'unknown' values", milestone: "later", diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 417f13c20..c1759e8a5 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -357,6 +357,7 @@ import { overridesDtsPath, npmStaticDepSf7, requireSpecOf, + isJsWriteTarget7, resolveImport, workspacePackageOfPath, type CycleEdge, @@ -15512,6 +15513,18 @@ export class Lowerer { node: ts.Node, fallback: string, ): never { + // A `.js` WRITE to a name nothing declares. Sloppy mode resolves it by + // creating a property on the global object — process-wide state a native + // binary has no object for, so those programs run in a Node island + // (plan D-5). Until the write is routed there it is refused by name + // rather than by the generic no-lowering text. + if (symbol === null && isJsWriteTarget7(node) && isJsSourceFile(ts.getSourceFileOfNode(node))) { + this.unsupported( + "SC1091", + node, + `writing the undeclared name '${name}' in a JavaScript source`, + ); + } if (this.isBlockedBinding(symbol)) { this.pushDiag(blockedBindingUseDiag(name, locOf(node))); throw new PoisonError(); diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 0d3269b3c..2bd726de4 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -2759,6 +2759,107 @@ function isAssignmentTarget7(node: ts.Node): boolean { ); } +/** True when the identifier is a reference position that WRITES: an + * assignment (or compound-assignment) target, a `++`/`--` operand, a + * for-in/for-of head, or an element of a destructuring pattern — through + * parentheses and pattern containers, exactly the shapes a sloppy-mode + * implicit global can be introduced by. */ +export function isJsWriteTarget7(node: ts.Node): boolean { + let cur: ts.Node = node; + for (;;) { + const parent: ts.Node | undefined = cur.parent; + if (parent === undefined) return false; + if (ts.isParenthesizedExpression(parent)) { + cur = parent; + continue; + } + if (ts.isBinaryExpression(parent)) + return ( + parent.left === cur && + parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment + ); + if (ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) + return ( + parent.operand === cur && + (parent.operator === ts.SyntaxKind.PlusPlusToken || + parent.operator === ts.SyntaxKind.MinusMinusToken) + ); + if (ts.isForInStatement(parent) || ts.isForOfStatement(parent)) + return parent.initializer === cur; + if (ts.isArrayLiteralExpression(parent) || ts.isObjectLiteralExpression(parent)) { + cur = parent; + continue; + } + if ( + (ts.isShorthandPropertyAssignment(parent) && parent.name === cur) || + (ts.isPropertyAssignment(parent) && parent.initializer === cur) || + ((ts.isSpreadElement(parent) || ts.isSpreadAssignment(parent)) && parent.expression === cur) + ) { + cur = parent; + continue; + } + return false; + } +} + +/** Nothing binds `node`: the tsc had no symbol, and a shorthand pattern's + * name has no VALUE binding either (its property symbol is the target, not a + * declaration of the name being written). */ +function undeclaredJsName7( + checker: ReturnType, + node: ts.Identifier, +): boolean { + const parent = node.parent; + if (parent !== undefined && ts.isShorthandPropertyAssignment(parent) && parent.name === node) + return checker.getShorthandAssignmentValueSymbol(parent) === undefined; + return checker.getSymbolAtLocation(node) === undefined; +} + +/** A `.js` WRITE to a name nothing declares (plan D-2/D-5). Sloppy mode + * resolves such a write by creating a property on Node's global object — + * process-wide state a native binary has no object for — so the program is + * refused at build time, under SC1091, whose text names the Node-island + * route those semantics need. A READ of the same name is admitted instead: + * it throws Node's own catchable `ReferenceError` at the access + * (lowering/expressions/identifiers.ts). TypeScript input never reaches + * either path — an undeclared name is a compile error there. */ +function jsUndeclaredWriteDiags( + program: ts.Program, + files: readonly ts.SourceFile[], +): ScrDiagnostic[] { + const diags: ScrDiagnostic[] = []; + const checker = program.getTypeChecker(); + for (const sf of files) { + if (!isJsSourceFileName(sf.fileName)) continue; + const stack: ts.Node[] = [sf]; + while (stack.length > 0) { + const node = stack.pop()!; + // An empty name is a parse-recovery artifact (`@import x = require(…)` + // in a JSDoc prologue leaves one behind), never a reference a program + // writes; the syntax error that created it is already reported. + if ( + ts.isIdentifier(node) && + node.text !== "" && + isJsWriteTarget7(node) && + undeclaredJsName7(checker, node) + ) + diags.push( + unsupportedDiag( + "SC1091", + { file: sf.fileName, start: node.getStart(sf), end: node.end }, + `writing the undeclared name '${node.text}' in a JavaScript source`, + ), + ); + ts.forEachChild(node, (child) => { + stack.push(child); + return undefined; + }); + } + } + return diags; +} + /** True when `node` is inside the try block of a try statement in the same * function (a catch clause or finally block does not count). A class field * initializer runs when the class is instantiated, so it is a boundary too. */ @@ -6583,6 +6684,11 @@ function preflight7(load: LoadResult): { }; visit(entry); + // ECMAScript's sloppy-mode WRITE to an undeclared name, which the JS + // relaxation above admits for READS (plan D-2): refused by name, with the + // island route in the text (SC1091). + for (const d of jsUndeclaredWriteDiags(program, order)) diags.push(d); + // The startup crash Node's RESOLUTION phase reports: modules resolve // their request specifiers in source order, children linking depth-first // as each resolves (ModuleJob.syncLink) — a preorder walk over the diff --git a/packages/compiler/src/frontend/tsc-codes.ts b/packages/compiler/src/frontend/tsc-codes.ts index 79a95c2b4..9bf16dc80 100644 --- a/packages/compiler/src/frontend/tsc-codes.ts +++ b/packages/compiler/src/frontend/tsc-codes.ts @@ -175,6 +175,20 @@ export const JS_RELAXED_TSC_CODES: ReadonlySet = new Set([ // ride the checked-dynamic tree; unsupported operations report a compile-time diagnostic). 18046, 2571, + // UNDECLARED NAMES in a JavaScript source: Node resolves them at run time, + // never at compile time — a READ throws the catchable + // `ReferenceError: is not defined` at the access (`typeof` of one + // answers "undefined"), and a sloppy-mode WRITE creates a global. The + // lowerer turns the read into `scr_undef_global_read` (exact Node text and + // behavior) and refuses the write with SC1091, which names the Node-island + // route the sloppy semantics need (the plan's D-2/D-5). TypeScript input + // keeps the compile error: an undeclared name there is a bug, not a + // runtime path. 2552 is 2304's did-you-mean spelling, and 18004 is the + // same unresolved name in SHORTHAND position (`{ x }`), which the checker + // reports against the property instead of the reference. + 2304, + 2552, + 18004, ]); /* Arithmetic on an `any` operand (TS2362/2363/2365 fire when the other diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index 34914a034..793625daa 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -170,6 +170,14 @@ "status": "unsupported", "code": "SC1090" }, + { + "id": "diagnostic.sc1091", + "kind": "diagnostic-fence", + "name": "writing an undeclared name in a JavaScript source", + "status": "unsupported", + "code": "SC1091", + "note": "a write to an undeclared name has no native lowering: sloppy mode makes it a property on Node's global object, which a native binary has none of, and strict mode makes it throw; declare the name, or keep the module in a Node island (sloppy-mode programs route there)" + }, { "id": "diagnostic.sc1100", "kind": "diagnostic-fence", diff --git a/tests/diagnostics/sloppy-implicit-global-write.js b/tests/diagnostics/sloppy-implicit-global-write.js new file mode 100644 index 000000000..895e27e93 --- /dev/null +++ b/tests/diagnostics/sloppy-implicit-global-write.js @@ -0,0 +1,6 @@ +// A sloppy-mode implicit-global WRITE in a JavaScript source. Node resolves +// the write by creating a property on the global object — process-wide state +// a native binary has none of — so the write is refused by name (SC1091) and +// names the Node-island route the sloppy semantics need (plan D-2/D-5). +implicitGlobal = 42; +console.log(implicitGlobal); diff --git a/tests/harness/__snapshots__/sloppy-implicit-global-write.js.txt b/tests/harness/__snapshots__/sloppy-implicit-global-write.js.txt new file mode 100644 index 000000000..31d294056 --- /dev/null +++ b/tests/harness/__snapshots__/sloppy-implicit-global-write.js.txt @@ -0,0 +1,8 @@ +sloppy-implicit-global-write.js:5:1 - error SC1091: writing the undeclared name 'implicitGlobal' in a JavaScript source is not supported yet + + 4 | // names the Node-island route the sloppy semantics need (plan D-2/D-5). + 5 | implicitGlobal = 42; + | ^~~~~~~~~~~~~~ + 6 | console.log(implicitGlobal); + + hint: a write to an undeclared name has no native lowering: sloppy mode makes it a property on Node's global object, which a native binary has none of, and strict mode makes it throw; declare the name, or keep the module in a Node island (sloppy-mode programs route there) \ No newline at end of file From 48d524a3216d650de55e925c5e9327c69dee1eab Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 15:04:04 -0700 Subject: [PATCH 5/8] Lower a JavaScript read of an undeclared name to Node's ReferenceError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D-2: Node resolves a name nothing declares at run time, and the read always throws the catchable `ReferenceError: x is not defined` at the access. A `.js` read of such a name now compiles to exactly that throw instead of refusing the program, with Node's message and Node's catchability. The read's value is checked-dynamic (the checker's error-any after the relaxation in the previous commit, narrowed by the use site where one provides context), and every path through it unwinds before a use observes the value, so the dynamic slot never carries a wrong value. Shorthand position (`{ x }`) needs its own declaration test: there the checker answers the PROPERTY symbol, so the name is bound when `getShorthandAssignmentValueSymbol` has no value for it. `typeof` is the one operator that never touches its operand value, and Node answers "undefined" for an undeclared name, so `lowerDynTypeofTest` folds the test to a literal before the read is ever lowered — `typeof x === "undefined"` stays true and `typeof x` alone prints "undefined". No name that Node serves from the global object reaches this branch: the compiler models those as declared (that is how the corpus reads `process`, `console` and friends), so this is the name Node itself would report missing. The corpus program pins every shape: bare read, call, member read, arithmetic, comparison, shorthand, destructuring default, typeof test, a guarded read that keeps running after the throw, and a declared control. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/identifiers.ts | 21 ++++++++++ .../frontend/lowering/expressions/typeof.ts | 18 +++++++++ tests/corpus/8423-js-undeclared-read.js | 40 +++++++++++++++++++ .../8423-js-undeclared-read.js.json | 1 + 4 files changed, 80 insertions(+) create mode 100644 tests/corpus/8423-js-undeclared-read.js create mode 100644 tests/harness/corpus-costs/8423-js-undeclared-read.js.json diff --git a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts index c73217ef8..807f47413 100644 --- a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts +++ b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts @@ -1031,6 +1031,27 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: ); } } + // A `.js` READ of a name NOTHING declares. Node resolves such a name at + // run time: the access throws the catchable `ReferenceError: is not + // defined` (a `typeof` test lowers earlier, in expressions/typeof.ts, and + // answers "undefined" — exactly Node's rule), so the read compiles to that + // throw and never yields a value. The value is checked-dynamic (the + // checker's error-any), which every use site carries or fences — and the + // read always unwinds before any of them observes it. A WRITE to the same + // name is not a read: it reaches rejectUnresolved and its SC1091 story. + // Typed sources keep the compile error (preflight's JS relaxation is + // `isJsSourceFileName`-gated, so no TS file reaches this branch). + // In shorthand position (`{ x }`) `resolveValueSymbol` answers the + // PROPERTY symbol, so the declaration test there is the checker's + // shorthand-VALUE symbol. + const parent = expr.parent; + const undeclared = + parent !== undefined && ts.isShorthandPropertyAssignment(parent) && parent.name === expr + ? lowerer.checker.getShorthandAssignmentValueSymbol(parent) === undefined + : lowerer.resolveValueSymbol(expr) === null; + if (undeclared && isJsSourceFile(ts.getSourceFileOfNode(expr))) { + return nsUndefRead(lowerer, expr.text, expr, contextualUndefReadType(lowerer, expr) ?? DYN); + } // Preflight guarantees no unresolved identifiers; anything else here // is a blocked declaration's binding (the SC2004 cascade) or a // binding form we don't model yet. diff --git a/packages/compiler/src/frontend/lowering/expressions/typeof.ts b/packages/compiler/src/frontend/lowering/expressions/typeof.ts index 5f1e4a830..6a545ffcb 100644 --- a/packages/compiler/src/frontend/lowering/expressions/typeof.ts +++ b/packages/compiler/src/frontend/lowering/expressions/typeof.ts @@ -493,6 +493,24 @@ export function lowerDynTypeofTest( // preserving both operands' evaluation order without a narrowing test. if (!ts.isStringLiteral(b)) continue; const declared = lowerer.mapTypeOf(lowerer.typeOf(a.expression)); + // `typeof undeclared === "..."` in a JavaScript source: nothing declares + // the name, so Node answers "undefined" without ever reading it — the + // test is that constant's comparison, never a read (a read throws). + { + let operand = a.expression; + while (ts.isParenthesizedExpression(operand)) operand = operand.expression; + if ( + ts.isIdentifier(operand) && + isJsSourceFile(ts.getSourceFileOfNode(operand)) && + lowerer.resolveValueSymbol(operand) === null + ) + return { + kind: "boolLit", + value: (b.text === "undefined") !== negated, + type: BOOL, + loc, + }; + } let value: IrExpr; if (declared?.kind === "dyn") { value = lowerer.lowerExpr(a.expression); diff --git a/tests/corpus/8423-js-undeclared-read.js b/tests/corpus/8423-js-undeclared-read.js new file mode 100644 index 000000000..cd9caef55 --- /dev/null +++ b/tests/corpus/8423-js-undeclared-read.js @@ -0,0 +1,40 @@ +// A JavaScript READ of a name nothing declares resolves at run time, exactly +// as Node does: the access throws a catchable ReferenceError whose message +// names the identifier, `typeof` answers "undefined" without throwing, and a +// call through the same name throws at the callee read (plan D-2 / C5). The +// undeclared name is lowered to the runtime's global-read helper, which +// throws Node's " is not defined". +'use strict'; + +const describe = (label, fn) => { + try { + console.log(`${label}: ${String(fn())}`); + } catch (e) { + console.log(`${label}: threw ${e.name}: ${e.message}`); + } +}; + +console.log("typeof:", typeof neverDeclared); + +describe("bare read", () => neverDeclared); +describe("call", () => neverDeclared(1, 2)); +describe("member read", () => neverDeclared.length); +describe("arithmetic", () => neverDeclared + 1); +describe("comparison", () => neverDeclared === undefined); +describe("shorthand property", () => { + const wrapper = { neverDeclared }; + return String(wrapper.neverDeclared); +}); +describe("destructuring default", () => { + const pick = ({ value = unresolvableReference } = {}) => value; + return pick({}); +}); +describe("typeof test", () => typeof neverDeclared === "undefined"); + +// `typeof` never throws, so a guarded read is the one safe shape. +if (typeof maybeConfigured !== "undefined") console.log("configured:", maybeConfigured); +else console.log("configured: unset"); + +// A declared binding keeps the ordinary lowered path. +let declared = 3; +describe("declared", () => declared * 2); diff --git a/tests/harness/corpus-costs/8423-js-undeclared-read.js.json b/tests/harness/corpus-costs/8423-js-undeclared-read.js.json new file mode 100644 index 000000000..23aca49c0 --- /dev/null +++ b/tests/harness/corpus-costs/8423-js-undeclared-read.js.json @@ -0,0 +1 @@ +{ "plain": 3300 } From b1b7aff25cf69f46a0435501d0d69c0992aea04b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 16:41:21 -0700 Subject: [PATCH 6/8] Renumber the new corpus programs to 8514 and 8515 Group C adds two corpus programs whose 8423 prefix another group also proposes; main's highest corpus number is 8513, so take the next two free numbers and move the matching per-program cost files with them. Co-Authored-By: Claude Opus 5.5 --- ...-protocol-shapes.js => 8514-async-iterator-protocol-shapes.js} | 0 .../{8423-js-undeclared-read.js => 8515-js-undeclared-read.js} | 0 ...shapes.js.json => 8514-async-iterator-protocol-shapes.js.json} | 0 ...js-undeclared-read.js.json => 8515-js-undeclared-read.js.json} | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename tests/corpus/{8423-async-iterator-protocol-shapes.js => 8514-async-iterator-protocol-shapes.js} (100%) rename tests/corpus/{8423-js-undeclared-read.js => 8515-js-undeclared-read.js} (100%) rename tests/harness/corpus-costs/{8423-async-iterator-protocol-shapes.js.json => 8514-async-iterator-protocol-shapes.js.json} (100%) rename tests/harness/corpus-costs/{8423-js-undeclared-read.js.json => 8515-js-undeclared-read.js.json} (100%) diff --git a/tests/corpus/8423-async-iterator-protocol-shapes.js b/tests/corpus/8514-async-iterator-protocol-shapes.js similarity index 100% rename from tests/corpus/8423-async-iterator-protocol-shapes.js rename to tests/corpus/8514-async-iterator-protocol-shapes.js diff --git a/tests/corpus/8423-js-undeclared-read.js b/tests/corpus/8515-js-undeclared-read.js similarity index 100% rename from tests/corpus/8423-js-undeclared-read.js rename to tests/corpus/8515-js-undeclared-read.js diff --git a/tests/harness/corpus-costs/8423-async-iterator-protocol-shapes.js.json b/tests/harness/corpus-costs/8514-async-iterator-protocol-shapes.js.json similarity index 100% rename from tests/harness/corpus-costs/8423-async-iterator-protocol-shapes.js.json rename to tests/harness/corpus-costs/8514-async-iterator-protocol-shapes.js.json diff --git a/tests/harness/corpus-costs/8423-js-undeclared-read.js.json b/tests/harness/corpus-costs/8515-js-undeclared-read.js.json similarity index 100% rename from tests/harness/corpus-costs/8423-js-undeclared-read.js.json rename to tests/harness/corpus-costs/8515-js-undeclared-read.js.json From f76a8202dd82be7d0ad6faa4bc44c6f3d4296858 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 17:58:14 -0700 Subject: [PATCH 7/8] Record order-parity baselines for the group C fixtures Add the TS7 preflight/order baselines for the new diagnostics and corpus fixtures so the completeness check and the parity batches cover them. Co-Authored-By: Claude Opus 5.5 --- ...514-async-iterator-protocol-shapes.js.json | 6 ++ .../corpus/8515-js-undeclared-read.js.json | 6 ++ .../js-block-redeclaration.js.json | 61 ++++++++++++++++ .../js-single-statement-declaration.js.json | 70 +++++++++++++++++++ .../js-single-statement-strict.js.json | 34 +++++++++ .../sloppy-implicit-global-write.js.json | 18 +++++ 6 files changed, 195 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8514-async-iterator-protocol-shapes.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8515-js-undeclared-read.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/js-block-redeclaration.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/js-single-statement-declaration.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/js-single-statement-strict.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/sloppy-implicit-global-write.js.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8514-async-iterator-protocol-shapes.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8514-async-iterator-protocol-shapes.js.json new file mode 100644 index 000000000..736bdb4c4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8514-async-iterator-protocol-shapes.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8514-async-iterator-protocol-shapes.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8515-js-undeclared-read.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8515-js-undeclared-read.js.json new file mode 100644 index 000000000..a8baf582c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8515-js-undeclared-read.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8515-js-undeclared-read.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/js-block-redeclaration.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/js-block-redeclaration.js.json new file mode 100644 index 000000000..616d4614d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/js-block-redeclaration.js.json @@ -0,0 +1,61 @@ +{ + "order": [ + "/tests/diagnostics/js-block-redeclaration.js" + ], + "diags": [ + { + "code": "SC0001", + "message": "Class declaration cannot implement overload list for 'collide'.", + "loc": { + "file": "/tests/diagnostics/js-block-redeclaration.js", + "start": 386, + "end": 393 + } + }, + { + "code": "SC0001", + "message": "Function with bodies can only merge with classes that are ambient.", + "loc": { + "file": "/tests/diagnostics/js-block-redeclaration.js", + "start": 414, + "end": 421 + } + }, + { + "code": "SC0001", + "message": "Type '2' is not comparable to type '1'.", + "loc": { + "file": "/tests/diagnostics/js-block-redeclaration.js", + "start": 491, + "end": 492 + } + }, + { + "code": "SC0001", + "message": "Cannot initialize outer scoped variable 'scoped' in the same scope as block scoped declaration 'scoped'.", + "loc": { + "file": "/tests/diagnostics/js-block-redeclaration.js", + "start": 502, + "end": 508 + } + }, + { + "code": "SC0006", + "message": "the lexical declaration 'scoped' collides with a 'var' declaration of the same name in this block", + "loc": { + "file": "/tests/diagnostics/js-block-redeclaration.js", + "start": 461, + "end": 467 + } + }, + { + "code": "SC0005", + "message": "the declaration 'collide' is already declared in this block's statement list", + "loc": { + "file": "/tests/diagnostics/js-block-redeclaration.js", + "start": 399, + "end": 426 + } + } + ] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/js-single-statement-declaration.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/js-single-statement-declaration.js.json new file mode 100644 index 000000000..4dcb13d8f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/js-single-statement-declaration.js.json @@ -0,0 +1,70 @@ +{ + "order": [ + "/tests/diagnostics/js-single-statement-declaration.js" + ], + "diags": [ + { + "code": "SC0001", + "message": "A label is not allowed here.", + "loc": { + "file": "/tests/diagnostics/js-single-statement-declaration.js", + "start": 405, + "end": 406 + } + }, + { + "code": "SC0001", + "message": "A label is not allowed here.", + "loc": { + "file": "/tests/diagnostics/js-single-statement-declaration.js", + "start": 471, + "end": 473 + } + }, + { + "code": "SC0007", + "message": "an async function or generator declaration can only appear at the top level or inside a block", + "loc": { + "file": "/tests/diagnostics/js-single-statement-declaration.js", + "start": 505, + "end": 529 + } + }, + { + "code": "SC0007", + "message": "a labelled function declaration cannot be the body of a loop, a 'with' statement, or an 'if' clause", + "loc": { + "file": "/tests/diagnostics/js-single-statement-declaration.js", + "start": 475, + "end": 501 + } + }, + { + "code": "SC0007", + "message": "an async function or generator declaration can only appear at the top level or inside a block", + "loc": { + "file": "/tests/diagnostics/js-single-statement-declaration.js", + "start": 435, + "end": 459 + } + }, + { + "code": "SC0007", + "message": "a class declaration cannot appear in a single-statement position", + "loc": { + "file": "/tests/diagnostics/js-single-statement-declaration.js", + "start": 408, + "end": 424 + } + }, + { + "code": "SC0007", + "message": "a function declaration can only appear at the top level, inside a block, or as the body of an if statement", + "loc": { + "file": "/tests/diagnostics/js-single-statement-declaration.js", + "start": 384, + "end": 404 + } + } + ] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/js-single-statement-strict.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/js-single-statement-strict.js.json new file mode 100644 index 000000000..d70f7bb28 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/js-single-statement-strict.js.json @@ -0,0 +1,34 @@ +{ + "order": [ + "/tests/diagnostics/js-single-statement-strict.js" + ], + "diags": [ + { + "code": "SC0001", + "message": "A label is not allowed here.", + "loc": { + "file": "/tests/diagnostics/js-single-statement-strict.js", + "start": 355, + "end": 356 + } + }, + { + "code": "SC0007", + "message": "strict mode code may not declare a function in a single-statement position", + "loc": { + "file": "/tests/diagnostics/js-single-statement-strict.js", + "start": 358, + "end": 379 + } + }, + { + "code": "SC0007", + "message": "strict mode code may not declare a function in a single-statement position", + "loc": { + "file": "/tests/diagnostics/js-single-statement-strict.js", + "start": 336, + "end": 354 + } + } + ] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/sloppy-implicit-global-write.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/sloppy-implicit-global-write.js.json new file mode 100644 index 000000000..edbbc475e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/sloppy-implicit-global-write.js.json @@ -0,0 +1,18 @@ +{ + "order": [ + "/tests/diagnostics/sloppy-implicit-global-write.js" + ], + "diags": [ + { + "code": "SC1091", + "message": "writing the undeclared name 'implicitGlobal' in a JavaScript source is not supported yet", + "loc": { + "file": "/tests/diagnostics/sloppy-implicit-global-write.js", + "start": 306, + "end": 320 + }, + "milestone": "later", + "hint": "a write to an undeclared name has no native lowering: sloppy mode makes it a property on Node's global object, which a native binary has none of, and strict mode makes it throw; declare the name, or keep the module in a Node island (sloppy-mode programs route there)" + } + ] +} From 352842f020e0bc6b8b058c9278b018b846784fc9 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 08:37:25 -0500 Subject: [PATCH 8/8] Scope early errors to function bodies and class strictness, and match the upstream compareArray reads - A function body or class static block treats its top-level function declarations as var-scoped: duplicates are legal in strict code, and a let/const/class of the same name is SC0006. - Strictness is the code around a node: a function declaration's own "use strict" no longer makes its declaring context strict. - Every part of a class is strict; object literal methods and accessors are strict only by their own prologue or their surroundings, and an arrow function's block body can carry "use strict". - The bare compareArray reads b.length, then a.length, and a.length again before every element pair, converting it as the upstream `i < a.length` does, so a string length compares its elements. - Tests that relied on the old handling of undeclared names now use constructs that are still refused or still compile: the installed-entry diagnostics fixture reports a syntax error (a read of an undeclared name is now Node's run-time ReferenceError), and static-objects-order's loops.js stores from its for-in head into `undefined` instead of an undeclared name (a store into an undeclared name refuses its package with SC1091). --- .../src/frontend/early-errors.test.ts | 43 ++++++++++++ .../compiler/src/frontend/early-errors.ts | 66 ++++++++++++------- .../static-objects-order/loops.js | 10 +-- tests/harness/installed-entry.test.ts | 4 +- tests/harness/static-objects-order.test.ts | 6 +- tests/harness/test262.test.ts | 17 +++++ tests/test262/harness.ts | 14 ++-- 7 files changed, 123 insertions(+), 37 deletions(-) create mode 100644 packages/compiler/src/frontend/early-errors.test.ts diff --git a/packages/compiler/src/frontend/early-errors.test.ts b/packages/compiler/src/frontend/early-errors.test.ts new file mode 100644 index 000000000..718294a48 --- /dev/null +++ b/packages/compiler/src/frontend/early-errors.test.ts @@ -0,0 +1,43 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { checkPreflight, loadProgram } from "./program-node.js"; + +/** The preflight diagnostics of a CommonJS script (sloppy unless it says + * "use strict"). */ +function diagnostics(source: string) { + const directory = mkdtempSync(join(tmpdir(), "scriptc-early-errors-")); + writeFileSync(join(directory, "main.cjs"), source); + const load = loadProgram(join(directory, "main.cjs")); + try { + return checkPreflight(load).map((diagnostic) => diagnostic.code); + } finally { + load.dispose(); + rmSync(directory, { recursive: true, force: true }); + } +} + +test.for([ + // A function body's top-level function declarations are var-scoped, so + // duplicates are legal in strict code too. + 'function outer() { "use strict"; function d() {} function d() {} }\nouter();', + "class C { static { function s() {} function s() {} } }\nnew C();", + // Object literal methods and accessors are sloppy unless they say otherwise. + "var o = { m() { if (true) function f() {} } };\no.m();", + "var o = { get g() { { function h() {} function h() {} } return 1; } };\no.g;", + // A function's own directive makes its body strict, not the code declaring it. + 'if (true) function k() { "use strict"; }', + '{ function z() { "use strict"; } function z() {} }', +])("Node accepts: %s", (source) => { + expect(diagnostics(source)).toEqual([]); +}); + +test.for([ + ["function outer() { let d; function d() {} }\nouter();", "SC0006"], + ['(() => { "use strict"; if (true) function f() {} })();', "SC0007"], + ["class C { m() { if (true) function f() {} } }\nnew C().m();", "SC0007"], + ['var o = { m() { "use strict"; { function h() {} function h() {} } } };\no.m();', "SC0005"], +])("Node refuses: %s", ([source, code]) => { + expect(diagnostics(source!)).toContain(code); +}); diff --git a/packages/compiler/src/frontend/early-errors.ts b/packages/compiler/src/frontend/early-errors.ts index 37dd71b48..e37112115 100644 --- a/packages/compiler/src/frontend/early-errors.ts +++ b/packages/compiler/src/frontend/early-errors.ts @@ -92,7 +92,13 @@ type EarlyErrorCode = (typeof EARLY_ERROR_CODES)[number]; * reach into nested blocks (a `var` anywhere below the block belongs to it) * but never into a function or class body. The duplicate rule's one carve-out * is Annex B's B.3.3.1: two plain function declarations of the same name in - * the same block are legal in non-strict code. */ + * the same block are legal in non-strict code. + * + * A function body and a class static block have the same two rules over + * their top-level declarations (FunctionStatementList and + * ClassStaticBlockStatementList), but there a function declaration is + * var-scoped: it is one of the VarDeclaredNames, so two of them are legal in + * strict code too, while `let f; function f() {}` still collides. */ function checkStatementListScope( sf: ts.SourceFile, @@ -107,10 +113,11 @@ function checkStatementListScope( } if (statements === null) return; if (statements.length < 2) return; + const functionBody = isFunctionBody(node); const lexical = new Map(); for (const stmt of statements) { - for (const [name, decl] of lexicalDeclarationsOf(stmt)) { + for (const [name, decl] of lexicalDeclarationsOf(stmt, functionBody)) { const list = lexical.get(name); if (list === undefined) lexical.set(name, [decl]); else list.push(decl); @@ -119,7 +126,11 @@ function checkStatementListScope( if (lexical.size === 0) return; const varNames = new Set(); - for (const stmt of statements) collectVarDeclaredNames(stmt, varNames); + for (const stmt of statements) { + collectVarDeclaredNames(stmt, varNames); + if (functionBody && ts.isFunctionDeclaration(stmt) && stmt.name !== undefined) + varNames.add(stmt.name.text); + } for (const [name, decls] of lexical) { if (varNames.has(name)) { @@ -127,7 +138,9 @@ function checkStatementListScope( diags, LEXICAL_VAR_COLLISION, decls[0]!, - `the lexical declaration '${name}' collides with a 'var' declaration of the same name in this block`, + functionBody + ? `the lexical declaration '${name}' collides with a 'var' or function declaration of the same name in this function body` + : `the lexical declaration '${name}' collides with a 'var' declaration of the same name in this block`, ); continue; } @@ -146,10 +159,22 @@ function checkStatementListScope( } } +/** Whether a block is the body of a function or a class static block, whose + * top-level function declarations are var-scoped. */ +function isFunctionBody(node: ts.Node): boolean { + const parent = node.parent; + if (parent === undefined || !ts.isBlock(node)) return false; + if (ts.isClassStaticBlockDeclaration(parent)) return true; + return ts.isFunctionLike(parent) && (parent as { body?: ts.Node }).body === node; +} + /** The names a DIRECT statement of a statement list declares lexically - * (let/const/using, class, and every function declaration form), paired with - * the node to point the diagnostic at. */ -function lexicalDeclarationsOf(stmt: ts.Statement): Array<[string, ts.Node]> { + * (let/const/using, class, and every function declaration form outside a + * function body), paired with the node to point the diagnostic at. */ +function lexicalDeclarationsOf( + stmt: ts.Statement, + functionBody: boolean, +): Array<[string, ts.Node]> { const names: Array<[string, ts.Node]> = []; if (ts.isVariableStatement(stmt)) { if ((stmt.declarationList.flags & ts.NodeFlags.BlockScoped) === 0) return names; @@ -157,7 +182,7 @@ function lexicalDeclarationsOf(stmt: ts.Statement): Array<[string, ts.Node]> { collectBindingNames(decl.name, names); return names; } - if (ts.isClassDeclaration(stmt) || ts.isFunctionDeclaration(stmt)) { + if (ts.isClassDeclaration(stmt) || (ts.isFunctionDeclaration(stmt) && !functionBody)) { const name = stmt.name; if (name !== undefined && ts.isIdentifier(name)) names.push([name.text, stmt]); } @@ -322,28 +347,23 @@ function labelledFunctionOf(stmt: ts.Statement): ts.FunctionDeclaration | null { /* ── strictness of the code around a node ─────────────────────────────── * * A directive prologue's "use strict" applies to the function (or script) it - * opens, arrows cannot carry one, and a class body is strict by - * construction. Module sources are strict whether they say so or not, and - * Node's classification — not the checker's forced external-module scope — - * decides which sources are modules. */ + * opens, an arrow function's block body included, and every part of a class is strict by + * construction. Object literal methods and accessors are ordinary functions: + * strict only by their own prologue or their surroundings. Module sources are + * strict whether they say so or not, and Node's classification — not the + * checker's forced external-module scope — decides which sources are modules. + * + * The walk starts at the node's parent: a function declaration's own "use + * strict" makes its body strict, not the code that declares it. */ function isStrictCode( sf: ts.SourceFile, node: ts.Node, isModuleCode: (sf: ts.SourceFile) => boolean, ): boolean { if (isModuleCode(sf)) return true; - for (let n: ts.Node | undefined = node; n !== undefined; n = n.parent) { + for (let n: ts.Node | undefined = node.parent; n !== undefined; n = n.parent) { if (ts.isSourceFile(n)) return hasUseStrictPrologue(n.statements.nodes); - if ( - ts.isClassStaticBlockDeclaration(n) || - ts.isMethodDeclaration(n) || - ts.isConstructorDeclaration(n) || - ts.isGetAccessorDeclaration(n) || - ts.isSetAccessorDeclaration(n) || - ts.isPropertyDeclaration(n) - ) - return true; - if (ts.isArrowFunction(n)) continue; + if (ts.isClassDeclaration(n) || ts.isClassExpression(n)) return true; if (ts.isFunctionLike(n)) { const body = n.body; if (body !== undefined && ts.isBlock(body) && hasUseStrictPrologue(body.statements.nodes)) diff --git a/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/loops.js b/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/loops.js index ef5c2565b..8b759735e 100644 --- a/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/loops.js +++ b/tests/fixtures/npm/cases/static-objects-order/node_modules/static-objects-order/loops.js @@ -1,13 +1,15 @@ // Methods that store from a for-in or for-of head where sloppy mode and -// strict mode differ: into an undeclared name (sloppy mode creates a -// global) and into a property of a string (sloppy mode drops the store). +// strict mode differ: into the read-only global `undefined` and into a +// property of a string (sloppy mode drops both stores; strict mode throws). +// A store into an undeclared name is refused for the whole package (SC1091), +// so it cannot appear here. var shapes = {}; shapes.Bag = function (size) { this.size = size; }; shapes.Bag.prototype.lastKey = function () { - for (bagLastKey in { first: 1, last: 2 }); - return bagLastKey + " " + this.size; + for (undefined in { first: 1, last: 2 }); + return typeof undefined + " " + this.size; }; shapes.Cell = function (label) { this.label = label; diff --git a/tests/harness/installed-entry.test.ts b/tests/harness/installed-entry.test.ts index 0bf9928a4..2c7473941 100644 --- a/tests/harness/installed-entry.test.ts +++ b/tests/harness/installed-entry.test.ts @@ -132,7 +132,9 @@ test("explicit installed JavaScript entry keeps diagnostics in its own modules", mkdirSync(dirname(entry), { recursive: true }); writeFileSync(join(dirname(entry), "package.json"), '{"type":"module"}\n'); writeFileSync(entry, 'import { value } from "./value.js"; console.log(value);\n'); - writeFileSync(join(dirname(entry), "value.js"), "export const value = missingName;\n"); + // A syntax error: a read of an undeclared name is Node's run-time + // ReferenceError in JavaScript, not a diagnostic. + writeFileSync(join(dirname(entry), "value.js"), "export const value = ;\n"); const { coverage } = analyze(entry); expect(coverage.preflightFailed).toBe(true); expect(coverage.diagnostics.map((d) => d.code)).toEqual(["SC0001"]); diff --git a/tests/harness/static-objects-order.test.ts b/tests/harness/static-objects-order.test.ts index 208048f18..bb68a0908 100644 --- a/tests/harness/static-objects-order.test.ts +++ b/tests/harness/static-objects-order.test.ts @@ -104,9 +104,9 @@ test( expect(rewrittenIfAny(debug, join(pkg, "coerce.js")) ?? "").not.toContain( "class __ns_shapes_Box", ); - // loops.js's methods store from for-in and for-of heads into an - // undeclared name and into a property of a string, which throws in a - // class body. + // loops.js's methods store from for-in and for-of heads into the + // read-only global `undefined` and into a property of a string, which + // throws in a class body. const loops = rewrittenIfAny(debug, join(pkg, "loops.js")) ?? ""; expect(loops).not.toContain("class __ns_shapes_Bag"); expect(loops).not.toContain("class __ns_shapes_Cell"); diff --git a/tests/harness/test262.test.ts b/tests/harness/test262.test.ts index 948c016bb..72dad6531 100644 --- a/tests/harness/test262.test.ts +++ b/tests/harness/test262.test.ts @@ -204,6 +204,23 @@ assert.notSameValue(1, "1"); status: "pass", source: "assert(compareArray(1, 2)); assert(compareArray({}, {}));", }, + { + name: "bare compareArray compares the elements of a string length", + status: "pass", + source: + 'assert(!compareArray({ length: "2", 0: 1, 1: 2 }, { length: "2", 0: 1, 1: 3 })); assert(compareArray({ length: "1", 0: 1 }, { length: "1", 0: 1 }));', + }, + { + name: "bare compareArray reads the lengths in the upstream order", + status: "pass", + source: ` +var log = []; +var a = { get length() { log.push("a"); return 1; }, 0: 1 }; +var b = { get length() { log.push("b"); return 1; }, 0: 1 }; +assert(compareArray(a, b)); +assert.compareArray(log, ["b", "a", "a", "a"]); +`, + }, { name: "bare compareArray does not throw on a mismatch", status: "fail", diff --git a/tests/test262/harness.ts b/tests/test262/harness.ts index 8a0f7bd1a..40e09734a 100644 --- a/tests/test262/harness.ts +++ b/tests/test262/harness.ts @@ -70,13 +70,15 @@ function elementAt(value: unknown, index: number): unknown { /** The bare `compareArray(a, b)` the upstream `harness/assert.js` defines * alongside `assert`. It RETURNS a boolean (the throwing assertion is - * `assert.compareArray`), and it has no shape checks of its own: it reads - * `.length` off both operands, so a null operand throws TypeError there and - * here, and two operands without a length of their own compare equal. */ + * `assert.compareArray`), and it has no shape checks of its own. It makes the + * upstream reads in the upstream order: `b.length`, then `a.length`, then + * `a.length` again before every element pair, `b[i]` before `a[i]`. A null + * operand throws TypeError there and here, and two operands without a length + * of their own compare equal. The loop bound converts the length the way the + * upstream `i < a.length` does, so a length of "2" compares two elements. */ export function compareArray(actual: unknown, expected: unknown): boolean { - const length = lengthOf(actual); - if (lengthOf(expected) !== length) return false; - for (let i = 0; typeof length === "number" && i < length; i++) { + if (lengthOf(expected) !== lengthOf(actual)) return false; + for (let i = 0; i < Number(lengthOf(actual)); i++) { if (!same(elementAt(expected, i), elementAt(actual, i))) return false; } return true;