fix: distinguish arguments property names from reads (#475)

* fix: distinguish arguments property names from reads

- Share one runtime arguments scan across type mapping and call lowering.
- Add Node differential coverage for names in keys, types, bindings, and labels.

Fixes #431

Co-authored-by: Artem Gurtovoi <1233869+temich@users.noreply.github.com>

* test: record TypeScript 7 parity for arguments properties

- Add the new corpus case to the preflight and module order baseline.

Fixes #431

Co-authored-by: Artem Gurtovoi <1233869+temich@users.noreply.github.com>

---------

Co-authored-by: Artem Gurtovoi <1233869+temich@users.noreply.github.com>
This commit is contained in:
Chris Tate
2026-09-26 17:07:06 -05:00
committed by GitHub
co-authored by Artem Gurtovoi
parent 0ad3fc7d43
commit c1634d4167
5 changed files with 83 additions and 53 deletions
@@ -0,0 +1,34 @@
import * as ts from "./ts7/adapter.js";
/** Whether an identifier in a function body reads the runtime arguments object. */
function isArgumentsRead(id: ts.Identifier): boolean {
const parent = id.parent;
// A shorthand object property reads its value, but other `name` positions
// declare a binding or spell a property (including `o.arguments`).
if (ts.isShorthandPropertyAssignment(parent)) return true;
if ((parent as ts.Node & { name?: ts.Node }).name === id) return false;
if (ts.isBindingElement(parent) && parent.propertyName === id) return false;
if (ts.isLabeledStatement(parent) && parent.label === id) return false;
if ((ts.isBreakStatement(parent) || ts.isContinueStatement(parent)) && parent.label === id) return false;
return true;
}
/** Does this function's own body read `arguments`? Nested plain functions and
* methods own theirs; arrows inherit the enclosing one. */
export function bodyReadsArguments(fn: { body?: ts.Node | undefined }): boolean {
if (fn.body === undefined) return false;
let found = false;
// Iterative walking preserves the frontend's nesting fence on deep trees.
ts.walkPreorder(fn.body, (node) => {
if (ts.isTypeNode(node)) return "skip";
if (ts.isIdentifier(node) && node.text === "arguments" && isArgumentsRead(node)) {
found = true;
return "stop";
}
if (ts.isFunctionLike(node) && !ts.isArrowFunction(node) && node !== fn) {
return "skip";
}
return undefined;
});
return found;
}
@@ -4,6 +4,7 @@ import { InternalCompilerError } from "../../errors.js";
* function/lambda lowering and signature collection, and monomorphizing
* generic instantiation (bounded by MAX_GENERIC_INSTANCES). */
import * as ts from "../ts7/adapter.js";
import { bodyReadsArguments } from "../arguments-usage.js";
import type { Lowerer } from "./lowerer.js";
import { lowerGenMethodCall } from "./lower-generators.js";
import { BIGINT_T, BOOL, CAUGHT, DYN, F64, IrExpr, IrFunction, IrLocal, IrParam, IrStmt, IrType, JSVAL, STRING, SYMBOL_T, SrcLoc, UNDEFINED_T, VOID, arrayOf, canBoxFuncIntoDyn, canConvertToDyn, canDynCheckTo, canMarshalTypedFuncIntoIsland, ffiClassType, ffiSourceParamTypes, funcOf, isFfiCallbackParam, isFfiContextParam, isFfiReleaseParam, isUnitType, shapeHasAccessorSlots, typeEquals } from "../../ir/ir.js";
@@ -37,6 +38,8 @@ import { rejectStaticThis } from "./static-this.js";
import { fenceNodeModuleMutationCall, lowerRequireCacheKeys } from "./lower-node-module.js";
import { defaultAfterUndefined, lowerOptionalArgument, lowerStaticallyUndefinedArgument, positionNumber } from "./optional-arguments.js";
export { bodyReadsArguments };
/** How a parameter participates in CALL-SITE COMPLETION (the frontend
* completes every call to the one full signature, so the IR and backends
* stay count-exact — see docs/ir.md). `required` params must be passed;
@@ -6865,33 +6868,6 @@ function loweredTemplateStrings(
return { shapes, funcType };
}
/** Does this function's OWN body read `arguments`? Nested plain functions
* and methods have their own `arguments` (the walk skips them); arrows
* see the enclosing one (the walk descends). Exported for the lowerer's
* dynFallbackType: tsgo does not synthesize the `arguments` rest
* parameter into inferred signatures (5.9.3 did — its param-count
* mismatch was the detector), so the 7 world asks the BODY directly. */
export function bodyReadsArguments(fn: { body?: ts.Node | undefined }): boolean {
let found = false;
if (fn.body === undefined) return false;
// Iterative walk (walkPreorder): function bodies can hold pathologically
// deep expression chains that a recursive visit would die on.
ts.walkPreorder(fn.body, (n) => {
if (ts.isIdentifier(n) && n.text === "arguments" && !(ts.isPropertyAccessExpression(n.parent) && n.parent.name === n)) {
found = true;
return "stop";
}
if (
(ts.isFunctionExpression(n) || ts.isFunctionDeclaration(n) || ts.isMethodDeclaration(n)) &&
n !== fn
) {
return "skip"; // own `arguments` scope
}
return undefined;
});
return found;
}
/** Lifts an arrow function / function expression / nested declaration /
* object-literal shorthand method to a module-level function and yields
* the `closure` expression creating it. */
+3 -26
View File
@@ -1,5 +1,6 @@
import { InternalCompilerError } from "../errors.js";
import * as ts from "./ts7/adapter.js";
import { bodyReadsArguments } from "./arguments-usage.js";
import type { IrRecordShape, IrType, IrUnionDef } from "../ir/ir.js";
import { arrayOf, BOOL, bytesOf, canConvertToDyn, CHILD_T, CRYPTOHASH_T, CRYPTOHMAC_T, DATE_T, DYN, F64, funcOf, isSupportedArrayElem, isSupportedIndexValue, isSupportedMapKey, isSupportedMapValue, isSupportedSetElem, isUnitType, JSVAL, mapOf, NULL_T, PROCSTREAM_T, RUNTIME_EMITTER_CLASS, RUNTIME_ERROR_CLASSES, RUNTIME_STREAM_CLASSES, setOf, STRING, SYMBOL_T, typeEquals, typeKey, UNDEFINED_T, VOID } from "../ir/ir.js";
import { BIGINT_T } from "../ir/ir.js";
@@ -752,30 +753,6 @@ export interface TypeMapperCtx {
* embedded engine there. */
moduleNamespaceId?: (type: ts.Type) => string | null;
}
/** lower-calls.ts's bodyReadsArguments, duplicated here (type-mapper.ts must not
* import from lowering/ — that edge is a module cycle): does the function's
* OWN body read `arguments`? Nested plain functions/methods own theirs
* (skipped); arrows see the enclosing one (descended). */
function bodyReadsArgumentsLocal(fn: { body?: ts.Node | undefined }): boolean {
let found = false;
if (fn.body === undefined) return false;
// Iterative walk (walkPreorder): function bodies can hold pathologically
// deep expression chains that a recursive visit would die on.
ts.walkPreorder(fn.body, (n) => {
if (ts.isIdentifier(n) && n.text === "arguments" && !(ts.isPropertyAccessExpression(n.parent) && n.parent.name === n)) {
found = true;
return "stop";
}
if ((ts.isFunctionExpression(n) || ts.isFunctionDeclaration(n) || ts.isMethodDeclaration(n)) && (n as unknown) !== fn) {
return "skip"; // own `arguments` scope
}
return undefined;
});
return found;
}
/** A generator type's normalized value channels (Generator<T, TReturn,
* TNext> and the IteratorResult alias share this):
* - yield: `never` (a generator that never yields) rides the VOID
@@ -2592,7 +2569,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
// tsgo never SYNTHESIZES that rest param into the inferred signature
// (5.9.3 did — the count mismatch above was the whole detector
// there), so the declaration's own body answers directly.
if (sigDecl !== undefined && ts.isFunctionLike(sigDecl) && bodyReadsArgumentsLocal(sigDecl as { body?: ts.Node })) {
if (sigDecl !== undefined && ts.isFunctionLike(sigDecl) && bodyReadsArguments(sigDecl as { body?: ts.Node })) {
return null;
}
}
@@ -3975,7 +3952,7 @@ export function describeComponentBlocker(widened: ts.Type, ctx: TypeMapperCtx):
sigDecl !== undefined &&
ts.isFunctionLike(sigDecl) &&
(sigDecl.parameters.length !== sig.getParameters().length ||
bodyReadsArgumentsLocal(sigDecl as { body?: ts.Node }))
bodyReadsArguments(sigDecl as { body?: ts.Node }))
) {
return `the function shape is supported, but its signature is variadic ('arguments'-reading), and a compiled signature is fixed-arity`;
}
@@ -7195,6 +7195,12 @@
],
"diags": []
},
"<repo>/tests/corpus/3059-arguments-property-names.ts": {
"order": [
"<repo>/tests/corpus/3059-arguments-property-names.ts"
],
"diags": []
},
"<repo>/tests/corpus/400-fib.ts": {
"order": [
"<repo>/tests/corpus/400-fib.ts"
@@ -0,0 +1,37 @@
// Property names in an arrow body are not reads of the enclosing arguments object.
interface Options { keys: string[]; arguments: string[] }
const make = (): { arguments: number } => ({ arguments: 1 });
console.log("literal", make().arguments);
const count = (options: Options): number => options.keys.length + options.arguments.length;
const later = (): number => count({ keys: ["a"], arguments: ["b", "c"] });
console.log("options", later());
const unpack = (): number => {
const { arguments: value } = { arguments: 4 };
return value;
};
console.log("binding", unpack());
const typed = (): number => {
const value: { arguments: number } = { arguments: 5 };
return value.arguments;
};
console.log("type", typed());
const withMethod = (): number => {
const value = { arguments(): number { return 6; } };
return value.arguments();
};
console.log("method", withMethod());
const withLabel = (): number => {
let result = 0;
arguments: {
result = 7;
break arguments;
}
return result;
};
console.log("label", withLabel());