From 70e8595c7b9bc2be179611521cccebcb6c907666 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 12:24:20 -0700 Subject: [PATCH 1/3] Compile sloppy-mode arguments where parameter aliasing cannot be observed A JavaScript function with parameters outside an ES module was refused as soon as it read `arguments`, because Node maps a sloppy-mode function's arguments object onto its parameters. The hidden array scriptc passes is exactly Node's unmapped object, so the refusal now stays only where the difference can be observed: - strict code ('use strict' file or function prologue, class bodies) and non-simple parameter lists get Node's unmapped object, so they compile; - sloppy simple-parameter functions compile when no parameter whose index some read can reach is written (closures included); - in both, `arguments` must be used only as `arguments.length`, an rvalue `arguments[i]`, a spread, `f.apply(x, arguments)`, `Array.prototype.slice.call(arguments)` or `Array.from(arguments)`. Writes, escapes and other members keep a named refusal. Co-Authored-By: Claude Opus 5.5 --- docs/content/docs/limitations.mdx | 2 + .../language-features/arguments-object.mdx | 1 + .../compiler/src/frontend/arguments-usage.ts | 20 ++ .../src/frontend/lowering/call-signatures.ts | 13 +- .../frontend/lowering/generic-functions.ts | 2 +- .../src/frontend/lowering/lower-stmts.ts | 2 +- .../compiler/src/frontend/lowering/lowerer.ts | 5 +- .../src/frontend/lowering/sloppy-arguments.ts | 266 ++++++++++++++++++ .../arguments-sloppy-strict-file.cjs.json | 6 + .../arguments-sloppy-unaliased.cjs.json | 6 + .../corpus/arguments-sloppy-unmapped.cjs.json | 6 + tests/corpus/arguments-sloppy-strict-file.cjs | 28 ++ tests/corpus/arguments-sloppy-unaliased.cjs | 116 ++++++++ tests/corpus/arguments-sloppy-unmapped.cjs | 59 ++++ .../coverage-fixtures/js-sloppy-arguments.cjs | 38 +++ .../coverage-js-sloppy-arguments.txt | 37 +++ tests/harness/coverage.test.ts | 6 + 17 files changed, 603 insertions(+), 10 deletions(-) create mode 100644 docs/content/limitations/language-features/arguments-object.mdx create mode 100644 packages/compiler/src/frontend/lowering/sloppy-arguments.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arguments-sloppy-strict-file.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arguments-sloppy-unaliased.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arguments-sloppy-unmapped.cjs.json create mode 100644 tests/corpus/arguments-sloppy-strict-file.cjs create mode 100644 tests/corpus/arguments-sloppy-unaliased.cjs create mode 100644 tests/corpus/arguments-sloppy-unmapped.cjs create mode 100644 tests/coverage-fixtures/js-sloppy-arguments.cjs create mode 100644 tests/harness/__snapshots__/coverage-js-sloppy-arguments.txt diff --git a/docs/content/docs/limitations.mdx b/docs/content/docs/limitations.mdx index ed2c5b1ec3..30ca2ac173 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 ### Types and class definitions 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 0000000000..3b5a517e1d --- /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/packages/compiler/src/frontend/arguments-usage.ts b/packages/compiler/src/frontend/arguments-usage.ts index 317599f4cc..2aa7d754de 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 8e30e8b829..6794912c96 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, @@ -673,8 +674,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, @@ -697,11 +699,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 bd785a2566..c5b31651e0 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 cffe4c6619..2c5511d39f 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -5433,7 +5433,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 087b4e628b..11102f8e4a 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -355,6 +355,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"; @@ -1385,7 +1386,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 0000000000..f079ba7c4b --- /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/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 0000000000..dda4382276 --- /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 0000000000..94ff4f390f --- /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 0000000000..0bb6c69379 --- /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/tests/corpus/arguments-sloppy-strict-file.cjs b/tests/corpus/arguments-sloppy-strict-file.cjs new file mode 100644 index 0000000000..f88945972d --- /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 0000000000..b180358547 --- /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 0000000000..b5368eacdb --- /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 0000000000..5de4954b53 --- /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/harness/__snapshots__/coverage-js-sloppy-arguments.txt b/tests/harness/__snapshots__/coverage-js-sloppy-arguments.txt new file mode 100644 index 0000000000..a9811f7e93 --- /dev/null +++ b/tests/harness/__snapshots__/coverage-js-sloppy-arguments.txt @@ -0,0 +1,37 @@ +scriptc coverage tests/coverage-fixtures/js-sloppy-arguments.cjs + + statements analyzed 20 + compile statically 5 (25%) + (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 d4582d1512..9d2ff9b206 100644 --- a/tests/harness/coverage.test.ts +++ b/tests/harness/coverage.test.ts @@ -310,6 +310,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; From b27a353e6c07afce8b6c6fa8955bee11094d1057 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 12:24:20 -0700 Subject: [PATCH 2/3] Resolve minified package entries to their verified unminified siblings lru-cache 11 and glob 13 point their exports at one minified esbuild bundle and ship the per-file build next to it. Auto-selection refused the bundle as minified, and naming the package compiled the bundle, where every diagnostic lands on one enormous line. An opted-in package's minified main/exports target now resolves to the sibling without '.min' when the source maps prove the two are one program: the bundle's sources, including dependencies it inlined at build time, are exactly the sources the per-file graph loads (relative edges and the installed dependencies Node would load), byte for byte, and both export the same names. Both resolution worlds read the same respelled package.json. Otherwise the minified target stays, and auto-selection names the mismatch. lru-cache 11.5.3 now joins the program from dist/esm/node/index.js. glob 13.0.6 stays refused with current dependency releases, because its bundle inlined older minimatch and lru-cache releases; with the bundled releases installed, its per-file build compiles up to the same lru-cache blocker. Co-Authored-By: Claude Opus 5.5 --- docs/content/docs/dependencies.mdx | 2 +- package.json | 4 + .../frontend/npm-static-minified-syntax.ts | 76 +++++ .../src/frontend/npm-static-minified.ts | 291 ++++++++++++++++++ packages/compiler/src/frontend/npm-static.ts | 84 ++++- packages/compiler/src/frontend/program.ts | 10 + packages/compiler/src/frontend/resolve.ts | 5 +- pnpm-lock.yaml | 18 ++ tests/fixtures/npm-static/glob13-cli.ts | 3 + tests/fixtures/npm-static/lru-cache-cli.ts | 8 + .../fixtures/npm-static/minified-drift-cli.ts | 3 + .../fixtures/npm-static/minified-pair-cli.ts | 9 + tests/fixtures/npm-static/node-fetch-cli.cjs | 4 + .../node_modules/minified-dep/lib/index.d.ts | 1 + .../node_modules/minified-dep/lib/index.js | 4 + .../minified-dep/lib/index.js.map | 1 + .../node_modules/minified-dep/package.json | 11 + .../minified-drift/lib/index.d.ts | 1 + .../node_modules/minified-drift/lib/index.js | 6 + .../minified-drift/lib/index.js.map | 1 + .../minified-drift/lib/index.min.js | 2 + .../minified-drift/lib/index.min.js.map | 1 + .../node_modules/minified-drift/package.json | 14 + .../node_modules/minified-pair/lib/index.d.ts | 6 + .../node_modules/minified-pair/lib/index.js | 16 + .../minified-pair/lib/index.js.map | 1 + .../minified-pair/lib/index.min.js | 2 + .../minified-pair/lib/index.min.js.map | 1 + .../node_modules/minified-pair/lib/math.d.ts | 2 + .../node_modules/minified-pair/lib/math.js | 5 + .../minified-pair/lib/math.js.map | 1 + .../node_modules/minified-pair/lib/names.d.ts | 2 + .../node_modules/minified-pair/lib/names.js | 9 + .../minified-pair/lib/names.js.map | 1 + .../node_modules/minified-pair/package.json | 19 ++ tests/fixtures/npm-static/signal-exit-cli.cjs | 7 + tests/harness/npm-static.test.ts | 90 +++++- 37 files changed, 706 insertions(+), 15 deletions(-) create mode 100644 packages/compiler/src/frontend/npm-static-minified-syntax.ts create mode 100644 packages/compiler/src/frontend/npm-static-minified.ts create mode 100644 tests/fixtures/npm-static/glob13-cli.ts create mode 100644 tests/fixtures/npm-static/lru-cache-cli.ts create mode 100644 tests/fixtures/npm-static/minified-drift-cli.ts create mode 100644 tests/fixtures/npm-static/minified-pair-cli.ts create mode 100644 tests/fixtures/npm-static/node-fetch-cli.cjs create mode 100644 tests/fixtures/npm-static/node_modules/minified-dep/lib/index.d.ts create mode 100644 tests/fixtures/npm-static/node_modules/minified-dep/lib/index.js create mode 100644 tests/fixtures/npm-static/node_modules/minified-dep/lib/index.js.map create mode 100644 tests/fixtures/npm-static/node_modules/minified-dep/package.json create mode 100644 tests/fixtures/npm-static/node_modules/minified-drift/lib/index.d.ts create mode 100644 tests/fixtures/npm-static/node_modules/minified-drift/lib/index.js create mode 100644 tests/fixtures/npm-static/node_modules/minified-drift/lib/index.js.map create mode 100644 tests/fixtures/npm-static/node_modules/minified-drift/lib/index.min.js create mode 100644 tests/fixtures/npm-static/node_modules/minified-drift/lib/index.min.js.map create mode 100644 tests/fixtures/npm-static/node_modules/minified-drift/package.json create mode 100644 tests/fixtures/npm-static/node_modules/minified-pair/lib/index.d.ts create mode 100644 tests/fixtures/npm-static/node_modules/minified-pair/lib/index.js create mode 100644 tests/fixtures/npm-static/node_modules/minified-pair/lib/index.js.map create mode 100644 tests/fixtures/npm-static/node_modules/minified-pair/lib/index.min.js create mode 100644 tests/fixtures/npm-static/node_modules/minified-pair/lib/index.min.js.map create mode 100644 tests/fixtures/npm-static/node_modules/minified-pair/lib/math.d.ts create mode 100644 tests/fixtures/npm-static/node_modules/minified-pair/lib/math.js create mode 100644 tests/fixtures/npm-static/node_modules/minified-pair/lib/math.js.map create mode 100644 tests/fixtures/npm-static/node_modules/minified-pair/lib/names.d.ts create mode 100644 tests/fixtures/npm-static/node_modules/minified-pair/lib/names.js create mode 100644 tests/fixtures/npm-static/node_modules/minified-pair/lib/names.js.map create mode 100644 tests/fixtures/npm-static/node_modules/minified-pair/package.json create mode 100644 tests/fixtures/npm-static/signal-exit-cli.cjs diff --git a/docs/content/docs/dependencies.mdx b/docs/content/docs/dependencies.mdx index f939522217..ece1130c71 100644 --- a/docs/content/docs/dependencies.mdx +++ b/docs/content/docs/dependencies.mdx @@ -51,7 +51,7 @@ scriptc finds packages in `node_modules` with Node.js's resolution rules, includ 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. diff --git a/package.json b/package.json index 7318e04f57..33486845e4 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/npm-static-minified-syntax.ts b/packages/compiler/src/frontend/npm-static-minified-syntax.ts new file mode 100644 index 0000000000..bdd02da708 --- /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 0000000000..77c974f659 --- /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 1f83a12d40..2d0a79d10e 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"]; @@ -192,6 +194,66 @@ export function npmStaticTransformPkgJson(pkg: Record): void { if (pkg["exports"] !== undefined) transform(pkg["exports"]); } +/** 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. */ @@ -312,7 +374,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; } @@ -394,15 +458,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)); } @@ -425,7 +480,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 3a4ec16479..301293b32a 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 { @@ -895,6 +897,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 db440c1996..c6eb314c7d 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"; @@ -901,7 +902,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/pnpm-lock.yaml b/pnpm-lock.yaml index 41d9e02acc..a8b383ddb3 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/fixtures/npm-static/glob13-cli.ts b/tests/fixtures/npm-static/glob13-cli.ts new file mode 100644 index 0000000000..0922dc9aac --- /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 0000000000..cd62a927d0 --- /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 0000000000..904804d4c3 --- /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 0000000000..2d67ad2250 --- /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 0000000000..72e3486ee2 --- /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 0000000000..86a8272c9b --- /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 0000000000..26cfda440b --- /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 0000000000..7979dfc3ee --- /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 0000000000..d915e2663e --- /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 0000000000..bbe750c067 --- /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 0000000000..ba2a440828 --- /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 0000000000..ef1f6d2bca --- /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 0000000000..d4062a60bd --- /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 0000000000..b7d0c4bd02 --- /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 0000000000..6ab6963e60 --- /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 0000000000..595d486b66 --- /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 0000000000..acb4826f6c --- /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 0000000000..05bf2c37cd --- /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 0000000000..3a163e3181 --- /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 0000000000..4f0ac28fd0 --- /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 0000000000..c71f1ea38f --- /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 0000000000..844689f4aa --- /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 0000000000..d670e9d2f3 --- /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 0000000000..e183a8cfae --- /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 0000000000..fa734bb2bb --- /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 0000000000..47477c4eb4 --- /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 0000000000..886f5caff6 --- /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 0000000000..16b2c4cbe8 --- /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/npm-static.test.ts b/tests/harness/npm-static.test.ts index 89bd2e0b8d..9ad18436fa 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,d.ts}")).sort(), + ...globSync(join(pilotRoot, "**/node_modules/**/*.{js,mjs,cjs,json,d.ts,map}")).sort(), ...globSync( join(fixturesRoot, "commander-calc/node_modules/**/*.{js,mjs,cjs,json,d.ts}"), ).sort(), @@ -1306,6 +1306,94 @@ 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", + }, + ]); + }, 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$/, + ), + }, + ]); + }, 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 From 997adc463c69d54ae97ee2a14627d65635900bd0 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 13:42:18 -0700 Subject: [PATCH 3/3] Record the dynamic-tier line in the sloppy-arguments coverage snapshot main's coverage report now prints the dynamic-tier statement count; the refusals the fixture pins are unchanged. Co-Authored-By: Claude Opus 5.5 --- tests/harness/__snapshots__/coverage-js-sloppy-arguments.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/harness/__snapshots__/coverage-js-sloppy-arguments.txt b/tests/harness/__snapshots__/coverage-js-sloppy-arguments.txt index a9811f7e93..873bbe6acc 100644 --- a/tests/harness/__snapshots__/coverage-js-sloppy-arguments.txt +++ b/tests/harness/__snapshots__/coverage-js-sloppy-arguments.txt @@ -2,6 +2,7 @@ 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)