Skip to content

Compile chalk 5 through the dynamic object model - #1309

Merged
ctate merged 2 commits into
mainfrom
opt/callable-dyn
Oct 18, 2026
Merged

ctate merged 2 commits into
mainfrom
opt/callable-dyn

Conversation

@cramforce

Copy link
Copy Markdown
Contributor

Summary

chalk 5 now compiles from its published JavaScript and matches Node byte for byte at every color level, from TypeScript and from JavaScript callers. No chalk-specific lowering: the builder runs on the existing checked-dynamic machinery, and this change closes the general gaps around it.

Reducing chalk's builder to a minimal repro showed that the core of the builder already worked on main. That core is a callable whose prototype chain is set with Object.setPrototypeOf, whose styles are getters defined with Object.defineProperties on a function, and whose every property read creates a new callable. The failures were all in the surrounding code:

Step Failure on main Fix
import ansiStyles from '#ansi-styles' inside the package runtime TypeError: Package import specifier "#ansi-styles" is not defined (the resolver answered the .d.ts twin, and { node, default } targets ignored node) Inside a --npm-static package, imports entries resolve like the tsgo host's fs shadow: the node condition is hoisted and the shipped JS is used, never a declaration twin
ansi-styles' assembleStyles reshapes an object literal (styles[name] = …, group[name] = …, Object.defineProperty(styles, …)) assignment to non-array elements fence, then wrong-typed reads (expected object | object | object) A syntactic scan of JavaScript files marks object literals bound to a variable that the file reshapes with runtime keys, delete or Object.defineProperty(ies); the type mapper answers the checked-dynamic type for them (and the literals nested in them)
supports-color's const {env} = process SC1031 detached-member fence Data members of process (env, argv, platform, arch, pid, execPath, versions) bind their values when destructured in JavaScript
supports-color's streamIsTTY: stream && stream.isTTY SC1042 mixed logical operands o && x / o || x with an always-truthy object, array or function left operand
Number.parseInt(env.FORCE_COLOR, 10) SC2012 refusal on an unknown argument Number.parseInt / Number.parseFloat over a checked-dynamic value apply ToString, like the global parseFloat already did
Object.setPrototypeOf(createChalk.prototype, Function.prototype) SC2020 Function.prototype has no lowering Function.prototype as a value is one anonymous zero-capture function answering undefined (closure identity, so every read is the same function)
class Chalk { constructor(options) { return chalkFactory(options); } } silently wrong: the constructor's return value was discarded and new Chalk() answered an empty instance A JavaScript class whose only member is a constructor that returns a value and never reads this: its instance type is checked-dynamic and new C() runs the constructor body as a function and answers the returned object or function
TypeScript caller: chalk.red.bold("x") SC1090 method calls like 'chalk.red.bold': bold is a String.prototype name, so calls on untyped receivers were refused On a checker-untyped receiver, names only String.prototype declares (bold, italics, link, …) call the receiver's own or inherited member; a string receiver throws the refusal at run time

Zero cost for good code

Every change sits on a path that ended in a refusal or a runtime fence, or is confined to JavaScript files and to declarations the scan finds. No runtime C changed. The checks below compare the baseline (4ed75111a) and this branch on Linux x86_64, building the same input paths with both compilers:

Check Inputs Result
LLVM text (--emit=llvm, header line dropped) all 28 benchmarks/runtime/workloads programs, builds/log-summary, builds/inventory-report 30/30 identical
Release executables (SCRIPTC_NO_CACHE=1, SHA-256) the same 30 programs 30/30 identical
Self-hosted compiler, LLVM the baseline's packages/compiler/src/native/cli.ts built by both compilers identical (518,225,068 bytes)
Self-hosted compiler, release executable same identical (54,581,816 bytes)
IR (--emit=ir) every TypeScript corpus program of the baseline (2,313) 2,313/2,313 identical
IR (--emit=ir) every JavaScript corpus program of the baseline (669) 669/669 identical

An earlier iteration of the scan also marked empty literals (const results = {}; results[k] = v) and changed the IR of two JavaScript corpus programs. Empty literals already have a checked-dynamic binding, so the scan now skips them, and both programs compile exactly as before.

The first version kept Function.prototype in the C runtime (scr_json.c). Its LLVM was identical, but every release executable differed: the new string literal joined scr_json.o's merged string section, which every program links. The singleton is now a compiler-emitted closure, and the runtime is untouched.

Evidence

Corpus programs (Node vs native, stdout/stderr/exit byte-identical):

  • js-callable-builder.js: the chalk builder pattern: getters on a function prototype chain, symbol-keyed builder state, getter caching with defineProperty, nested styles, level get/set through the generator, Function.prototype link, apply through the chain.
  • js-function-prototype-value.js: Function.prototype as a value and as a prototype link.
  • js-reshaped-object-literal.js: the ansi-styles reshaping, delete, runtime-keyed writes.
  • js-class-constructor-returns/: value-returning constructors (function, object, per-call), from JavaScript and TypeScript.
  • js-process-destructure.mjs: const { env, argv, platform } = process.
  • js-number-parse-dynamic.js: Number.parseInt/parseFloat over dyn values.
  • truthy-object-logical.ts: &&/|| with object, array and function left operands, including evaluation order.
  • js-builder-string-method-names/: a TypeScript caller of builders whose members are named bold, italics, link, big.

Real package:

  • tests/fixtures/npm/cases/chalk5: chalk 5.6.2 vendored (MIT), a program exercising every color model, nested styles, chalk.level, new Chalk({ level }) at levels 0–3, chalkStderr, supportsColor and the exported name lists. It runs in npm.test.ts.
  • tests/harness/chalk.test.ts: the same program under FORCE_COLOR unset, 0–3, true, false, empty, and the --color, --no-color, --color=256, --color=16m flags (supports-color reads both), compared with Node.

Timing (Linux x86_64 sandbox, chalk 5.6.2, new Chalk({ level: 3 }), warmed in-process loops, three runs each, median per call):

Call Node 24.15 scriptc Slowdown
c.red(s) 67 ns 995 ns 15×
c.red.bold(s) 64 ns 1,321 ns 21×
c.hex("#ff8800")(s) 644 ns 6,334 ns 10×
c.blue("a " + c.red(s) + " b") 271 ns 2,647 ns 10×
new Chalk({ level: 1 }).green(s) 750 ns 4,886 ns 6.5×
chalk.red(s) at level 0 43 ns 729 ns 17×

chalk calls run on the checked-dynamic path, so they are 6–21× slower than Node in a hot loop. A typical CLI makes few of them. The whole fixture program (FORCE_COLOR=3) takes 2 ms per run as a native binary and 71 ms under Node, because process startup dominates.

Not in this change

  • chalk 4 (CommonJS) is blocked before its builder by CommonJS export forms that belong to the CommonJS tickets: a late require inside a function (template = require('./templates'), PR Compile classic CommonJS packages: late requires, JSON requires, nested builtin destructuring, settled require cycles #1287), Object.defineProperty(module, 'exports', { get }) in ansi-styles 4, and color-name's string-keyed module.exports = { "aliceblue": … } table. The builder code itself is the same pattern chalk 5 uses.
  • A value-returning constructor whose class has other members, a base class, or reads this keeps today's lowering, which still discards the returned value (pre-existing, not fixed here). For the classes this change claims, a constructor that falls through or returns a primitive throws a deferred SC1090 refusal instead of answering the bare instance.
  • Reading one of Function.prototype's methods as a value through a custom prototype chain (typeof f.apply after Object.setPrototypeOf(f, P) with P linked to Function.prototype) answers undefined; calling them (f.apply(…), f.call(…)) works. Fixing the read needs a change on the shared dyn read path in the runtime, which this change avoids.
  • Seen while testing, not changed here: under FORCE_COLOR, Node's console.log colors numbers and scriptc does not. In TypeScript, arr && arr[0] (a union result) still stores the array in the result union and throws a TypeError at run time; the new always-truthy rule only covers results that are not unions holding a different arm.

🤖 Generated with Claude Code

chalk's callable builder (a function whose prototype chain is set with
Object.setPrototypeOf, with style getters from Object.defineProperties)
already ran on the checked-dynamic model. What failed was the code
around it. Each fix is general and sits on a path that used to end in a
refusal or a runtime fence, or is confined to JavaScript files:

- package.json "imports" inside a --npm-static package resolve to the
  shipped JS with the "node" condition hoisted, like the tsgo fs shadow;
- JavaScript object literals that the file reshapes at run time (keyed
  writes with runtime keys, delete, Object.defineProperty/ies) map to
  the checked-dynamic type, with the literals nested in them;
- a JavaScript class whose only member is a constructor that returns a
  value and never reads `this` answers that value from `new` (the return
  used to be discarded silently);
- `const { env } = process` binds the data member in JavaScript;
- `o && x` / `o || x` with an always-truthy object, array or function
  left operand;
- Number.parseInt/parseFloat over checked-dynamic values apply ToString;
- Function.prototype as a value is one compiler-emitted function;
- calls of String.prototype-only names (bold, italics, ...) on untyped
  receivers call the receiver's own or inherited member; a string
  receiver throws the refusal at run time.

Programs without these patterns compile to identical bytes: the runtime
benchmark workloads, the self-hosted compiler (LLVM and executable) and
the TypeScript corpus IR were compared against the baseline.

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 5:02pm UTC

This branch was successfully deployed

1 active deployment
Preview — 9ef9c038 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