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