MODES.md — Stator operational specification
This document operationalizes plan.md §1 "Product spec — the two modes." On conflict, plan.md wins; contradictions are reported in plan-notes.md.
1. Purpose and authority
Stator compiles TypeScript/JavaScript to native binaries in one of two modes that differ radically in what code they accept and how they type untyped or dynamically-valued code. Mode is a policy layer: the frontend gate (file acceptance + diagnostic table + typing of unresolved code). Nothing below the frontend—passes, lowering, codegen, runtime—knows the mode exists; if a pass or the emitter reads the mode, the design is wrong (plan §0.8).
tsmode (default): Static TypeScript compilation..tsfiles only.any,as any,eval,new Function,Proxy, prototype mutation,var,argumentsare compile errors. Unresolved types are errors. Types fully trusted inside type boundaries.jsmode: JavaScript + TypeScript mixed, never rejected. Untyped code compiles via a dynamic representation.evalis "not yet" (Phase 8). Types trusted only at narrow points where dynamic values enter typed code.
2. ts mode (default)
Inputs
- Files:
.tsonly. A.js,.jsx, or.tsxfile anywhere in the module graph (including transitive dependencies) isSTA1002(error) with message "expected .ts, got [ext]; use--mode=jsfor untyped code." - Module format: ESM only (enforced by
tsconfig.jsonmodule: NodeNext). - Semantic: ECMAScript semantics, not Node.js; no global
__dirname,require,process(these are runtime-provided via standard library or rejected as undefined). Node's modules are a platform, not a mode:--node(§6) resolvesnode:*and bare built-ins topackages/nodein either mode.
Typing contract
Stator owns tsconfig.json (plan §4 Task 1.0): strict: true, noUncheckedIndexedAccess, exactOptionalPropertyTypes, verbatimModuleSyntax, isolatedModules, erasableSyntaxOnly. User code cannot relax these; they are the foundation of the mode's correctness.
- Implicit
any: Untyped function parameters, unresolved global identifiers, or index accesses without type guards areSTA1003(error): "implicitany; annotate the type or use--mode=js." - Explicit
anyoras any:STA1001(error): "explicitanyis not allowed intsmode; useunknowninstead and narrow at type boundaries." - Unresolved identifiers:
STA1003(error): "unknown identifier; import it or declare its type." - Constructor/function calls with unknown return type:
STA1003(error): "return type isany; check the function's type signature."
Permanently rejected (by design)
No escape hatches; these are design errors, not missing features.
Each construct gets its own code — docs/DIAGNOSTICS.md holds the exact message text, and
a test that asserts "this file is rejected for using Proxy" must not also pass when the file
is rejected for using var.
eval:STA1101— dynamic code execution defeats static analysis outright.new Function:STA1103— the constructor form of the same thing.Proxy,Reflect:STA1106— every property access becomes an opaque trap call.- Prototype mutation (
Object.setPrototypeOf,__proto__writes):STA1107— shapes are fixed at compile time. deleteon a class field:STA1108— class instances are C structs with a fixed layout.vardeclarations:STA1104— function scoping, hoisting, andundefinedinitialization; uselet/const.argumentsobject:STA1105— use rest parameters.with:STA1109, CommonJSrequire():STA1110,.jsx/.tsx:STA1111— these apply in both modes, not justts.STA1110is the one narrowed by a platform:jsmode under--nodehands a CommonJS project file to the bundler (§6); without the flag itsrequire,module.exportsandexportsareSTA1110.- Untyped catch bindings:
STA1003— the implicit-anyrule; annotate the parameterunknown(orError) and narrow.
Not on this list: Symbol and BigInt. They are deferred, not rejected — STA1212 and
STA1213, both Phase 5 (docs/SUBSET.md). Nothing outside plan §1.1's closed list may be
described as permanently rejected, and Symbol.iterator in particular cannot be: for…of
over a typed iterable is a static, supported construct that depends on the protocol.
Type soundness at boundaries
Inside checked ts code, types are trusted fully. At boundaries where typed and untyped code meet (e.g., imports from ambient declarations, JSON.parse results, FFI calls), types are narrowed by runtime checks:
unknownand unions: The type checker forces narrowing via guards (typeof,instanceof, or type predicates) before code can use the value. Stator verifies the narrowing logic statically; the emitted code includes a runtime check that halts compilation if the narrowed type is violated at runtime (a bug in the caller, not Stator).JSON.parse: Returns typeunknown; must be narrowed.- Ambient module declarations (
.d.ts): Types are trusted as declared; if they lie (e.g., a function signature in a.d.tsdoesn't match the implementation), the runtime check at the call site may fail. Stator generates a safe boundary with a source location so the lie is caught immediately with precise feedback.
3. js mode
Inputs
- Files: Any mix of
.tsand.js(and.jsx,.tsxin Phase 2+). ESM only; always strict (ESM enforces strict mode). - Module format: ESM enforced by the pipeline (not configurable).
- Packages and CommonJS (plan.md §11d T12.1,
docs/BUNDLER.md): a package import (a bare specifier that is notnode:*, a built-in orstd/*) and, under--nodeonly (plan-notes 315), a CommonJS project file go to a bundler first.--bundler=vite|none|<module>picks it (defaultvite, thevite-statorpackage;--bundlerintsmode isSTA0004), and it loads only when the graph has something to bundle. The bundle joins the program as one virtual ESM module,__stator_vendor__.jsin the entry's directory, compiled under the samejs-mode rules as every other.jsfile; each project import declaration of a package is rewritten in place to import from it, keeping every line, so project diagnostics and#linestill point at the user's lines (only code after a rewritten import on the same line can shift columns). A CommonJS file is.cjs, a.jsunder"type": "commonjs", or a.jswith no"type", no ES-module syntax and a read ofrequire,module.exportsorexports(plan-notes 320). Without--nodea CommonJS project file stays in the graph, and its freerequire,module.exportsandexportsareSTA1110; packages are bundled with or without the flag. Diagnostics inside the bundle are reported at the package's own file and line through the bundle's source map, or as<package bundle>with "(bundler runtime helper, no source mapping)".STA0014means the adapter cannot be loaded,STA0015that the bundle step failed.--bundler=nonekeeps a package importSTA1214.
Typing rules
.tsfiles: The full static treatment (strict, implicitanyis an error,as anyis an error, unresolved types are errors)..tsis an assertion that the code is statically typeable..jsfiles: Treated asallowJs: true+checkJs: true(TypeScript's JavaScript inference mode).- Function parameters without JSDoc
@paramannotations are untyped; they lower toUnknown(the dynamic representation). - Return types without JSDoc
@returnsare inferred from return statements; if inference fails, the return type isUnknown. - JSDoc annotations are trusted and checked at runtime (see "Mixed-graph boundaries" below).
- Untyped object literals
{ a: 1, b: "x" }are inferred as{ a: number, b: string }; if inference is impossible (e.g.,{ [someVariable]: value }), the object lowers to a dynamic shape table. - Array literals without type hints are inferred element-wise; heterogeneous arrays (e.g.,
[1, "x", true]) are allowed and lower toUnknown[]. - Untyped variables are inferred from initial assignment; if no assignment, the type is
Unknown.
- Function parameters without JSDoc
anyin.jsfiles: Allowed. The typeanyis a no-op injsmode; it downgrades to the dynamic representation (same asUnknown). No error.Unresolved identifiers in
.jsfiles: Not an error; the identifier is assumed to be a runtime global or dynamic property and lowers toUnknown.
Which checker diagnostics are fatal
checkJs runs the full checker over .js files, and its diagnostics surface as STA0012. In
js mode they split three ways (plan-notes 297):
- Always fatal: every syntactic diagnostic, and every JavaScript early error — the
binder and grammar groups of TypeScript's own
plainJSErrorslist, the codestscreports for a.jsfile even withoutcheckJs(let x; let x;,with,breakoutside a loop, …). A program carrying one is not JavaScript; Node refuses it before running a line. No code may name one of these in the js-mode list below, andtests/unit/js-early-errors.test.tsreads the list out of the pinnedtypescriptto hold that. - Degraded to the dynamic path: a type-level refusal of JavaScript Node runs, listed in
JS_MODE_RUNTIME_CODES(src/frontend/program.ts) together with its run-time answer — a widened binding, a dynamic read, a runtime throw. Examples: a possibly-undefinedread (TS2532),"" == 0(TS2367), a namespace IIFE reassigning a function declaration's binding (TS2630; a named function expression's own name is immutable and stays fatal), a spread the checker narrowed tonever(TS2698),new F()on a function that returns a value and a read ofF.prototypebefore the program replaces it (TS2350, TS2565; docs/VALUE.md §4.20), a spread into fixed parameters, which calls with the list's own count (TS2556; docs/VALUE.md §4.23), and twoexport *re-exports binding one name differently (TS2308: ES drops the name from the namespace, and importing it by name isSTA3003, plan.md §11c T11.5a;tsmode keeps TS2308 fatal). - Fatal until listed: every other type-level refusal. The lowering trusts JSDoc types and
the checker's control flow, so dropping a refusal it has no answer for compiles to an internal
error at best and to a silent miscompile at worst (
const c = 1; c = 2printed2where Node throws aTypeError). A code moves to the degraded list with its answer and a test. - The bundler's vendor module is the exception (plan-notes 320 Q4,
docs/BUNDLER.md§6): package code is not the user's to fix, so there a type-level refusal is never fatal. The binding it names is widened to dynamic instead, and the four codes where Node throws and the compiled program would not (TDZ reads TS2448/2449/2450,constassignment TS2588) stay fatal. A construct the lowering still has no answer for surfaces as an internal error (STA4xxx) at the package file's position: a compiler bug to report, never a user error.
JS-only constructs that compile
vardeclarations: Function scoping, hoisting, andundefinedinitialization are honored by lowering to a dynamic representation with explicit scope tracking. Hoisting is visible (assignment without declaration before use initializes toundefined).==and!=operators: Full ToPrimitive coercion (ES5 spec) on the dynamic path. When both operands are typed, the typed comparison is used; when either isUnknown, a dynamic comparison is emitted.- Untyped object literals: Heterogeneous properties, computed property names, and getters/setters compile via dynamic shape tables and inline caches.
- Function
argumentsobject: Compiled to anUnknown[]that mirrors the actual arguments at runtime. new.target: Accessible asUnknown.thisbinding in untyped functions:thisisUnknownunless a type annotation provides it.
Not yet (Phase 8)
eval,new Function:STA1206— both land with the interpreter tier, so they share one code.Proxy:STA1203. Prototype mutation:STA1204.deleteon a class field:STA1205.
These are the same five constructs ts mode rejects permanently, which is the clearest
illustration of what a mode is: identical code, identical pipeline below the gate, different
policy. In ts mode the answer is "no"; in js mode it is "not until Phase 8."
Mixed-mode type narrowing in .js
When a value flows from untyped or loosely-typed .js into strictly-typed .ts, a runtime boundary check enforces the declared type:
// util.js (untyped)
export function getValue() {
return 42 || "fallback"; // inferred as unknown (heterogeneous)
}
// main.ts (typed)
import { getValue } from "./util.js";
const x: number = getValue(); // type error at narrowing, not import
// Emitted code: runtime_check(x, "number"), halt if mismatch
4. Mixed-graph boundaries
When a value flows from a .js module (untyped or partially typed) into .ts code (strictly typed), Stator inserts a runtime type check at the import site or use site. The check is deterministic and pinpointed to the source location of the lie.
Boundary-check examples
Example 1: a value the checker cannot see, narrowed in .ts
// math.js
/**
* @param {number} x
* @returns {number}
*/
export function double(x) {
return x * 2;
}
/** No annotation, so `settings` is `any` and so is everything read out of it. */
export function factorFrom(settings) {
return settings.factor;
}
// main.ts
import { double, factorFrom } from "./math.js";
const factor: number = factorFrom(JSON.parse(raw)); // Type-checks: `any` narrows to `number`
// Emitted: check(factorFrom(...), "number") → STA2001 runtime error:
// "type error at main.ts:3:24: expected number, got string"
console.log(double(factor));
The JSDoc on double is not what needs checking, and cannot be: checkJs verifies it against the
body and Stator makes that verdict fatal, so double("5") is a compile error (STA0012 [js] Argument of type 'string' is not assignable to parameter of type 'number') rather than a runtime
one — measured 2026-09-01, plan-notes 140. What no checker can see is the value factorFrom
actually answers, and narrowing it to number is where the boundary check goes.
Example 2: inferred number from untyped .js is already a number
// config.js
export const MAX_RETRIES = 3;
// app.ts
import { MAX_RETRIES } from "./config.js";
const retries: number = MAX_RETRIES; // no check: the checker inferred `number`
// `const retries: string = MAX_RETRIES` is checked: check(MAX_RETRIES, "string") → STA2001
When the checker's inferred type and the .ts annotation disagree (TS2322), ts mode refuses
the program (STA0012). js mode does not: it suppresses TS2322 because in a .js file the
disagreement is ordinary JavaScript (let x = 1; x = 'a' widens x to a dynamic binding). A
.ts variable annotated number, string or boolean is not widened. Its annotation stays,
and every declaration or assignment whose value has another type gets a check. A value that
matches passes. One that does not fails with STA2001 at the narrowing point (plan-notes 301):
// lib.js
export function pick(wantNumber) { return wantNumber ? 1 : "one"; } // inferred 1 | "one"
export function label(x) { return `${x}`; } // inferred string
// main.ts
import { label, pick } from "./lib.js";
const n: number = pick(true); // check(pick(true), "number") passes
const s: number = label(10); // check(label(10), "number") → STA2001
An annotation no tag settles (an object type or a union) still widens the binding to the dynamic
path, the way a .js binding does.
The call and return edges follow the same rule (plan-notes 308). An argument the checker refuses
for a parameter (TS2345), or a returned value it refuses for the function's return type (TS2322
on a return or an arrow's concise body), is suppressed in js mode and checked when a TypeScript
file annotated that parameter or return. Function, method and constructor parameters all count;
async functions and generators do not, since their annotation is a Promise or a generator, not
the returned value's type:
// main.ts
import { label, pick } from "./lib.js";
function inc(x: number): number { return x + 1; }
function first(): number { return pick(true); } // check(pick(true), "number") passes
const h = (): number => label(4); // check(label(4), "number") → STA2001
inc(pick(true)); // check(pick(true), "number") passes
inc(label(1)); // check(label(1), "number") → STA2001, `inc` never runs
A callee declared in a .js file (or described by a .d.ts) keeps Node's coercion: its JSDoc is
not a TypeScript annotation, so increment("2") against /** @param {number} value */ still
prints 21, as Node does (golden js/argument_mismatch).
A check appears only when the imported value is still Unknown:
export function retriesFrom(settings) {
return settings.n;
}
const retries: number = retriesFrom({}); // check(retriesFrom(...), "number")
Example 3: Heterogeneous array from .js
// data.js
export const items = [1, "two", true]; // Inferred as unknown[]
// consumer.ts
import { items } from "./data.js";
const nums: number[] = items;
// `number[]` is not a tag the runtime can settle in constant time, so this assignment
// is NOT wrapped (docs/HIR.md §3.2.1). Element reads stay on the dynamic path until a
// checkable narrowing (`as number`, `typeof`) at the use.
Typing contract at boundaries
- A value type-checked at a boundary is checked exactly once (at import or use).
- If the check passes, the value is trusted downstream without further checks.
- A check failure is a runtime error (
STA2001) carrying the source location of the narrowing point — never silent undefined behavior, and never memory corruption. It has to be a runtime error: the whole reason a boundary exists is that the value's real type is unknowable at compile time (plan §0.2). What is guaranteed statically is that the check is emitted, and that every place one is needed is visible in the source (an import, an assignment, or anascast Stator audits).
5. Mode mechanics
Mode selection
The --mode=ts|js flag (default ts) determines the mode. No inference magic: a .js file under default ts mode is always STA1002 (error), never a silent mode switch. The entry point's extension does not dictate the mode.
stator build app.ts -o app # ts mode (default)
stator build app.ts -o app --mode=js # js mode; .ts files in .ts mode rules
stator build app.js -o app --mode=js # js mode; .js files allowed
stator build app.js -o app # ERROR: STA1002 (.js in ts mode)
Diagnostic format
Every diagnostic carries three parts: source span, stable STA code, mode, and message.
Human format (stdout/stderr):
file.ts:10:5 STA1001 [ts] explicit 'any' is not allowed in ts mode; use 'unknown' instead
Structure: path:line:col STA#### [mode] message
path:line:col— source location, 1-indexed in both axes (matchestsc,clang, and editor gutters)STA####— stable diagnostic code (4 digits), never reused or renumbered[mode]— the active mode ([ts]or[js])message— human-readable, actionable
JSON format (--diagnostics=json):
{
"diagnostics": [
{
"file": "file.ts",
"line": 10,
"column": 5,
"code": "STA1001",
"mode": "ts",
"message": "explicit 'any' is not allowed in ts mode; use 'unknown' instead",
"severity": "error"
}
]
}
severity— one of"error","warning"(warnings in Phase 2+)- All codes must be stable; tests reference them
Module init and top-level await
Stator evaluates the program's modules in Task 3.11's topological order (dependencies first, entry last) as a single unit; each module keeps its own top-level namespace (plan.md §11c T11.5a), so only the order is shared. When the body contains a top-level await, that unit is async: main starts it and drains the microtask queue until it settles.
Node's ESM loader may interleave sibling subgraphs — two modules that do not import each other can both run their prefix, hit await, and continue in registration order. Stator does not. A dependency's top-level await runs to completion before the next file in topological order begins. The difference is observable only in sibling interleavings; a linear import chain matches Node. Mirroring Node would need per-file init promises and a scheduler, which the whole-program unit does not have.
Package evaluation order (docs/BUNDLER.md §1, documented only): every package body runs together, where the project first imports any package, because the packages are one vendor module. A project module imported between two packages therefore moves: where Node prints pkg-a a pkg-b main, Stator prints pkg-a pkg-b a main. A linear import chain matches Node.
Imports are live bindings, as in Node, however they are spelled: a named, renamed or default import and every member read through a namespace (ns.x, const { x } = ns, ns["x"]) read the exporter's own binding. The namespace OBJECT is where Stator differs: it is built once, when its first importer starts, so a value that reaches it without a member name (console.log(ns), Object.keys(ns), ns passed to a function taking a plain object type) sees each export as it was then, and prints as a plain object rather than Node's [Module: null prototype]. docs/VALUE.md §4.14.
6. stator explain — what the compiler will do with a program
explain runs the build's front half — program, gate, module graph, lowering — and stops before
codegen. It reports one file verdict for the whole module graph, the diagnostics that decided
it, and, when the program lowers, one row per compiled function. Source:
packages/compiler/src/cli/explain.ts. A rejected program still exits 0: the verdict is the answer.
Usage
stator explain file.ts # human-readable
stator explain file.js --mode=js # js mode
stator explain file.ts --json # one JSON object on stdout (never through ink)
Verdicts
| Verdict | Meaning |
|---|---|
error | a never-class diagnostic (rejected by design), or an error/internal one |
not-yet | a STA12xx diagnostic: outside today's subset, scheduled |
dynamic | compiles; some value anywhere in the graph — signature or body — is Unknown |
static | compiles fully typed |
Precedence when one stage reports several classes: never > not-yet > error/internal. The
first stage that reports a deciding diagnostic ends the run, so a later stage's diagnostics are
never in the report — the program stage and the gate decide together, then the module graph, then
lowering.
JSON schema (--json)
| Field | Present | Content |
|---|---|---|
verdict | always | one of the four verdicts |
code | exactly when verdict is error or not-yet | the deciding diagnostic's code — the first of the highest-precedence class |
diagnostics | exactly when code is | every diagnostic of the deciding stage, in source order (file, line, column): { file, line, column, code, mode, message } (plan-notes 291) |
functions | when the program lowered | { name, line, provenance, verdict } per compiled function, in source order; provenance is typed, inferred or dynamic, verdict is static or dynamic |
externCalls | when the program makes an extern call | { name, line } per call — the unchecked boundaries (docs/FFI.md §5) |
Optional fields are omitted, never null or empty, so a report carries only what applies.
Consumers key on verdict and code, never on message (docs/DIAGNOSTICS.md).
packages/tests/subset/run.ts reads only verdict and code: a decision fixture isolates one
construct, so the file verdict is that construct's verdict.
Why diagnostics lists them all. One code says a file fails; it does not say how far the file
is from compiling. Sizing a large input — Phase 11's _tsc.js (§11c T11.4) — is a tally of codes,
and the human output prints that tally first.
Per-function rows describe signatures. A row is static when the function's signature is
typed; the file verdict still counts every Unknown in every body, so a dynamic file over
all-static rows is a body-level dynamic site, not a contradiction.
Human output
The file line, then — when more than one diagnostic decided — a count per code, most frequent
first, then every diagnostic in the build's rendering (file:line:col CODE [mode] message), then
the function rows and the unchecked boundaries.
Worked example: ts mode, rejected
function unsafe(data: any) {
return data.x;
}
const other: any = 1;
console.log(unsafe({ x: 1 }), other);
unsafe.ts: error (STA1001)
STA1001 x2
unsafe.ts:1:17 STA1001 [ts] explicit 'any' is not allowed in ts mode; use 'unknown' instead
unsafe.ts:4:7 STA1001 [ts] explicit 'any' is not allowed in ts mode; use 'unknown' instead
{"verdict":"error","code":"STA1001","diagnostics":[
{"file":"unsafe.ts","line":1,"column":17,"code":"STA1001","mode":"ts","message":"explicit 'any' is not allowed in ts mode; use 'unknown' instead"},
{"file":"unsafe.ts","line":4,"column":7,"code":"STA1001","mode":"ts","message":"explicit 'any' is not allowed in ts mode; use 'unknown' instead"}]}
(file is the absolute path the program loaded; shortened here.)
Worked example: ts mode, compiles
function add(a: number, b: number): number {
return a + b;
}
const parsed: unknown = JSON.parse('2');
const n = typeof parsed === 'number' ? parsed : 0;
console.log(add(1, n));
example.ts: dynamic
1: add: static (typed)
{"verdict":"dynamic","functions":[{"name":"add","line":1,"provenance":"typed","verdict":"static"}]}
JSON.parse is a boundary in both modes (plan §1.1), so parsed is tagged and the file is
dynamic; add's signature is typed, so its row is static.
Worked example: js mode
/** @param {number} a @param {number} b @returns {number} */
function add(a, b) {
return a + b;
}
function pluck(record) {
return record.x;
}
console.log(add(1, 2), pluck({ x: 3 }));
example.js: dynamic
2: add: static (typed)
6: pluck: dynamic (dynamic)
{"verdict":"dynamic","functions":[{"name":"add","line":2,"provenance":"typed","verdict":"static"},{"name":"pluck","line":6,"provenance":"dynamic","verdict":"dynamic"}]}
Untyped pluck is not an error in js mode — it compiles through the dynamic representation.
--node: the Node platform (Phase 11)
--node (plan §11c T11.5) is a platform flag, orthogonal to --mode, and explain accepts it
like build does; stator.config.json spells it "node": true (docs/CONFIG.md). Like the mode,
it is a frontend policy: nothing below the gate reads it. It changes four things, and only these.
stator build app.ts -o app --node # ts mode on the Node platform
stator explain app.js --mode=js --node --json
Resolution. A Node built-in is node:<id> for any public id of the pinned Node's
builtinModules, or the bare <id> where Node accepts one (path, fs/promises; test and
sqlite only with the prefix, as in Node). Under --node both spellings resolve to
packages/node/src/<id>.ts, strict TypeScript over std, through paths entries on the
program's own options, the same mechanism std/ uses (packages/compiler/src/frontend/node.ts).
A landed module is ordinary source from there on. A bare built-in is never a package, with or
without the flag (docs/BUNDLER.md §1).
Landed modules (T11.6, one slice at a time; docs/NODE.md is the per-member list):
node:path and node:path/posix, POSIX semantics — Stator builds for POSIX hosts, so path is
path.posix, as on the pinned Node there. node:assert (T11.7), the slice Node's own tests use:
ok, strictEqual, notStrictEqual, deepStrictEqual, match, fail, throws, rejects and
AssertionError; its default export is an object, not yet a callable function. node:module
(T11.5): createRequire, isBuiltin and builtinModules. node:fs (T11.6), the synchronous
subset tsc calls: closeSync, existsSync, mkdirSync, openSync, readFileSync,
readdirSync, realpathSync, rmdirSync, statSync, unlinkSync, utimesSync,
writeFileSync and writeSync, failing with Node's system errors (code, errno, syscall,
path and the same message). Its gaps: paths are strings only (no Buffer or URL);
readFileSync without an encoding answers a Uint8Array until Buffer lands; readdirSync lists
in byte order; a Dirent follows symbolic links; a Stats carries size, mtimeMs, mtime,
isFile, isDirectory and isSymbolicLink only; a system error is not an Error instance;
watch, watchFile and unwatchFile wait on the event loop (N2). Each module's default export is the module object
(import path from 'node:path'), which the bundle's import * as m plus m.default also needs.
path.win32, node:path/win32 and path.matchesGlob have not landed.
Platform gaps are diagnostics. Under --node a built-in packages/node has not landed, or a
member its module does not export yet while the pinned Node's module does, is STA1214 naming
Phase 11 (T11.6). So explain --node's diagnostics lists the platform gaps the same way it
lists the language ones, and docs/NODE.md is the coverage the two must agree with. A member
Node itself does not have stays the checker's error (STA0012). Without --node, a built-in is
STA1214 with no phase, and its message names the flag.
require, module.exports, exports. STA1110 narrows to "without --node". In ts mode
it stays, flag or not. --node is also what routes a CommonJS project file to the bundler whole
(T12.1, docs/BUNDLER.md §4; decided 2026-10-02, plan-notes 315), so under it such a file never
reaches the gate. Without the flag the file stays in the graph and its free require,
module.exports and exports are STA1110; a CommonJS file that reads none of them compiles as
written. Packages are bundled either way. With --node in js mode the bundle's require is
createRequire(import.meta.url) (Rolldown's __require), a require over built-ins only:
node:module (T11.5) answers every built-in packages/node has landed, throws
ERR_UNKNOWN_BUILTIN_MODULE naming T11.6 for one it has not, and throws Node's
MODULE_NOT_FOUND for anything else, because the project's files and packages were bundled at
build time and nothing is left to load. An ES module gets the same require from
createRequire(import.meta.url). A free require, module.exports or exports that still
reaches the gate under --node (beside ES-module syntax, or under --bundler=none) is STA1110,
as in Node, where an ES module has none of them. require is a plain function: require.resolve,
require.cache and require.main are absent (a function with properties is not-yet).
__filename, __dirname and import.meta (decided 2026-10-02, plan-notes 312; landed in
plan-notes 316; docs/BUNDLER.md §9). __filename and __dirname exist where Node defines them:
in a CommonJS file, which under --node reaches the build through the bundler, so in the vendor
module. An ES module has neither, exactly as in Node (ReferenceError: __dirname is not defined in ES module scope), so a free read that reaches the gate is STA1110, under the flag or not.
import.meta.url, import.meta.filename and import.meta.dirname exist in every module under
--node; without the flag import.meta is STA1214. The values are relative to the
executable, resolved at run time:
__dirnameis the directory of the running binary (the pathprocess.execPathanswers), joined with the module's directory relative to the entry file's directory. For a module beside the entry it is the binary's directory itself.__filenameis__dirnamejoined with the module's file name.- Inside the vendor module a read takes the location of the file it was written in, which the
bundle's source map names (
node_modules/edge/index.jsreads<bin dir>/node_modules/edge). A read with no mapping, in a bundler helper, takes the vendor module's own: it sits at the entry's level, so its__dirnameis the binary's directory. import.meta.filenameis__filename,import.meta.dirnameis__dirname, andimport.meta.urlis thefile:URL of__filename(url.pathToFileURL's encoding).
No build-machine path is ever baked into the binary. The frontend rewrites every read into a call
to packages/node/src/internal/location.ts that carries the file's path relative to the entry
(frontend/location.ts); the call joins it to the binary's directory when the program runs. The
rule keeps the common idiom working: join(__dirname, "data.json") finds assets laid out beside
the binary the way they sat beside the source.
7. One pipeline, one gate
The pipeline is uniform:
- Frontend gate (mode-aware): parse + typecheck (via
typescriptAPI) → mode policy (file acceptance, diagnostic table, unresolved type handling) - Typed HIR (mode-agnostic): lowering (
ts.Type→HType), verifier - Passes (mode-agnostic): monomorphize, boundary-insert, const-fold, DCE, inline
- Codegen (mode-agnostic): C emitter + runtime library → machine code
Mode touches only:
- File acceptance:
.tsonly (modets), or any mix.ts+.js(modejs) - Diagnostic table: Different error codes and constraints per mode (table in §5)
- Unresolved type handling: Error in
tsmode; lower toUnknowninjsmode
Below the gate (lowering, passes, codegen, runtime): No reference to the mode. If any pass reads mode or branches on it, the design is wrong. Mode is baked into the typed HIR or lowered to Unknown by the frontend gate.