diff --git a/docs/content/docs/dependencies.mdx b/docs/content/docs/dependencies.mdx
index 3c6958841..b5354ea5e 100644
--- a/docs/content/docs/dependencies.mdx
+++ b/docs/content/docs/dependencies.mdx
@@ -53,7 +53,7 @@ Type-only imports follow Node.js's type stripping. `import type` and `export typ
scriptc compiles a package automatically when it:
- ships its own type declarations, and
-- ships readable, unminified JavaScript.
+- ships readable, unminified JavaScript. A minified entry point qualifies when the package also ships the per-file build it was bundled from, and their source maps show the same sources with the same contents, including any dependencies the bundle inlined. scriptc then compiles the per-file build.
A package that doesn't meet these requirements, or that uses something scriptc can't compile, gets a package-specific diagnostic that explains why. The usual fixes are to switch to a version or alternative package that compiles, or to replace the call with code you write yourself. A package whose declarations come from a separate `@types/` package doesn't ship its own; its diagnostic names the package and the `--npm-static` option that compiles it anyway.
diff --git a/docs/content/docs/limitations.mdx b/docs/content/docs/limitations.mdx
index 6f21e2ce3..0b81b1413 100644
--- a/docs/content/docs/limitations.mdx
+++ b/docs/content/docs/limitations.mdx
@@ -30,6 +30,8 @@ title: "Limitations"
../limitations/language-features/argument-spreads.mdx
+../limitations/language-features/arguments-object.mdx
+
../limitations/language-features/dynamic-imports.mdx
../limitations/language-features/commonjs-require.mdx
diff --git a/docs/content/limitations/language-features/arguments-object.mdx b/docs/content/limitations/language-features/arguments-object.mdx
new file mode 100644
index 000000000..3b5a517e1
--- /dev/null
+++ b/docs/content/limitations/language-features/arguments-object.mdx
@@ -0,0 +1 @@
+- **The `arguments` object:** `arguments` holds a native array of the actual arguments. In ES modules, in class methods, and in functions without parameters it accepts any use, but `Array.isArray(arguments)` returns `true`, `util.inspect` prints it as an array, and writing past its length extends it. In other JavaScript functions with parameters it compiles when it is used only as `arguments.length`, a read of `arguments[i]`, `...arguments`, `f.apply(thisArg, arguments)`, `Array.prototype.slice.call(arguments)`, or `Array.from(arguments)`. Node.js aliases `arguments[i]` and the matching parameter in sloppy-mode functions with simple parameter lists, so such a function also must not write a parameter that one of those reads can reach. Functions that do, or that use `arguments` any other way (including writes and `arguments.callee`), compile to a runtime error.
diff --git a/package.json b/package.json
index 7318e04f5..33486845e 100644
--- a/package.json
+++ b/package.json
@@ -38,10 +38,14 @@
"@scriptc/types": "workspace:*",
"@types/node": "^24.0.0",
"effect": "3.22.2",
+ "glob": "13.0.6",
"js-yaml": "4.3.0",
+ "lru-cache": "11.5.3",
+ "node-fetch": "2.7.0",
"oxfmt": "0.71.0",
"oxlint": "1.86.0",
"rollup": "4.62.2",
+ "signal-exit": "3.0.7",
"three": "0.186.1",
"tsx": "^4.19.0",
"typescript": "5.9.3",
diff --git a/packages/compiler/src/frontend/arguments-usage.ts b/packages/compiler/src/frontend/arguments-usage.ts
index 317599f4c..2aa7d754d 100644
--- a/packages/compiler/src/frontend/arguments-usage.ts
+++ b/packages/compiler/src/frontend/arguments-usage.ts
@@ -33,3 +33,23 @@ export function bodyReadsArguments(fn: ts.Node): boolean {
});
return found;
}
+
+/** Every identifier in this function's own body (arrows included, nested
+ * plain functions and methods excluded) that reads `arguments` — the
+ * bodyReadsArguments walk, collecting instead of stopping at the first. */
+export function argumentsReadsOf(fn: ts.Node): ts.Identifier[] {
+ const reads: ts.Identifier[] = [];
+ if (fn.body === undefined) return reads;
+ ts.walkPreorder(fn.body, (node) => {
+ if (ts.isTypeNode(node)) return "skip";
+ if (ts.isIdentifier(node) && node.text === "arguments" && isArgumentsRead(node)) {
+ reads.push(node);
+ return undefined;
+ }
+ if (ts.isFunctionLike(node) && !ts.isArrowFunction(node) && node !== fn) {
+ return "skip";
+ }
+ return undefined;
+ });
+ return reads;
+}
diff --git a/packages/compiler/src/frontend/lowering/call-signatures.ts b/packages/compiler/src/frontend/lowering/call-signatures.ts
index c1cc72021..5181d8f06 100644
--- a/packages/compiler/src/frontend/lowering/call-signatures.ts
+++ b/packages/compiler/src/frontend/lowering/call-signatures.ts
@@ -1,6 +1,7 @@
import { InternalCompilerError } from "../../errors.js";
import * as ts from "../ts7/adapter.js";
import { bodyReadsArguments } from "../arguments-usage.js";
+import { sloppyArgumentsRefusal } from "./sloppy-arguments.js";
import type { Lowerer } from "./lowerer.js";
import {
DYN,
@@ -691,8 +692,9 @@ export function collectSignatureInner(lowerer: Lowerer, decl: ts.FunctionDeclara
/** Arrows inherit arguments. Ordinary JS functions receive a hidden
* array unless a declared rest parameter already owns that contract.
- * Class methods are strict; other parameterized forms require ES modules
- * because sloppy-mode arguments alias named parameters. */
+ * Class methods and ES modules are strict; other parameterized forms
+ * admit the array only where sloppy-mode aliasing between `arguments`
+ * and named parameters cannot be observed (sloppy-arguments.ts). */
function appendArgumentsParameter(
lowerer: Lowerer,
node: ts.SignatureDeclaration,
@@ -715,11 +717,8 @@ function appendArgumentsParameter(
ts.isMethodDeclaration(node) &&
(ts.isClassDeclaration(node.parent) || ts.isClassExpression(node.parent));
if (!classMethod && !isNodeEsmFile(node.getSourceFile(), lowerer.program)) {
- lowerer.unsupported(
- "SC1090",
- node,
- "parameterized 'arguments' outside an ES module (sloppy-mode parameter aliases)",
- );
+ const refusal = sloppyArgumentsRefusal(lowerer, node);
+ if (refusal !== null) lowerer.unsupported("SC1090", refusal.node, refusal.reason);
}
}
shapes.push({ type: DYN, mode });
diff --git a/packages/compiler/src/frontend/lowering/generic-functions.ts b/packages/compiler/src/frontend/lowering/generic-functions.ts
index bd785a256..c5b31651e 100644
--- a/packages/compiler/src/frontend/lowering/generic-functions.ts
+++ b/packages/compiler/src/frontend/lowering/generic-functions.ts
@@ -1045,7 +1045,7 @@ export function implicitMonoFile(sf: ts.SourceFile): boolean {
* parameter symbol — assignment, compound assignment, ++/--, a
* destructuring-assignment target, or a for-in/of cursor. A written
* param's binding could lie after the write, so it stays dyn. */
-function paramWrittenInBody(
+export function paramWrittenInBody(
lowerer: Lowerer,
body: ts.Node,
sym: ts.Symbol,
diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts
index 57e6c2311..1a6ca42e2 100644
--- a/packages/compiler/src/frontend/lowering/lower-stmts.ts
+++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts
@@ -5709,7 +5709,7 @@ export function lowerTry(lowerer: Lowerer, stmt: ts.TryStatement): IrStmt {
};
}
-function hasUseStrictDirective(statements: readonly ts.Statement[]): boolean {
+export function hasUseStrictDirective(statements: readonly ts.Statement[]): boolean {
for (const statement of statements) {
if (!ts.isExpressionStatement(statement) || !ts.isStringLiteral(statement.expression))
return false;
diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts
index 2b86d6617..4cf9a10bb 100644
--- a/packages/compiler/src/frontend/lowering/lowerer.ts
+++ b/packages/compiler/src/frontend/lowering/lowerer.ts
@@ -373,6 +373,7 @@ import {
import { bindingNeverReassigned } from "./binding-analysis.js";
import type { InstantiationPath } from "./instantiation-path.js";
import { bodyReadsArguments } from "../arguments-usage.js";
+import { sloppyArgumentsRefusal } from "./sloppy-arguments.js";
import { completeArgs, wrappedUndefined, undefinedArgFor } from "./call-arguments.js";
import { bodyReturnType, declaredReturnType } from "./function-returns.js";
import { lowerCall, lowerPromiseMethodCall, lowerFilterNarrowCall } from "./lower-calls.js";
@@ -1451,7 +1452,9 @@ function jsArgumentsFunctionType(lowerer: Lowerer, t: ts.Type): IrType | null {
!hasRest &&
(ts.isArrowFunction(decl) ||
!bodyReadsArguments(decl) ||
- (decl.parameters.length > 0 && !isNodeEsmFile(decl.getSourceFile(), lowerer.program)))
+ (decl.parameters.length > 0 &&
+ !isNodeEsmFile(decl.getSourceFile(), lowerer.program) &&
+ sloppyArgumentsRefusal(lowerer, decl) !== null))
)
return null;
const shapes = paramShapes(lowerer, decl.parameters);
diff --git a/packages/compiler/src/frontend/lowering/sloppy-arguments.ts b/packages/compiler/src/frontend/lowering/sloppy-arguments.ts
new file mode 100644
index 000000000..f079ba7c4
--- /dev/null
+++ b/packages/compiler/src/frontend/lowering/sloppy-arguments.ts
@@ -0,0 +1,266 @@
+import * as ts from "../ts7/adapter.js";
+import { argumentsReadsOf } from "../arguments-usage.js";
+import { paramWrittenInBody } from "./generic-functions.js";
+import { hasUseStrictDirective } from "./lower-stmts.js";
+import type { Lowerer } from "./lowerer.js";
+
+/** Why a parameterized function's `arguments` cannot use the unmapped
+ * hidden array: the node to blame and the refused feature. */
+export interface SloppyArgumentsRefusal {
+ node: ts.Node;
+ reason: string;
+}
+
+const MAPPED = "in a sloppy-mode function (mapped 'arguments' aliases its parameters)";
+const UNMAPPED = "in a function with parameters outside an ES module";
+
+/** Decides whether a JavaScript function with parameters outside an ES
+ * module may read `arguments` through the hidden array of actual
+ * arguments. That array is a snapshot, so it is exact only while nothing
+ * writes it or inspects it as an object:
+ * - `arguments` is used only as `arguments.length`, an rvalue
+ * `arguments[i]`, `...arguments`, `f.apply(x, arguments)` (an untyped
+ * `f` is taken to be a function, the forwarding idiom's assumption),
+ * `Array.prototype.slice.call(arguments[, n])`,
+ * or `Array.from(arguments[, fn])` — none can write it or let it escape.
+ * Node's object is UNMAPPED in strict code and when the parameter list is
+ * not simple (defaults, rest, patterns); those functions are admitted on
+ * that condition alone. Sloppy-mode functions with simple parameter lists
+ * get a MAPPED object, whose elements alias the parameters, so they also
+ * need:
+ * - no parameter that some read can reach (a constant index, or every
+ * index for the other forms) is ever written, including by closures.
+ * Returns null when admitted, else the precise refusal. Only functions
+ * that reach the non-module fence run this analysis. */
+export function sloppyArgumentsRefusal(
+ lowerer: Lowerer,
+ fn: ts.SignatureDeclaration,
+): SloppyArgumentsRefusal | null {
+ const params = fn.parameters;
+ if (params.length === 0 || fn.body === undefined) return null;
+ // A default, rest, or destructuring parameter makes the list non-simple,
+ // and Node then creates an unmapped arguments object even in sloppy mode.
+ const unmapped =
+ params.some(
+ (p) =>
+ !ts.isIdentifier(p.name) || p.initializer !== undefined || p.dotDotDotToken !== undefined,
+ ) || isStrictCode(fn);
+ const where = unmapped ? UNMAPPED : MAPPED;
+ let readsAll = false;
+ const readIndices = new Set();
+ for (const id of argumentsReadsOf(fn)) {
+ const symbol = lowerer.checker.getSymbolAtLocation(id);
+ const declaration = symbol ? lowerer.checker.valueDeclarationOf(symbol) : undefined;
+ if (declaration !== undefined) {
+ return { node: id, reason: `a binding named 'arguments' ${where}` };
+ }
+ const use = classifyUse(lowerer, id);
+ switch (use.kind) {
+ case "write":
+ return { node: use.node, reason: `writing 'arguments' ${where}` };
+ case "escape":
+ return { node: use.node, reason: `${use.what} ${where}` };
+ case "index":
+ readIndices.add(use.index);
+ break;
+ case "all":
+ readsAll = true;
+ break;
+ case "none":
+ break;
+ }
+ }
+ if (unmapped) return null;
+ const names = new Set();
+ for (const [index, param] of params.entries()) {
+ const name = (param.name as ts.Identifier).text;
+ if (names.has(name)) {
+ return { node: param, reason: `a duplicate parameter name ${MAPPED}` };
+ }
+ names.add(name);
+ if (!readsAll && !readIndices.has(index)) continue;
+ const symbol = lowerer.checker.getSymbolAtLocation(param.name);
+ if (symbol === undefined || paramWrittenInBody(lowerer, fn.body, symbol, name)) {
+ return {
+ node: param,
+ reason: `writing parameter '${name}' while reading it through 'arguments' ${MAPPED}`,
+ };
+ }
+ }
+ return null;
+}
+
+type ArgumentsUse =
+ | { kind: "none" }
+ | { kind: "index"; index: number }
+ | { kind: "all" }
+ | { kind: "write"; node: ts.Node }
+ | { kind: "escape"; node: ts.Node; what: string };
+
+function classifyUse(lowerer: Lowerer, id: ts.Identifier): ArgumentsUse {
+ let node: ts.Node = id;
+ while (ts.isParenthesizedExpression(node.parent)) node = node.parent;
+ const access = accessKind(node);
+ if (access === "write") return { kind: "write", node };
+ const parent = node.parent;
+ if (ts.isPropertyAccessExpression(parent) && parent.expression === node) {
+ if (parent.name.text !== "length") {
+ return { kind: "escape", node: parent, what: `'arguments.${parent.name.text}'` };
+ }
+ const lengthAccess = accessKind(parent);
+ if (lengthAccess === "write") return { kind: "write", node: parent };
+ if (lengthAccess === "call") {
+ return { kind: "escape", node: parent, what: "calling a member of 'arguments'" };
+ }
+ return { kind: "none" };
+ }
+ if (ts.isElementAccessExpression(parent) && parent.expression === node) {
+ const elementAccess = accessKind(parent);
+ if (elementAccess === "write") return { kind: "write", node: parent };
+ if (elementAccess === "call") {
+ return { kind: "escape", node: parent, what: "calling a member of 'arguments'" };
+ }
+ const index = constantIndex(parent.argumentExpression);
+ return index === null ? { kind: "all" } : { kind: "index", index };
+ }
+ if (
+ ts.isSpreadElement(parent) &&
+ (ts.isCallExpression(parent.parent) ||
+ ts.isNewExpression(parent.parent) ||
+ ts.isArrayLiteralExpression(parent.parent))
+ ) {
+ return { kind: "all" };
+ }
+ if (ts.isCallExpression(parent) && parent.expression !== node) {
+ const callee = parent.expression;
+ const args = parent.arguments;
+ if (
+ args.length === 2 &&
+ args[1] === node &&
+ ts.isPropertyAccessExpression(callee) &&
+ callee.name.text === "apply" &&
+ isFunctionApply(lowerer, callee)
+ ) {
+ return { kind: "all" };
+ }
+ if (args[0] === node && args.length <= 2 && isArraySliceCall(lowerer, callee)) {
+ return { kind: "all" };
+ }
+ if (
+ args[0] === node &&
+ args.length <= 2 &&
+ ts.isPropertyAccessExpression(callee) &&
+ callee.name.text === "from" &&
+ lowerer.isStdlibGlobal(callee.expression, "Array")
+ ) {
+ return { kind: "all" };
+ }
+ }
+ return { kind: "escape", node, what: "'arguments' as a value" };
+}
+
+/** How an expression position uses its value: a write target (assignment,
+ * compound assignment, ++/--, delete, a destructuring-assignment or
+ * for-in/of target), a call/tagged-template callee (the callee receives
+ * the object as `this`), or a plain read. */
+function accessKind(expr: ts.Node): "read" | "write" | "call" {
+ let node = expr;
+ while (ts.isParenthesizedExpression(node.parent)) node = node.parent;
+ const parent = node.parent;
+ if (ts.isDeleteExpression(parent)) return "write";
+ if (
+ (ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) &&
+ (parent.operator === ts.SyntaxKind.PlusPlusToken ||
+ parent.operator === ts.SyntaxKind.MinusMinusToken)
+ ) {
+ return "write";
+ }
+ if (
+ (ts.isCallExpression(parent) || ts.isTaggedTemplateExpression(parent)) &&
+ parent.expression === node
+ ) {
+ return "call";
+ }
+ // Climb a destructuring-assignment pattern to its root.
+ let target = node;
+ for (;;) {
+ const up = target.parent;
+ if (
+ ts.isArrayLiteralExpression(up) ||
+ ts.isObjectLiteralExpression(up) ||
+ ts.isSpreadElement(up) ||
+ ts.isSpreadAssignment(up) ||
+ ts.isShorthandPropertyAssignment(up) ||
+ ts.isParenthesizedExpression(up) ||
+ (ts.isPropertyAssignment(up) && up.initializer === target)
+ ) {
+ target = up;
+ continue;
+ }
+ break;
+ }
+ const root = target.parent;
+ if (ts.isBinaryExpression(root) && root.left === target) {
+ const k = root.operatorToken.kind;
+ if (k >= ts.SyntaxKind.FirstAssignment && k <= ts.SyntaxKind.LastAssignment) return "write";
+ }
+ if ((ts.isForInStatement(root) || ts.isForOfStatement(root)) && root.initializer === target) {
+ return "write";
+ }
+ return "read";
+}
+
+/** `f.apply` where `apply` is Function.prototype.apply: the member resolves
+ * to the standard library, or the receiver is an untyped JavaScript value
+ * (`var emit = process.emit`), which the forwarding idiom calls for its
+ * function value. A typed receiver declaring its own `apply` is an escape. */
+function isFunctionApply(lowerer: Lowerer, callee: ts.PropertyAccessExpression): boolean {
+ if (lowerer.isStdlibMember(callee)) return true;
+ return (
+ lowerer.checker.getSymbolAtLocation(callee.name) === undefined &&
+ (lowerer.typeOf(callee.expression).flags & ts.TypeFlags.Any) !== 0
+ );
+}
+
+/** `Array.prototype.slice.call` on the global Array. (`[].slice.call`
+ * stays an escape: its `[].slice` read does not lower to the prototype
+ * method.) */
+function isArraySliceCall(lowerer: Lowerer, callee: ts.Expression): boolean {
+ if (!ts.isPropertyAccessExpression(callee) || callee.name.text !== "call") return false;
+ const slice = callee.expression;
+ if (!ts.isPropertyAccessExpression(slice) || slice.name.text !== "slice") return false;
+ const owner = slice.expression;
+ return (
+ ts.isPropertyAccessExpression(owner) &&
+ owner.name.text === "prototype" &&
+ lowerer.isStdlibGlobal(owner.expression, "Array")
+ );
+}
+
+/** A literal array index (`arguments[1]`, `arguments["1"]`), else null. */
+function constantIndex(expr: ts.Expression): number | null {
+ let text: string;
+ if (ts.isNumericLiteral(expr)) text = String(Number(expr.text));
+ else if (ts.isStringLiteral(expr)) text = expr.text;
+ else return null;
+ const index = Number(text);
+ return Number.isInteger(index) && index >= 0 && String(index) === text ? index : null;
+}
+
+/** Class bodies, functions with a 'use strict' directive, and everything
+ * nested in them are strict code; so is a file whose prologue says so. */
+function isStrictCode(fn: ts.Node): boolean {
+ for (let node: ts.Node | undefined = fn; node; node = node.parent) {
+ if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) return true;
+ if (ts.isSourceFile(node)) return hasUseStrictDirective(node.statements);
+ if (
+ ts.isFunctionLike(node) &&
+ node.body !== undefined &&
+ ts.isBlock(node.body) &&
+ hasUseStrictDirective(node.body.statements)
+ ) {
+ return true;
+ }
+ }
+ return false;
+}
diff --git a/packages/compiler/src/frontend/npm-static-minified-syntax.ts b/packages/compiler/src/frontend/npm-static-minified-syntax.ts
new file mode 100644
index 000000000..bdd02da70
--- /dev/null
+++ b/packages/compiler/src/frontend/npm-static-minified-syntax.ts
@@ -0,0 +1,76 @@
+/** The syntax answers minified-entry verification asks of a parsed
+ * JavaScript file (npm-static-minified.ts keeps no AST dependency, so the
+ * package.json transform stays parser-free). */
+import * as ts from "./ts7/syntax.js";
+import { moduleSpecifiersOfFile } from "./module-syntax.js";
+import { cjsLexedExportsOfFile } from "./cjs-syntax.js";
+import type { ModuleEdge, ModuleExportNames } from "./npm-static-minified.js";
+
+/** Every specifier the file loads at run time, by edge kind. */
+export function moduleEdgesOfFile(sf: ts.SourceFile): ModuleEdge[] {
+ return moduleSpecifiersOfFile(sf).uses.map((use) => ({
+ specifier: use.specifier,
+ imported: use.static || use.dynamicImport,
+ required: use.require,
+ }));
+}
+
+/** ES module export declarations, or the Node-visible CommonJS names when
+ * the file has no module syntax. */
+export function moduleExportsOfFile(sf: ts.SourceFile): ModuleExportNames | null {
+ if (!hasModuleSyntax(sf)) {
+ const lexed = cjsLexedExportsOfFile(sf);
+ return { esm: false, names: [...lexed.exports], reexports: lexed.reexports };
+ }
+ const names: string[] = [];
+ const reexports: string[] = [];
+ for (const stmt of sf.statements) {
+ if (ts.isExportDeclaration(stmt)) {
+ if (stmt.isTypeOnly) continue;
+ const clause = stmt.exportClause;
+ if (clause === undefined) {
+ const spec = stmt.moduleSpecifier;
+ if (spec === undefined || !ts.isStringLiteral(spec)) return null;
+ reexports.push(spec.text);
+ } else if (ts.isNamedExports(clause)) {
+ for (const element of clause.elements) {
+ if (!element.isTypeOnly) names.push(element.name.text);
+ }
+ } else {
+ names.push(clause.name.text);
+ }
+ } else if (ts.isExportAssignment(stmt)) {
+ names.push("default");
+ } else if (
+ (ts.isFunctionDeclaration(stmt) ||
+ ts.isClassDeclaration(stmt) ||
+ ts.isVariableStatement(stmt)) &&
+ stmt.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)
+ ) {
+ if (stmt.modifiers.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword)) {
+ names.push("default");
+ } else if (ts.isVariableStatement(stmt)) {
+ for (const decl of stmt.declarationList.declarations) {
+ if (!ts.isIdentifier(decl.name)) return null;
+ names.push(decl.name.text);
+ }
+ } else if (stmt.name !== undefined) {
+ names.push(stmt.name.text);
+ }
+ }
+ }
+ return { esm: true, names, reexports };
+}
+
+function hasModuleSyntax(sf: ts.SourceFile): boolean {
+ return sf.statements.some(
+ (stmt) =>
+ ts.isImportDeclaration(stmt) ||
+ ts.isExportDeclaration(stmt) ||
+ ts.isExportAssignment(stmt) ||
+ ((ts.isFunctionDeclaration(stmt) ||
+ ts.isClassDeclaration(stmt) ||
+ ts.isVariableStatement(stmt)) &&
+ stmt.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) === true),
+ );
+}
diff --git a/packages/compiler/src/frontend/npm-static-minified.ts b/packages/compiler/src/frontend/npm-static-minified.ts
new file mode 100644
index 000000000..77c974f65
--- /dev/null
+++ b/packages/compiler/src/frontend/npm-static-minified.ts
@@ -0,0 +1,291 @@
+/** Minified package entries resolve to their unminified siblings when the
+ * two are provably the same program. Packages built with tshy and esbuild
+ * (lru-cache 11, glob 13) point their exports at one `index.min.js` bundle
+ * and still ship the per-file build next to it (`index.js`, …) with source
+ * maps. The bundle cannot be compiled readably, but the per-file build can,
+ * so an opted-in package's minified target is respelled to the sibling when
+ * all of these hold:
+ * - the target looks minified (the eligibility heuristic's own test) and
+ * a sibling with the same name minus `.min` exists;
+ * - the bundle's source map lists every source with its content, and the
+ * per-file module graph reached from the sibling (relative edges and the
+ * installed dependencies Node would load, through their source maps)
+ * compiles exactly the same sources with byte-identical content — so a
+ * dependency inlined into the bundle at build time must match the
+ * installed version the per-file build would import;
+ * - the bundle and the sibling export the same names.
+ * Anything else keeps the minified target, and the reason is recorded for
+ * the eligibility refusal. */
+import { dirname, join, relative, resolve } from "node:path";
+import { canonicalBuiltinModule } from "./builtin-modules.js";
+import { trackedExists, trackedReadFile } from "./input-tracker.js";
+
+/** One runtime module edge of a JavaScript file: the specifier and whether
+ * an import (static or dynamic) or a require loads it. */
+export interface ModuleEdge {
+ specifier: string;
+ imported: boolean;
+ required: boolean;
+}
+
+/** A module's own export names and the specifiers it re-exports whole
+ * (ES `export * from`, or the CommonJS lexer's reexports). Hosts answer
+ * null for an export shape the comparison cannot name. */
+export interface ModuleExportNames {
+ esm: boolean;
+ names: string[];
+ reexports: string[];
+}
+
+/** The services verification needs from the load that owns resolution:
+ * syntax answers over parsed JavaScript (npm-static-minified-syntax.ts)
+ * and the opted-in runtime resolver (`resolveBareModule` in js-only mode,
+ * which resolves dependencies through the same respelled package.json
+ * documents). */
+export interface MinifiedEntryHost {
+ moduleEdges: (path: string, source: string) => ModuleEdge[];
+ moduleExports: (path: string, source: string) => ModuleExportNames | null;
+ resolveBare: (fromFile: string, specifier: string, kind: "import" | "require") => string | null;
+}
+
+let host: MinifiedEntryHost | null = null;
+/** Per-load verdicts by minified file path: null when verified, else the
+ * refusal reason. */
+const verdicts = new Map();
+const inProgress = new Set();
+
+export function setMinifiedEntryHost(next: MinifiedEntryHost | null): void {
+ host = next;
+ verdicts.clear();
+}
+
+/** Unminified-JS heuristic over an entry source: at least two lines and
+ * an average line length under 200 characters (minified dists are one
+ * enormous line; readable code averages well under 100). */
+export function looksUnminified(source: string): boolean {
+ const lines = source.split("\n");
+ if (lines.length < 2) return false;
+ return source.length / lines.length <= 200;
+}
+
+const MIN_NAME = /\.min(\.[cm]?js)$/;
+
+/** The verified unminified sibling for a package.json target string
+ * (`./dist/esm/index.min.js` → `./dist/esm/index.js`), else the target. */
+export function unminifiedTarget(pkgDir: string, target: string): string {
+ if (!MIN_NAME.test(target) || !target.startsWith("./")) return target;
+ const verdict = verifyMinifiedEntry(join(pkgDir, target));
+ return verdict === null ? target.replace(MIN_NAME, "$1") : target;
+}
+
+/** Why a minified entry did not resolve to its sibling, or null when it
+ * has no `.min` sibling candidate or was verified. */
+export function minifiedEntryRefusal(file: string): string | null {
+ if (!MIN_NAME.test(file)) return null;
+ return verifyMinifiedEntry(file);
+}
+
+/** Null when the minified file's sibling is verified, else the reason. */
+function verifyMinifiedEntry(file: string): string | null {
+ const norm = resolve(file);
+ if (verdicts.has(norm)) return verdicts.get(norm) ?? null;
+ // A dependency cycle through two minified entries resolves the inner one
+ // unverified (its bundle then appears in the per-file graph and fails
+ // the comparison).
+ if (inProgress.has(norm)) return "its verification depends on itself";
+ inProgress.add(norm);
+ let verdict: string | null;
+ try {
+ verdict = verify(norm);
+ } catch {
+ verdict = "its source maps could not be read";
+ } finally {
+ inProgress.delete(norm);
+ }
+ verdicts.set(norm, verdict);
+ return verdict;
+}
+
+function verify(minFile: string): string | null {
+ const sibling = minFile.replace(MIN_NAME, "$1");
+ const h = host;
+ if (h === null) return "no verifier is active";
+ const minSource = trackedReadFile(minFile);
+ if (minSource === null) return `${minFile} cannot be read`;
+ if (looksUnminified(minSource)) return "it is not minified";
+ if (!trackedExists(sibling)) return `it has no unminified sibling ${sibling}`;
+ const bundled = sourcesOf(minFile, minSource);
+ if (bundled === null) return "its bundle has no source map with source contents";
+
+ // The per-file graph Node would load from the sibling.
+ const compiled = new Map();
+ const seen = new Set();
+ const queue = [sibling];
+ while (queue.length > 0) {
+ const file = queue.pop();
+ if (file === undefined) break;
+ if (seen.has(file)) continue;
+ seen.add(file);
+ const source = trackedReadFile(file);
+ if (source === null) return `${file} cannot be read`;
+ const mapped = file.endsWith(".json") ? null : sourcesOf(file, source);
+ const contributed = new Map();
+ if (mapped === null) contributed.set(sourceKey(file), source);
+ else for (const [key, content] of mapped) contributed.set(key, content);
+ for (const [key, content] of contributed) {
+ const prior = compiled.get(key);
+ if (prior !== undefined && prior !== content) {
+ return `two installed copies of ${key} differ`;
+ }
+ compiled.set(key, content);
+ }
+ if (file.endsWith(".json")) continue;
+ for (const edge of h.moduleEdges(file, source)) {
+ const spec = edge.specifier;
+ if (spec.startsWith("node:") || canonicalBuiltinModule(spec) !== null) continue;
+ const kinds: ("import" | "require")[] = [];
+ if (edge.imported) kinds.push("import");
+ if (edge.required) kinds.push("require");
+ for (const kind of kinds) {
+ const target =
+ spec.startsWith("./") || spec.startsWith("../")
+ ? relativeTarget(file, spec, kind)
+ : h.resolveBare(file, spec, kind);
+ if (target === null) return `'${spec}' does not resolve from ${file}`;
+ queue.push(target);
+ }
+ }
+ }
+
+ for (const [key, content] of bundled) {
+ const per = compiled.get(key);
+ if (per === undefined) {
+ return `the bundle inlines ${key}, which its per-file build does not load`;
+ }
+ if (per !== content) {
+ return `the bundle inlines a ${key} that differs from the installed one its per-file build loads`;
+ }
+ }
+ for (const key of compiled.keys()) {
+ if (!bundled.has(key)) {
+ return `its per-file build loads ${key}, which the bundle does not contain`;
+ }
+ }
+ const minExports = exportNames(h, minFile, minSource);
+ const siblingExports = exportNames(h, sibling, trackedReadFile(sibling) ?? "");
+ if (minExports === null || siblingExports === null || minExports !== siblingExports) {
+ return "its bundle and its per-file entry export different names";
+ }
+ return null;
+}
+
+/** A relative edge resolved the way Node does for the edge's kind: ESM
+ * specifiers name the file exactly; require also tries the extensions and
+ * a directory index. */
+function relativeTarget(from: string, spec: string, kind: "import" | "require"): string | null {
+ const base = resolve(dirname(from), spec);
+ const candidates =
+ kind === "import"
+ ? [base]
+ : [base, `${base}.js`, `${base}.cjs`, `${base}.json`, join(base, "index.js")];
+ for (const candidate of candidates) {
+ if (trackedExists(candidate) && trackedReadFile(candidate) !== null) return candidate;
+ }
+ return null;
+}
+
+/** The source map's (source → content) pairs, keyed package-relative so a
+ * bundle's build-time `node_modules/minimatch/src/index.ts` and the
+ * installed `minimatch/src/index.ts` compare. Null without a map file, or
+ * when any source lacks its content. */
+function sourcesOf(file: string, source: string): Map | null {
+ const url = /\/\/# sourceMappingURL=(\S+)\s*$/.exec(source)?.[1];
+ if (url === undefined || url.includes(":")) return null;
+ const mapFile = resolve(dirname(file), url);
+ const text = trackedReadFile(mapFile);
+ if (text === null) return null;
+ const parsed: unknown = JSON.parse(text);
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
+ const map = parsed as Record;
+ const sources = map["sources"];
+ const contents = map["sourcesContent"];
+ const rootValue = map["sourceRoot"];
+ if (
+ map["version"] !== 3 ||
+ !Array.isArray(sources) ||
+ !Array.isArray(contents) ||
+ sources.length !== contents.length
+ )
+ return null;
+ const root = typeof rootValue === "string" ? rootValue : "";
+ if (root.includes(":")) return null;
+ const out = new Map();
+ for (let i = 0; i < sources.length; i++) {
+ const entry: unknown = sources[i];
+ const content: unknown = contents[i];
+ if (typeof entry !== "string" || entry.includes(":") || typeof content !== "string") {
+ return null;
+ }
+ out.set(sourceKey(resolve(dirname(mapFile), root, entry)), content);
+ }
+ return out;
+}
+
+/** `pkg/rest` below the innermost node_modules, else the path relative to
+ * the nearest named package root (a workspace package), else the path. */
+function sourceKey(path: string): string {
+ const norm = path.split("\\").join("/");
+ const idx = norm.lastIndexOf("/node_modules/");
+ if (idx !== -1) return norm.slice(idx + "/node_modules/".length);
+ for (let dir = dirname(norm); ;) {
+ const pkg = trackedReadFile(join(dir, "package.json"));
+ if (pkg !== null) {
+ try {
+ const parsed: unknown = JSON.parse(pkg);
+ const name =
+ parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
+ ? (parsed as Record)["name"]
+ : undefined;
+ if (typeof name === "string" && name !== "") return `${name}/${relative(dir, norm)}`;
+ } catch {
+ /* malformed: keep walking */
+ }
+ }
+ const parent = dirname(dir);
+ if (parent === dir) return norm;
+ dir = parent;
+ }
+}
+
+/** The names a module exports, as one sorted key: its own names plus,
+ * recursively, the names of every module it re-exports whole (never their
+ * `default`). Null when some shape or edge cannot be named. */
+function exportNames(h: MinifiedEntryHost, file: string, source: string): string | null {
+ const top = h.moduleExports(file, source);
+ if (top === null) return null;
+ const names = new Set();
+ const visiting = new Set();
+ const visit = (path: string, own: ModuleExportNames, star: boolean): boolean => {
+ if (visiting.has(path)) return true;
+ visiting.add(path);
+ for (const name of own.names) {
+ if (!(star && name === "default")) names.add(name);
+ }
+ const kind = own.esm ? "import" : "require";
+ for (const spec of own.reexports) {
+ if (spec.startsWith("node:") || canonicalBuiltinModule(spec) !== null) return false;
+ const target =
+ spec.startsWith("./") || spec.startsWith("../")
+ ? relativeTarget(path, spec, kind)
+ : h.resolveBare(path, spec, kind);
+ if (target === null) return false;
+ const text = trackedReadFile(target);
+ if (text === null) return false;
+ const inner = h.moduleExports(target, text);
+ if (inner === null || !visit(target, inner, true)) return false;
+ }
+ return true;
+ };
+ if (!visit(file, top, false)) return null;
+ return [...names].sort().join(",");
+}
diff --git a/packages/compiler/src/frontend/npm-static.ts b/packages/compiler/src/frontend/npm-static.ts
index 3c8fa22e9..f14805202 100644
--- a/packages/compiler/src/frontend/npm-static.ts
+++ b/packages/compiler/src/frontend/npm-static.ts
@@ -14,6 +14,7 @@ import {
workspacePackageOfPath,
} from "./workspace-registry.js";
import { trackedExists, trackedReadFile, trackedRealpath } from "./input-tracker.js";
+import { looksUnminified, minifiedEntryRefusal, unminifiedTarget } from "./npm-static-minified.js";
let activePackages: ReadonlySet = new Set();
let declarationOverloads: ReadonlyMap = new Map();
@@ -146,7 +147,8 @@ function shadowTargetOf(path: string): { pkg: string; viaTypes: boolean } | null
* runtime JS;
* 2. preserve distinct import and require targets. Static compilation
* must execute the same module Node selects, including packages that
- * ship separate ESM and CommonJS builds. */
+ * ship separate ESM and CommonJS builds.
+ * npmStaticRespellMinifiedText then respells verified minified targets. */
export function npmStaticTransformPkgJson(pkg: Record): void {
delete pkg["types"];
delete pkg["typings"];
@@ -202,6 +204,66 @@ export function npmStaticTransformPkgJson(pkg: Record): void {
if (pkg["imports"] !== undefined) transform(pkg["imports"]);
}
+/** Both resolution worlds' minified-entry step, over the package.json TEXT
+ * (compact JSON in both): every `.min.` "main"/"exports" target whose
+ * unminified sibling npm-static-minified.ts verifies is respelled to that
+ * sibling. A document without `.min.` comes back as the same string. The
+ * walk only reads the parsed document; the respelling replaces the quoted
+ * target where it appears as a value, never as a key. */
+export function npmStaticRespellMinifiedText(text: string, pkgDir: string): string {
+ if (!text.includes(".min.")) return text;
+ let doc: unknown;
+ try {
+ doc = JSON.parse(text);
+ } catch {
+ return text;
+ }
+ if (doc === null || typeof doc !== "object" || Array.isArray(doc)) return text;
+ const record = doc as Record;
+ const targets = new Set();
+ const main = record["main"];
+ if (typeof main === "string" && main.includes(".min.")) targets.add(main);
+ collectMinifiedTargets(record["exports"], targets);
+ let out = text;
+ for (const target of targets) {
+ // "main" may omit the leading "./" that exports targets require.
+ const spelled = target.startsWith("./") ? target : `./${target}`;
+ const respelled = unminifiedTarget(pkgDir, spelled);
+ if (respelled === spelled) continue;
+ const replacement = target.startsWith("./") ? respelled : respelled.slice(2);
+ out = replaceJsonStringValue(out, target, replacement);
+ }
+ return out;
+}
+
+function collectMinifiedTargets(value: unknown, into: Set): void {
+ if (typeof value === "string") {
+ if (value.startsWith("./") && value.includes(".min.")) into.add(value);
+ return;
+ }
+ if (value === null || typeof value !== "object") return;
+ if (Array.isArray(value)) {
+ for (const item of value) collectMinifiedTargets(item, into);
+ return;
+ }
+ for (const item of Object.values(value as Record)) {
+ collectMinifiedTargets(item, into);
+ }
+}
+
+/** Replaces the JSON string literal `from` with `to` wherever it is a value
+ * in compact JSON text (a key is followed by its colon). */
+function replaceJsonStringValue(text: string, from: string, to: string): string {
+ const quoted = JSON.stringify(from);
+ const parts = text.split(quoted);
+ let out = parts[0] ?? "";
+ for (let i = 1; i < parts.length; i++) {
+ const rest = parts[i] ?? "";
+ out += (rest.startsWith(":") ? quoted : JSON.stringify(to)) + rest;
+ }
+ return out;
+}
+
/** npmStaticTransformPkgJson over a package.json TEXT (the fs shadow's
* form). Malformed documents pass through untouched — resolution reports
* them exactly as it always did. */
@@ -343,7 +405,9 @@ export function npmStaticFsShadow(services: FrontendServices): NpmStaticFsShadow
if (path.endsWith("/package.json") || path.endsWith("\\package.json")) {
try {
const source = trackedReadFile(path);
- return source === null ? undefined : npmStaticTransformPkgJsonText(source);
+ return source === null
+ ? undefined
+ : npmStaticRespellMinifiedText(npmStaticTransformPkgJsonText(source), dirname(path));
} catch {
return undefined;
}
@@ -425,15 +489,6 @@ export function npmStaticFsShadow(services: FrontendServices): NpmStaticFsShadow
* gracefully per-package-degraded when the attempt fails. */
const TRANSFORM_MARKERS = ["__webpack_require__"];
-/** Unminified-JS heuristic over an entry source: at least two lines and
- * an average line length under 200 characters (minified dists are one
- * enormous line; readable code averages well under 100). */
-function looksUnminified(source: string): boolean {
- const lines = source.split("\n");
- if (lines.length < 2) return false;
- return source.length / lines.length <= 200;
-}
-
function hasTransformMarkers(source: string): boolean {
return TRANSFORM_MARKERS.some((m) => source.includes(m));
}
@@ -456,7 +511,12 @@ export function npmStaticIneligibleReason(
if (jsEntry === null) return "no runtime JS entry resolves";
const source = trackedReadFile(jsEntry);
if (source === null) return `its runtime entry ${jsEntry} cannot be read`;
- if (!looksUnminified(source)) return "its shipped JS looks minified";
+ if (!looksUnminified(source)) {
+ const detail = minifiedEntryRefusal(jsEntry);
+ return detail === null
+ ? "its shipped JS looks minified"
+ : `its shipped JS looks minified, and its per-file build cannot replace it: ${detail}`;
+ }
if (hasTransformMarkers(source)) {
return "its shipped JS carries build-transform markers (bundled/transpiled dist)";
}
diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts
index 0c033b19c..e0c78a22b 100644
--- a/packages/compiler/src/frontend/program.ts
+++ b/packages/compiler/src/frontend/program.ts
@@ -73,6 +73,8 @@ import {
setNpmStaticDeclarationOverloads,
setNpmStaticPackages,
} from "./npm-static.js";
+import { setMinifiedEntryHost } from "./npm-static-minified.js";
+import { moduleEdgesOfFile, moduleExportsOfFile } from "./npm-static-minified-syntax.js";
import { isPrunedNpmReexport, planNpmStaticReexports } from "./npm-static-prune.js";
import { isNpmStaticSubclassArgument } from "./npm-static-subtyping.js";
import {
@@ -953,6 +955,14 @@ export function loadProgram(
}
setNpmStaticPackages(npmStaticPackages);
setNpmStaticDeclarationOverloads(declarationOverloads, declarationProperties);
+ // Minified-entry verification parses the per-file build and resolves its
+ // dependencies the way the opted-in program will (npm-static-minified.ts).
+ setMinifiedEntryHost({
+ moduleEdges: (path, source) => moduleEdgesOfFile(services.parse(path, source, "js")),
+ moduleExports: (path, source) => moduleExportsOfFile(services.parse(path, source, "js")),
+ resolveBare: (fromFile, specifier, kind) =>
+ resolveBareModule(fromFile, specifier, "js-only", kind)?.typesFile ?? null,
+ });
// Workspace-package registrations reset per load (same discipline as the
// npm-static set), then the opted-in names are probed UP FRONT: a
// workspace-linked opt-in resolves to files whose realpaths carry no
diff --git a/packages/compiler/src/frontend/resolve.ts b/packages/compiler/src/frontend/resolve.ts
index 463400475..1d95828d7 100644
--- a/packages/compiler/src/frontend/resolve.ts
+++ b/packages/compiler/src/frontend/resolve.ts
@@ -19,6 +19,7 @@ import { dirname, isAbsolute, join, resolve } from "node:path";
import {
isNpmStaticPackage,
npmStaticPackageOfPath,
+ npmStaticRespellMinifiedText,
npmStaticTransformPkgJson,
} from "./npm-static.js";
import { provenanceEntryFor } from "./provenance-registry.js";
@@ -913,7 +914,9 @@ export function resolveBareModule(
const pkg =
npmStatic && rawPkg !== null
? (() => {
- const clone = JSON.parse(JSON.stringify(rawPkg)) as PkgJson & Record;
+ const clone = JSON.parse(
+ npmStaticRespellMinifiedText(JSON.stringify(rawPkg), nmPkgDir),
+ ) as PkgJson & Record;
npmStaticTransformPkgJson(clone);
return clone;
})()
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arguments-sloppy-strict-file.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arguments-sloppy-strict-file.cjs.json
new file mode 100644
index 000000000..dda438227
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arguments-sloppy-strict-file.cjs.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/corpus/arguments-sloppy-strict-file.cjs"
+ ],
+ "diags": []
+}
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arguments-sloppy-unaliased.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arguments-sloppy-unaliased.cjs.json
new file mode 100644
index 000000000..94ff4f390
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arguments-sloppy-unaliased.cjs.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/corpus/arguments-sloppy-unaliased.cjs"
+ ],
+ "diags": []
+}
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arguments-sloppy-unmapped.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arguments-sloppy-unmapped.cjs.json
new file mode 100644
index 000000000..0bb6c6937
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arguments-sloppy-unmapped.cjs.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/corpus/arguments-sloppy-unmapped.cjs"
+ ],
+ "diags": []
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 41d9e02ac..a8b383ddb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -29,9 +29,18 @@ importers:
effect:
specifier: 3.22.2
version: 3.22.2
+ glob:
+ specifier: 13.0.6
+ version: 13.0.6
js-yaml:
specifier: 4.3.0
version: 4.3.0
+ lru-cache:
+ specifier: 11.5.3
+ version: 11.5.3
+ node-fetch:
+ specifier: 2.7.0
+ version: 2.7.0
oxfmt:
specifier: 0.71.0
version: 0.71.0
@@ -41,6 +50,9 @@ importers:
rollup:
specifier: 4.62.2
version: 4.62.2
+ signal-exit:
+ specifier: 3.0.7
+ version: 3.0.7
three:
specifier: 0.186.1
version: 0.186.1
@@ -2069,6 +2081,10 @@ packages:
resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}
engines: {node: 20 || >=22}
+ lru-cache@11.5.3:
+ resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==}
+ engines: {node: 20 || >=22}
+
lru-cache@6.0.0:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
engines: {node: '>=10'}
@@ -4256,6 +4272,8 @@ snapshots:
lru-cache@11.5.2: {}
+ lru-cache@11.5.3: {}
+
lru-cache@6.0.0:
dependencies:
yallist: 4.0.0
diff --git a/tests/corpus/arguments-sloppy-strict-file.cjs b/tests/corpus/arguments-sloppy-strict-file.cjs
new file mode 100644
index 000000000..f88945972
--- /dev/null
+++ b/tests/corpus/arguments-sloppy-strict-file.cjs
@@ -0,0 +1,28 @@
+'use strict';
+// A 'use strict' file prologue makes every function strict: arguments is
+// unmapped, so parameter writes never show through it.
+
+function update(a, b) {
+ a = a * 2;
+ b = 'b2';
+ return [arguments[0], arguments[1], arguments.length, a, b];
+}
+console.log(update(1, 'b'));
+console.log(update(3));
+
+function counter(n) {
+ var bump = () => { n++; return arguments[0]; };
+ bump();
+ bump();
+ return [n, arguments[0], bump()];
+}
+console.log(counter(10));
+
+const saved = update;
+console.log(saved(5, 'x', 'extra'));
+
+function spreadAfterWrite(a, b) {
+ a = 'changed';
+ return [...arguments].join(' ');
+}
+console.log(spreadAfterWrite('a', 'b', 'c'));
diff --git a/tests/corpus/arguments-sloppy-unaliased.cjs b/tests/corpus/arguments-sloppy-unaliased.cjs
new file mode 100644
index 000000000..b18035854
--- /dev/null
+++ b/tests/corpus/arguments-sloppy-unaliased.cjs
@@ -0,0 +1,116 @@
+// Sloppy-mode functions with simple parameter lists get a MAPPED arguments
+// object in Node: arguments[i] and the i-th parameter alias each other.
+// When the function never writes arguments, never lets it escape, and
+// never writes a parameter whose index some read can reach, the aliasing
+// cannot be observed, so these functions compile natively.
+
+// arguments.length and constant-index reads beside the named parameters
+// (glob@7's globSync guard).
+function globSync(pattern, options) {
+ if (typeof options === 'function' || arguments.length === 3)
+ return 'callback provided to sync glob';
+ return 'sync ' + pattern + ' ' + (options === undefined ? 'no options' : options.cwd);
+}
+console.log(globSync('*.js'));
+console.log(globSync('*.js', { cwd: '/tmp' }));
+console.log(globSync('*.js', {}, 'cb'));
+console.log(globSync('*.js', function () {}));
+
+function positions(a, b) {
+ return [arguments.length, arguments[0], arguments[1], arguments[2], arguments['1'], a, b];
+}
+console.log(positions());
+console.log(positions(1));
+console.log(positions(1, 2, 3));
+
+// A written parameter is fine while no read reaches its index (node-fetch's
+// transpiled Body(body) reads only arguments.length and arguments[1]).
+function Body(body) {
+ var opts = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
+ if (body == null) body = 'empty';
+ else body = 'body:' + body;
+ return [body, opts.size === undefined ? 0 : opts.size, arguments.length];
+}
+console.log(Body(null));
+console.log(Body('x', { size: 3 }));
+console.log(Body(undefined, undefined));
+
+// A same-named parameter of a nested function is a different binding.
+function shadowed(a) {
+ (function (a) { a = 'inner'; return a; })('x');
+ var write = function (other) { other = 2; return other; };
+ write(1);
+ return [arguments[0], a];
+}
+console.log(shadowed('outer'));
+
+// Every index is read through these forms; no parameter is written.
+function total() {
+ var sum = 0;
+ for (var i = 0; i < arguments.length; i++) sum += arguments[i];
+ return sum;
+}
+function forwardSpread(a, b) { return total(...arguments); }
+function forwardApply(a, b) { return total.apply(null, arguments); }
+function copied(a) { return Array.prototype.slice.call(arguments, 1); }
+function copiedAll(a, b) { return Array.prototype.slice.call(arguments); }
+function listed(a) { return Array.from(arguments); }
+function mapped(a) { return Array.from(arguments, function (v) { return v * 10; }); }
+function spreadArray(a) { return [...arguments, 'end']; }
+function indexed(a, b) {
+ var out = [];
+ for (var i = arguments.length - 1; i >= 0; i--) out.push(arguments[i]);
+ return out.join(',') + ' ' + a + b;
+}
+console.log(forwardSpread(1, 2, 3), forwardSpread(4), forwardApply(5, 6, 7), forwardApply());
+console.log(copied(1, 2, 3), copied(1), copiedAll('p', 'q'));
+console.log(listed('x', 'y'), mapped(1, 2, 3), spreadArray(1, 2));
+console.log(indexed('a', 'b', 'c'));
+
+// Arrows read the enclosing function's arguments.
+function later(a) {
+ return () => arguments[1] + ':' + arguments.length + ':' + a;
+}
+console.log(later('x', 'y')());
+
+// Object-literal methods are sloppy too.
+var methods = {
+ first(a, b) { return arguments.length + ' ' + a + ' ' + arguments[1]; },
+ none() { return arguments.length; },
+};
+console.log(methods.first(1, 2, 3), methods.first(), methods.none(1, 2));
+
+// Through values: the hidden array still carries every argument.
+var viaValue = positions;
+console.log(viaValue('v', 'w', 'z'));
+var expressions = [function (x) { return arguments.length + x; }, globSync];
+console.log(expressions[0](1, 2), expressions[1]('*.ts', {}, 'cb'));
+function callWith(fn) { return fn('a', 'b', 'c'); }
+console.log(callWith(positions));
+
+// signal-exit's emit wrapper: forward everything with apply.
+var emitter = {
+ calls: [],
+ emit: function (ev, arg) { this.calls.push(Array.prototype.slice.call(arguments).join('/')); return true; },
+};
+var original = emitter.emit;
+emitter.emit = function processEmit(ev, arg) {
+ if (ev === 'exit') {
+ if (arg !== undefined) emitter.code = arg;
+ var ret = original.apply(this, arguments);
+ return ret;
+ }
+ return original.apply(this, arguments);
+};
+emitter.emit('exit', 3, 'extra');
+emitter.emit('other');
+console.log(emitter.calls, emitter.code);
+
+// Forwarding through an untyped function value with apply.
+function forward(fn) {
+ return function (a, b) {
+ return fn.apply(null, arguments);
+ };
+}
+var joined = forward(function (x, y, z) { return [x, y, z].join('-'); });
+console.log(joined(1, 2, 3), joined('only'));
diff --git a/tests/corpus/arguments-sloppy-unmapped.cjs b/tests/corpus/arguments-sloppy-unmapped.cjs
new file mode 100644
index 000000000..b5368eacd
--- /dev/null
+++ b/tests/corpus/arguments-sloppy-unmapped.cjs
@@ -0,0 +1,59 @@
+// Node creates an UNMAPPED arguments object for strict code and for
+// functions whose parameter list is not simple (defaults, patterns), even
+// in a sloppy CommonJS module: a parameter write never shows through it.
+
+function defaults(a, b = 'B') {
+ a = 'changed';
+ b = 'b changed';
+ return [arguments[0], arguments[1], arguments.length, a, b];
+}
+console.log(defaults('a'));
+console.log(defaults('a', 'b', 'c'));
+console.log(defaults());
+
+function destructured({ x }, y) {
+ y = 'new y';
+ return [arguments.length, arguments[1], y, x];
+}
+console.log(destructured({ x: 1 }, 'old y'));
+
+function strictFunction(a, b) {
+ 'use strict';
+ a = 'changed';
+ return [arguments[0], arguments[1], a, b, arguments.length];
+}
+console.log(strictFunction('a', 'b'));
+console.log(strictFunction());
+
+function strictOuter() {
+ 'use strict';
+ function inner(a) {
+ a = 'changed';
+ return [arguments[0], a];
+ }
+ return inner('original');
+}
+console.log(strictOuter());
+
+class Holder {
+ method() {
+ // Functions nested in a class body are strict code too.
+ var nested = function (a) { a = 2; return arguments[0]; };
+ return nested(1);
+ }
+}
+console.log(new Holder().method());
+
+function copiedFirst(a) {
+ 'use strict';
+ var seen = Array.prototype.slice.call(arguments);
+ a = 'later';
+ return seen.concat([arguments[0], a]);
+}
+console.log(copiedFirst('first', 'second'));
+
+function forwarded(a, b = 0) {
+ a = -1;
+ return Array.from(arguments).concat([...arguments], [a, b]);
+}
+console.log(forwarded(1, 2, 3));
diff --git a/tests/coverage-fixtures/js-sloppy-arguments.cjs b/tests/coverage-fixtures/js-sloppy-arguments.cjs
new file mode 100644
index 000000000..5de4954b5
--- /dev/null
+++ b/tests/coverage-fixtures/js-sloppy-arguments.cjs
@@ -0,0 +1,38 @@
+// Sloppy-mode functions whose mapped `arguments` aliasing is observable,
+// and `arguments` uses the native array cannot represent, keep a named
+// runtime fence. The admitted forms compile statically.
+function readsWritten(a) {
+ a = 2;
+ return arguments[0];
+}
+function writesArguments(a) {
+ arguments[0] = 7;
+ return a;
+}
+function escapes(a) {
+ inspect(arguments);
+ return a;
+}
+function inspect(args) {
+ return args.length;
+}
+function callee(a) {
+ return arguments.callee === callee;
+}
+function strictWrite(a) {
+ "use strict";
+ arguments[0] = 1;
+ return a;
+}
+function admitted(a, b) {
+ b = 1;
+ return [arguments.length, arguments[0], a, b];
+}
+console.log(admitted(1, 2));
+if (process.argv.length > 9) {
+ readsWritten(1);
+ writesArguments(1);
+ escapes(1);
+ callee(1);
+ strictWrite(1);
+}
diff --git a/tests/fixtures/npm-static/glob13-cli.ts b/tests/fixtures/npm-static/glob13-cli.ts
new file mode 100644
index 000000000..0922dc9aa
--- /dev/null
+++ b/tests/fixtures/npm-static/glob13-cli.ts
@@ -0,0 +1,3 @@
+import { globSync } from "glob";
+
+console.log(globSync("glob13-*.ts", { cwd: import.meta.dirname }));
diff --git a/tests/fixtures/npm-static/lru-cache-cli.ts b/tests/fixtures/npm-static/lru-cache-cli.ts
new file mode 100644
index 000000000..cd62a927d
--- /dev/null
+++ b/tests/fixtures/npm-static/lru-cache-cli.ts
@@ -0,0 +1,8 @@
+import { LRUCache } from "lru-cache";
+
+const cache = new LRUCache({ max: 2 });
+cache.set("a", 1);
+cache.set("b", 2);
+cache.get("a");
+cache.set("c", 3);
+console.log([...cache.keys()], cache.has("b"), cache.size);
diff --git a/tests/fixtures/npm-static/minified-drift-cli.ts b/tests/fixtures/npm-static/minified-drift-cli.ts
new file mode 100644
index 000000000..904804d4c
--- /dev/null
+++ b/tests/fixtures/npm-static/minified-drift-cli.ts
@@ -0,0 +1,3 @@
+import { label } from "minified-drift";
+
+console.log(label(1), label(50));
diff --git a/tests/fixtures/npm-static/minified-pair-cli.ts b/tests/fixtures/npm-static/minified-pair-cli.ts
new file mode 100644
index 000000000..2d67ad225
--- /dev/null
+++ b/tests/fixtures/npm-static/minified-pair-cli.ts
@@ -0,0 +1,9 @@
+import { Counter, describe, nameOf } from "minified-pair";
+
+console.log(describe(3));
+console.log(describe(9));
+console.log(describe(70));
+console.log(nameOf(12), nameOf(-1));
+const counter = new Counter();
+counter.add(2);
+console.log(counter.add(5), counter.count);
diff --git a/tests/fixtures/npm-static/node-fetch-cli.cjs b/tests/fixtures/npm-static/node-fetch-cli.cjs
new file mode 100644
index 000000000..72e3486ee
--- /dev/null
+++ b/tests/fixtures/npm-static/node-fetch-cli.cjs
@@ -0,0 +1,4 @@
+// node-fetch@2 reads sloppy-mode `arguments` in transpiled defaults
+// (`arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}`).
+const fetch = require("node-fetch");
+console.log(typeof fetch);
diff --git a/tests/fixtures/npm-static/node_modules/minified-dep/lib/index.d.ts b/tests/fixtures/npm-static/node_modules/minified-dep/lib/index.d.ts
new file mode 100644
index 000000000..86a8272c9
--- /dev/null
+++ b/tests/fixtures/npm-static/node_modules/minified-dep/lib/index.d.ts
@@ -0,0 +1 @@
+export declare function pad(text: string, width?: number): string;
diff --git a/tests/fixtures/npm-static/node_modules/minified-dep/lib/index.js b/tests/fixtures/npm-static/node_modules/minified-dep/lib/index.js
new file mode 100644
index 000000000..26cfda440
--- /dev/null
+++ b/tests/fixtures/npm-static/node_modules/minified-dep/lib/index.js
@@ -0,0 +1,4 @@
+export function pad(text, width = 6) {
+ return text.length >= width ? text : " ".repeat(width - text.length) + text;
+}
+//# sourceMappingURL=index.js.map
\ No newline at end of file
diff --git a/tests/fixtures/npm-static/node_modules/minified-dep/lib/index.js.map b/tests/fixtures/npm-static/node_modules/minified-dep/lib/index.js.map
new file mode 100644
index 000000000..7979dfc3e
--- /dev/null
+++ b/tests/fixtures/npm-static/node_modules/minified-dep/lib/index.js.map
@@ -0,0 +1 @@
+{"version": 3, "file": "index.js", "sourceRoot": "", "sources": ["../src/index.ts"], "names": [], "mappings": "AAAA", "sourcesContent": ["export function pad(text: string, width = 6): string {\n return text.length >= width ? text : \" \".repeat(width - text.length) + text;\n}\n"]}
\ No newline at end of file
diff --git a/tests/fixtures/npm-static/node_modules/minified-dep/package.json b/tests/fixtures/npm-static/node_modules/minified-dep/package.json
new file mode 100644
index 000000000..d915e2663
--- /dev/null
+++ b/tests/fixtures/npm-static/node_modules/minified-dep/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "minified-dep",
+ "version": "1.1.0",
+ "type": "module",
+ "exports": {
+ ".": {
+ "types": "./lib/index.d.ts",
+ "default": "./lib/index.js"
+ }
+ }
+}
diff --git a/tests/fixtures/npm-static/node_modules/minified-drift/lib/index.d.ts b/tests/fixtures/npm-static/node_modules/minified-drift/lib/index.d.ts
new file mode 100644
index 000000000..bbe750c06
--- /dev/null
+++ b/tests/fixtures/npm-static/node_modules/minified-drift/lib/index.d.ts
@@ -0,0 +1 @@
+export declare function label(n: number): string;
diff --git a/tests/fixtures/npm-static/node_modules/minified-drift/lib/index.js b/tests/fixtures/npm-static/node_modules/minified-drift/lib/index.js
new file mode 100644
index 000000000..ba2a44082
--- /dev/null
+++ b/tests/fixtures/npm-static/node_modules/minified-drift/lib/index.js
@@ -0,0 +1,6 @@
+import { pad } from "minified-dep";
+const LABELS = ["alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta", "iota", "kappa", "lambda", "mu", "nu", "xi", "omicron", "pi", "rho", "sigma", "tau", "upsilon", "phi", "chi", "psi", "omega", "aleph", "beth", "gimel", "daleth", "he", "waw", "zayin", "heth", "teth", "yodh", "kaph", "lamedh", "mem", "nun", "samekh", "ayin", "pe", "tsade", "qoph", "resh", "shin", "taw"];
+export function label(n) {
+ return pad(LABELS[n % LABELS.length]) + "!";
+}
+//# sourceMappingURL=index.js.map
\ No newline at end of file
diff --git a/tests/fixtures/npm-static/node_modules/minified-drift/lib/index.js.map b/tests/fixtures/npm-static/node_modules/minified-drift/lib/index.js.map
new file mode 100644
index 000000000..ef1f6d2bc
--- /dev/null
+++ b/tests/fixtures/npm-static/node_modules/minified-drift/lib/index.js.map
@@ -0,0 +1 @@
+{"version": 3, "file": "index.js", "sourceRoot": "", "sources": ["../src/index.ts"], "names": [], "mappings": "AAAA", "sourcesContent": ["import { pad } from \"minified-dep\";\nconst LABELS = [\"alpha\", \"beta\", \"gamma\", \"delta\", \"epsilon\", \"zeta\", \"eta\", \"theta\", \"iota\", \"kappa\", \"lambda\", \"mu\", \"nu\", \"xi\", \"omicron\", \"pi\", \"rho\", \"sigma\", \"tau\", \"upsilon\", \"phi\", \"chi\", \"psi\", \"omega\", \"aleph\", \"beth\", \"gimel\", \"daleth\", \"he\", \"waw\", \"zayin\", \"heth\", \"teth\", \"yodh\", \"kaph\", \"lamedh\", \"mem\", \"nun\", \"samekh\", \"ayin\", \"pe\", \"tsade\", \"qoph\", \"resh\", \"shin\", \"taw\"];\nexport function label(n: number): string {\n return pad(LABELS[n % LABELS.length]!) + \"!\";\n}\n"]}
\ No newline at end of file
diff --git a/tests/fixtures/npm-static/node_modules/minified-drift/lib/index.min.js b/tests/fixtures/npm-static/node_modules/minified-drift/lib/index.min.js
new file mode 100644
index 000000000..d4062a60b
--- /dev/null
+++ b/tests/fixtures/npm-static/node_modules/minified-drift/lib/index.min.js
@@ -0,0 +1,2 @@
+function a(e,t=4){return e.length>=t?e:" ".repeat(t-e.length)+e}var l=["alpha","beta","gamma","delta","epsilon","zeta","eta","theta","iota","kappa","lambda","mu","nu","xi","omicron","pi","rho","sigma","tau","upsilon","phi","chi","psi","omega","aleph","beth","gimel","daleth","he","waw","zayin","heth","teth","yodh","kaph","lamedh","mem","nun","samekh","ayin","pe","tsade","qoph","resh","shin","taw"];function p(e){return a(l[e%l.length])+"!"}export{p as label};
+//# sourceMappingURL=index.min.js.map
\ No newline at end of file
diff --git a/tests/fixtures/npm-static/node_modules/minified-drift/lib/index.min.js.map b/tests/fixtures/npm-static/node_modules/minified-drift/lib/index.min.js.map
new file mode 100644
index 000000000..b7d0c4bd0
--- /dev/null
+++ b/tests/fixtures/npm-static/node_modules/minified-drift/lib/index.min.js.map
@@ -0,0 +1 @@
+{"version": 3, "file": "index.min.js", "sourceRoot": "", "sources": ["../node_modules/minified-dep/src/index.ts", "../src/index.ts"], "names": [], "mappings": "AAAA", "sourcesContent": ["export function pad(text: string, width = 4): string {\n return text.length >= width ? text : \" \".repeat(width - text.length) + text;\n}\n", "import { pad } from \"minified-dep\";\nconst LABELS = [\"alpha\", \"beta\", \"gamma\", \"delta\", \"epsilon\", \"zeta\", \"eta\", \"theta\", \"iota\", \"kappa\", \"lambda\", \"mu\", \"nu\", \"xi\", \"omicron\", \"pi\", \"rho\", \"sigma\", \"tau\", \"upsilon\", \"phi\", \"chi\", \"psi\", \"omega\", \"aleph\", \"beth\", \"gimel\", \"daleth\", \"he\", \"waw\", \"zayin\", \"heth\", \"teth\", \"yodh\", \"kaph\", \"lamedh\", \"mem\", \"nun\", \"samekh\", \"ayin\", \"pe\", \"tsade\", \"qoph\", \"resh\", \"shin\", \"taw\"];\nexport function label(n: number): string {\n return pad(LABELS[n % LABELS.length]!) + \"!\";\n}\n"]}
\ No newline at end of file
diff --git a/tests/fixtures/npm-static/node_modules/minified-drift/package.json b/tests/fixtures/npm-static/node_modules/minified-drift/package.json
new file mode 100644
index 000000000..6ab6963e6
--- /dev/null
+++ b/tests/fixtures/npm-static/node_modules/minified-drift/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "minified-drift",
+ "version": "1.0.0",
+ "type": "module",
+ "exports": {
+ ".": {
+ "types": "./lib/index.d.ts",
+ "default": "./lib/index.min.js"
+ }
+ },
+ "dependencies": {
+ "minified-dep": "^1.0.0"
+ }
+}
diff --git a/tests/fixtures/npm-static/node_modules/minified-pair/lib/index.d.ts b/tests/fixtures/npm-static/node_modules/minified-pair/lib/index.d.ts
new file mode 100644
index 000000000..595d486b6
--- /dev/null
+++ b/tests/fixtures/npm-static/node_modules/minified-pair/lib/index.d.ts
@@ -0,0 +1,6 @@
+export { nameOf } from "./names.js";
+export declare function describe(n: number): string;
+export declare class Counter {
+ count: number;
+ add(n: number): number;
+}
diff --git a/tests/fixtures/npm-static/node_modules/minified-pair/lib/index.js b/tests/fixtures/npm-static/node_modules/minified-pair/lib/index.js
new file mode 100644
index 000000000..acb4826f6
--- /dev/null
+++ b/tests/fixtures/npm-static/node_modules/minified-pair/lib/index.js
@@ -0,0 +1,16 @@
+import { LIMIT, twice } from "./math.js";
+import { nameOf } from "./names.js";
+import { pad } from "minified-dep";
+export { nameOf } from "./names.js";
+export function describe(n) {
+ const doubled = twice(n);
+ return pad(nameOf(doubled)) + (doubled > LIMIT ? " over" : " within");
+}
+export class Counter {
+ count = 0;
+ add(n) {
+ this.count += twice(n);
+ return this.count;
+ }
+}
+//# sourceMappingURL=index.js.map
\ No newline at end of file
diff --git a/tests/fixtures/npm-static/node_modules/minified-pair/lib/index.js.map b/tests/fixtures/npm-static/node_modules/minified-pair/lib/index.js.map
new file mode 100644
index 000000000..05bf2c37c
--- /dev/null
+++ b/tests/fixtures/npm-static/node_modules/minified-pair/lib/index.js.map
@@ -0,0 +1 @@
+{"version": 3, "file": "index.js", "sourceRoot": "", "sources": ["../src/index.ts"], "names": [], "mappings": "AAAA", "sourcesContent": ["import { LIMIT, twice } from \"./math.js\";\nimport { nameOf } from \"./names.js\";\nimport { pad } from \"minified-dep\";\nexport { nameOf } from \"./names.js\";\nexport function describe(n: number): string {\n const doubled = twice(n);\n return pad(nameOf(doubled)) + (doubled > LIMIT ? \" over\" : \" within\");\n}\nexport class Counter {\n count = 0;\n add(n: number): number {\n this.count += twice(n);\n return this.count;\n }\n}\n"]}
\ No newline at end of file
diff --git a/tests/fixtures/npm-static/node_modules/minified-pair/lib/index.min.js b/tests/fixtures/npm-static/node_modules/minified-pair/lib/index.min.js
new file mode 100644
index 000000000..3a163e318
--- /dev/null
+++ b/tests/fixtures/npm-static/node_modules/minified-pair/lib/index.min.js
@@ -0,0 +1,2 @@
+var e=100;function o(t){return t*2}var n=["zero","one","two","three","four","five","six","seven","eight","nine","ten","eleven","twelve","thirteen","fourteen","fifteen","sixteen","seventeen","eighteen","nineteen","twenty"];function r(t){return t>=0&&t=i?t:" ".repeat(i-t.length)+t}function c(t){let i=o(t);return s(r(i))+(i>e?" over":" within")}var u=class{count=0;add(i){return this.count+=o(i),this.count}};export{u as Counter,c as describe,r as nameOf};
+//# sourceMappingURL=index.min.js.map
\ No newline at end of file
diff --git a/tests/fixtures/npm-static/node_modules/minified-pair/lib/index.min.js.map b/tests/fixtures/npm-static/node_modules/minified-pair/lib/index.min.js.map
new file mode 100644
index 000000000..4f0ac28fd
--- /dev/null
+++ b/tests/fixtures/npm-static/node_modules/minified-pair/lib/index.min.js.map
@@ -0,0 +1 @@
+{"version": 3, "file": "index.min.js", "sourceRoot": "", "sources": ["../src/math.ts", "../src/names.ts", "../node_modules/minified-dep/src/index.ts", "../src/index.ts"], "names": [], "mappings": "AAAA", "sourcesContent": ["export const LIMIT = 100;\nexport function twice(n: number): number {\n return n * 2;\n}\n", "export const NAMES = [\n \"zero\", \"one\", \"two\", \"three\", \"four\", \"five\", \"six\", \"seven\", \"eight\", \"nine\", \"ten\",\n \"eleven\", \"twelve\", \"thirteen\", \"fourteen\", \"fifteen\", \"sixteen\", \"seventeen\", \"eighteen\",\n \"nineteen\", \"twenty\",\n];\nexport function nameOf(n: number): string {\n return n >= 0 && n < NAMES.length ? NAMES[n]! : String(n);\n}\n", "export function pad(text: string, width = 6): string {\n return text.length >= width ? text : \" \".repeat(width - text.length) + text;\n}\n", "import { LIMIT, twice } from \"./math.js\";\nimport { nameOf } from \"./names.js\";\nimport { pad } from \"minified-dep\";\nexport { nameOf } from \"./names.js\";\nexport function describe(n: number): string {\n const doubled = twice(n);\n return pad(nameOf(doubled)) + (doubled > LIMIT ? \" over\" : \" within\");\n}\nexport class Counter {\n count = 0;\n add(n: number): number {\n this.count += twice(n);\n return this.count;\n }\n}\n"]}
\ No newline at end of file
diff --git a/tests/fixtures/npm-static/node_modules/minified-pair/lib/math.d.ts b/tests/fixtures/npm-static/node_modules/minified-pair/lib/math.d.ts
new file mode 100644
index 000000000..c71f1ea38
--- /dev/null
+++ b/tests/fixtures/npm-static/node_modules/minified-pair/lib/math.d.ts
@@ -0,0 +1,2 @@
+export declare const LIMIT = 100;
+export declare function twice(n: number): number;
diff --git a/tests/fixtures/npm-static/node_modules/minified-pair/lib/math.js b/tests/fixtures/npm-static/node_modules/minified-pair/lib/math.js
new file mode 100644
index 000000000..844689f4a
--- /dev/null
+++ b/tests/fixtures/npm-static/node_modules/minified-pair/lib/math.js
@@ -0,0 +1,5 @@
+export const LIMIT = 100;
+export function twice(n) {
+ return n * 2;
+}
+//# sourceMappingURL=math.js.map
\ No newline at end of file
diff --git a/tests/fixtures/npm-static/node_modules/minified-pair/lib/math.js.map b/tests/fixtures/npm-static/node_modules/minified-pair/lib/math.js.map
new file mode 100644
index 000000000..d670e9d2f
--- /dev/null
+++ b/tests/fixtures/npm-static/node_modules/minified-pair/lib/math.js.map
@@ -0,0 +1 @@
+{"version": 3, "file": "math.js", "sourceRoot": "", "sources": ["../src/math.ts"], "names": [], "mappings": "AAAA", "sourcesContent": ["export const LIMIT = 100;\nexport function twice(n: number): number {\n return n * 2;\n}\n"]}
\ No newline at end of file
diff --git a/tests/fixtures/npm-static/node_modules/minified-pair/lib/names.d.ts b/tests/fixtures/npm-static/node_modules/minified-pair/lib/names.d.ts
new file mode 100644
index 000000000..e183a8cfa
--- /dev/null
+++ b/tests/fixtures/npm-static/node_modules/minified-pair/lib/names.d.ts
@@ -0,0 +1,2 @@
+export declare const NAMES: string[];
+export declare function nameOf(n: number): string;
diff --git a/tests/fixtures/npm-static/node_modules/minified-pair/lib/names.js b/tests/fixtures/npm-static/node_modules/minified-pair/lib/names.js
new file mode 100644
index 000000000..fa734bb2b
--- /dev/null
+++ b/tests/fixtures/npm-static/node_modules/minified-pair/lib/names.js
@@ -0,0 +1,9 @@
+export const NAMES = [
+ "zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten",
+ "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen",
+ "nineteen", "twenty",
+];
+export function nameOf(n) {
+ return n >= 0 && n < NAMES.length ? NAMES[n] : String(n);
+}
+//# sourceMappingURL=names.js.map
\ No newline at end of file
diff --git a/tests/fixtures/npm-static/node_modules/minified-pair/lib/names.js.map b/tests/fixtures/npm-static/node_modules/minified-pair/lib/names.js.map
new file mode 100644
index 000000000..47477c4eb
--- /dev/null
+++ b/tests/fixtures/npm-static/node_modules/minified-pair/lib/names.js.map
@@ -0,0 +1 @@
+{"version": 3, "file": "names.js", "sourceRoot": "", "sources": ["../src/names.ts"], "names": [], "mappings": "AAAA", "sourcesContent": ["export const NAMES = [\n \"zero\", \"one\", \"two\", \"three\", \"four\", \"five\", \"six\", \"seven\", \"eight\", \"nine\", \"ten\",\n \"eleven\", \"twelve\", \"thirteen\", \"fourteen\", \"fifteen\", \"sixteen\", \"seventeen\", \"eighteen\",\n \"nineteen\", \"twenty\",\n];\nexport function nameOf(n: number): string {\n return n >= 0 && n < NAMES.length ? NAMES[n]! : String(n);\n}\n"]}
\ No newline at end of file
diff --git a/tests/fixtures/npm-static/node_modules/minified-pair/package.json b/tests/fixtures/npm-static/node_modules/minified-pair/package.json
new file mode 100644
index 000000000..886f5caff
--- /dev/null
+++ b/tests/fixtures/npm-static/node_modules/minified-pair/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "minified-pair",
+ "version": "2.0.0",
+ "type": "module",
+ "main": "./lib/index.min.js",
+ "exports": {
+ ".": {
+ "types": "./lib/index.d.ts",
+ "default": "./lib/index.min.js"
+ },
+ "./raw": {
+ "types": "./lib/index.d.ts",
+ "default": "./lib/index.js"
+ }
+ },
+ "dependencies": {
+ "minified-dep": "^1.1.0"
+ }
+}
diff --git a/tests/fixtures/npm-static/signal-exit-cli.cjs b/tests/fixtures/npm-static/signal-exit-cli.cjs
new file mode 100644
index 000000000..16b2c4cbe
--- /dev/null
+++ b/tests/fixtures/npm-static/signal-exit-cli.cjs
@@ -0,0 +1,7 @@
+// signal-exit@3 forwards process.emit through a sloppy-mode wrapper that
+// reads `arguments` with original.apply(this, arguments).
+const onExit = require("signal-exit");
+onExit(function (code) {
+ console.log("exit", code);
+});
+console.log("registered");
diff --git a/tests/harness/__snapshots__/coverage-js-sloppy-arguments.txt b/tests/harness/__snapshots__/coverage-js-sloppy-arguments.txt
new file mode 100644
index 000000000..873bbe6ac
--- /dev/null
+++ b/tests/harness/__snapshots__/coverage-js-sloppy-arguments.txt
@@ -0,0 +1,38 @@
+scriptc coverage tests/coverage-fixtures/js-sloppy-arguments.cjs
+
+ statements analyzed 20
+ compile statically 5 (25%)
+ in dynamic tier 3 (operate on values typed only at runtime; --explain-dynamic lists them)
+ (5 functions not analyzed — signature blocked; their statements count as not static)
+
+ deferred to runtime 10 sites (JS statements that throw their fence if executed)
+ ×1 SC1090 writing parameter 'a' while reading it through 'arguments' in a sloppy-mode function (mapped 'arguments' aliases its parameters)
+ at tests/coverage-fixtures/js-sloppy-arguments.cjs:4:23
+ hint: this form is valid TypeScript but does not compile yet — rewrite it with a supported equivalent of the construct the message names (https://scriptc.dev/limitations lists the boundary)
+ ×1 SC1090 writing 'arguments' in a sloppy-mode function (mapped 'arguments' aliases its parameters)
+ at tests/coverage-fixtures/js-sloppy-arguments.cjs:9:3
+ hint: this form is valid TypeScript but does not compile yet — rewrite it with a supported equivalent of the construct the message names (https://scriptc.dev/limitations lists the boundary)
+ ×1 SC1090 'arguments' as a value in a sloppy-mode function (mapped 'arguments' aliases its parameters)
+ at tests/coverage-fixtures/js-sloppy-arguments.cjs:13:11
+ hint: this form is valid TypeScript but does not compile yet — rewrite it with a supported equivalent of the construct the message names (https://scriptc.dev/limitations lists the boundary)
+ ×1 SC1090 'arguments.callee' in a sloppy-mode function (mapped 'arguments' aliases its parameters)
+ at tests/coverage-fixtures/js-sloppy-arguments.cjs:20:10
+ hint: this form is valid TypeScript but does not compile yet — rewrite it with a supported equivalent of the construct the message names (https://scriptc.dev/limitations lists the boundary)
+ ×1 SC1090 writing 'arguments' in a function with parameters outside an ES module
+ at tests/coverage-fixtures/js-sloppy-arguments.cjs:24:3
+ hint: this form is valid TypeScript but does not compile yet — rewrite it with a supported equivalent of the construct the message names (https://scriptc.dev/limitations lists the boundary)
+ ×1 SC2004 uses of 'readsWritten' inherit the blocker on its declaration
+ at tests/coverage-fixtures/js-sloppy-arguments.cjs:33:3
+ hint: the declaration of 'readsWritten' did not compile — fix the diagnostic reported there and these sites clear with it
+ ×1 SC2004 uses of 'writesArguments' inherit the blocker on its declaration
+ at tests/coverage-fixtures/js-sloppy-arguments.cjs:34:3
+ hint: the declaration of 'writesArguments' did not compile — fix the diagnostic reported there and these sites clear with it
+ ×1 SC2004 uses of 'escapes' inherit the blocker on its declaration
+ at tests/coverage-fixtures/js-sloppy-arguments.cjs:35:3
+ hint: the declaration of 'escapes' did not compile — fix the diagnostic reported there and these sites clear with it
+ ×1 SC2004 uses of 'callee' inherit the blocker on its declaration
+ at tests/coverage-fixtures/js-sloppy-arguments.cjs:36:3
+ hint: the declaration of 'callee' did not compile — fix the diagnostic reported there and these sites clear with it
+ ×1 SC2004 uses of 'strictWrite' inherit the blocker on its declaration
+ at tests/coverage-fixtures/js-sloppy-arguments.cjs:37:3
+ hint: the declaration of 'strictWrite' did not compile — fix the diagnostic reported there and these sites clear with it
diff --git a/tests/harness/coverage.test.ts b/tests/harness/coverage.test.ts
index 420651418..d17a76dc0 100644
--- a/tests/harness/coverage.test.ts
+++ b/tests/harness/coverage.test.ts
@@ -417,6 +417,12 @@ test("JavaScript builtin aliases keep their deferred value fence", async () => {
);
});
+test("observable sloppy-mode arguments aliasing keeps a named runtime fence", async () => {
+ await expect(report(fixture("js-sloppy-arguments.cjs"))).toMatchFileSnapshot(
+ "__snapshots__/coverage-js-sloppy-arguments.txt",
+ );
+});
+
test("any-typed checked-dynamic operators compile natively", () => {
const file = join(repoRoot, "tests/corpus/2856-dynamic-any-local-operators.ts");
const staticCoverage = analyze(file).coverage;
diff --git a/tests/harness/npm-static.test.ts b/tests/harness/npm-static.test.ts
index 356d2fa6d..c30b6a7bd 100644
--- a/tests/harness/npm-static.test.ts
+++ b/tests/harness/npm-static.test.ts
@@ -69,7 +69,7 @@ async function buildStatic(entry: string, npmStatic: string[] | "auto"): Promise
const hash = createHash("sha256");
const inputs = [
entry,
- ...globSync(join(pilotRoot, "**/node_modules/**/*.{js,mjs,cjs,json,ts}")).sort(),
+ ...globSync(join(pilotRoot, "**/node_modules/**/*.{js,mjs,cjs,json,ts,map}")).sort(),
...globSync(
join(fixturesRoot, "commander-calc/node_modules/**/*.{js,mjs,cjs,json,d.ts}"),
).sort(),
@@ -1420,6 +1420,96 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => {
expect(coverage.preflightFailed).toBe(false);
}, 120_000);
+ // A minified entry compiles from its per-file build once the source maps
+ // prove the two are one program: the bundle's sources (its own modules
+ // and the minified-dep it inlined at build time) are exactly the ones the
+ // per-file graph loads, byte for byte, and both export the same names.
+ test("a verified minified entry compiles from its per-file build and byte-matches Node", async () => {
+ const entry = join(pilotRoot, "minified-pair-cli.ts");
+ const { coverage } = analyze(entry, { npmStatic: "auto" });
+ expect(coverage.npmStatic).toEqual([
+ { package: "minified-pair", status: "static" },
+ { package: "minified-dep", status: "static" },
+ ]);
+ expect(coverage.diagnostics).toEqual([]);
+ expect(coverage.runtimeFences ?? []).toEqual([]);
+ const binary = await buildStatic(entry, "auto");
+ const [nodeRes, nativeRes] = await Promise.all([
+ runBinary("node", [entry]),
+ runBinary(binary, []),
+ ]);
+ expect(nodeRes.stdout.toString("utf8")).toContain("140 over");
+ expect(nativeRes.stdout).toEqual(nodeRes.stdout);
+ expect(comparableStderr(nativeRes.stderr)).toEqual(nodeRes.stderr);
+ expect(nativeRes.exitCode).toBe(nodeRes.exitCode);
+ }, 180_000);
+
+ // The bundle inlined an older minified-dep than the one installed: the
+ // per-file build would run different code than Node runs, so the
+ // minified entry keeps its refusal and names the mismatch.
+ test("a minified entry whose bundle inlines another dependency version stays refused", () => {
+ const entry = join(pilotRoot, "minified-drift-cli.ts");
+ const { coverage } = analyze(entry, { npmStatic: "auto" });
+ expect(coverage.npmStatic).toEqual([
+ {
+ package: "minified-drift",
+ status: "refused",
+ detail:
+ "its shipped JS looks minified, and its per-file build cannot replace it: the bundle inlines a minified-dep/src/index.ts that differs from the installed one its per-file build loads",
+ hint: "to compile it anyway, name it with --npm-static=minified-drift (or --npm-static=minified-drift... to include its dependencies)",
+ },
+ ]);
+ }, 120_000);
+
+ // lru-cache 11 points every export at index.min.js. Its per-file build
+ // now joins the program, so the next blocker reports at a readable line
+ // of dist/esm/node/index.js instead of the minified-entry refusal.
+ test("lru-cache 11 compiles from its per-file build", () => {
+ const { coverage } = analyze(join(pilotRoot, "lru-cache-cli.ts"), { npmStatic: "auto" });
+ expect(coverage.npmStatic).toEqual([{ package: "lru-cache", status: "static" }]);
+ const files = coverage.diagnostics.map((d) => d.loc?.file ?? "");
+ expect(files.some((f) => f.endsWith(".min.js"))).toBe(false);
+ expect(files.some((f) => f.endsWith("/lru-cache/dist/esm/node/index.js"))).toBe(true);
+ }, 120_000);
+
+ // glob 13.0.6's bundle inlined the minimatch, path-scurry and lru-cache
+ // releases current at its build; the installed ones are newer, so the
+ // per-file build is a different program and the entry stays refused.
+ test("glob 13 with newer installed dependencies keeps the minified-entry refusal", () => {
+ const { coverage } = analyze(join(pilotRoot, "glob13-cli.ts"), { npmStatic: "auto" });
+ expect(coverage.npmStatic).toEqual([
+ {
+ package: "glob",
+ status: "refused",
+ detail: expect.stringMatching(
+ /^its shipped JS looks minified, and its per-file build cannot replace it: the bundle inlines a .* that differs from the installed one its per-file build loads$/,
+ ),
+ hint: "to compile it anyway, name it with --npm-static=glob (or --npm-static=glob... to include its dependencies)",
+ },
+ ]);
+ }, 120_000);
+
+ // Sloppy-mode `arguments` reads whose parameter aliasing cannot be
+ // observed no longer fence these packages' functions.
+ test.each([
+ ["node-fetch-cli.cjs", ["node-fetch"]],
+ ["signal-exit-cli.cjs", ["signal-exit"]],
+ ])(
+ "%s keeps no sloppy-mode arguments fence",
+ (name, npmStatic) => {
+ const { coverage } = analyze(join(pilotRoot, name), { npmStatic });
+ const messages = [
+ ...coverage.diagnostics,
+ ...(coverage.runtimeFences ?? []),
+ ...(coverage.unreached?.diagnostics ?? []),
+ ...(coverage.unreached?.runtimeFences ?? []),
+ ].map((d) => d.message);
+ expect(messages.length).toBeGreaterThan(0);
+ expect(messages.filter((m) => m.includes("'arguments'"))).toEqual([]);
+ },
+ 120_000,
+ );
+
// The COPIED workspace shape classifies exactly like the symlinked one:
// node_modules/wscopied is a real directory (no realpath escape — some
// workspace installers copy members into node_modules), so detection