Merge remote-tracking branch 'origin/ni/i4-deferred' into HEAD

This commit is contained in:
Malte Ubl
2026-10-23 06:33:28 -07:00
1171 changed files with 77001 additions and 3822 deletions
+125
View File
@@ -0,0 +1,125 @@
// Writes packages/runtime/src/scr_cipher_info.h from the running Node's
// crypto.getCipherInfo: one record per cipher, the names (lowercase) and nids
// that find it, and the keyLength and ivLength options each accepts. The
// candidate names are getCiphers() and `openssl list -cipher-algorithms`
// (OpenSSL's aliases and its legacy-provider ciphers, which getCiphers()
// leaves out). Run with the pinned Node: node scripts/gen-cipher-info.mjs
import crypto from "node:crypto";
import { execFileSync } from "node:child_process";
import fs from "node:fs";
const listed = execFileSync("openssl", ["list", "-cipher-algorithms"], { encoding: "utf8" })
.match(/[A-Za-z0-9][A-Za-z0-9._-]*/g)
.filter((word) => !["Legacy", "Provided", "default", "legacy", "base", "fips"].includes(word));
const candidates = new Set([...crypto.getCiphers(), ...listed]);
for (let nid = 1; nid < 1400; nid++) {
const info = crypto.getCipherInfo(nid);
if (info) candidates.add(info.name);
}
function ivRule(name, info) {
const accepted = [];
for (let v = 0; v <= 70; v++) if (crypto.getCipherInfo(name, { ivLength: v })) accepted.push(v);
const large = !!crypto.getCipherInfo(name, { ivLength: 100000 });
const own = info.ivLength ?? 0;
if (accepted.length === 0) return "SC_INFO_NONE";
if (accepted.length === 1 && accepted[0] === own && !large) return "SC_INFO_EXACT";
if (accepted.join() === "7,8,9,10,11,12,13" && !large) return "SC_INFO_CCM";
if (accepted.join() === [...Array(15)].map((_, i) => i + 1).join() && !large)
return "SC_INFO_OCB";
if (accepted.length === 71 && large) return "SC_INFO_ANY";
throw new Error(`unclassified ivLength rule for ${name}: ${accepted}`);
}
function keyRule(name, info) {
const accepted = [];
for (let k = 0; k <= 80; k++) if (crypto.getCipherInfo(name, { keyLength: k })) accepted.push(k);
if (accepted.length === 0) return "SC_INFO_NONE";
if (accepted.length === 1 && accepted[0] === info.keyLength) return "SC_INFO_EXACT";
throw new Error(`unclassified keyLength rule for ${name}: ${accepted}`);
}
const records = [];
const recordIndex = new Map();
function recordOf(name, info) {
const key = JSON.stringify(info);
let index = recordIndex.get(key);
const rules = [keyRule(name, info), ivRule(name, info)];
if (index === undefined) {
index = records.length;
records.push({ info, rules });
recordIndex.set(key, index);
} else if (records[index].rules.join() !== rules.join()) {
throw new Error(`rules differ for ${name}`);
}
return index;
}
const names = [];
for (const name of [...candidates].map((n) => n.toLowerCase()).sort()) {
if (names.length && names.at(-1)[0] === name) continue;
const info = crypto.getCipherInfo(name);
if (info) names.push([name, recordOf(name, info)]);
}
const nids = [];
for (let nid = 1; nid < 1400; nid++) {
const info = crypto.getCipherInfo(nid);
if (info) nids.push([nid, recordOf(info.name, info)]);
}
const str = (s) => (s === undefined ? "NULL" : JSON.stringify(s));
const num = (n) => (n === undefined ? -1 : n);
let out = `/* Generated by scripts/gen-cipher-info.mjs from Node ${process.version}'s
* crypto.getCipherInfo: every cipher OpenSSL names, with the fields Node
* reports (-1 or NULL for a field it leaves out) and the keyLength and
* ivLength options each accepts. Do not edit. */
#ifndef SCR_CIPHER_INFO_H
#define SCR_CIPHER_INFO_H
enum { SC_INFO_NONE, SC_INFO_EXACT, SC_INFO_CCM, SC_INFO_OCB, SC_INFO_ANY };
typedef struct {
const char *mode;
const char *name;
int nid;
int block_size;
int iv_length;
int key_length;
unsigned char key_rule;
unsigned char iv_rule;
} ScCipherInfo;
static const ScCipherInfo sc_cipher_infos[] = {
`;
for (const { info, rules } of records)
out += ` {${str(info.mode)}, ${str(info.name)}, ${num(info.nid)}, ${num(info.blockSize)}, ${num(info.ivLength)}, ${info.keyLength}, ${rules[0]}, ${rules[1]}},\n`;
out += `};
/* Lowercase names, sorted, with their record. */
static const struct {
const char *name;
unsigned short info;
} sc_cipher_info_names[] = {
`;
for (const [name, index] of names) out += ` {${JSON.stringify(name)}, ${index}},\n`;
out += `};
/* Nids, ascending, with their record. */
static const struct {
unsigned short nid;
unsigned short info;
} sc_cipher_info_nids[] = {
`;
for (const [nid, index] of nids) out += ` {${nid}, ${index}},\n`;
out += `};
#endif
`;
for (const { info } of records) {
const keys = Object.keys(info).join();
const expected = ["mode", "name", "nid", "blockSize", "ivLength", "keyLength"]
.filter((k) => k in info)
.join();
if (keys !== expected) throw new Error(`unexpected key order ${keys}`);
}
fs.writeFileSync(new URL("../packages/runtime/src/scr_cipher_info.h", import.meta.url), out);
console.log(records.length, "records,", names.length, "names,", nids.length, "nids");
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env node
/* Writes packages/runtime/src/scr_dyn_methods.h: the method-name ids the
* checked-dynamic dispatch in scr_dyn_invoke.c compares (DYN_M_<name>), the
* names, and an open-addressed hash table mapping a name to its id. The ids
* are the DYN_M_ names scr_dyn_invoke.c uses, so the header follows the
* dispatch code.
*
* node scripts/gen-dyn-methods.mjs write the header
* node scripts/gen-dyn-methods.mjs --check fail when it is out of date */
import { readFileSync, writeFileSync } from "node:fs";
const source = new URL("../packages/runtime/src/scr_dyn_invoke.c", import.meta.url);
const target = new URL("../packages/runtime/src/scr_dyn_methods.h", import.meta.url);
const SLOTS = 256;
const used = new Set();
for (const match of readFileSync(source, "utf8").matchAll(/\bDYN_M_([A-Za-z][A-Za-z0-9]*)\b/g))
if (match[1] !== "NONE" && match[1] !== "COUNT") used.add(match[1]);
const names = [...used].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
if (names.length >= SLOTS / 2) throw new Error("too many method names for the hash table");
// The slot dyn_method_of in the header computes.
const slotOf = (name) =>
(name.length * 7 + name.charCodeAt(0) * 31 + name.charCodeAt(name.length - 1)) & (SLOTS - 1);
const slots = new Array(SLOTS).fill(0);
names.forEach((name, i) => {
let slot = slotOf(name);
while (slots[slot] !== 0) slot = (slot + 1) & (SLOTS - 1);
slots[slot] = i + 1;
});
const rows = (values, width) => {
const out = [];
for (let i = 0; i < values.length; i += width)
out.push(` ${values.slice(i, i + width).join(", ")},`);
return out;
};
const text = [
"/* Generated by scripts/gen-dyn-methods.mjs from the DYN_M_ ids scr_dyn_invoke.c",
" * uses. Do not edit by hand. */",
"#ifndef SCR_DYN_METHODS_H",
"#define SCR_DYN_METHODS_H",
"",
"#include <stddef.h>",
"#include <string.h>",
"",
"enum {",
" DYN_M_NONE,",
...names.map((name) => ` DYN_M_${name},`),
" DYN_M_COUNT",
"};",
"",
"static const char *const dyn_method_names[DYN_M_COUNT] = {",
" NULL,",
...names.map((name) => ` ${JSON.stringify(name)},`),
"};",
"",
"static const unsigned char dyn_method_lengths[DYN_M_COUNT] = {",
...rows([0, ...names.map((name) => name.length)], 16),
"};",
"",
`/* Each name's id at its slot (or the next free one); 0 ends a probe. */`,
`static const unsigned char dyn_method_slots[${SLOTS}] = {`,
...rows(slots, 16),
"};",
"",
`#define DYN_METHOD_MAX_LENGTH ${Math.max(...names.map((name) => name.length))}`,
"",
"/* The id of a method name, compared by content; DYN_M_NONE for any other",
" * name. */",
"static inline int dyn_method_of(const char *m) {",
" size_t length = strlen(m);",
" if (length == 0 || length > DYN_METHOD_MAX_LENGTH) return DYN_M_NONE;",
" unsigned slot = (unsigned)(length * 7 + (unsigned char)m[0] * 31 +",
` (unsigned char)m[length - 1]) & ${SLOTS - 1};`,
" for (;;) {",
" unsigned id = dyn_method_slots[slot];",
" if (id == 0) return DYN_M_NONE;",
" if (dyn_method_lengths[id] == length && memcmp(m, dyn_method_names[id], length) == 0) return (int)id;",
` slot = (slot + 1) & ${SLOTS - 1};`,
" }",
"}",
"",
"#endif",
"",
].join("\n");
if (process.argv.includes("--check")) {
let current = "";
try {
current = readFileSync(target, "utf8");
} catch {}
if (current !== text) {
console.error(
"packages/runtime/src/scr_dyn_methods.h is out of date: run node scripts/gen-dyn-methods.mjs",
);
process.exit(1);
}
} else {
writeFileSync(target, text);
}
+32 -4
View File
@@ -282,6 +282,26 @@ const adaptedGetters = new Set([
"operand",
"questionToken",
]);
// A getter selects its child's wire slot by kind directly: an integer
// switch, without astChildOrder's switch on the child's name. `answer`
// maps a slot to the returned expression; `missing` is returned for kinds
// without the child.
const slotCases = (name, answer, missing) => {
const kindsBySlot = new Map();
for (const [kind, childNames] of Object.entries(protocol.childProperties)) {
const slot = childNames.indexOf(name);
if (slot < 0) continue;
if (!kindsBySlot.has(slot)) kindsBySlot.set(slot, []);
kindsBySlot.get(slot).push(kind);
}
const lines = [" switch (this.kind) {"];
for (const [slot, kinds] of [...kindsBySlot].sort((a, b) => a[0] - b[0])) {
for (const kind of kinds) lines.push(` case ${kind}:`);
lines.push(` return ${answer(slot)};`);
}
lines.push(" default:", ` return ${missing};`, " }");
return lines.join("\n");
};
const getters = names
.filter((name) => !adaptedGetters.has(name))
.map((name) => {
@@ -292,12 +312,19 @@ const getters = names
.replaceAll("RemoteNode", "AstNode");
const method =
type.includes("AstNode |") && type.includes("AstNodeList")
? "child"
? "childAt"
: type.includes("AstNodeList")
? "childList"
: "childNode";
return ` get ${name}(): ${type} {\n return this.${method}(${JSON.stringify(name)});\n }`;
? "childListAt"
: "childNodeAt";
return ` get ${name}(): ${type} {\n${slotCases(name, (slot) => `this.${method}(${slot})`, "undefined")}\n }`;
});
// The adapted getters read their raw child through the same slot switch.
const adaptedSlots = [...adaptedGetters]
.sort()
.map(
(name) =>
` private ${name}Slot(): number {\n${slotCases(name, (slot) => String(slot), "-1")}\n }`,
);
const nodeTarget = join(root, "packages/compiler/src/frontend/ts7/ast-node.ts");
const before = readFileSync(nodeTarget, "utf8");
if (
@@ -311,6 +338,7 @@ const after = before.replace(
" // BEGIN GENERATED CHILD GETTERS",
` // Generated from typescript@${version}; run scripts/generate-ts7-ast-schema.mjs.`,
...getters,
...adaptedSlots,
" // END GENERATED CHILD GETTERS",
].join("\n"),
);
+24
View File
@@ -0,0 +1,24 @@
# Split tools
These scripts split a large source file into smaller files without changing behavior, and carry open branches across the split. Each transform is deterministic: the same config gives the same result on any version of the tree, and rehoming depends on that.
Run them from a checkout with `node_modules` installed. That checkout is the tools directory (`--tools`) and supplies `typescript` and `oxfmt`.
## Transforms
- `split-module.mjs plan|apply <config>` moves top-level declarations into new files. `plan` computes a manifest from seed declarations, and `apply` does the move.
- `split-methods.mjs apply <config>` moves class methods into exported functions that take the instance as a parameter (`emitter`). The class keeps a one-line stub with the same signature, so callers don't change.
- `move-state.mjs apply <config>` moves a group of class fields into a feature's state object, defined in the feature's file with `defineModuleState` (`packages/compiler/src/backend/llvm/emitter-state.ts`). It rewrites `this.<field>` and `emitter.<field>` to `KEY.of(this).<field>` and creates files from templates. It also applies the change's other edits as literal replacements, in order. A replacement whose text isn't found the expected number of times is reported and skipped.
Each transform takes `--repo <dir>` (the tree to rewrite) and `--tools <dir>`, and formats what it writes with the tools checkout's oxfmt and its `.oxfmtrc.jsonc` (`format.mjs`). The formatter reads no configuration from `--repo`, so an `oxfmt.config.ts` a branch commits is never run. The tools read and write the tree's files only at real paths inside `--repo`, and a config's templates and manifest only inside the config's directory. A path that resolves elsewhere, through a symlink or `..`, stops the tool with an error (`confine.mjs`). `configs/` holds the configs of splits that have landed. For example, `configs/loop-state/` moves the loop optimizations' state out of `LlEmitter`.
## Rehoming an open branch
```sh
git fetch origin pull/<n>/head:pr-<n>
node scripts/split/rehome-split.mjs --branch pr-<n> --onto origin/main --config scripts/split/configs/<split>/<config>.json
```
The result is the branch `pr-<n>-rehomed`, a merge of `origin/main` into the branch.
The script applies the split's transform to the branch's merge base and to its tip, then cherry-picks the difference onto `--onto`. It records the result as a merge commit with parents (branch tip, `--onto`), so it can be pushed without rewriting the branch; `--squash` makes one ordinary commit instead. Conflicts made only of import lines in the split's files resolve to both sides' imports. Any other conflict stops the script, which lists the files and leaves its scratch worktree for you to resolve.
@@ -0,0 +1,24 @@
/* Per-feature emitter state. A feature defines its state in its own file
* with defineModuleState and reads it through the returned key, so adding
* or extending a feature does not edit LlEmitter's field list or
* constructor. */
import type { LlEmitter } from "./emitter.js";
/** A feature's state on an emitter, read with `key.of(emitter)`. */
export interface EmitterStateKey<T> {
of(emitter: LlEmitter): T;
}
let moduleSlots = 0;
/** State that lives for the whole module, created on first read. */
export function defineModuleState<T>(create: (emitter: LlEmitter) => T): EmitterStateKey<T> {
const slot = moduleSlots++;
return {
of(emitter) {
const states = emitter.moduleStates;
if (slot >= states.length || states[slot] === undefined) states[slot] = create(emitter);
return states[slot] as T;
},
};
}
@@ -0,0 +1,29 @@
{
"module": "packages/compiler/src/backend/llvm/emitter.ts",
"class": "LlEmitter",
"dir": "packages/compiler/src/backend/llvm",
"create": {
"emitter-state.ts": "emitter-state.template",
"loop-state.ts": "loop-state.template"
},
"groups": [
{
"file": "loop-state.ts",
"key": "LOOPS",
"interfaceName": "LoopState",
"fields": ["unaliasedArrays", "denseLoopsEnabled", "denseLoopEntries", "denseLoop", "countedLoopsEnabled", "byteWindowsEnabled", "byteWindowEntries", "bytesLoopVersionsEnabled", "bytesLoopVersioning", "bytesLoopEntries", "bytesLoopFunction", "provenCountedGuards"]
}
],
"replacements": [
{ "file": "emitter.ts", "find": "export class LlEmitter {\n", "replace": "export class LlEmitter {\n /** Per-feature state (emitter-state.ts), indexed by each key's slot. */\n readonly moduleStates: unknown[] = [];\n" },
{ "file": "emitter.ts", "find": "import { type DenseArrayLoop } from \"./dense-array-loops.js\";\n", "replace": "" },
{ "file": "emitter.ts", "find": " const initialized = LOOPS.of(this).bytesLoopEntries.get(s);\n", "replace": " const initialized = LOOPS.of(this).bytesLoopEntries!.get(s);\n" },
{ "file": "emit-statements.ts", "find": " ? LOOPS.of(emitter).denseLoop.range\n", "replace": " ? LOOPS.of(emitter).denseLoop!.range\n", "count": 2 },
{ "file": "emit-function.ts", "find": " LOOPS.of(emitter).unaliasedArrays = findUnaliasedLocalArrays(fn);\n", "replace": "" },
{ "file": "emit-function.ts", "find": " LOOPS.of(emitter).countedLoopsEnabled = emitter.debug === null && !fn.async && !fn.generator;\n", "replace": " beginFunctionLoops(emitter, fn, numericFn);\n" },
{ "file": "emit-function.ts", "find": " LOOPS.of(emitter).byteWindowsEnabled = LOOPS.of(emitter).countedLoopsEnabled;\n LOOPS.of(emitter).bytesLoopVersionsEnabled = LOOPS.of(emitter).countedLoopsEnabled;\n LOOPS.of(emitter).bytesLoopEntries = null;\n LOOPS.of(emitter).bytesLoopFunction = fn;\n LOOPS.of(emitter).denseLoopsEnabled = LOOPS.of(emitter).countedLoopsEnabled;\n LOOPS.of(emitter).denseLoop = null;\n LOOPS.of(emitter).denseLoopEntries =\n LOOPS.of(emitter).denseLoopsEnabled && numericFn.locals.some((l) => l.type.kind === \"array\")\n ? findInitializedByteLoopBindings(fn)\n : new Map();\n LOOPS.of(emitter).byteWindowEntries =\n LOOPS.of(emitter).byteWindowsEnabled && numericFn.locals.some((l) => l.type.kind === \"bytes\")\n ? findInitializedByteLoopBindings(fn)\n : new Map();\n", "replace": "" },
{ "file": "emit-function.ts", "find": "import { LOOPS } from \"./loop-state.js\";\n", "replace": "import { beginFunctionLoops, LOOPS } from \"./loop-state.js\";\n" },
{ "file": "emit-function.ts", "find": "import { findUnaliasedLocalArrays } from \"../../ir/array-length-stability.js\";\n", "replace": "" },
{ "file": "emit-function.ts", "find": "import { findInitializedByteLoopBindings } from \"./byte-windows.js\";\n", "replace": "" }
]
}
@@ -0,0 +1,34 @@
/* The loop optimizations' per-function state: counted loops, dense array
* loops, byte windows and typed-array loop versioning, and the unaliased
* local arrays they rely on. */
import { findUnaliasedLocalArrays } from "../../ir/array-length-stability.js";
import type { IrFunction, IrStmt } from "../../ir/ir.js";
import { findInitializedByteLoopBindings } from "./byte-windows.js";
import type { DenseArrayLoop } from "./dense-array-loops.js";
import type { LlEmitter } from "./emitter.js";
/** Sets the loop state for `fn` at the start of its emission; `numericFn`
* is `fn` with its initializer bindings (emitFunction). */
export function beginFunctionLoops(
emitter: LlEmitter,
fn: IrFunction,
numericFn: IrFunction,
): void {
const loops = LOOPS.of(emitter);
loops.unaliasedArrays = findUnaliasedLocalArrays(fn);
loops.countedLoopsEnabled = emitter.debug === null && !fn.async && !fn.generator;
loops.byteWindowsEnabled = loops.countedLoopsEnabled;
loops.bytesLoopVersionsEnabled = loops.countedLoopsEnabled;
loops.bytesLoopEntries = null;
loops.bytesLoopFunction = fn;
loops.denseLoopsEnabled = loops.countedLoopsEnabled;
loops.denseLoop = null;
loops.denseLoopEntries =
loops.denseLoopsEnabled && numericFn.locals.some((l) => l.type.kind === "array")
? findInitializedByteLoopBindings(fn)
: new Map();
loops.byteWindowEntries =
loops.byteWindowsEnabled && numericFn.locals.some((l) => l.type.kind === "bytes")
? findInitializedByteLoopBindings(fn)
: new Map();
}
+89
View File
@@ -0,0 +1,89 @@
/* Path confinement for the split tools. A branch they rewrite can commit
* symlinks, so every file a tool reads or writes goes through a guard for
* the directory it belongs to (the checkout being rewritten, or the
* config's directory). The guard resolves the path's real location, through
* every symlink on the way, and throws when that location is outside the
* directory's real path. A path that doesn't exist yet resolves through its
* nearest existing parent, so a write can't create a file through a
* symlinked directory or a dangling symlink either. */
import {
existsSync,
lstatSync,
mkdirSync,
readdirSync,
readFileSync,
readlinkSync,
realpathSync,
writeFileSync,
} from "node:fs";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
const isInside = (root, path) => {
const rel = relative(root, path);
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
};
/** The real location of `path`, which need not exist: the real path of its
* nearest existing entry joined with the rest. A dangling symlink resolves
* to where its target would be. */
function realLocation(path, hops = 0) {
const rest = [];
for (let at = resolve(path); ; at = dirname(at)) {
let stat = null;
try {
stat = lstatSync(at);
} catch (e) {
if (e.code !== "ENOENT" && e.code !== "ENOTDIR") throw e;
}
if (stat) {
try {
return join(realpathSync(at), ...rest);
} catch (e) {
if (e.code !== "ENOENT" || !stat.isSymbolicLink() || hops > 40) throw e;
return realLocation(join(resolve(dirname(at), readlinkSync(at)), ...rest), hops + 1);
}
}
if (dirname(at) === at) return resolve(path);
rest.unshift(basename(at));
}
}
/** A guard for the files under `root`. */
export function confine(root) {
const lexical = resolve(root);
const real = realpathSync(lexical);
const check = (path) => {
const location = realLocation(path);
if (!isInside(real, location))
throw new Error(`refusing ${path}: it resolves to ${location}, outside ${real}`);
return location;
};
return {
/** The path's real location; throws when it is outside the root. */
check,
/** True when `path` names a file under the root (before symlinks). */
contains: (path) => isInside(lexical, resolve(path)) || isInside(real, resolve(path)),
read: (path) => readFileSync(check(path), "utf8"),
write: (path, text) => writeFileSync(check(path), text),
exists: (path) => existsSync(check(path)),
mkdir: (path) => mkdirSync(check(path), { recursive: true }),
list: (path) => readdirSync(check(path)),
};
}
/** Routes a TypeScript host's (or `ts.sys`-like object's) file reads under
* the guard's root through the guard; other files (the TypeScript libraries)
* read as before. */
export function confineHost(host, guard) {
const checked =
(read) =>
(fileName, ...rest) => {
if (guard.contains(fileName)) guard.check(fileName);
return read.call(host, fileName, ...rest);
};
return {
...host,
...(host.getSourceFile ? { getSourceFile: checked(host.getSourceFile) } : {}),
readFile: checked(host.readFile),
};
}
+20
View File
@@ -0,0 +1,20 @@
/* Formatting for the split tools. oxfmt reads its configuration from the
* directory it runs in, its parents and the formatted files' directories,
* and an oxfmt.config.ts there is code it runs. The tools format files in
* a checkout they rewrite, which can be a pull request's, so they pass the
* tools checkout's own configuration and turn the nested lookup off: no
* configuration file of the rewritten checkout is read or run. */
import { execFileSync } from "node:child_process";
import { join } from "node:path";
/** Formats `files` (paths relative to `cwd`) with the tools checkout's
* oxfmt and configuration. */
export function formatFiles(toolsDir, files, { cwd, stdio }) {
// With no file arguments oxfmt would format the whole directory.
if (files.length === 0) return;
execFileSync(
join(toolsDir, "node_modules/.bin/oxfmt"),
["--config", join(toolsDir, ".oxfmtrc.jsonc"), "--disable-nested-config", ...files],
{ cwd, stdio },
);
}
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env node
/* Move a group of LlEmitter fields into a feature's state object, as a
* deterministic rewrite (the same config gives the same result on any
* version of the tree, which the rehoming tool relies on).
*
* node move-state.mjs apply <config.json> [--repo <dir>] [--tools <dir>]
*
* <config.json>:
* { "module": "packages/compiler/src/backend/llvm/emitter.ts",
* "class": "LlEmitter",
* "dir": "packages/compiler/src/backend/llvm",
* "groups": [{ "file": "loop-state.ts", "key": "LOOPS",
* "fields": ["denseLoop", ...] }],
* "create": { "loop-state.ts": "loop-state.template.ts" },
* "replacements": [{ "file": "emit-function.ts", "find": "...", "replace": "...", "count": 1 }],
* "receivers": ["this", "emitter"] }
*
* For each group: the fields' declarations leave the class (their doc
* comments and initializers move into the state object's interface and
* factory in the group's file), every `this.<field>` and `emitter.<field>`
* (or the config's receivers) in the directory's sources becomes
* `<KEY>.of(this).<field>` / `<KEY>.of(emitter).<field>`, and each
* rewritten file imports the key.
* The group's file must already exist and hold everything else (header,
* setup function); the tool appends the interface and key to it. */
import { readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { createRequire } from "node:module";
import { confine } from "./confine.mjs";
import { formatFiles } from "./format.mjs";
const [command, configPath, ...flags] = process.argv.slice(2);
if (command !== "apply") throw new Error("usage: move-state.mjs apply <config.json>");
const config = JSON.parse(readFileSync(configPath, "utf8"));
const flag = (name) => {
const i = flags.indexOf(name);
return i >= 0 ? flags[i + 1] : undefined;
};
const repo = resolve(flag("--repo") ?? process.cwd());
const toolsDir = resolve(flag("--tools") ?? process.cwd());
const require = createRequire(join(toolsDir, "package.json"));
const ts = require("typescript");
const modulePath = join(repo, config.module);
const dir = join(repo, config.dir);
// Every read and write stays inside the checkout, or for the templates, the
// config's directory (confine.mjs).
const checkout = confine(repo);
// Adds an import line after the file's first block of imports (up to a
// blank line or other code).
const addImport = (text, line) => {
const sf = ts.createSourceFile("x.ts", text, ts.ScriptTarget.Latest, true);
let last;
for (const s of sf.statements) {
if (!ts.isImportDeclaration(s)) break;
if (last && /\n[ \t]*\n/.test(text.slice(last.getEnd(), s.getStart()))) break;
last = s;
}
const at = last ? last.getEnd() + 1 : 0;
return text.slice(0, at) + line + "\n" + text.slice(at);
};
const configDir = dirname(resolve(configPath));
const configFiles = confine(configDir);
// The names the class's instance goes by in the directory's sources.
const receivers = config.receivers ?? ["this", "emitter"];
const touched = new Set();
// Files the change creates, from templates next to the config (kept when a
// branch already has the file).
for (const [file, template] of Object.entries(config.create ?? {})) {
const path = join(dir, file);
if (checkout.exists(path)) continue;
checkout.write(path, configFiles.read(join(configDir, template)));
touched.add(path);
}
let moduleText = checkout.read(modulePath);
for (const group of config.groups) {
const sf = ts.createSourceFile(modulePath, moduleText, ts.ScriptTarget.Latest, true);
const cls = sf.statements.find((s) => ts.isClassDeclaration(s) && s.name?.text === config.class);
if (!cls) throw new Error(`no class ${config.class}`);
const wanted = new Set(group.fields);
const members = [];
for (const m of cls.members) {
if (ts.isPropertyDeclaration(m) && ts.isIdentifier(m.name) && wanted.has(m.name.text))
members.push(m);
}
// A field the tree does not have yet (an older merge base) is left out, so
// the move still applies there.
const missing = group.fields.filter((f) => !members.some((m) => m.name.text === f));
if (missing.length)
console.warn(`${group.file}: fields not in ${config.class}: ${missing.join(", ")}; skipped`);
group.present = members.map((m) => m.name.text);
// Interface members and factory entries, in the class's order.
const iface = [];
const factory = [];
for (const m of members) {
const lead = moduleText.slice(m.getFullStart(), m.getStart()).replace(/^\s*\n/, "");
const readonly = m.modifiers?.some((x) => x.kind === ts.SyntaxKind.ReadonlyKeyword)
? "readonly "
: "";
const name = m.name.text;
if (!m.initializer)
throw new Error(
`${name}: a field without an initializer needs its constructor line moved by hand`,
);
// An unannotated field takes its initializer's type: a boolean,
// number or string literal, or `new C<T>(...)`.
const init = m.initializer;
const type = m.type
? m.type.getText(sf)
: init.kind === ts.SyntaxKind.TrueKeyword || init.kind === ts.SyntaxKind.FalseKeyword
? "boolean"
: ts.isNumericLiteral(init)
? "number"
: ts.isStringLiteral(init)
? "string"
: ts.isNewExpression(init) && init.typeArguments?.length
? `${init.expression.getText(sf)}<${init.typeArguments.map((t) => t.getText(sf)).join(", ")}>`
: null;
if (type === null) throw new Error(`${name}: give the field a type annotation first`);
iface.push(`${lead} ${readonly}${name}: ${type};`);
factory.push(` ${name}: ${m.initializer.getText(sf)},`);
}
// Remove the declarations (with their leading comments) from the class.
const cuts = members.map((m) => [m.getFullStart(), m.getEnd()]).sort((a, b) => b[0] - a[0]);
for (const [s, e] of cuts) moduleText = moduleText.slice(0, s) + moduleText.slice(e);
const iName = group.interface ?? `${group.key[0]}${group.key.slice(1).toLowerCase()}State`;
const stateText =
`\n/** The state ${group.key} holds on each emitter. */\n` +
`export interface ${group.interfaceName ?? iName} {\n${iface.join("\n")}\n}\n\n` +
`export const ${group.key} = defineModuleState<${group.interfaceName ?? iName}>(() => ({\n${factory.join("\n")}\n}));\n`;
const groupPath = join(dir, group.file);
let groupText = checkout.read(groupPath);
if (!groupText.includes('from "./emitter-state.js"'))
groupText = addImport(groupText, `import { defineModuleState } from "./emitter-state.js";`);
groupText = groupText.trimEnd() + "\n" + stateText;
checkout.write(groupPath, groupText);
touched.add(groupPath);
group.rename = group.present.length
? new RegExp(`(?<![\\w$.])(${receivers.join("|")})\\.(${group.present.join("|")})\\b`, "g")
: null;
}
checkout.write(modulePath, moduleText);
// Rewrite accesses across the directory.
for (const file of checkout.list(dir).filter((f) => f.endsWith(".ts"))) {
const path = join(dir, file);
let text = path === modulePath ? moduleText : checkout.read(path);
let changed = false;
for (const group of config.groups) {
if (!group.rename) continue;
const next = text.replace(
group.rename,
(_, recv, field) => `${group.key}.of(${recv}).${field}`,
);
if (next === text) continue;
text = next;
changed = true;
if (file === group.file) continue;
const spec = `./${group.file.replace(/\.ts$/, ".js")}`;
const existing = new RegExp(
`^import \\{([^}]*)\\} from "${spec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}";$`,
"m",
);
const m = existing.exec(text);
if (m) {
const names = new Set(
m[1]
.split(",")
.map((s) => s.trim())
.filter(Boolean),
);
names.add(group.key);
text = text.replace(existing, `import { ${[...names].sort().join(", ")} } from "${spec}";`);
} else {
text = addImport(text, `import { ${group.key} } from "${spec}";`);
}
}
if (changed) {
checkout.write(path, text);
touched.add(path);
}
}
// The change's remaining edits, as literal replacements in order (the
// setup function call, imports, narrowing the rename loses). One whose text
// is not found as often as expected is reported and skipped: the branch
// changed that code, and the rehome's cherry-pick shows the difference.
let skipped = 0;
for (const r of config.replacements ?? []) {
const path = join(dir, r.file);
const text = checkout.exists(path) ? checkout.read(path) : "";
const count = text.split(r.find).length - 1;
if (count !== (r.count ?? 1)) {
console.warn(
`${r.file}: expected ${r.count ?? 1} of ${JSON.stringify(r.find.slice(0, 60))}, found ${count}; skipped`,
);
skipped++;
continue;
}
checkout.write(path, text.split(r.find).join(r.replace));
touched.add(path);
}
formatFiles(toolsDir, [...touched], { cwd: repo, stdio: "inherit" });
if (skipped) console.warn(`${skipped} replacement(s) skipped`);
console.log(
`moved ${config.groups.map((g) => g.present.length).reduce((a, b) => a + b, 0)} fields; rewrote ${touched.size} files`,
);
+316
View File
@@ -0,0 +1,316 @@
#!/usr/bin/env node
/* Rehome a branch's changes across a module split.
*
* A split moves whole declarations out of one module into new files, so git
* cannot follow a branch's edits into them: merging the split reports every
* edit to a moved declaration as a conflict. This script carries the branch
* across instead. It applies the same split (split-module.mjs,
* split-methods.mjs or move-state.mjs with the split's config; each also
* formats with oxfmt) to the branch's merge base and to the branch tip, and
* cherry-picks the difference onto the target. That is a three-way merge
* whose base and branch side already use the new layout, so each hunk lands
* in the file its declaration moved to. Hunks in declarations that stay, and
* declarations the branch adds, stay in the module.
*
* node rehome-split.mjs --branch <ref> --onto <ref> --config <split config>
* [--out <branch name>] [--squash] [--scratch <dir prefix>]
*
* Run it from a checkout with node_modules installed (it supplies typescript
* and oxfmt). --onto is a commit that contains the split, usually main.
* The result is a new branch (default <branch>-rehomed). By default its tip
* is a merge commit with parents <branch> and <onto>: the branch's history
* is kept and the result reads as "merge main into the branch", so it can be
* pushed without rewriting the branch. --squash makes one ordinary commit on
* <onto> instead. When the cherry-pick conflicts, the script stops and
* leaves the work tree with the conflicts to resolve; see the message. */
import { execFileSync, spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { confine } from "./confine.mjs";
import { formatFiles } from "./format.mjs";
const args = process.argv.slice(2);
const opt = (name, fallback) => {
const i = args.indexOf(`--${name}`);
return i >= 0 ? args[i + 1] : fallback;
};
const has = (name) => args.includes(`--${name}`);
const branch = opt("branch");
const onto = opt("onto");
const configPath = opt("config");
if (!branch || !onto || !configPath) {
console.error(
"usage: rehome-split.mjs --branch <ref> --onto <ref> --config <split config> [--out <name>] [--squash]",
);
process.exit(2);
}
const out = opt("out", `${branch.replace(/^.*\//, "")}-rehomed`);
const tools = process.cwd();
const here = dirname(fileURLToPath(import.meta.url));
const config = JSON.parse(readFileSync(configPath, "utf8"));
// A config with "groups" moves fields into feature state (move-state.mjs),
// one with "methods" moves methods (split-methods.mjs), and any other moves
// top-level declarations (split-module.mjs).
const splitTool = join(
here,
config.groups ? "move-state.mjs" : config.class ? "split-methods.mjs" : "split-module.mjs",
);
const git = (...a) =>
execFileSync("git", a, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
const gitIn = (cwd, ...a) =>
execFileSync("git", a, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
// Every commit the script makes is authored and committed as the branch's
// last author.
const author = (format) => git("log", "-1", `--format=${format}`, branch);
const ident = {
GIT_AUTHOR_NAME: author("%an"),
GIT_AUTHOR_EMAIL: author("%ae"),
};
ident.GIT_COMMITTER_NAME = ident.GIT_AUTHOR_NAME;
ident.GIT_COMMITTER_EMAIL = ident.GIT_AUTHOR_EMAIL;
Object.assign(process.env, ident);
const tip = git("rev-parse", `${branch}^{commit}`);
const target = git("rev-parse", `${onto}^{commit}`);
// The split must not be in the branch already, or there is nothing to rehome.
const moduleFile = config.module;
// With several merge bases (criss-cross history, such as a branch that
// carries another branch's commits that main merged), merge them into one
// virtual base, as git's merge does, so the cherry-pick sees the same base.
let base = git("merge-base", tip, target);
{
const bases = git("merge-base", "--all", tip, target).split("\n").filter(Boolean);
for (const next of bases.slice(1)) {
const merged = spawnSync("git", ["merge-tree", "--write-tree", base, next], {
encoding: "utf8",
});
const tree = merged.stdout.split("\n")[0];
base = git("commit-tree", tree, "-p", base, "-p", next, "-m", "virtual merge base");
}
}
// Scratch worktrees go next to the checkout: not inside it (they would show
// as untracked files) and not under node_modules (oxfmt skips those paths).
const scratch = mkdtempSync(opt("scratch", join(dirname(tools), ".rehome-")));
const worktree = (name, commit) => {
const dir = join(scratch, name);
git("worktree", "add", "--detach", "-q", dir, commit);
return dir;
};
const split = (dir) => {
execFileSync(
process.execPath,
[splitTool, "apply", configPath, "--repo", dir, "--tools", tools],
{
stdio: ["ignore", "pipe", "inherit"],
},
);
gitIn(dir, "add", "-A");
return gitIn(dir, "write-tree");
};
const commitTree = (tree, parents, message) =>
execFileSync("git", ["commit-tree", tree, ...parents.flatMap((p) => ["-p", p]), "-m", message], {
encoding: "utf8",
env: {
...process.env,
...ident,
},
}).trim();
let baseDir, tipDir;
try {
baseDir = worktree("base", base);
tipDir = worktree("tip", tip);
const splitBase = commitTree(split(baseDir), [base], "split of the merge base");
const splitTip = commitTree(split(tipDir), [splitBase], "split of the branch tip");
if (git("rev-parse", `${splitBase}^{tree}`) === git("rev-parse", `${splitTip}^{tree}`)) {
console.log(`${branch} has no changes relative to ${base.slice(0, 9)}`);
process.exit(0);
}
const touched = git("diff", "--name-only", splitBase, splitTip).split("\n").filter(Boolean);
console.log(`${branch}: ${touched.length} files changed in the split layout`);
// Apply the branch's changes onto the target in a worktree of its own.
const resultDir = worktree("result", target);
const pick = spawnSync(
"git",
["-c", "merge.conflictStyle=merge", "cherry-pick", "--no-commit", splitTip],
{
cwd: resultDir,
encoding: "utf8",
},
);
// Moved declarations bring short import blocks, where an import main added
// and one the branch added can sit on adjacent lines. Resolve conflicts
// made only of import declarations to the union of both sides.
if (pick.status !== 0) {
// Only the files the split writes: other conflicts are the branch's own.
// A method split names its files in the config; a module split, in its manifest.
const files = config.groups
? [
...config.groups.map((g) => g.file),
...Object.keys(config.create ?? {}),
...(config.replacements ?? []).map((r) => r.file),
]
: config.class
? Object.keys(config.methods)
: Object.keys(
(({ files, ...rest }) => files ?? rest)(
JSON.parse(
confine(dirname(resolve(configPath))).read(
resolve(dirname(resolve(configPath)), config.manifest),
),
),
),
);
const moduleDir = config.dir ?? dirname(moduleFile);
const splitFiles = new Set([moduleFile, ...files.map((f) => join(moduleDir, f))]);
for (const file of gitIn(resultDir, "diff", "--name-only", "--diff-filter=U")
.split("\n")
.filter(Boolean)) {
if (
splitFiles.has(file) &&
resolveImportConflicts(confine(resultDir), join(resultDir, file))
) {
formatFiles(tools, [file], { cwd: resultDir, stdio: ["ignore", "ignore", "inherit"] });
gitIn(resultDir, "add", file);
console.log(`${file}: import conflicts resolved to both sides' imports`);
}
}
}
if (pick.status !== 0 && gitIn(resultDir, "diff", "--name-only", "--diff-filter=U") !== "") {
const conflicts = gitIn(resultDir, "diff", "--name-only", "--diff-filter=U");
console.error(
`conflicts in:\n${conflicts}\n\nResolve them in ${resultDir}, then run:\n git -C ${resultDir} add -A && git -C ${resultDir} commit -m "Merge ${onto} into ${branch}" # or rerun with the resolution\nThe scratch worktrees stay until you remove ${scratch}.`,
);
process.exit(1);
}
const tree = gitIn(resultDir, "write-tree");
const subjects = git("log", "--format=%s", `${base}..${tip}`).split("\n").filter(Boolean);
const result = has("squash")
? commitTree(
tree,
[target],
subjects.length === 1
? subjects[0]
: `${subjects.at(-1)}\n\n${subjects.map((s) => `- ${s}`).join("\n")}`,
)
: commitTree(
tree,
[tip, target],
`Merge ${onto} into ${branch.replace(/^.*\//, "")}\n\nCarries the branch's changes to ${moduleFile} into the files its declarations moved to.`,
);
git("branch", "-f", out, result);
console.log(
`${out} -> ${result.slice(0, 9)} (${has("squash") ? "one commit on" : "merge of"} ${onto})`,
);
for (const dir of [baseDir, tipDir, resultDir]) git("worktree", "remove", "--force", dir);
rmSync(scratch, { recursive: true, force: true });
} catch (error) {
console.error(error.stderr?.toString() || error.message);
console.error(`scratch worktrees left in ${scratch}`);
process.exit(1);
}
/* Rewrites every conflict in `path` whose two sides hold only import
* declarations as the union of both: one declaration per module and kind,
* with the named imports of both sides in first-seen order, read and
* written through the result checkout's guard. Returns false,
* leaving the file unchanged, when any conflict holds other code. */
function resolveImportConflicts(checkout, path) {
const ts = createRequire(join(tools, "package.json"))("typescript");
const text = checkout.read(path);
const region = /^<<<<<<< [^\n]*\n([\s\S]*?)^=======\n([\s\S]*?)^>>>>>>> [^\n]*\n/gm;
const imports = (side) => {
const sf = ts.createSourceFile("side.ts", side, ts.ScriptTarget.Latest, true);
return sf.statements.every(ts.isImportDeclaration) ? sf.statements : null;
};
let ok = true;
const imported = new Set();
const resolved = text.replace(region, (whole, ours, theirs) => {
const a = imports(ours),
b = imports(theirs);
if (!a || !b) {
ok = false;
return whole;
}
const merged = new Map();
for (const decl of [...a, ...b]) {
const clause = decl.importClause;
const named =
clause?.namedBindings && ts.isNamedImports(clause.namedBindings)
? clause.namedBindings
: null;
if (!clause || !named || clause.name) {
// Side-effect, default and namespace imports are kept as written, once.
merged.set(decl.getText(), null);
continue;
}
const key = `${clause.isTypeOnly ? "type " : ""}${decl.moduleSpecifier.getText()}`;
const entry = merged.get(key) ?? {
typeOnly: clause.isTypeOnly,
from: decl.moduleSpecifier.getText(),
names: [],
};
for (const element of named.elements) {
imported.add(element.name.text);
if (!entry.names.includes(element.getText())) entry.names.push(element.getText());
}
merged.set(key, entry);
}
return (
[...merged]
.map(([key, entry]) =>
entry === null
? key
: `import ${entry.typeOnly ? "type " : ""}{ ${entry.names.join(", ")} } from ${entry.from};`,
)
.join("\n") + "\n"
);
});
if (!ok || resolved === text) return false;
checkout.write(path, pruneUnusedImports(ts, resolved, imported));
return true;
}
/* Drops the named imports among `names` that nothing outside import
* declarations references: a side's import whose use the other side's code
* replaced. The split computes each file's imports from its references, so
* this keeps a rehomed file's imports the ones the split would write. */
function pruneUnusedImports(ts, text, names) {
const sf = ts.createSourceFile("file.ts", text, ts.ScriptTarget.Latest, true);
const used = new Set();
const visit = (node) => {
if (ts.isImportDeclaration(node)) return;
if (ts.isIdentifier(node)) used.add(node.text);
ts.forEachChild(node, visit);
};
visit(sf);
const edits = [];
for (const decl of sf.statements) {
const named = decl.importClause?.namedBindings;
if (
!ts.isImportDeclaration(decl) ||
!named ||
!ts.isNamedImports(named) ||
decl.importClause.name
)
continue;
const kept = named.elements.filter((e) => !names.has(e.name.text) || used.has(e.name.text));
if (kept.length === named.elements.length) continue;
const from = decl.moduleSpecifier.getText();
const prefix = decl.importClause.isTypeOnly ? "import type" : "import";
// A dropped declaration takes its line break with it.
const end = kept.length || text[decl.getEnd()] !== "\n" ? decl.getEnd() : decl.getEnd() + 1;
edits.push([
decl.getStart(),
end,
kept.length ? `${prefix} { ${kept.map((e) => e.getText()).join(", ")} } from ${from};` : "",
]);
}
for (const [start, end, replacement] of edits.reverse())
text = text.slice(0, start) + replacement + text.slice(end);
return text;
}
+417
View File
@@ -0,0 +1,417 @@
#!/usr/bin/env node
/* Move methods of one class into other files as functions of a host, as a
* pure move.
*
* node split-methods.mjs apply <config.json> [--repo <dir>] [--tools <dir>]
*
* <config.json>:
* { "tsconfig": "packages/compiler/tsconfig.json",
* "module": "packages/compiler/src/backend/llvm/emitter.ts",
* "class": "LlEmitter",
* "methods": { "emit-statements.ts": ["emitStmtBody", ...], ... },
* "headers": { "emit-statements.ts": "/* ... *\/" } }
*
* Each listed method becomes `export function <name>(host: <Class>, ...)`
* in its file (relative to the module's directory), with its doc comment
* and `this` replaced by the host parameter (`emitter`, or "hostName"). The class keeps a stub with the method's
* signature that calls the function, so every caller is unchanged. Members
* the moved bodies reach through `host` lose a `private` modifier (a
* compile-time check only). Each new file imports what its functions
* reference from the module's scope, with paths rebased; the module imports
* the functions and drops imports nothing uses anymore. Names the config
* lists but the class lacks are skipped. */
import { readFileSync } from "node:fs";
import { dirname, join, relative, resolve, posix } from "node:path";
import { createRequire } from "node:module";
import { confine, confineHost } from "./confine.mjs";
import { formatFiles } from "./format.mjs";
const [command, configPath, ...flags] = process.argv.slice(2);
if (command !== "apply") throw new Error("usage: split-methods.mjs apply <config.json>");
const config = JSON.parse(readFileSync(configPath, "utf8"));
const flag = (name) => {
const i = flags.indexOf(name);
return i >= 0 ? flags[i + 1] : undefined;
};
const repo = resolve(flag("--repo") ?? process.cwd());
// Every read and write in the checkout stays inside it (confine.mjs).
const checkout = confine(repo);
const toolsDir = resolve(flag("--tools") ?? process.cwd());
const require = createRequire(join(toolsDir, "package.json"));
const ts = require("typescript");
const modulePath = join(repo, config.module);
const moduleDir = dirname(modulePath);
const className = config.class;
// The host parameter's name; a moved body that already uses it is refused.
const hostName = config.hostName ?? "emitter";
const text = checkout.read(modulePath);
const host = confineHost(ts.createCompilerHost({ noEmit: true }), checkout);
const sf = ts.createSourceFile(modulePath, text, ts.ScriptTarget.Latest, true);
const realGet = host.getSourceFile;
host.getSourceFile = (f, l, ...r) =>
resolve(f) === modulePath ? sf : realGet.call(host, f, l, ...r);
const program = ts.createProgram([modulePath], { noEmit: true, noResolve: true }, host);
const checker = program.getTypeChecker();
const source = program.getSourceFile(modulePath);
const cls = source.statements.find((s) => ts.isClassDeclaration(s) && s.name?.text === className);
if (!cls) throw new Error(`no class ${className}`);
const memberByName = new Map();
for (const m of cls.members)
if (m.name && ts.isIdentifier(m.name)) {
if (!memberByName.has(m.name.text)) memberByName.set(m.name.text, []);
memberByName.get(m.name.text).push(m);
}
// Module scope: import bindings and top-level declarations.
const imports = new Map();
const topLevel = new Map();
for (const st of source.statements) {
if (ts.isImportDeclaration(st)) {
const c = st.importClause;
if (!c) continue;
if (c.name) imports.set(c.name.text, { st, kind: "default", typeOnly: c.isTypeOnly });
const nb = c.namedBindings;
if (nb && ts.isNamespaceImport(nb))
imports.set(nb.name.text, { st, kind: "namespace", typeOnly: c.isTypeOnly });
if (nb && ts.isNamedImports(nb))
for (const el of nb.elements)
imports.set(el.name.text, {
st,
kind: "named",
typeOnly: c.isTypeOnly || el.isTypeOnly,
imported: (el.propertyName ?? el.name).text,
});
continue;
}
const names = [];
if (
(ts.isFunctionDeclaration(st) ||
ts.isClassDeclaration(st) ||
ts.isInterfaceDeclaration(st) ||
ts.isTypeAliasDeclaration(st) ||
ts.isEnumDeclaration(st)) &&
st.name
)
names.push(st.name.text);
if (ts.isVariableStatement(st))
for (const d of st.declarationList.declarations)
if (ts.isIdentifier(d.name)) names.push(d.name.text);
for (const n of names) topLevel.set(n, st);
}
const isExported = (st) =>
st.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ?? false;
const isTypeDecl = (st) => ts.isInterfaceDeclaration(st) || ts.isTypeAliasDeclaration(st);
/** Module-scope references in `node`: name -> { kind: "import"|"local" }, and `this.<member>` names. */
function references(node) {
const refs = new Map();
const members = new Set();
const visit = (n) => {
if (
n.kind === ts.SyntaxKind.ThisKeyword &&
n.parent &&
ts.isPropertyAccessExpression(n.parent) &&
n.parent.expression === n
)
members.add(n.parent.name.text);
if (ts.isIdentifier(n)) {
const p = n.parent;
const propName =
p &&
((ts.isPropertyAccessExpression(p) && p.name === n) ||
(ts.isQualifiedName(p) && p.right === n) ||
(ts.isPropertyAssignment(p) && p.name === n));
if (!propName) {
let sym = checker.getSymbolAtLocation(n);
if (p && ts.isShorthandPropertyAssignment(p))
sym = checker.getShorthandAssignmentValueSymbol(p);
const decl = sym?.declarations?.[0];
if (decl && decl.getSourceFile() === source) {
if (ts.isImportSpecifier(decl) || ts.isNamespaceImport(decl) || ts.isImportClause(decl))
refs.set(decl.name.text, { kind: "import" });
else if (
topLevel.get(n.text) &&
(decl === topLevel.get(n.text) ||
decl.parent?.parent === topLevel.get(n.text) ||
decl.parent === topLevel.get(n.text))
)
refs.set(n.text, { kind: "local" });
}
}
}
ts.forEachChild(n, visit);
};
visit(node);
return { refs, members };
}
const rebase = (spec, toDir) => {
if (!spec.startsWith(".")) return spec;
let rel = posix.normalize(relative(toDir, resolve(moduleDir, spec)).split("\\").join("/"));
return rel.startsWith(".") ? rel : "./" + rel;
};
const specTo = (fromDir, file) => {
let rel = relative(fromDir, file).split("\\").join("/").replace(/\.ts$/, ".js");
return rel.startsWith(".") ? rel : "./" + rel;
};
const edits = []; // [start, end, replacement] in the module
const stubs = [];
const files = new Map();
const publicize = new Set();
const moduleNeeds = new Map(); // spec -> names (functions the stubs call)
const exportLocals = new Set();
let moved = 0;
for (const [file, names] of Object.entries(config.methods)) {
const path = join(moduleDir, file);
const dir = dirname(path);
const entry = { decls: [], named: new Map(), other: [] };
const addNamed = (spec, name, imported, typeOnly) => {
if (!entry.named.has(spec)) entry.named.set(spec, new Map());
const m = entry.named.get(spec);
const prev = m.get(name);
m.set(name, { imported, typeOnly: prev ? prev.typeOnly && typeOnly : typeOnly });
};
addNamed(specTo(dir, modulePath), className, className, true);
for (const name of names) {
const decls = memberByName.get(name);
if (!decls) {
console.error(`skip: ${className}.${name} not found`);
continue;
}
if (decls.length !== 1 || !ts.isMethodDeclaration(decls[0]) || !decls[0].body)
throw new Error(`${name}: expected one method with a body`);
const m = decls[0];
if (m.typeParameters?.length || m.asteriskToken)
throw new Error(`${name}: generic and generator methods are not handled`);
const isAsync = m.modifiers?.some((x) => x.kind === ts.SyntaxKind.AsyncKeyword) ?? false;
// The function: doc comment, signature (initializers kept, so a
// defaulted parameter after an optional one stays valid; the stub
// passes every argument), and the body with this -> host.
const lead = text.slice(m.getFullStart(), m.getStart()).replace(/^\s*\n/, "");
const params = m.parameters.map((p) => {
const nameText = p.name.getText();
// A parameter typed only by its default takes the default's widened type.
const type = p.type
? `: ${p.type.getText()}`
: p.initializer
? `: ${checker.typeToString(checker.getBaseTypeOfLiteralType(checker.getTypeAtLocation(p.initializer)))}`
: "";
const optional = p.questionToken ? "?" : "";
let init = "";
if (p.initializer) {
const initText = p.initializer.getText();
if (/\bthis\b/.test(initText))
throw new Error(`${name}: a parameter default that reads this is not handled`);
init = ` = ${initText}`;
}
return `${p.dotDotDotToken ? "..." : ""}${nameText}${optional}${type}${init}`;
});
const ret = m.type ? `: ${m.type.getText()}` : "";
const body = m.body;
const thisEdits = [];
const findThis = (n) => {
// `this` in an expression, or in a type query (`typeof this.x`).
// (In a type query `this` parses as an identifier.)
if (
n.kind === ts.SyntaxKind.ThisKeyword ||
(ts.isIdentifier(n) && n.text === "this" && ts.findAncestor(n, ts.isTypeQueryNode))
)
thisEdits.push(n.getStart());
if (ts.isFunctionExpression(n) || ts.isFunctionDeclaration(n) || ts.isClassLike(n))
throw new Error(`${name}: a nested function or class rebinds this`);
ts.forEachChild(n, findThis);
};
ts.forEachChild(body, findThis);
const clash = (n) => {
if (ts.isIdentifier(n) && n.text === hostName)
throw new Error(`${name}: already uses the name ${hostName}`);
ts.forEachChild(n, clash);
};
clash(m);
let bodyText = "";
let cursor = body.getStart();
for (const at of thisEdits.sort((a, b) => a - b)) {
bodyText += text.slice(cursor, at) + hostName;
cursor = at + 4;
}
bodyText += text.slice(cursor, body.end);
const leadOut = lead
.split("\n")
.map((l) => (l.startsWith(" ") ? l.slice(2) : l))
.join("\n");
const fn = `${leadOut}export ${isAsync ? "async " : ""}function ${name}(${hostName}: ${className}${params.length ? ", " + params.join(", ") : ""})${ret} ${bodyText}`;
entry.decls.push(fn);
stubs.push(m);
if (!moduleNeeds.has(specTo(moduleDir, path)))
moduleNeeds.set(specTo(moduleDir, path), new Set());
moduleNeeds.get(specTo(moduleDir, path)).add(name);
// What the function needs.
const { refs, members } = references(m);
for (const member of members) publicize.add(member);
for (const [ref, { kind }] of refs) {
if (kind === "import") {
const imp = imports.get(ref);
const spec = rebase(imp.st.moduleSpecifier.text, dir);
if (imp.kind === "named") addNamed(spec, ref, imp.imported, imp.typeOnly);
else entry.other.push({ spec, kind: imp.kind, name: ref, typeOnly: imp.typeOnly });
} else {
const st = topLevel.get(ref);
if (!isExported(st)) exportLocals.add(st);
addNamed(specTo(dir, modulePath), ref, ref, isTypeDecl(st));
}
}
moved++;
}
files.set(file, entry);
}
const renderImports = (entry) => {
const lines = [];
const seen = new Set();
for (const o of entry.other) {
const key = `${o.kind}:${o.name}:${o.spec}`;
if (seen.has(key)) continue;
seen.add(key);
lines.push(
o.kind === "namespace"
? `import ${o.typeOnly ? "type " : ""}* as ${o.name} from "${o.spec}";`
: `import ${o.typeOnly ? "type " : ""}${o.name} from "${o.spec}";`,
);
}
for (const [spec, names] of entry.named) {
const all = [...names.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
const allType = all.every(([, v]) => v.typeOnly);
const parts = all.map(
([n, v]) =>
`${!allType && v.typeOnly ? "type " : ""}${v.imported === n ? n : `${v.imported} as ${n}`}`,
);
lines.push(`import ${allType ? "type " : ""}{ ${parts.join(", ")} } from "${spec}";`);
}
return lines;
};
for (const [file, entry] of files) {
if (!entry.decls.length) continue;
const path = join(moduleDir, file);
if (checkout.exists(path)) throw new Error(`${file} exists`);
const header = config.headers?.[file];
checkout.write(
path,
[...(header ? [header] : []), ...renderImports(entry), "", entry.decls.join("\n\n"), ""].join(
"\n",
),
);
}
// Each moved method leaves a stub with its signature (defaults included)
// that calls the function; one the moved bodies call loses `private`.
const stubSet = new Set(stubs);
for (const m of stubs) {
let signature = m.modifiers
? text.slice(m.getStart(), m.body.getStart()).trimEnd()
: text.slice(m.getStart(), m.body.getStart()).trimEnd();
if (publicize.has(m.name.text)) signature = signature.replace(/^private /, "");
const args = m.parameters.map((p) => `${p.dotDotDotToken ? "..." : ""}${p.name.getText()}`);
const call = `${m.name.text}(this${args.length ? ", " + args.join(", ") : ""})`;
edits.push([m.getFullStart(), m.end, `\n ${signature} {\n return ${call};\n }`]);
}
// Other members the moved bodies reach through the host lose `private`.
for (const name of publicize)
for (const m of memberByName.get(name) ?? [])
if (!stubSet.has(m))
for (const mod of m.modifiers ?? [])
if (mod.kind === ts.SyntaxKind.PrivateKeyword)
edits.push([mod.getStart(), mod.end + 1, ""]);
// Top-level declarations the new files import get `export`.
for (const st of exportLocals) edits.push([st.getStart(), st.getStart(), "export "]);
let out = "";
let cursor = 0;
for (const [a, b, r] of edits.sort((x, y) => x[0] - y[0])) {
out += text.slice(cursor, a) + r;
cursor = b;
}
out += text.slice(cursor);
// Re-analyze the rewritten module for unused imports, then add the new ones.
const sf2 = ts.createSourceFile(modulePath, out, ts.ScriptTarget.Latest, true);
const host2 = confineHost(ts.createCompilerHost({ noEmit: true }), checkout);
const get2 = host2.getSourceFile;
host2.getSourceFile = (f, l, ...r) =>
resolve(f) === modulePath ? sf2 : get2.call(host2, f, l, ...r);
const program2 = ts.createProgram([modulePath], { noEmit: true, noResolve: true }, host2);
const checker2 = program2.getTypeChecker();
const src2 = program2.getSourceFile(modulePath);
const used = new Set();
const walk = (n) => {
if (
ts.isIdentifier(n) &&
!(n.parent && ts.isImportSpecifier(n.parent)) &&
!(n.parent && ts.isImportClause(n.parent)) &&
!(n.parent && ts.isNamespaceImport(n.parent))
) {
const sym =
n.parent && ts.isShorthandPropertyAssignment(n.parent)
? checker2.getShorthandAssignmentValueSymbol(n.parent)
: checker2.getSymbolAtLocation(n);
const d = sym?.declarations?.[0];
if (d && (ts.isImportSpecifier(d) || ts.isImportClause(d) || ts.isNamespaceImport(d)))
used.add(d.name.text);
}
ts.forEachChild(n, walk);
};
for (const st of src2.statements) if (!ts.isImportDeclaration(st)) walk(st);
const importEdits = [];
for (const st of src2.statements) {
if (!ts.isImportDeclaration(st) || !st.importClause) continue;
const c = st.importClause;
const def = c.name && used.has(c.name.text) ? c.name.text : null;
const nb = c.namedBindings;
const ns = nb && ts.isNamespaceImport(nb) && used.has(nb.name.text) ? nb.name.text : null;
const named =
nb && ts.isNamedImports(nb) ? nb.elements.filter((el) => used.has(el.name.text)) : [];
const all =
(c.name ? def !== null : true) &&
(!nb || (ts.isNamespaceImport(nb) ? ns !== null : named.length === nb.elements.length));
if (all) continue;
let r = "";
if (def || ns || named.length) {
const bits = [];
if (def) bits.push(def);
if (ns) bits.push(`* as ${ns}`);
if (named.length) bits.push(`{ ${named.map((el) => el.getText()).join(", ")} }`);
r = `import ${c.isTypeOnly ? "type " : ""}${bits.join(", ")} from ${st.moduleSpecifier.getText()};`;
}
const end = r === "" && out[st.end] === "\n" ? st.end + 1 : st.end;
importEdits.push([st.getStart(), end, r]);
}
let final = "";
cursor = 0;
for (const [a, b, r] of importEdits.sort((x, y) => x[0] - y[0])) {
final += out.slice(cursor, a) + r;
cursor = b;
}
final += out.slice(cursor);
const lastImport = [
...ts.createSourceFile(modulePath, final, ts.ScriptTarget.Latest, true).statements,
]
.filter(ts.isImportDeclaration)
.at(-1);
const add = [...moduleNeeds].map(
([spec, names]) => `import { ${[...names].sort().join(", ")} } from "${spec}";`,
);
const at = lastImport ? lastImport.end : 0;
final = final.slice(0, at) + (add.length ? "\n" + add.join("\n") : "") + final.slice(at);
checkout.write(modulePath, final);
// Format what changed with the tools checkout's oxfmt, so the split and
// every rehome of it are formatted the same way.
const touched = [
modulePath,
...[...files].filter(([, e]) => e.decls.length).map(([f]) => join(moduleDir, f)),
];
formatFiles(toolsDir, touched, { cwd: repo, stdio: ["ignore", "ignore", "inherit"] });
console.log(`${moved} methods moved; ${publicize.size} members reached through host`);
+545
View File
@@ -0,0 +1,545 @@
#!/usr/bin/env node
/* Split a large TypeScript module into files by a manifest of top-level
* declaration names, as a pure move.
*
* node split-module.mjs plan <config.json> compute the manifest from seeds
* node split-module.mjs apply <config.json> extract branches, move declarations
*
* <config.json>:
* { "tsconfig": "packages/compiler/tsconfig.json",
* "module": "packages/compiler/src/frontend/lowering/lower-exprs.ts",
* "extract": [{ "function": "lowerExprInner", "condition": "ts.isIdentifier(expr)",
* "name": "lowerIdentifierExpr", "exprType": "ts.Identifier" }, ...],
* "seeds": { "expressions/binary.ts": ["lowerBinary", ...], ... },
* "manifest": "<manifest.json, relative to the config>" }
*
* It runs on the checkout in the current directory, or --repo <dir>, and
* loads typescript from that checkout or --tools <dir>.
*
* extract turns `if (<condition>) { ...block... }` at the top level of the
* named function into `if (<condition>) return <name>(lowerer, expr, loc);`
* and adds `function <name>(lowerer: Lowerer, expr: <exprType>, loc: SrcLoc):
* IrExpr { ...block... }` after it. The block must end in a call that never
* returns, so no path falls through.
*
* plan assigns each seed to its file, then moves every other top-level
* declaration whose referrers all sit in one file (other than the module)
* into that file, to a fixpoint. Module-level `let` bindings stay put (an
* importer cannot assign them). The manifest lists file → names.
*
* apply moves the manifest's declarations with their leading comments,
* in source order, into the new files. Each new file imports what its
* declarations reference: names from the module or another new file, and
* the module's own imports with their paths rebased. The module imports the
* moved names it still uses, re-exports the moved names that were exported,
* and drops imports nothing uses anymore. Moved declarations that something
* outside their file references get `export`. Names a manifest lists but
* the module lacks are skipped, and new declarations stay in the module,
* so the same manifest applies to an edited copy of the module. */
import { readFileSync } from "node:fs";
import { dirname, join, relative, resolve, posix } from "node:path";
import { createRequire } from "node:module";
import { confine, confineHost } from "./confine.mjs";
import { formatFiles } from "./format.mjs";
const [command, configPath, ...flags] = process.argv.slice(2);
const config = JSON.parse(readFileSync(configPath, "utf8"));
const flag = (name) => {
const i = flags.indexOf(name);
return i >= 0 ? flags[i + 1] : undefined;
};
// --repo runs on another checkout (a temporary worktree); --tools names the
// checkout whose node_modules supply typescript.
const repo = resolve(flag("--repo") ?? config.repo ?? process.cwd());
// Every read and write stays inside the checkout, or for the manifest, the
// config's directory (confine.mjs).
const checkout = confine(repo);
const configFiles = confine(dirname(resolve(configPath)));
// The manifest path is relative to the config file.
config.manifest = resolve(dirname(resolve(configPath)), flag("--manifest") ?? config.manifest);
const toolsDir = resolve(flag("--tools") ?? config.repo ?? process.cwd());
const require = createRequire(join(toolsDir, "package.json"));
const ts = require("typescript");
const modulePath = join(repo, config.module);
const moduleDir = dirname(modulePath);
function parse(text, path = modulePath) {
return ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true);
}
/* ── extract ─────────────────────────────────────────────────────────── */
function extractBranches(text) {
for (const spec of config.extract ?? []) {
const sf = parse(text);
const fn = sf.statements.find(
(s) => ts.isFunctionDeclaration(s) && s.name?.text === spec.function,
);
if (!fn) throw new Error(`no function ${spec.function}`);
const branch = fn.body.statements.find(
(s) =>
ts.isIfStatement(s) &&
s.expression.getText() === spec.condition &&
ts.isBlock(s.thenStatement) &&
!s.elseStatement,
);
if (!branch) {
console.error(`skip: no \`if (${spec.condition}) { ... }\` in ${spec.function}`);
continue;
}
const block = branch.thenStatement;
const last = block.statements.at(-1);
if (
!last ||
!(ts.isExpressionStatement(last) || ts.isReturnStatement(last) || ts.isThrowStatement(last))
)
throw new Error(
`${spec.condition}: the block must end in a never-returning call, return or throw`,
);
// The block's inner text, dedented one level (two spaces).
const inner = text.slice(block.statements[0].getFullStart(), block.statements.at(-1).end);
const body = inner
.split("\n")
.map((line) => (line.startsWith(" ") ? line.slice(2) : line))
.join("\n")
.replace(/^\n+/, "");
const call = `if (${spec.condition}) return ${spec.name}(lowerer, expr, loc);`;
const fnText = `\n\nfunction ${spec.name}(lowerer: Lowerer, expr: ${spec.exprType}, loc: SrcLoc): IrExpr {\n ${body.trimStart()}\n}`;
text =
text.slice(0, branch.getStart()) +
call +
text.slice(branch.end, fn.end) +
fnText +
text.slice(fn.end);
}
return text;
}
/* ── analysis ────────────────────────────────────────────────────────── */
function declNames(st) {
if (
ts.isFunctionDeclaration(st) ||
ts.isClassDeclaration(st) ||
ts.isInterfaceDeclaration(st) ||
ts.isTypeAliasDeclaration(st) ||
ts.isEnumDeclaration(st)
)
return st.name ? [st.name.text] : [];
if (ts.isVariableStatement(st))
return st.declarationList.declarations.flatMap((d) =>
ts.isIdentifier(d.name) ? [d.name.text] : [],
);
return [];
}
const isExported = (st) =>
st.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ?? false;
const isTypeDecl = (st) => ts.isInterfaceDeclaration(st) || ts.isTypeAliasDeclaration(st);
const isMutable = (st) =>
ts.isVariableStatement(st) && !(st.declarationList.flags & ts.NodeFlags.Const);
function inTypePosition(node) {
for (let n = node; n.parent; n = n.parent) {
// A class's `extends` names a value; `implements` and an interface's
// `extends` name types.
if (ts.isExpressionWithTypeArguments(n) && ts.isHeritageClause(n.parent))
return !(n.parent.token === ts.SyntaxKind.ExtendsKeyword && ts.isClassLike(n.parent.parent));
if (ts.isTypeNode(n) || ts.isTypeQueryNode?.(n)) return true;
if (ts.isStatement(n) || (ts.isExpression(n) && !ts.isIdentifier(n))) break;
}
return false;
}
/** For the module text: its statements with names, its import bindings, and
* each declaration's references (local names and import bindings), each
* marked type-only when every reference sits in a type position. */
function analyze(text) {
const sf = parse(text);
const host = confineHost(ts.createCompilerHost({}), checkout);
const tsconfigPath = join(repo, config.tsconfig);
checkout.check(tsconfigPath);
const tsconfig = ts.getParsedCommandLineOfConfigFile(
tsconfigPath,
{},
{ ...confineHost(ts.sys, checkout), onUnRecoverableConfigFileDiagnostic: () => {} },
);
const options = { ...tsconfig.options, noEmit: true };
const realGet = host.getSourceFile;
host.getSourceFile = (fileName, lang, ...rest) =>
resolve(fileName) === modulePath ? sf : realGet.call(host, fileName, lang, ...rest);
const program = ts.createProgram([modulePath], options, host);
const checker = program.getTypeChecker();
const source = program.getSourceFile(modulePath);
const imports = new Map(); // binding name -> { decl, spec, kind, typeOnly }
const statements = [];
for (const st of source.statements) {
if (ts.isImportDeclaration(st)) {
const clause = st.importClause;
const typeOnlyDecl = clause?.isTypeOnly ?? false;
if (clause?.name)
imports.set(clause.name.text, {
decl: st,
kind: "default",
typeOnly: typeOnlyDecl,
node: clause,
});
const nb = clause?.namedBindings;
if (nb && ts.isNamespaceImport(nb))
imports.set(nb.name.text, {
decl: st,
kind: "namespace",
typeOnly: typeOnlyDecl,
node: nb,
});
if (nb && ts.isNamedImports(nb))
for (const el of nb.elements)
imports.set(el.name.text, {
decl: st,
kind: "named",
typeOnly: typeOnlyDecl || el.isTypeOnly,
node: el,
imported: (el.propertyName ?? el.name).text,
});
continue;
}
statements.push({ node: st, names: declNames(st) });
}
const byNode = new Map();
for (const s of statements) byNode.set(s.node, s);
const ownerOf = (decl) => {
for (let n = decl; n; n = n.parent) if (byNode.has(n)) return byNode.get(n);
return undefined;
};
for (const s of statements) {
const refs = new Map(); // name -> { kind: "local"|"import", typeOnly }
const visit = (node) => {
if (ts.isIdentifier(node)) {
const parent = node.parent;
const isDeclName =
parent &&
parent.name === node &&
(ts.isFunctionDeclaration(parent) ||
ts.isVariableDeclaration(parent) ||
ts.isInterfaceDeclaration(parent) ||
ts.isTypeAliasDeclaration(parent) ||
ts.isParameter(parent) ||
ts.isPropertyAssignment(parent) ||
ts.isPropertySignature(parent) ||
ts.isPropertyDeclaration(parent) ||
ts.isMethodDeclaration(parent) ||
ts.isBindingElement(parent));
const isPropertyName =
parent &&
((ts.isPropertyAccessExpression(parent) && parent.name === node) ||
(ts.isQualifiedName(parent) && parent.right === node));
if (!isPropertyName) {
let sym = checker.getSymbolAtLocation(node);
if (ts.isShorthandPropertyAssignment(parent))
sym = checker.getShorthandAssignmentValueSymbol(parent);
const decl = sym?.declarations?.[0];
if (decl && decl.getSourceFile() === source) {
const typePos = inTypePosition(node);
let target;
if (
ts.isImportSpecifier(decl) ||
ts.isNamespaceImport(decl) ||
ts.isImportClause(decl)
) {
const name = (ts.isImportClause(decl) ? decl.name : decl.name).text;
target = { name, kind: "import" };
} else {
const owner = ownerOf(decl);
if (owner && owner !== s && !isDeclName) {
const name = owner.names.find((n) => n === node.text) ?? node.text;
if (owner.names.includes(name)) target = { name, kind: "local" };
}
}
if (target) {
const prev = refs.get(target.name);
refs.set(target.name, {
kind: target.kind,
typeOnly: (prev ? prev.typeOnly : true) && typePos,
});
}
}
}
}
ts.forEachChild(node, visit);
};
visit(s.node);
s.refs = refs;
}
return { source, statements, imports };
}
/* ── plan ────────────────────────────────────────────────────────────── */
function plan() {
const text = extractBranches(checkout.read(modulePath));
const { statements } = analyze(text);
const fileOf = new Map();
for (const [file, names] of Object.entries(config.seeds))
for (const name of names) {
if (!statements.some((s) => s.names.includes(name)))
throw new Error(`seed ${name} not found`);
fileOf.set(name, file);
}
const keep = new Set(config.keep ?? []);
const referrers = new Map();
for (const s of statements)
for (const [name, ref] of s.refs)
if (ref.kind === "local") {
if (!referrers.has(name)) referrers.set(name, new Set());
for (const n of s.names) referrers.get(name).add(n);
}
for (let changed = true; changed;) {
changed = false;
for (const s of statements) {
if (
s.names.length === 0 ||
s.names.some((n) => fileOf.has(n) || keep.has(n)) ||
isMutable(s.node)
)
continue;
const users = new Set();
for (const n of s.names)
for (const u of referrers.get(n) ?? []) if (!s.names.includes(u)) users.add(u);
const files = new Set([...users].map((u) => fileOf.get(u) ?? null));
if (users.size > 0 && files.size === 1 && !files.has(null)) {
const [file] = files;
for (const n of s.names) fileOf.set(n, file);
changed = true;
}
}
}
const manifest = {};
for (const s of statements) {
const file = s.names.map((n) => fileOf.get(n)).find(Boolean);
if (file) (manifest[file] ??= []).push(...s.names);
}
configFiles.write(config.manifest, JSON.stringify(manifest, null, 2) + "\n");
for (const [file, names] of Object.entries(manifest)) console.log(`${file}: ${names.length}`);
}
/* ── apply ───────────────────────────────────────────────────────────── */
function rebase(spec, fromDir, toDir) {
if (!spec.startsWith(".")) return spec;
let rel = posix.normalize(relative(toDir, resolve(fromDir, spec)).split("\\").join("/"));
if (!rel.startsWith(".")) rel = "./" + rel;
return rel;
}
const moduleSpecifierTo = (fromDir, file) => {
let rel = relative(fromDir, file).split("\\").join("/").replace(/\.ts$/, ".js");
return rel.startsWith(".") ? rel : "./" + rel;
};
/** The statement's text with its leading comments, without leading blank lines. */
function fullText(text, st) {
return text.slice(st.getFullStart(), st.end).replace(/^\s*\n/, "");
}
function apply() {
const manifest = JSON.parse(configFiles.read(config.manifest));
let text = extractBranches(checkout.read(modulePath));
const { source, statements, imports } = analyze(text);
const fileOf = new Map();
for (const [file, names] of Object.entries(manifest)) for (const n of names) fileOf.set(n, file);
const stFile = (s) => s.names.map((n) => fileOf.get(n)).find(Boolean) ?? null;
const nameFile = new Map();
const nameStmt = new Map();
for (const s of statements)
for (const n of s.names) {
nameFile.set(n, stFile(s));
nameStmt.set(n, s);
}
// Which names need `export`: referenced from another file, or exported already.
const needsExport = new Set();
for (const s of statements)
for (const [name, ref] of s.refs)
if (ref.kind === "local" && nameFile.get(name) !== stFile(s)) needsExport.add(name);
const files = new Map(); // file -> { decls: [], imports: Map<spec, {...}> }
const fileImports = (file) => {
if (!files.has(file)) files.set(file, { decls: [], named: new Map(), other: [] });
return files.get(file);
};
const addNamed = (entry, spec, name, imported, typeOnly) => {
if (!entry.named.has(spec)) entry.named.set(spec, new Map());
const m = entry.named.get(spec);
const prev = m.get(name);
m.set(name, { imported, typeOnly: prev ? prev.typeOnly && typeOnly : typeOnly });
};
const moduleEntry = { decls: [], named: new Map(), other: [] };
for (const s of statements) {
const file = stFile(s);
const entry = file ? fileImports(file) : moduleEntry;
const dir = file ? dirname(join(moduleDir, file)) : moduleDir;
if (file) {
let t = fullText(text, s.node);
if (!isExported(s.node) && s.names.some((n) => needsExport.has(n))) {
const start = s.node.getStart() - s.node.getFullStart();
const lead = text.slice(s.node.getFullStart(), s.node.getStart()).replace(/^\s*\n/, "");
t = lead + "export " + text.slice(s.node.getStart(), s.node.end);
void start;
}
entry.decls.push(t);
}
for (const [name, ref] of s.refs) {
if (ref.kind === "local") {
const target = nameFile.get(name) ?? null;
if (target === file) continue;
const targetPath = target ? join(moduleDir, target) : modulePath;
const typeOnly = ref.typeOnly || isTypeDecl(nameStmt.get(name).node);
addNamed(entry, moduleSpecifierTo(dir, targetPath), name, name, typeOnly);
} else if (file) {
const imp = imports.get(name);
const spec = rebase(imp.decl.moduleSpecifier.text, moduleDir, dir);
if (imp.kind === "named") addNamed(entry, spec, name, imp.imported, imp.typeOnly);
else entry.other.push({ spec, kind: imp.kind, name, typeOnly: imp.typeOnly });
}
}
}
const renderImports = (entry) => {
const lines = [];
const seen = new Set();
for (const o of entry.other) {
const key = `${o.kind}:${o.name}:${o.spec}`;
if (seen.has(key)) continue;
seen.add(key);
lines.push(
o.kind === "namespace"
? `import ${o.typeOnly ? "type " : ""}* as ${o.name} from "${o.spec}";`
: `import ${o.typeOnly ? "type " : ""}${o.name} from "${o.spec}";`,
);
}
for (const [spec, names] of entry.named) {
const all = [...names.entries()];
const allType = all.every(([, v]) => v.typeOnly);
const parts = all
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(
([name, v]) =>
`${!allType && v.typeOnly ? "type " : ""}${v.imported === name ? name : `${v.imported} as ${name}`}`,
);
lines.push(`import ${allType ? "type " : ""}{ ${parts.join(", ")} } from "${spec}";`);
}
return lines;
};
// New files.
for (const [file, entry] of files) {
const header = config.headers?.[file];
const out = [
...(header ? [header] : []),
...renderImports(entry),
"",
entry.decls.join("\n\n"),
"",
].join("\n");
const path = join(moduleDir, file);
checkout.mkdir(dirname(path));
if (checkout.exists(path)) throw new Error(`${file} exists`);
checkout.write(path, out);
}
// The module: drop moved declarations, rewrite its import block.
const keptRefs = new Set();
for (const s of statements)
if (!stFile(s)) for (const [name, ref] of s.refs) if (ref.kind === "import") keptRefs.add(name);
const pieces = [];
let cursor = 0;
const removals = [];
for (const s of statements) if (stFile(s)) removals.push([s.node.getFullStart(), s.node.end]);
// Import declarations: rebuild each without bindings nothing uses.
const importEdits = [];
for (const st of source.statements) {
if (!ts.isImportDeclaration(st)) continue;
const clause = st.importClause;
if (!clause) continue; // side-effect import
const used = (n) => keptRefs.has(n);
const def = clause.name && used(clause.name.text) ? clause.name.text : null;
const nb = clause.namedBindings;
let ns = null;
let named = [];
if (nb && ts.isNamespaceImport(nb) && used(nb.name.text)) ns = nb.name.text;
if (nb && ts.isNamedImports(nb)) named = nb.elements.filter((el) => used(el.name.text));
const keepAll =
def === (clause.name?.text ?? null) &&
(!nb || (ts.isNamespaceImport(nb) ? ns !== null : named.length === nb.elements.length));
if (keepAll) continue;
let replacement = "";
if (def || ns || named.length) {
const bits = [];
if (def) bits.push(def);
if (ns) bits.push(`* as ${ns}`);
if (named.length) bits.push(`{ ${named.map((el) => el.getText()).join(", ")} }`);
replacement = `import ${clause.isTypeOnly ? "type " : ""}${bits.join(", ")} from ${st.moduleSpecifier.getText()};`;
}
// A removed declaration takes its line break with it.
const end = replacement === "" && text[st.end] === "\n" ? st.end + 1 : st.end;
importEdits.push([st.getStart(), end, replacement]);
}
// Declarations that stay but that a moved one references get `export`.
const exportEdits = [];
for (const s of statements)
if (!stFile(s) && !isExported(s.node) && s.names.some((n) => needsExport.has(n)))
exportEdits.push([s.node.getStart(), s.node.getStart(), "export "]);
const edits = [...removals.map(([a, b]) => [a, b, ""]), ...importEdits, ...exportEdits].sort(
(x, y) => x[0] - y[0],
);
for (const [a, b, r] of edits) {
pieces.push(text.slice(cursor, a), r);
cursor = b;
}
pieces.push(text.slice(cursor));
let moduleText = pieces.join("");
// Imports of moved names the module still uses, and re-exports of moved
// names that were exported (other modules import them from here).
const addLines = renderImports(moduleEntry);
const reexports = new Map();
for (const s of statements) {
const file = stFile(s);
if (!file || !isExported(s.node)) continue;
const spec = moduleSpecifierTo(moduleDir, join(moduleDir, file));
if (!reexports.has(spec)) reexports.set(spec, []);
for (const n of s.names) reexports.get(spec).push({ name: n, type: isTypeDecl(s.node) });
}
for (const [spec, names] of reexports) {
const values = names
.filter((n) => !n.type)
.map((n) => n.name)
.sort();
const types = names
.filter((n) => n.type)
.map((n) => n.name)
.sort();
if (values.length) addLines.push(`export { ${values.join(", ")} } from "${spec}";`);
if (types.length) addLines.push(`export type { ${types.join(", ")} } from "${spec}";`);
}
// Insert after the last import declaration.
const reparsed = parse(moduleText);
const lastImport = [...reparsed.statements].filter(ts.isImportDeclaration).at(-1);
const at = lastImport ? lastImport.end : 0;
moduleText =
moduleText.slice(0, at) +
(addLines.length ? "\n" + addLines.join("\n") : "") +
moduleText.slice(at);
moduleText = moduleText.replace(/\n{3,}/g, "\n\n");
checkout.write(modulePath, moduleText);
// Format what changed with the tools checkout's oxfmt, so the split and
// every rehome of it are formatted the same way.
const touched = [modulePath, ...[...files.keys()].map((f) => join(moduleDir, f))];
formatFiles(toolsDir, touched, { cwd: repo, stdio: ["ignore", "ignore", "inherit"] });
for (const [file, entry] of files) console.log(`${file}: ${entry.decls.length} declarations`);
}
if (command === "plan") plan();
else if (command === "apply") apply();
else {
console.error("usage: split-module.mjs plan|apply <config.json>");
process.exit(2);
}
+10
View File
@@ -94,5 +94,15 @@ if (process.argv.includes("--run")) {
}).trim();
if (version !== identity.version)
throw new Error(`native command reported ${version}, expected ${identity.version}`);
// tsc-ts, the default checker, runs on the distribution's own platform.
const tscTs = resolve(dirname(binary), hostPath(toolchain.tsc_ts));
if (windows && !tscTs.endsWith("tsc-ts.exe"))
throw new Error(`tsc_ts is not tsc-ts.exe: ${tscTs}`);
const tscTsVersion = execFileSync(tscTs, ["--version"], {
encoding: "utf8",
env: { ...process.env, PATH: "" },
}).trim();
if (!tscTsVersion.startsWith("Version "))
throw new Error(`the distribution's tsc-ts reported ${JSON.stringify(tscTsVersion)}`);
}
console.log(`${identity.name}@${identity.version}: native distribution verified`);
+3 -1
View File
@@ -405,7 +405,8 @@ export function nodeIslandSymbols(names) {
* not yet traced to its typed root. bench/thread-slots,
* bench/channel-messages and bench/worker-messages, added later, link them
* through worker messages:
* node:worker_threads values are dyn objects. Every other bench workload is fully typed and must link none. The gate and
* node:worker_threads values are dyn objects. bench/dynamic-methods is
* untyped JavaScript. Every other bench workload is fully typed and must link none. The gate and
* tests/harness/bench-dynamic-tier.test.ts enforce that, and the test also
* fails when a listed workload stops linking them, so remove a workload here
* when a leak fix makes its binary clean. */
@@ -419,6 +420,7 @@ export const DYNAMIC_WORKLOADS = new Set([
"bench/intern-table",
"bench/thread-slots",
"bench/channel-messages",
"bench/dynamic-methods",
"bench/worker-messages",
"bench/typed-array-scan-workers",
"bench/log-summary",