Skip to content

Compile sloppy-mode arguments without observable aliasing, and resolve minified package entries to their verified per-file builds - #1324

Open
cramforce wants to merge 5 commits into
mainfrom
opt/sloppy-args-minified
Open

cramforce wants to merge 5 commits into
mainfrom
opt/sloppy-args-minified

Conversation

@cramforce

Copy link
Copy Markdown
Contributor

Summary

Two tickets from the zero-cost JavaScript compatibility plan (T1 and T2). Both replace sites that today end in a refusal, so programs that compiled before produce the same IR, LLVM and executables (evidence below).

T1: sloppy-mode arguments

A JavaScript function with parameters outside an ES module was refused as soon as it read arguments ("parameterized 'arguments' outside an ES module (sloppy-mode parameter aliases)"). Node maps a sloppy-mode function's arguments onto its parameters, so writing one shows through the other. The hidden array scriptc passes is a snapshot of the actual arguments, which is exactly Node's unmapped object. The refusal now stays only where the difference can be observed.

  • arguments must be used only as arguments.length, an rvalue arguments[i], ...arguments, f.apply(thisArg, arguments), Array.prototype.slice.call(arguments[, n]) or Array.from(arguments[, fn]). None of these can write the object or let it escape.
  • Strict code (a 'use strict' file or function prologue, anything inside a class body) and functions with non-simple parameter lists (defaults, rest, destructuring) get Node's unmapped object, so that condition is enough.
  • Sloppy-mode functions with simple parameter lists additionally must not write a parameter whose index some read can reach: a constant arguments[k] reaches parameter k, the other forms reach every parameter. Writes in nested closures count (the existing paramWrittenInBody). node-fetch's transpiled function Body(body) { var opts = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; … body = …; } compiles because only index 1 is read.
  • Everything else keeps a precise SC1090 that names the problem: writing 'arguments' …, 'arguments' as a value …, 'arguments.callee' …, writing parameter 'a' while reading it through 'arguments' in a sloppy-mode function (mapped 'arguments' aliases its parameters). In JavaScript files these are runtime fences, as before.
  • f.apply(x, arguments) on an untyped receiver (var emit = process.emit in signal-exit) is accepted as Function.prototype.apply. A typed receiver whose apply is not the standard library's is an escape.
  • The analysis lives in lowering/sloppy-arguments.ts and runs only at the two existing non-module fence sites (appendArgumentsParameter and jsArgumentsFunctionType), after the ES module and class-method checks.

T2: minified package entries

lru-cache 11 and glob 13 point every export at one minified esbuild bundle (dist/esm/index.min.js) and ship the per-file build next to it. Auto-selection refused them ("its shipped JS looks minified"); 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 that the two are the same program (frontend/npm-static-minified.ts):

  • the target looks minified (the existing eligibility heuristic) and the sibling exists;
  • the bundle's source map lists every source with its content, and the per-file module graph reached from the sibling compiles exactly the same sources with byte-identical content. The graph follows relative edges and the installed dependencies Node would load (through scriptc's own opted-in resolver), keyed package-relative, so a dependency the bundle inlined at build time (node_modules/minimatch/src/index.ts) must match the installed minimatch the per-file build imports;
  • the bundle and the sibling export the same names (ES module declarations with star re-exports followed, or the Node-visible CommonJS names).

The respelling (npmStaticRespellMinifiedText) runs on the package.json text in both resolution worlds, the tsgo host's fs shadow and resolve.ts, so they read the same document and answer the same file. A document without .min. comes back as the same string, and only .min. target values are replaced. Verification lives in npm-static-minified.ts, which has no AST dependency; the parsing it needs comes from the load through a host (npm-static-minified-syntax.ts). When verification fails, the minified target stays and auto-selection names the mismatch, for example its shipped JS looks minified, and its per-file build cannot replace it: the bundle inlines a brace-expansion/src/index.ts that differs from the installed one its per-file build loads. Explicitly naming such a package compiles the bundle as before.

Packages (Linux sandbox, Node 24.15)

"main" is origin/main b14377864. "+pending" is a local, unpushed merge of opt/cjs-interop (#1287) and opt/npm-small-fixes (#1285) on top of it, because glob@7 and node-fetch@2 stop at their fixes first.

Package Base Before After Next blocker
glob@7.2.3 (closure named) main SC1013 late require (fixed by #1287) same —
glob@7.2.3 (closure named) +pending build fails: SC1090 sloppy arguments in sync.js globSync builds, 1,719 statements, 96% static runtime fence: fs.realpath's var origRealpathSync = fs.realpathSync (builtin function as a value, T6)
node-fetch@2.7.0, CommonJS consumer (closure named) main SC1010 punycode (fixed by #1285) same —
node-fetch@2.7.0, CommonJS consumer +pending build fails: SC1090 sloppy arguments builds, 733 statements, 94% static runtime fence: _interopDefault(require('stream')) ("'Require' is typed by @types/node but has no scriptc lowering", T3)
node-fetch@2.7.0, ES module import fetch both SC1090 module namespace as a value + sloppy arguments SC1090 module namespace as a value module.exports = exports = fetch default import (T3)
signal-exit@3.0.7, CommonJS consumer main (same with +pending) builds; 39 of 52 statements static, processEmit's arguments fenced builds; 48 of 63 statements static, no arguments fence runtime fence: module.exports = function inside if (!processOk(process)) (T3)
signal-exit@3.0.7, ES module default import both SC1090 module namespace as a value same T3
lru-cache 11.5.3 (auto or named) main SC2013 "looks minified" (auto); "entry index.min.js did not join the type-checked program" (named) dist/esm/node/index.js and its imports join the program SC1090 at dist/esm/node/index.js:693: [Symbol.toStringTag] = 'LRUCache' computed class field (T7)
glob 13.0.6, current dependency releases main SC2013 "looks minified" (auto); bundle compiled when named, fails inside it ("derived constructors without a lowered super() call") auto: refused, naming the bundled brace-expansion that differs from the installed one; named: unchanged the bundle inlined minimatch/lru-cache/brace-expansion releases older than npm installs today, so the per-file build is a different program
glob 13.0.6, dependencies pinned to the bundled releases (minimatch 10.2.2, lru-cache 11.2.6, path-scurry 2.0.2, minipass 7.1.3, brace-expansion 5.0.2, balanced-match 4.0.3) main as above verified; the per-file build compiles through glob, minimatch, path-scurry and minipass lru-cache's [Symbol.toStringTag] field (T7)

Zero-cost evidence

Pattern signatures: T1 changes only functions in non-module JavaScript that read arguments and have parameters (previously refused, or fenced at run time in JavaScript files). T2 changes only opted-in packages whose main/exports name a .min. file that looks minified.

Measured in a Linux sandbox on the final commits against b14377864 (both trees built with their own runtime pack and LLVM helper), comparing .ll files and release executables directly (the zero-cost gate tool is not on main yet):

Good-code set Result
29 runtime benchmark workloads plus benchmarks/builds/{log-summary,inventory-report} (--emit=llvm and SCRIPTC_NO_CACHE=1 release builds from one shared path) LLVM and executables byte-identical for all of them. Three workloads import @scriptc/experimental: intern-table was built from a shared path with that package linked in; published-graphs and region-builds were built in each tree, where the only LLVM difference is the tree path (header line and the program-path literal) and the executables are byte-identical
Self-hosted compiler: b14377864's packages/compiler/src/native/cli.ts built with --emit=llvm by each compiler identical, 508,485,914 bytes
Corpus, --emit=ir from one shared path: all 669 .js/.cjs/.mjs programs and every 20th .ts program (115) IR and build output identical for all 784 (an earlier run on the T1 code also matched 810 .ts programs)

No existing corpus program changed, which is expected: none had a sloppy-mode function with parameters reading arguments (they were refused) or an npm package with a minified entry.

Tests

  • Corpus (Node vs native, byte-identical): arguments-sloppy-unaliased.cjs (length, constant and computed indexes, written parameters no read reaches, nested shadowing, spread, apply including an untyped receiver, Array.prototype.slice.call, Array.from with a map function, arrows, object-literal methods, function values, the glob@7, node-fetch and signal-exit shapes), arguments-sloppy-unmapped.cjs (defaults, destructuring, function-level and class-body strict code), arguments-sloppy-strict-file.cjs (file prologue: parameter writes never show through arguments). Existing arguments corpus (1703, 3034, arguments-arrow-capture) unchanged.
  • Coverage snapshot coverage-js-sloppy-arguments.txt: each observable form keeps its named fence (parameter write read through arguments, arguments[0] = …, passing arguments, arguments.callee, a write in a strict function).
  • Harness (tests/harness/npm-static.test.ts): a synthetic tshy-style package whose verified minified entry compiles from its per-file build and byte-matches Node; a bundle that inlined an older dependency stays refused with the reason; the published lru-cache 11.5.3 joins from dist/esm/node/index.js; the published glob 13.0.6 with the lockfile's newer dependencies stays refused with a named mismatch; the published node-fetch 2.7.0 and signal-exit 3.0.7 keep no arguments fence. lru-cache, glob 13, node-fetch 2 and signal-exit 3 are added as root devDependencies (only lru-cache@11.5.3 is new to the lockfile).
  • Linux sandbox, final commits: pnpm lint, pnpm format:check, pnpm test:ts7 --baselines-only (order baselines recorded for the three new corpus programs; existing baselines unchanged), order-parity.test.ts (62 passed), pnpm node-compat:check, harness npm-static + coverage + diagnostics + code-quality + self-hosting-frontend-metadata (333 passed), packages/compiler/src/frontend unit tests (770 passed), differential corpus -t argument (31 passed; 5443-crypto-random-arguments.js fails because the sandbox's Node 24.15 itself crashes on it, a pinned-24.22 baseline failure), sanitized lane for the new programs (3 passed), scriptc coverage reports every arguments corpus program 100% static, self-hosted --emit=ir build of packages/compiler/src/native/cli.ts with no diagnostics, self-hosting-native-driver.test.ts (passed). Docs: NEXT_DIST_DIR=.next-check pnpm check passed (25 route tests) before a one-word wording fix.

Notes for review

  • Pre-existing, not changed here: in ES modules, class methods and parameterless functions, arguments accepts any use, so Array.isArray(arguments) is true, util.inspect prints an array, and arguments[3] = 1 with one argument makes arguments.length 4 (Node: 1). The newly admitted CommonJS functions refuse those uses instead. The limitations page now says this.
  • Pre-existing bug found: [].slice.call(arguments) throws TypeError: Cannot read properties of undefined (reading 'call') in every file kind; the [].slice read does not lower to the prototype method. It stays an escape in the new analysis.
  • T2 compares sources, not compiler output: the bundle and the per-file build were compiled from the same sources by two tools (esbuild and tsc for tshy packages). A package whose two builds use different emit semantics for the same source (for example useDefineForClassFields) would differ; the source maps cannot show that.
  • Self-hosting: a first version respelled targets by writing into the parsed Record<string, unknown> document. That made the self-hosted build of packages/compiler/src/native/cli.ts report 301 errors in untouched lowering files (ClassInfo values turning into unknown), even with the function unreached. Respelling the text instead keeps the self-hosted build clean. The trigger looks like a self-hosting limitation worth its own investigation; the bisect is in the notes.
  • glob 13 shows the check working as intended: npm installs today are newer than the releases glob 13.0.6 inlined, so its per-file build is a different program from what Node runs.

🤖 Generated with Claude Code

cramforce and others added 2 commits October 18, 2026 12:24
…rved

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
scriptc Ready Ready Preview, v0 Oct 18, 2026 11:14pm UTC

@vercel-security-reviewer

Copy link
Copy Markdown

Security review details

cramforce and others added 2 commits October 18, 2026 13:10
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 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — a602d64b Deployed Oct 18, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants