Merge main

This commit is contained in:
Chris Tate
2026-10-26 03:25:54 -05:00
232 changed files with 9222 additions and 643 deletions
+20 -9
View File
@@ -101,11 +101,11 @@ jobs:
if (receiptRun) appendFileSync(process.env.GITHUB_OUTPUT, `receipt_run=${receiptRun}\n`);
NODE
# Collects the native compiler's profile on this commit
# (scripts/refresh-cli-profile.mjs), so every platform's CLI build optimizes
# with a profile of the code it compiles. The committed profile falls behind
# main within a day or two. If this job fails, the CLI builds use the
# committed profile.
# Collects the native compiler's profile and function layout on this commit
# (scripts/refresh-cli-profile.mjs, scripts/refresh-cli-layout.mjs), so every
# platform's CLI build optimizes and lays out the code it compiles. The
# committed ones fall behind main within a day or two. If this job fails,
# the CLI builds use the committed profile and layout.
cli-profile:
name: Collect the native compiler profile
needs: check-release
@@ -139,11 +139,17 @@ jobs:
- name: Collect the profile
run: >-
node scripts/refresh-cli-profile.mjs --tsc-ts "$RUNNER_TEMP/tsc-ts" --jobs 4
--output "$RUNNER_TEMP/cli-profile/cli.profdata.gz"
--output "$RUNNER_TEMP/cli-profile/cli.profdata.gz" --work "$RUNNER_TEMP/cli-profile-work"
- name: Collect the function layout
run: >-
node scripts/refresh-cli-layout.mjs --jobs 4 --instrumented "$RUNNER_TEMP/cli-profile-work/cli"
--output "$RUNNER_TEMP/cli-profile/cli.layout.gz"
- uses: actions/upload-artifact@v4
with:
name: cli-profile
path: ${{ runner.temp }}/cli-profile/cli.profdata.gz
path: |
${{ runner.temp }}/cli-profile/cli.profdata.gz
${{ runner.temp }}/cli-profile/cli.layout.gz
retention-days: 7
if-no-files-found: error
@@ -336,8 +342,8 @@ jobs:
with:
name: cli-profile
path: ${{ runner.temp }}/cli-profile
# Without the artifact, SCRIPTC_NATIVE_PROFILE_USE stays unset and
# build-native-cli.mts uses the committed profile.
# Without the artifact, SCRIPTC_NATIVE_PROFILE_USE and SCRIPTC_NATIVE_LAYOUT
# stay unset and build-native-cli.mts uses the committed profile and layout.
- name: Select the native compiler profile
if: matrix.cli != ''
shell: bash
@@ -347,6 +353,11 @@ jobs:
if [ -n "$COLLECTED" ]; then
echo "SCRIPTC_NATIVE_PROFILE_USE=$COLLECTED" >> "$GITHUB_ENV"
echo "The native CLI builds with the profile collected on $GITHUB_SHA"
layout="$(dirname "$COLLECTED")/cli.layout.gz"
if [ -f "$layout" ]; then
echo "SCRIPTC_NATIVE_LAYOUT=$layout" >> "$GITHUB_ENV"
echo "The native CLI builds with the function layout collected on $GITHUB_SHA"
fi
else
echo "::warning::No profile was collected on this commit; the native CLI builds with the committed profile"
fi
+2
View File
@@ -18,6 +18,8 @@ The release job builds each pinned LLVM helper, precompiled runtime pack, and na
Release builds of the native CLI optimize the compiler with a profile collected on the release commit. The `cli-profile` job runs `node scripts/refresh-cli-profile.mjs` on Linux x64, which builds an instrumented compiler, runs it on tsc-ts, a slice of the corpus, and the runtime benchmark workloads, and writes the merged sparse profile. Every platform's CLI build uses that profile (`SCRIPTC_NATIVE_PROFILE_USE`), and the GitHub release attaches it as `scriptc-<version>-cli.profdata.gz`. Code the profile does not describe, or describes from older compiler sources, is optimized as without a profile. When the profile job fails, the CLI builds use the committed profile `packages/compiler/native/cli.profdata.gz`, which local and CI builds of the native compiler also use. Refresh the committed profile when compiler code has changed substantially since the last refresh: run `node scripts/refresh-cli-profile.mjs --tsc-ts <tsc-ts checkout>` on a quiet Linux x64 host and commit the new profile on its own.
Builds of the native compiler also order its functions by the committed layout `packages/compiler/native/cli.layout.gz`: the functions that `--version` and rebuilds of unchanged programs run come first, then the ones other builds run, ordered by the share of the layout's training builds that run each, so a run maps fewer pages of the executable. Functions the layout does not list keep their usual order. Function names of closures and generated helpers change with nearby edits, so the `cli-profile` job also collects a layout on the release commit (`scripts/refresh-cli-layout.mjs`, reusing the job's instrumented compiler), and the CLI builds use it (`SCRIPTC_NATIVE_LAYOUT`). Refresh the committed layout the same way as the profile, with `node scripts/refresh-cli-layout.mjs`, and commit it on its own.
Publishing uses npm trusted publishing (OIDC) — there is no npm token secret.
Each published package must have a GitHub Actions trusted publisher for
`release.yml` and the `Release` environment. A missing configuration fails
+20 -1
View File
@@ -170,7 +170,7 @@ With `--sparse`, the profile leaves out the functions the runs never reached. Th
<dd>On Linux, build an instrumented executable for profile-guided optimization. Each run writes a raw profile to the path pattern, where <code>%p</code> expands to the process ID; the <code>LLVM_PROFILE_FILE</code> environment variable overrides the pattern at run time. A relative pattern resolves from the current working directory. Instrumented programs run slower. Valid only for executable builds with <code>--optimization=release</code> or <code>speed</code>.</dd>
<dt><code>--profile-use &lt;file.profdata&gt;</code></dt>
<dd>Optimize the executable with a profile from <code>scriptc merge-profiles</code>: code is laid out, inlined, and split by how the profiled runs used it. Code the profile does not describe, or describes from an older version of the program, is optimized as without a profile, so a stale profile only loses part of its gain. A new or changed profile compiles every partition of the program again. Valid only for executable builds with <code>--optimization=release</code> or <code>speed</code>.</dd>
<dd>Optimize the executable with a profile from <code>scriptc merge-profiles</code>: code is laid out, inlined, and split by how the profiled runs used it. Code the profile does not describe, or describes from an older version of the program, is optimized as without a profile, so a stale profile only loses part of its gain. A new or changed profile compiles every partition of the program again. <code>scriptc build</code> prints how the profile matched the program's functions on stderr; see <a href="#profile-guided-optimization">Profile-guided optimization</a>. Valid only for executable builds with <code>--optimization=release</code> or <code>speed</code>.</dd>
<dt><code>--npm-static &lt;pkg[,pkg…]|auto&gt;</code></dt>
<dd>Name npm packages to compile even if they don't ship their own type declarations (repeatable). A trailing <code>...</code>, as in <code>fs-extra...</code>, also names every package that package depends on. <code>auto</code> uses the default automatic selection. Packages that can't compile produce diagnostics. See <a href="/docs/dependencies#explicit-package-selection">npm Dependencies</a>.</dd>
@@ -262,6 +262,25 @@ $ scriptc build app.ts --optimization=speed --profile-use app.profdata
Train on workloads like the ones the program will run: code the training never reached is optimized as without a profile. The profile can be committed and reused as the program changes. Functions whose control flow changed since the profile was collected lose their profile, and the rest keep theirs, so regenerate it when much of the program has changed. [Build parallelism](/docs/how-it-works#build-parallelism) describes what a profile adds to build time.
`scriptc build --profile-use` prints one line on stderr with how the profile matched the program's functions:
```
scriptc: profile app.profdata: 9170 functions annotated, 0 mismatched, 28636 without a record
```
- **annotated:** functions optimized with the profile's counts.
- **mismatched:** functions whose control flow changed since the profile was collected. They are optimized without counts.
- **without a record:** functions the profile does not describe: new functions, and functions the profiled runs never reached.
- When the build reuses cached partitions whose counts were not recorded, the line ends with the number of those partitions.
When more than 10% of the functions the profile describes (annotated and mismatched) are mismatched, the build also prints a warning:
```
scriptc: warning: 3739 of the 8947 functions app.profdata describes (41.8%) changed since it was collected and are optimized without their counts; collect the profile again
```
The build still succeeds. Collecting the profile again with the current program and scriptc version restores the counts.
## Target CPU
By default, x86-64 executables run on any x86-64 CPU, so the compiler uses only the baseline instruction set (SSE2). `--target-cpu` compiles the program's code for a higher x86-64 microarchitecture level. Each level includes the ones below it:
+1
View File
@@ -49,6 +49,7 @@ export function relocateToolchain(manifest, sourceDirectory, destinationDirector
"declarations",
"comptime",
"wasi_node_runner",
"lib_diagnostics",
]) {
if (typeof relocated[name] === "string") relocated[name] = pathFrom(relocated[name]);
}
+4
View File
@@ -53,6 +53,7 @@ function fixture() {
dsymutil: "dsymutil",
comptime: "../lib/comptime",
wasi_node_runner: "../lib/wasi/cli/wasi-runner.js",
lib_diagnostics: "../lib/lib-diagnostics.json",
};
writeFileSync(binary + ".json", JSON.stringify(toolchain));
const helpers = [
@@ -93,6 +94,9 @@ test("installs a direct executable and relocatable references to platform assets
expect(resolve(dirname(installed), manifest.wasi_node_runner)).toBe(
join(platform, "wasi/cli/wasi-runner.js"),
);
expect(resolve(dirname(installed), manifest.lib_diagnostics)).toBe(
join(platform, "lib-diagnostics.json"),
);
expect(resolve(dirname(installed), manifest.runtime_packs[0].path)).toBe(
join(platform, "runtime"),
);
+16 -3
View File
@@ -63,8 +63,16 @@ test.skipIf(process.platform !== "linux")(
expect(merge.stdout).toBe(`${merged}\n`);
const optimized = join(dir, "optimized");
await scriptc(["build", entry, "--profile-use", merged, "-o", optimized], env);
const fresh = await scriptc(["build", entry, "--profile-use", merged, "-o", optimized], env);
expect((await execFileAsync(optimized, ["1000"])).stdout).toBe("total 167499\n");
// The build reports how the profile matched the program's functions.
expect(fresh.stderr).toMatch(
/^scriptc: profile .*: [1-9]\d* functions annotated, 0 mismatched, \d+ without a record\n$/,
);
expect(fresh.stderr).toContain(`scriptc: profile ${merged}: `);
// A build from the cache reports the same match.
const again = await scriptc(["build", entry, "--profile-use", merged, "-o", optimized], env);
expect(again.stderr).toBe(fresh.stderr);
// A sparse profile leaves out the runtime functions the runs never
// reached.
@@ -77,10 +85,15 @@ test.skipIf(process.platform !== "linux")(
await scriptc(["build", entry, "--profile-use", sparse, "-o", optimized], env);
expect((await execFileAsync(optimized, ["1000"])).stdout).toBe("total 167499\n");
// A profile from an older version of the program still builds it.
// A profile from an older version of the program still builds it. The
// edited function no longer matches its counts, and the build warns.
await writeFile(entry, PROGRAM + 'console.log("edited");\n');
await scriptc(["build", entry, "--profile-use", merged, "-o", optimized], env);
const stale = await scriptc(["build", entry, "--profile-use", merged, "-o", optimized], env);
expect((await execFileAsync(optimized, ["1000"])).stdout).toBe("total 167499\nedited\n");
expect(stale.stderr).toMatch(/ [1-9]\d* mismatched, /);
expect(stale.stderr).toMatch(
/\nscriptc: warning: [1-9]\d* of the \d+ functions .* describes \([\d.]+%\) changed since it was collected and are optimized without their counts; collect the profile again\n$/,
);
} finally {
await rm(dir, { recursive: true, force: true });
}
+10
View File
@@ -1718,6 +1718,16 @@ declare module "node:fs" {
): void;
export function rmdirSync(path: string | URL): void;
export function readdirSync(path: string | URL): string[];
/* The recursive form: every entry below the path, relative to it. */
export function readdirSync(
path: string | URL,
options: {
recursive?: boolean;
withFileTypes?: false;
encoding?: "utf8" | "utf-8";
[option: string]: unknown;
},
): string[];
/* The withFileTypes form: Dirent rows (name + parentPath + the type
* probes — the honest subset of Node's Dirent surface). The type is
* interned explicitly in type-mapper.ts (the record's hidden %dtype field
Binary file not shown.
+26 -3
View File
@@ -56,7 +56,9 @@ void scr_collect_cycles_phase(void);
* frontend's syntax trees and program state refer to each other, so
* reference counting frees almost none of them when the frontend is
* disposed, and the collector's schedule alone may not run a full pass
* before the build ends. */
* before the build ends. The sweep then returns the freed pages to the
* system (scr_heap_trim), so code generation does not grow on top of the
* frontend's heap. */
void scriptc_native_collect_cycles(void) {
scr_collect_cycles_phase();
}
@@ -80,9 +82,30 @@ void scriptc_native_unlock(void) {}
#include <sys/file.h>
#include <sys/wait.h>
int32_t scriptc_native_fork(void) { return (int32_t)fork(); }
#if defined(__linux__)
/* LLVM's profile runtime, linked into instrumented builds of the compiler
* (SCRIPTC_NATIVE_PROFILE_GENERATE); other builds leave these null. A worker
* starts with zeroed counters and writes its profile before _exit, which
* skips the runtime's atexit writer, so the profile counts the worker's work
* once. */
extern void __llvm_profile_reset_counters(void) __attribute__((weak));
extern int __llvm_profile_write_file(void) __attribute__((weak));
#endif
void scriptc_native_exit(int32_t code) { _exit(code); }
int32_t scriptc_native_fork(void) {
pid_t pid = fork();
#if defined(__linux__)
if (pid == 0 && __llvm_profile_reset_counters != NULL) __llvm_profile_reset_counters();
#endif
return (int32_t)pid;
}
void scriptc_native_exit(int32_t code) {
#if defined(__linux__)
if (__llvm_profile_write_file != NULL) (void)__llvm_profile_write_file();
#endif
_exit(code);
}
/* The worker's exit status, 128 plus the signal for a worker a signal
* ended, or -1 when waitpid fails. */
@@ -1,6 +1,10 @@
#ifndef _WIN32
#define _POSIX_C_SOURCE 200809L
#endif
/* macOS declares mkdtemp only with the Darwin extensions visible. */
#if defined(__APPLE__) && !defined(_DARWIN_C_SOURCE)
#define _DARWIN_C_SOURCE 1
#endif
#include "ts7-process.h"
#include <assert.h>
#include <errno.h>
+1 -1
View File
@@ -40,7 +40,7 @@
},
"dependencies": {
"@scriptc/runtime": "workspace:*",
"tsc-ts": "0.0.9",
"tsc-ts": "0.0.10",
"typescript": "7.0.2",
"typescript5": "npm:typescript@5.9.3"
},
@@ -166,6 +166,7 @@ export async function compilePackedLibrary(
options.programPublicSymbols,
stem,
target.platform,
{ inProcessElf: true },
);
} else {
program = await emit(
@@ -0,0 +1,77 @@
/* Typed-array header fields read once at a speculative loop's entry.
*
* A view's length, data pointer and shared storage are set at construction
* and never change, and a view held by a const local or a parameter that
* the loop does not rebind is the same view in every iteration. The entry
* reads those fields once, and every element access in the loop's copies
* (the ordinary copy, the fast copy and the copy a failed check resumes in)
* uses the values. No header read is then left inside the copies for LLVM's
* load PRE to move ahead of the fast copy's exits: such a move keeps the
* fast loop's first iteration in the loop, with the checks that one
* iteration of it makes redundant. */
import type { IrExpr, IrStmt } from "../../ir/ir.js";
import { everyStmt } from "../../ir/traverse.js";
import { emitBytesData, emitBytesHeaderLength, emitBytesSharedStorage } from "./expr-bytes.js";
import type { LlEmitter } from "./emitter.js";
export interface BytesHeader {
length: string;
data: string;
/** The view's shared storage (worker programs only). */
shared: string | null;
}
export interface BytesHeaderScope {
/** The function the scope's values belong to. */
fn: string;
locals: ReadonlyMap<string, BytesHeader>;
/** The header of each receiver value read from one of `locals`. */
values: Map<string, BytesHeader>;
}
/** Reads the header fields of the views `loop` accesses through a const
* local or a parameter it does not rebind, at the current block, which must
* dominate every copy of the loop. Null when there are none. */
export function emitLoopBytesHeaders(
emitter: LlEmitter,
loop: IrStmt,
parameters: ReadonlySet<string>,
): BytesHeaderScope | null {
const receivers = new Set<string>();
const rebound = new Set<string>();
const receiver = (e: IrExpr): void => {
if (e.kind === "varRef") receivers.add(e.localId);
};
everyStmt(loop, {
expr: (e) => {
if (e.kind === "bytesIntrinsic") receiver(e.receiver);
else if (e.kind === "assignExpr" || e.kind === "incDec") rebound.add(e.localId);
return true;
},
stmt: (s) => {
if (s.kind === "bytesSet") receiver(s.arr);
else if (s.kind === "assign" || s.kind === "forOf" || s.kind === "varDecl")
rebound.add(s.localId);
else if (s.kind === "tryCatch" && s.catchLocalId !== null) rebound.add(s.catchLocalId);
return true;
},
});
const locals = new Map<string, BytesHeader>();
for (const id of receivers) {
if (rebound.has(id)) continue;
const local = emitter.currentLocals.get(id);
if (local === undefined || local.boxed === true || local.type.kind !== "bytes") continue;
if (local.mutable && !parameters.has(id)) continue;
if (emitter.integerLoopBindings.has(id) || emitter.int32Locals.has(id)) continue;
const B = emitter.B;
const view = B.tmp();
B.line(`${view} = load ptr, ptr ${emitter.binding(id).slot}${emitter.slotAlias(id)}`);
locals.set(id, {
length: emitBytesHeaderLength(emitter, view),
data: emitBytesData(emitter, view),
shared: emitter.mod.workers ? emitBytesSharedStorage(emitter, view) : null,
});
}
if (locals.size === 0) return null;
return { fn: emitter.currentFunctionName, locals, values: new Map() };
}
@@ -320,16 +320,17 @@ test("worker programs test each receiver's shared storage once in the guard", ()
const fast = /^bytes\.version\.fast\d+:$[^]*?^bytes\.version\.fast\.shared\d+:$/m.exec(body)![0];
expect(fast).not.toContain("bytes.shared");
expect(fast).not.toMatch(/(load|store) atomic double/);
// The checked copy keeps the shared-memory path for each access, and so
// does the copy for shared views whose bounds hold.
// The checked copy keeps the shared-memory path for each access. The copy
// for shared views whose bounds hold is a counted loop that polls between
// blocks, so its accesses at the counter are plain.
const slow = body.slice(body.search(/^bytes\.version\.slow\d+:$/m), body.indexOf(fast));
expect(slow).toContain("load atomic double");
expect(slow).toContain("store atomic double");
const shared = /^bytes\.version\.fast\.shared\d+:$[^]*?^bytes\.version\.done\d+:$/m.exec(
body,
)![0];
expect(shared).toContain("load atomic double");
expect(shared).toContain("store atomic double");
expect(shared).toMatch(/^loop\.poll\d+:$/m);
expect(shared).not.toMatch(/(load|store) atomic double/);
});
test("index temps of a version's counter are integers in the fast copy", () => {
+11 -3
View File
@@ -573,13 +573,21 @@ export function classIdLoadLines(
* 64-bit word. The count is the i32 at offset 0. */
export const CLASS_ID_OFFSET = 4;
/** The bound on layout ids (scr_rc.h SCR_RC_LAYOUT_ID_LIMIT): the high
* half of a 64-bit rc word keeps the layout id in its low 30 bits, beside
* the program-lifetime and frozen bits. A class id is the layout id of its
* class's canonical layout. */
export const LAYOUT_ID_LIMIT = 0x3fffffff;
/** The rc word an LlClassMeta.idInRc instance is created with: the class
* id, its preorder number plus 1, in the high 32 bits, and in the low 32 bits
* a count of 1, or for a program-lifetime instance the midpoint of the 32-bit
* count (as programLifetimeRc is of the 32-bit word). The id is never 0,
* 0x80000000 or 0xffffffff, the high halves of other objects' rc words, so
* the runtime can tell an object with a class id (scr_rc.h scr_rc_class_id). */
* count (as programLifetimeRc is of the 32-bit word). The id is a layout id
* (LAYOUT_ID_LIMIT), so it never sets the program-lifetime or frozen bit
* and the runtime reads it back (scr_rc.h scr_rc_layout_id). */
export function classIdRcWord(meta: LlClassMeta, lifetime: boolean): string {
if (meta.pre + 1 >= LAYOUT_ID_LIMIT)
throw new InternalCompilerError(`class id ${meta.pre + 1} is not a layout id`);
return (BigInt(meta.pre + 1) * 2n ** 32n + (lifetime ? 2n ** 31n : 1n)).toString();
}
@@ -0,0 +1,109 @@
import { expect, test } from "vitest";
import { BOOL, F64, type IrExpr, type IrStmt } from "../../ir/ir.js";
import { matchConstantChoice } from "./constant-choices.js";
const loc = { file: "choices.ts", start: 0, end: 0 };
const bytes = { kind: "bytes", elem: "u8" } as const;
const num = (value: number): IrExpr => ({ kind: "numLit", value, type: F64, loc });
const ref = (localId: string, type: IrExpr["type"] = F64): IrExpr => ({
kind: "varRef",
localId,
type,
loc,
});
const is = (left: IrExpr, right: IrExpr): IrExpr => ({
kind: "bin",
op: "===",
left,
right,
type: BOOL,
loc,
});
const store = (value: IrExpr, index: IrExpr = ref("i"), arr = "a"): IrStmt => ({
kind: "bytesSet",
arr: ref(arr, bytes),
index,
value,
loc,
});
const branch = (cond: IrExpr, then: IrStmt[], else_: IrStmt[] | null): IrStmt => ({
kind: "if",
cond,
then,
else_,
loc,
});
/** `if (x === c0) a[i] = r0; else if ... else a[i] = fallback`. */
function chain(cases: [number, number][], fallback: IrStmt[] | null, subject = "x"): IrStmt {
let rest = fallback;
for (let k = cases.length - 1; k >= 0; k--) {
const [value, result] = cases[k]!;
rest = [branch(is(ref(subject), num(value)), [store(num(result))], rest)];
}
return rest![0]!;
}
test("a chain of literal stores to one element is a constant choice", () => {
const choice = matchConstantChoice(
chain(
[
[97, 0],
[116, 1],
[99, 2],
],
[store(num(3))],
),
);
expect(choice?.cases).toEqual([
{ value: 97, result: 0 },
{ value: 116, result: 1 },
{ value: 99, result: 2 },
]);
expect(choice?.fallback).toBe(3);
expect(choice?.stores).toHaveLength(4);
});
test("a literal on the left, -0 and a repeated value keep strict equality's first arm", () => {
const first: IrStmt = branch(
is(num(-0), ref("x")),
[store(num(5))],
[
chain(
[
[1, 6],
[0, 7],
[2, 8],
],
[store(num(9))],
),
],
);
expect(matchConstantChoice(first)?.cases).toEqual([
{ value: 0, result: 5 },
{ value: 1, result: 6 },
{ value: 2, result: 8 },
]);
});
test("chains that are not one choice of literals keep their arms", () => {
const three: [number, number][] = [
[1, 10],
[2, 20],
[3, 30],
];
// Two values, no final else, a fraction, or cases spanning more than 256 values.
expect(matchConstantChoice(chain(three.slice(0, 2), [store(num(0))]))).toBeNull();
expect(matchConstantChoice(chain(three, null))).toBeNull();
expect(matchConstantChoice(chain([...three, [1.5, 40]], [store(num(0))]))).toBeNull();
expect(matchConstantChoice(chain([...three, [256, 40]], [store(num(0))]))).not.toBeNull();
expect(matchConstantChoice(chain([...three, [257, 40]], [store(num(0))]))).toBeNull();
// A final else that is not a literal, or that stores elsewhere.
expect(matchConstantChoice(chain(three, [store(ref("y"))]))).toBeNull();
expect(matchConstantChoice(chain(three, [store(num(0), ref("j"))]))).toBeNull();
expect(matchConstantChoice(chain(three, [store(num(0), ref("i"), "b")]))).toBeNull();
// An arm with a second statement.
expect(matchConstantChoice(chain(three, [store(num(0)), store(num(1))]))).toBeNull();
// A second subject.
const mixed = branch(is(ref("y"), num(0)), [store(num(1))], [chain(three, [store(num(0))])]);
expect(matchConstantChoice(mixed)).toBeNull();
});
@@ -0,0 +1,241 @@
import { createHash } from "node:crypto";
import { InternalCompilerError } from "../../errors.js";
import type { IrExpr, IrStmt } from "../../ir/ir.js";
import { exactInteger, widenInteger } from "./integer-values.js";
import type { LlValue, LlvmEmitterContext } from "./expr-context.js";
import { f64Lit } from "./common.js";
type BytesSet = Extract<IrStmt, { kind: "bytesSet" }>;
/** An if/else-if chain that stores one of several numeric literals to the
* same typed-array element, choosing it by comparing one number local with
* integer literals:
*
* if (x === 97) a[i] = 0; else if (x === 116) a[i] = 1; ... else a[i] = 3;
*
* The chain is emitted as one store of a value read from a constant table,
* with no branch on the comparisons. Branches on unpredictable input (the
* bases of a DNA sequence, the bytes of a text) mispredict about half the
* time; LLVM keeps them, because sparse cases (97, 99, 116) are too far
* apart for its own switch tables. */
export interface ConstantChoice {
subject: IrExpr;
/** The first arm for each distinct value, in chain order. */
cases: readonly { value: number; result: number }[];
fallback: number;
/** Every arm's store, the final else's last. */
stores: readonly BytesSet[];
}
/** At least this many distinct values: shorter chains predict as well as a
* table load. */
const MIN_CASES = 3;
/** The table covers the subject's values from the smallest case (or the
* subject's proven minimum) up to this many entries. */
const MAX_TABLE = 256;
function integerLiteral(expr: IrExpr): number | null {
if (expr.kind !== "numLit" || !Number.isSafeInteger(expr.value)) return null;
// `x === -0` and `x === 0` select the same values.
return expr.value === 0 ? 0 : expr.value;
}
function sameVarRef(a: IrExpr, b: IrExpr): boolean {
return a.kind === "varRef" && b.kind === "varRef" && a.localId === b.localId;
}
/** A local read or a number literal: reading it has no effect. */
function sameOperand(a: IrExpr, b: IrExpr): boolean {
if (a.kind === "numLit" && b.kind === "numLit") return Object.is(a.value, b.value);
return sameVarRef(a, b);
}
/** The store of a single-statement arm, when it stores a number literal to
* an element named by a local and a local or a literal index (so evaluating
* them once, after the comparisons, reads what every arm would). */
function literalStore(body: readonly IrStmt[] | null): BytesSet | null {
if (body === null || body.length !== 1) return null;
const s = body[0]!;
if (s.kind !== "bytesSet" || s.value.kind !== "numLit") return null;
if (s.arr.kind !== "varRef" || (s.index.kind !== "varRef" && s.index.kind !== "numLit"))
return null;
if (s.arr.type.kind !== "bytes" || s.index.type.kind !== "f64") return null;
return s;
}
export function matchConstantChoice(s: IrStmt): ConstantChoice | null {
if (s.kind !== "if") return null;
let subject: IrExpr | null = null;
const cases: { value: number; result: number }[] = [];
const seen = new Set<number>();
const stores: BytesSet[] = [];
let current: IrStmt = s;
for (;;) {
if (current.kind !== "if") return null;
const cond = current.cond;
if (cond.kind !== "bin" || cond.op !== "===") return null;
const [local, literal] =
cond.left.kind === "varRef" ? [cond.left, cond.right] : [cond.right, cond.left];
if (local.kind !== "varRef" || local.type.kind !== "f64") return null;
if (subject === null) subject = local;
else if (!sameVarRef(subject, local)) return null;
const value = integerLiteral(literal);
if (value === null) return null;
const store = literalStore(current.then);
if (store === null) return null;
stores.push(store);
// A repeated value never reaches its later arm.
if (!seen.has(value)) {
seen.add(value);
cases.push({ value, result: (store.value as IrExpr & { kind: "numLit" }).value });
}
const rest: readonly IrStmt[] | null = current.else_;
if (rest === null || rest.length !== 1) return null;
if (rest[0]!.kind === "if") {
current = rest[0]!;
continue;
}
const last = literalStore(rest);
if (last === null) return null;
stores.push(last);
break;
}
const first = stores[0]!;
for (const store of stores) {
if (!sameVarRef(store.arr, first.arr) || !sameOperand(store.index, first.index)) return null;
if (store.arr.type.kind !== "bytes" || first.arr.type.kind !== "bytes") return null;
if (store.arr.type.elem !== first.arr.type.elem) return null;
}
if (cases.length < MIN_CASES) return null;
const values = cases.map((c) => c.value);
if (Math.max(...values) - Math.min(...values) >= MAX_TABLE) return null;
const fallback = (stores[stores.length - 1]!.value as IrExpr & { kind: "numLit" }).value;
return { subject: subject!, cases, fallback, stores };
}
function int32(value: number): boolean {
return (
Number.isInteger(value) && value >= -2147483648 && value <= 2147483647 && !Object.is(value, -0)
);
}
/** The chosen number: the table entry for the subject's value, or the final
* else's literal for a value no case names (including NaN, a fraction and
* every value outside the table). */
function emitChoice(host: LlvmEmitterContext, choice: ConstantChoice): LlValue {
const B = host.B;
const subject = host.emitExpr(choice.subject);
const results = [...choice.cases.map((c) => c.result), choice.fallback];
const integral = results.every(int32);
const element = integral ? "i32" : "double";
const literal = (value: number): string => (integral ? String(value) : f64Lit(value));
const proven = exactInteger(host, subject, choice.subject);
const values = choice.cases.map((c) => c.value);
let low = Math.min(...values);
let high = Math.max(...values);
// A proven range that fits a table needs no test: every value it allows
// has an entry.
const covered =
proven !== null && proven.range.max - proven.range.min < MAX_TABLE ? proven : null;
if (covered !== null) {
low = covered.range.min;
high = covered.range.max;
}
const size = high - low + 1;
const entries: string[] = [];
for (let value = low; value <= high; value++) {
const hit = choice.cases.find((c) => c.value === value);
entries.push(`${element} ${literal(hit === undefined ? choice.fallback : hit.result)}`);
}
const table = `@sc_choice_${createHash("sha256")
.update(`${element}:${entries.join(",")}`)
.digest("hex")
.slice(0, 16)}`;
host.declare(
`${table} = private unnamed_addr constant [${size} x ${element}] [${entries.join(", ")}]`,
);
let index: string;
let ok: string | null = null;
if (covered !== null) {
const wide = widenInteger(host, covered);
index = B.tmp();
B.line(`${index} = sub i64 ${wide}, ${low}`);
} else {
// freeze keeps NaN and out-of-range conversions from producing poison;
// the round trip then rejects every value that is not an exact integer
// (-0 converts to 0, which `=== 0` selects too).
const raw = B.tmp();
const selector = B.tmp();
const restored = B.tmp();
const exact = B.tmp();
const offset = B.tmp();
const inside = B.tmp();
ok = B.tmp();
index = B.tmp();
B.line(`${raw} = fptosi double ${subject.name} to i64`);
B.line(`${selector} = freeze i64 ${raw}`);
B.line(`${restored} = sitofp i64 ${selector} to double`);
B.line(`${exact} = fcmp oeq double ${subject.name}, ${restored}`);
B.line(`${offset} = sub i64 ${selector}, ${low}`);
B.line(`${inside} = icmp ult i64 ${offset}, ${size}`);
B.line(`${ok} = and i1 ${exact}, ${inside}`);
B.line(`${index} = select i1 ${ok}, i64 ${offset}, i64 0`);
}
const slot = B.tmp();
const entry = B.tmp();
B.line(
`${slot} = getelementptr inbounds [${size} x ${element}], ptr ${table}, i64 0, i64 ${index}`,
);
B.line(`${entry} = load ${element}, ptr ${slot}`);
let chosen = entry;
if (ok !== null) {
chosen = B.tmp();
B.line(
`${chosen} = select i1 ${ok}, ${element} ${entry}, ${element} ${literal(choice.fallback)}`,
);
}
if (!integral) return { name: chosen, type: choice.stores[0]!.value.type };
const number = B.tmp();
B.line(`${number} = sitofp i32 ${chosen} to double`);
return {
name: number,
type: choice.stores[0]!.value.type,
uint32: chosen,
integer: {
name: chosen,
type: "i32",
signed: Math.min(...results) < 0,
range: { min: Math.min(...results), max: Math.max(...results) },
},
};
}
/** Emits a matched chain as one store of the chosen literal. The element
* write is the arms' own: every arm names the same receiver and index. */
export function emitConstantChoice(host: LlvmEmitterContext, choice: ConstantChoice): void {
const first = choice.stores[0]!;
if (first.arr.type.kind !== "bytes")
throw new InternalCompilerError("llvm emitter bug: constant choice on non-bytes");
// An arm's index carries the ranges proven inside that arm: an index that
// is the compared local holds the arm's value there. The one store runs
// for every value, so such an index reads the local through the first
// comparison's operand, whose range holds before the chain branches.
const indexExpr = sameVarRef(first.index, choice.subject) ? choice.subject : first.index;
const arr = host.emitStableReceiver(first.arr, [indexExpr]);
const index = host.emitExpr(indexExpr);
const value = emitChoice(host, choice);
const scopes = choice.stores.map((store) => host.bytesAliasScopes.get(store));
host.emitBytesSet(
first.arr.type.elem,
arr.name,
index,
value,
indexExpr,
undefined,
choice.stores.every((store) => host.bytesBounds.has(store)),
choice.stores.every((store) => host.bytesOwned.has(store)),
scopes.every((scope) => scope === scopes[0]) ? (scopes[0] ?? "") : "",
);
}
@@ -83,6 +83,7 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string {
emitter.unwindCleanups.clear();
emitter.jumpTargets = [];
emitter.currentLocals = new Map(fn.locals.map((l) => [l.id, l]));
emitter.currentParameters = new Set(fn.params.map((p) => p.localId));
emitter.lazyCaptureBoxes.clear();
emitter.lazyStackBoxes.clear();
// An unchanged captured parameter keeps its value in a plain slot; the
@@ -67,6 +67,7 @@ import { InternalCompilerError } from "../../errors.js";
import { targetCpuAttribute } from "../target-cpu.js";
import { CPU_LEVEL_INIT_CALL, cpuDispatchAttributes, cpuLevelDefs } from "./cpu-dispatch.js";
import type { IrFunction } from "../../ir/ir.js";
import { type FunctionLayout, layoutTierOf } from "./function-layout.js";
/** The function bodies emitted in `chunks` contiguous ranges, each by its
* own emitter (LlEmitter.chunkEmitter), then merged in function order: the
@@ -233,6 +234,61 @@ function partitionGroupLines(functions: readonly IrFunction[]): string[] {
/** Ends the group of the definitions that follow. */
const PARTITION_GROUP_END = "; scriptc-group";
/** Writes the generated helpers, then the function definitions with their
* group lines. With a layout, the definitions it lists, helpers included,
* move after the unlisted helpers and before the other functions, by tier,
* each in its tier's group (`layout-<tier>`). The helper compiles each
* partition's definitions in text order, so listed ones come first in their
* partitions, and it places a group's definitions in the same partitions.
* Everything else keeps its order and its source-file group. */
function writeDefinitions(
out: string[],
layout: FunctionLayout | undefined,
helperTexts: readonly (readonly string[])[],
fnDefs: readonly string[],
groups: readonly string[],
): void {
const tierLine = (tier: number) => `${PARTITION_GROUP_END} layout-${tier}`;
const listed = new Map<number, string[]>();
const listIn = (tier: number, lines: readonly string[]) => {
const list = listed.get(tier) ?? [];
if (!listed.has(tier)) listed.set(tier, list);
for (const line of lines) list.push(line);
};
if (layout === undefined || layout.size === 0) {
for (const texts of helperTexts) for (const text of texts) out.push(text);
} else {
// A helper's text can span several entries (one per line), so the
// definitions are found in the joined text. A listed definition moves
// through its closing brace; what follows it stays in place.
const joined: string[] = [];
for (const texts of helperTexts) for (const text of texts) joined.push(text);
for (const part of joined.join("\n").split(/\n(?=define )/)) {
const tier = part.startsWith("define ") ? layoutTierOf(layout, part) : undefined;
const close = tier === undefined ? -1 : part.search(/\n}(\n|$)/);
if (tier === undefined || close < 0) {
out.push(part);
continue;
}
listIn(tier, [tierLine(tier), part.slice(0, close + 2)]);
const rest = part.slice(close + 3);
if (rest !== "") out.push(rest);
}
}
const unlisted: number[] = [];
fnDefs.forEach((text, i) => {
const tier = layout === undefined || layout.size === 0 ? undefined : layoutTierOf(layout, text);
if (tier === undefined) unlisted.push(i);
else listIn(tier, ["", tierLine(tier), text]);
});
for (const tier of [...listed.keys()].sort((a, b) => a - b))
for (const line of listed.get(tier)!) out.push(line);
for (const [k, i] of unlisted.entries()) {
if (k > 0 || listed.size > 0) out.push("");
out.push(groups[i] ?? PARTITION_GROUP_END, fnDefs[i]!);
}
}
export function emitModuleParts(emitter: LlEmitter): string[] {
// Function bodies first (the literal/unit/fn-value tables fill as they
// emit), then the file assembles around them — the runtime ABI’s order.
@@ -948,21 +1004,24 @@ export function emitModuleParts(emitter: LlEmitter): string[] {
);
}
if (globals.length > 0) out.push(``);
for (const line of helpers) out.push(line);
for (const line of ffiCallbacks.defs) out.push(line);
for (const line of shapes.defs) out.push(line);
for (const line of classShapes.defs) out.push(line);
for (const line of classObjDefs) out.push(line);
for (const line of emitter.walkers.defs) out.push(line);
for (const line of emitter.dyn.defs) out.push(line);
for (const line of wrappers) out.push(line);
for (const line of asyncDefs) out.push(line);
for (const line of emitter.resolveThunkDefs) out.push(line);
const groups = partitionGroupLines(emitter.mod.functions);
for (let i = 0; i < fnDefs.length; i++) {
if (i > 0) out.push("");
out.push(groups[i] ?? PARTITION_GROUP_END, fnDefs[i]!);
}
writeDefinitions(
out,
emitter.targetOptions.functionLayout,
[
helpers,
ffiCallbacks.defs,
shapes.defs,
classShapes.defs,
classObjDefs,
emitter.walkers.defs,
emitter.dyn.defs,
wrappers,
asyncDefs,
emitter.resolveThunkDefs,
],
fnDefs,
partitionGroupLines(emitter.mod.functions),
);
if (fnDefs.length === 0) out.push("");
out.push(PARTITION_GROUP_END, "");
// WebAssembly modules translated into this program, and the registry.
@@ -1,4 +1,6 @@
/* LlEmitter's statement emission: the statement switch, try/catch, switch, loop copies, speculative writes and string self-concatenation. */
import { emitLoopBytesHeaders } from "./bytes-headers.js";
import { noteBytesReceiver } from "./expr-bytes.js";
import {
type LlEmitter,
type LlScopeEntry,
@@ -81,6 +83,7 @@ import {
planProvenIntegerLoop,
planSpeculativeLoop,
type ProvenLoopPlan,
type SpeculativeLoopPlan,
speculativeReads,
speculativeWriteTarget,
} from "./speculative-integers.js";
@@ -93,6 +96,7 @@ import {
import { emitStringInputs } from "./string-lifetimes.js";
import { LOOPS } from "./loop-state.js";
import { loopAccessesTypedArrays } from "../loop-polls.js";
import { emitConstantChoice, matchConstantChoice } from "./constant-choices.js";
/** A callback loop version copies the loop; keep the copies small. */
const MAX_CALLBACK_VERSION_COST = 400;
@@ -576,6 +580,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
// IrBytesElem is static, so never rediscover it through the
// generic runtime switch in a hot loop.
const arr = emitter.emitStableReceiver(s.arr, [s.index, s.value]);
noteBytesReceiver(emitter, s.arr, arr.name);
const idx = emitter.emitExpr(s.index);
const v = emitter.emitExpr(s.value);
if (s.arr.type.kind !== "bytes")
@@ -837,6 +842,13 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
break;
}
case "if": {
// A chain storing one of several literals to one element stores a
// table entry instead (debug builds keep the arms to step through).
const choice = emitter.debug === null ? matchConstantChoice(s) : null;
if (choice !== null) {
emitConstantChoice(emitter, choice);
break;
}
const cond = emitter.emitCondition(s.cond);
const trueLabel = B.newLabel("if.t");
const joinLabel = B.newLabel("if.j");
@@ -1265,6 +1277,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
if (stripMined) {
const counter: StripMinedCounter = countedLoop
? {
localId: countedLoop.localId,
slot: integerSlot!,
type: "i64",
signed: true,
@@ -1276,6 +1289,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
limit: () => countedLimit!,
}
: {
localId: integerLoop!.localId,
slot: integerSlot!,
type: emitter.sizeType,
signed: false,
@@ -2371,6 +2385,22 @@ export function emitLoopCopies(
else ordinary(exit);
return;
}
const outer = emitter.bytesHeaders;
emitter.bytesHeaders = emitLoopBytesHeaders(emitter, loop, emitter.currentParameters) ?? outer;
try {
emitSpeculativeLoop(emitter, plan, exit, emitCopy);
} finally {
emitter.bytesHeaders = outer;
}
}
/** The copies of a speculative loop (emitLoopCopies). */
function emitSpeculativeLoop(
emitter: LlEmitter,
plan: SpeculativeLoopPlan,
exit: string,
emitCopy: (exit: string) => void,
): void {
const B = emitter.B;
const entries = new Map<string, string>();
let ok = "true";
@@ -2705,10 +2735,11 @@ function boundedLoopRuns(emitter: LlEmitter, loop: IrStmt): number {
return trips * (1 + nested);
}
/** The counter of a strip-mined loop: its slot, the LLVM integer type and
* signedness of its values, its step, the counters stepped beside it, and
* the limit, emitted in the outer header before each block. */
/** The counter of a strip-mined loop: its binding, its slot, the LLVM
* integer type and signedness of its values, its step, the counters stepped
* beside it, and the limit, emitted in the outer header before each block. */
interface StripMinedCounter {
localId: string;
slot: string;
type: string;
signed: boolean;
@@ -2800,7 +2831,7 @@ function emitStripMinedCountedLoop(
const after = runSlot === null ? poll : B.newLabel("loop.end");
B.condBr(inBlock, lb, after);
B.startBlock(lb);
emitter.jumpTargets.push({
const target: LlEmitter["jumpTargets"][number] = {
kind: "loop",
brkLabel: exit,
contLabel: lu,
@@ -2808,8 +2839,19 @@ function emitStripMinedCountedLoop(
frameDepth: emitter.frames.length,
scopeDepth: emitter.scopes.length,
finallyDepth: emitter.finallyStack.length,
});
emitter.emitBlock(s.body);
};
emitter.jumpTargets.push(target);
// The counter steps by a nonzero constant and the body does not assign
// it, so an element at the counter is a different element on every
// iteration: its plain load or store is not merged with another
// iteration's or moved out of the loop.
const outer = emitter.plainSharedLoop;
emitter.plainSharedLoop = { counter: counter.localId, target, depth: emitter.loopDepth };
try {
emitter.emitBlock(s.body);
} finally {
emitter.plainSharedLoop = outer;
}
emitter.jumpTargets.pop();
B.br(lu);
B.startBlock(lu);
@@ -1,4 +1,5 @@
import type { TargetCpu } from "../target-cpu.js";
import type { FunctionLayout } from "./function-layout.js";
import { relieveHeapPressure } from "../../heap-pressure.js";
import { wasmBoundsChecks } from "./wasm-modules.js";
import { jsTruthy } from "./jsval.js";
@@ -63,6 +64,7 @@ import { captureFreeFunctions, observesStacks } from "../stack-observations.js";
import { findInitializedByteLoopBindings } from "./byte-windows.js";
import { matchBytesLoopVersion } from "./bytes-loop-versions.js";
import { globalSlotAlias } from "./bytes-alias.js";
import type { BytesHeaderScope } from "./bytes-headers.js";
import type { IntegerCountedForLoop, IntegerPairedForLoop } from "../../ir/integer-loops.js";
import {
materializeSplitPiece,
@@ -499,6 +501,8 @@ export interface LlvmTargetOptions {
/** The host's process primitives, where it can fork (the native
* compiler's). */
forkPrimitives?: ForkPrimitives | undefined;
/** Functions to write first, by tier (function-layout.ts). */
functionLayout?: FunctionLayout;
/** Exact frontend sources for dev-build locations and variable metadata. */
debugSources?: ReadonlyMap<string, string>;
/** Pointer width of the target C ABI. Native targets are 64-bit today. */
@@ -1823,11 +1827,26 @@ export class LlEmitter {
/** Worker programs: typed-array accesses whose views an enclosing version
* guard proved thread-owned. */
bytesOwned: ReadonlySet<IrExpr | IrStmt> = new Set();
/** Worker programs: the strip-mined counted loop whose body is being
* emitted (emitStripMinedCountedLoop), with its counter, its jump target
* and its loopDepth. An element access of a view that may be shared, at
* the counter and directly in that loop's body, is a plain load or store
* (plainSharedElement in expr-bytes.ts). */
plainSharedLoop: {
counter: string;
target: LlEmitter["jumpTargets"][number];
depth: number;
} | null = null;
/** The alias-scope attachment of each typed-array element read and write
* in the current loop version's fast copy (emitVersionedLoop), and the
* one the element access being emitted carries (bytesElementAlias). */
bytesAliasScopes: ReadonlyMap<IrExpr | IrStmt, string> = new Map();
bytesAccessScope = "";
/** Header fields a speculative loop's entry read for its views
* (bytes-headers.ts), while its copies are emitted. */
bytesHeaders: BytesHeaderScope | null = null;
/** The parameters of the function being emitted. */
currentParameters: ReadonlySet<string> = new Set();
/** Loop versions with alias scopes in the current function; it names
* their scopes. */
aliasScopedVersions = 0;
@@ -1,6 +1,7 @@
/* Focused LLVM expression emission extracted from emitter.ts. */
import { InternalCompilerError } from "../../errors.js";
import { emitBorrowedInput } from "./borrowed-inputs.js";
import type { BytesHeader } from "./bytes-headers.js";
import { BYTES_ELEMENT_SIZE, F64, type IrBytesElem, type IrExpr } from "../../ir/ir.js";
import type { LlvmEmitterContext, LlValue } from "./expr-context.js";
import { F64_INF, exactIndexLines, f64Lit, sizeToDouble } from "./common.js";
@@ -160,6 +161,22 @@ export function emitBytesIndex(
return idx;
}
/** Records that `value` is the view `receiver` reads, so the header reads of
* an access through it use the values a loop entry read (bytes-headers.ts). */
export function noteBytesReceiver(host: LlvmEmitterContext, receiver: IrExpr, value: string): void {
const scope = host.bytesHeaders;
if (scope === null || receiver.kind !== "varRef" || scope.fn !== host.currentFunctionName) return;
const header = scope.locals.get(receiver.localId);
if (header !== undefined) scope.values.set(value, header);
}
/** The header fields a loop entry read for the view `receiver`, if any. */
function cachedHeader(host: LlvmEmitterContext, receiver: string): BytesHeader | undefined {
const scope = host.bytesHeaders;
if (scope === null || scope.fn !== host.currentFunctionName) return undefined;
return scope.values.get(receiver);
}
/** The element count of a typed array, in the target's size type. `value`:
* the program reads the count itself (bytesHeaderValueAlias), rather than
* only bounding an access. */
@@ -168,6 +185,8 @@ export function emitBytesHeaderLength(
receiver: string,
value = false,
): string {
const cached = cachedHeader(host, receiver);
if (cached !== undefined) return cached.length;
const p = host.B.tmp();
const len = host.B.tmp();
const alias = value ? bytesHeaderValueAlias(host) : bytesHeaderAlias(host);
@@ -177,6 +196,8 @@ export function emitBytesHeaderLength(
}
export function emitBytesData(host: LlvmEmitterContext, receiver: string): string {
const cached = cachedHeader(host, receiver);
if (cached !== undefined) return cached.data;
const p = host.B.tmp();
const data = host.B.tmp();
host.B.line(`${p} = getelementptr inbounds %ScrBytes, ptr ${receiver}, i64 0, i32 3`);
@@ -203,6 +224,8 @@ export function emitBytesLength(
/** A view's shared backing (null for a buffer this thread owns). It is
* set at construction, before script code can see the view. */
export function emitBytesSharedStorage(host: LlvmEmitterContext, receiver: string): string {
const cached = cachedHeader(host, receiver)?.shared;
if (cached !== undefined && cached !== null) return cached;
const B = host.B;
const slot = B.tmp(),
storage = B.tmp();
@@ -317,7 +340,7 @@ function emitBytesLoad(
owned: boolean,
): LlValue {
// `owned`: an enclosing version guard proved the view thread-owned.
if (host.mod.workers && !owned) {
if (host.mod.workers && !owned && !plainSharedElement(host, elem, expr)) {
const idx = emitBytesIndex(host, receiver, index, expr, invalid, inBounds);
return emitSharedOrLocalLoad(host, elem, receiver, idx);
}
@@ -328,6 +351,31 @@ function emitBytesLoad(
return emitBytesLoadAt(host, elem, data, idx);
}
/** Worker programs: whether an element access at `index` of a view that
* may be shared is a plain load or store, like a thread-owned view's. It is
* when the index is the counter of the strip-mined counted loop whose body
* holds the access, outside any loop nested in that body (its condition and
* update included: a nested loop that waits on the element reads it on
* every iteration, so the read stays atomic): every iteration then reaches
* a different element, so LLVM cannot merge an element's reads across
* iterations or move them out of the loop, and the loop can be vectorized. A racing write from another thread can still be seen or
* missed within one iteration, the documented gap of non-atomic shared
* accesses. Float16 elements keep their runtime path. */
function plainSharedElement(
host: LlvmEmitterContext,
elem: IrBytesElem,
index: IrExpr | undefined,
): boolean {
const loop = host.plainSharedLoop;
if (loop === null || elem === "f16" || index?.kind !== "varRef") return false;
// A nested loop raises loopDepth before its condition is emitted.
if (index.localId !== loop.counter || host.loopDepth !== loop.depth) return false;
let innermost: (typeof host.jumpTargets)[number] | undefined;
for (let i = host.jumpTargets.length - 1; i >= 0 && innermost === undefined; i--)
if (host.jumpTargets[i]!.kind === "loop") innermost = host.jumpTargets[i];
return innermost === loop.target;
}
/** Worker programs: the element at a checked index of a view that may be
* shared. An integer element joins as its 32-bit value and converts to a
* number after the join, as the local load does, so compares and
@@ -666,7 +714,7 @@ function emitScopedBytesSet(
ty = "i32";
operand = stored!;
}
if (host.mod.workers && !owned) {
if (host.mod.workers && !owned && !plainSharedElement(host, elem, indexExpr)) {
// A shared view's element takes a relaxed atomic store of its width.
const local = emitSharedBytesSplit(host, receiver);
const p = B.tmp();
@@ -788,6 +836,7 @@ export function emitBytesIntrinsic(
const r = directElementAccess
? host.emitStableReceiver(e.receiver, e.args)
: emitBorrowedInput(host, e.receiver);
if (directElementAccess) noteBytesReceiver(host, e.receiver, r.name);
// Byte operations borrow every input; views and identity results return
// independent owners, while writes retain no operand beyond the call.
const args = e.args.map((a) => emitBorrowedInput(host, a));
@@ -0,0 +1,41 @@
/** Function layouts: the functions that runs of a program execute, in tiers
* (scripts/refresh-cli-layout.mjs writes the native compiler's). The emitter
* writes a listed function before the others, in a partition group of its
* tier, so the code that runs together sits together in the executable. */
/** Layout key → tier (1 first). */
export type FunctionLayout = ReadonlyMap<string, number>;
/** The layout key of an LLVM symbol: module numbers (`%m12.`, `%init.12`),
* which change when modules are added, are left out. */
export function functionLayoutKey(symbol: string): string {
return symbol.replace(/_x25_m\d+_/g, "_x25_m_").replace(/_x25_init_\d+$/, "_x25_init_");
}
/** Reads a layout's lines, each `<tier> <symbol>`. A function listed in two
* tiers keeps the first. */
export function parseFunctionLayout(text: string): FunctionLayout {
const layout = new Map<string, number>();
for (const line of text.split("\n")) {
const space = line.indexOf(" ");
if (space < 1) continue;
const tier = Number(line.slice(0, space));
const key = functionLayoutKey(line.slice(space + 1).trim());
if (Number.isInteger(tier) && tier > 0 && !layout.has(key)) layout.set(key, tier);
}
return layout;
}
/** The lowest tier of the functions `text` defines, or undefined when the
* layout lists none of them. */
export function layoutTierOf(layout: FunctionLayout, text: string): number | undefined {
let tier: number | undefined;
for (let at = text.indexOf("define "); at >= 0; at = text.indexOf("\ndefine ", at + 1)) {
const name = text.indexOf("@", at);
const open = text.indexOf("(", name);
if (name < 0 || open < 0) break;
const listed = layout.get(functionLayoutKey(text.slice(name + 1, open)));
if (listed !== undefined && (tier === undefined || listed < tier)) tier = listed;
}
return tier;
}
@@ -423,6 +423,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"fs.rmRetrySync": "scr_fs_rm_opts_retry",
"fs.rmdirSync": "scr_fs_rmdir",
"fs.readdirSync": "scr_fs_readdir",
"fs.readdirRecursiveSync": "scr_fs_readdir_recursive",
"fs.globSync": "scr_fs_glob_strings",
"fs.callbackGlob": "scr_fs_glob_callback",
"fsp.globStrings": "scr_fsp_glob_strings",
@@ -1715,6 +1716,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"dyn.objEntries": "scr_dyn_obj_entries",
"dyn.structuredClone": "scr_structured_clone",
"dyn.declAdopt": "scr_dyn_decl_adopt",
"dyn.adoptShared": "scr_dyn_adopt_shared",
"dyn.cloneMissing": "scr_structured_clone_missing",
"dyn.cloneTransferFail": "scr_structured_clone_transfer_fail",
"dyn.cloneTransferFailWith": "scr_structured_clone_transfer_fail_with",
@@ -0,0 +1,80 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { compile, deserializeModule } from "../../index.js";
import { validateModule } from "../../ir/validate.js";
import { emitLlvmModule } from "./emitter.js";
async function llvmFor(source: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), "scriptc-shape-union-tags-"));
try {
const entry = join(dir, "main.ts"),
output = join(dir, "main.ir.json");
await writeFile(entry, source);
const result = await compile(entry, { outDir: dir, outPath: output, outputKind: "ir" });
if (!result.ok)
throw new Error(result.diagnostics.map((item) => `${item.code}: ${item.message}`).join("\n"));
const module = deserializeModule(await readFile(output, "utf8"));
expect(validateModule(module)).toEqual([]);
return emitLlvmModule(module);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
function tagHelpers(ir: string): string[] {
return [...ir.matchAll(/^define internal i32 @sc_stag\.[^\n]*\{[^]*?^}/gm)].map(
(match) => match[0],
);
}
test("a union whose arms span few shape numbers switches on the shape word", async () => {
const ir = await llvmFor(`
type Num = { kind: "num"; value: number };
type Neg = { kind: "neg"; operand: Node };
type Add = { kind: "add"; left: Node; right: Node };
type Node = Num | Neg | Add;
function evaluate(node: Node): number {
switch (node.kind) {
case "num": return node.value;
case "neg": return -evaluate(node.operand);
case "add": return evaluate(node.left) + evaluate(node.right);
}
}
const one: Node = { kind: "num", value: 1 };
console.log(evaluate({ kind: "add", left: one, right: { kind: "neg", operand: one } }));
`);
expect(ir).not.toMatch(/@sc_stagtab\./);
const helpers = tagHelpers(ir);
expect(helpers.length).toBeGreaterThan(0);
for (const helper of helpers) expect(helper).toMatch(/switch i32 %w/);
});
test("a union whose arms span many shape numbers reads the arm from its table", async () => {
const arms = Array.from({ length: 40 }, (_, i) => i);
const ir = await llvmFor(`
${arms.map((i) => `type K${i} = { kind: "k${i}"; v${i}: number };`).join("\n")}
type Any = ${arms.map((i) => `K${i}`).join(" | ")};
function value(item: Any): number {
switch (item.kind) {
${arms.map((i) => ` case "k${i}": return item.v${i};`).join("\n")}
}
}
const items: Any[] = [${arms.map((i) => `{ kind: "k${i}", v${i}: ${i} }`).join(", ")}];
let total = 0;
for (const item of items) total += value(item);
console.log(total);
`);
// One byte per shape number from the arms' lowest to highest; 255 names no arm.
const table =
/^(@sc_stagtab\.\S+) = private unnamed_addr constant \[(\d+) x i8\] \[([^\]]*)\]$/m.exec(ir);
expect(table, "arm table").not.toBeNull();
const entries = table![3]!.split(", ").map((entry) => Number(entry.replace("i8 ", "")));
expect(entries).toHaveLength(Number(table![2]));
expect(entries.filter((entry) => entry !== 255).sort((a, b) => a - b)).toEqual(arms);
const helper = tagHelpers(ir).find((text) => text.includes(table![1]!));
expect(helper, "tag helper").toBeDefined();
expect(helper).toContain(`getelementptr inbounds [${table![2]} x i8], ptr ${table![1]}`);
expect(helper).not.toMatch(/switch i32 %w/);
});
+60 -14
View File
@@ -313,19 +313,46 @@ function shapeUnionRc(host: ShapeHost, union: ShapeUnion): { retain: string; rel
};
}
/** The widest span of shape numbers a union's arms may cover for its tag
* helper to switch on the shape word (emitShapeUnionHelpers). */
const SHAPE_TAG_SWITCH_SPAN = 32;
/** The tag, release and trace helpers of every shape union
* (nullable-unions.ts). The tag helper maps a value to its arm: NULL and
* the null sentinel are the unit arms, anything else a tagged record whose
* shape word names its arm (the switch folds to a lookup table). */
* shape word names its arm.
* - Arms within SHAPE_TAG_SWITCH_SPAN shape numbers: the helper switches on
* the shape word. Inlined, the switch folds into the caller's branch on
* the arm, one indirect jump with a jump table of at most that many
* entries per site.
* - Wider unions: the shape word indexes the union's arm table, one entry
* per shape number from the arms' lowest to highest, and callers branch
* on the small arm tag. Folded switches would give every site of such a
* union a jump table spanning all of its arms' shape numbers. */
export function emitShapeUnionHelpers(host: ShapeHost): string[] {
const out: string[] = [];
const S = host.sizeType;
for (const union of host.nullableUnions.allShapeUnions()) {
const header = shapeWordHeader(S);
const tagFn = shapeTagFn(union.unionId);
const cases = union.arms
.map((arm) => `i32 ${host.nullableUnions.shapeNumber(arm.shapeId)}, label %a${arm.tag}`)
.join(" ");
const numbers = union.arms.map((arm) => host.nullableUnions.shapeNumber(arm.shapeId));
const lo = Math.min(...numbers);
const span = Math.max(...numbers) - lo + 1;
const byTable = span > SHAPE_TAG_SWITCH_SPAN;
// Entries are bytes, or 16-bit words for a union of 255 arms or more.
// A shape number that names no arm maps to the all-ones entry, which is
// above every arm tag, so callers take their path for no arm as they do
// for the -1 of a shape number outside the table.
const wide = union.arms.some((arm) => arm.tag >= 255);
const entry = wide ? "i16" : "i8";
const none = wide ? 65535 : 255;
const table = new Array<number>(span).fill(none);
union.arms.forEach((arm, i) => (table[numbers[i]! - lo] = arm.tag));
const tableName = tagFn.replace("@sc_stag.", "@sc_stagtab.");
if (byTable)
out.push(
`${tableName} = private unnamed_addr constant [${span} x ${entry}] [${table.map((t) => `${entry} ${t}`).join(", ")}]`,
);
out.push(
`define internal i32 ${tagFn}(ptr %u) alwaysinline ${FN_ATTRS} { ; shape union tag`,
`entry:`,
@@ -343,16 +370,35 @@ export function emitShapeUnionHelpers(host: ShapeHost): string[] {
` ret i32 ${union.nullTag}`,
`rec:`,
);
out.push(
` %wp = getelementptr inbounds ${header}, ptr %u, i32 0, i32 ${SHAPE_WORD_INDEX}`,
` %w = load i32, ptr %wp`,
` switch i32 %w, label %bad [ ${cases} ]`,
...union.arms.flatMap((arm) => [`a${arm.tag}:`, ` ret i32 ${arm.tag}`]),
`bad:`,
` ret i32 -1`,
`}`,
``,
);
const cases = union.arms.map((arm, i) => `i32 ${numbers[i]}, label %a${arm.tag}`).join(" ");
if (!byTable)
out.push(
` %wp = getelementptr inbounds ${header}, ptr %u, i32 0, i32 ${SHAPE_WORD_INDEX}`,
` %w = load i32, ptr %wp`,
` switch i32 %w, label %bad [ ${cases} ]`,
...union.arms.flatMap((arm) => [`a${arm.tag}:`, ` ret i32 ${arm.tag}`]),
`bad:`,
` ret i32 -1`,
`}`,
``,
);
else
out.push(
` %wp = getelementptr inbounds ${header}, ptr %u, i32 0, i32 ${SHAPE_WORD_INDEX}`,
` %w = load i32, ptr %wp`,
` %i = sub i32 %w, ${lo}`,
` %inrange = icmp ult i32 %i, ${span}`,
` br i1 %inrange, label %look, label %bad`,
`look:`,
` %tp = getelementptr inbounds [${span} x ${entry}], ptr ${tableName}, i32 0, i32 %i`,
` %t8 = load ${entry}, ptr %tp`,
` %tag = zext ${entry} %t8 to i32`,
` ret i32 %tag`,
`bad:`,
` ret i32 -1`,
`}`,
``,
);
// Release and trace: one definition for the unions over the same
// record arms (helperOwner). Unit encodings are NULL or the immortal
// null sentinel; the shape word picks the arm.
@@ -283,3 +283,43 @@ test("calls that cannot unwind are marked nounwind", () => {
expect(out).toContain("%r = call ptr %b(ptr %b) nounwind");
expect(out).toContain(" call void @scr_unwind_raise()\n");
});
test("functions without a plain call of a raising function change only their call lines", () => {
const raising = [
"define internal void @sc_bf_raises() #0 {",
"entry:",
" call void @scr_unwind_raise()",
" unreachable",
"}",
].join("\n");
const other = [
"define internal void @sc_bf_raises.copy() #0 {",
"entry:",
" ret void",
"}",
].join("\n");
const plain = [
"define internal double @sc_f_plain(ptr %p) #0 {",
"entry: ; call sites below",
" %a = call double @sc_f_other(ptr %p)",
" %b = tail call double @sc_f_other(ptr %p) ; mentions @ and @sc_bf_raises.copy",
" store ptr @sc_bf_raises.copy, ptr %p",
" ret double %a",
"}",
].join("\n");
const parts = [`${raising}\n${other}`, plain, "@table = internal constant ptr @sc_f_plain"];
const out = bridgePlainUnwindCalls(parts);
expect(out[0]).toBe(parts[0]);
expect(out[1]).toBe(
plain
.replace("@sc_f_other(ptr %p)\n", "@sc_f_other(ptr %p) nounwind\n")
.replace("@sc_f_other(ptr %p) ;", "@sc_f_other(ptr %p) nounwind ;"),
);
expect(out[2]).toBe(parts[2]);
expect(() =>
bridgePlainUnwindCalls([
raising,
plain.replace("store ptr @sc_bf_raises.copy", "store ptr @sc_bf_raises"),
]),
).toThrow(UnwindAbiViolation);
});
@@ -301,6 +301,46 @@ function markNounwind(line: string): string {
: `${line.slice(0, close + 1)} nounwind${line.slice(close + 1)}`;
}
/** The functions of `part`: its text split before each `define` line
* (`part.split(/\n(?=define )/)`), found with indexOf. */
function splitFunctions(part: string): string[] {
const chunks: string[] = [];
let start = 0;
for (let at = part.indexOf("\ndefine "); at >= 0; at = part.indexOf("\ndefine ", at + 1)) {
chunks.push(part.slice(start, at));
start = at + 1;
}
chunks.push(part.slice(start));
return chunks;
}
/** Whether `code` can be part of a symbol name (`[\w.$%-]`). */
function isNameCode(code: number): boolean {
return (
(code >= 97 && code <= 122) ||
(code >= 65 && code <= 90) ||
(code >= 48 && code <= 57) ||
code === 95 /* _ */ ||
code === 46 /* . */ ||
code === 36 /* $ */ ||
code === 37 /* % */ ||
code === 45 /* - */
);
}
/** Calls `each` with every line of `chunk` that holds `call `, by its start
* and end offsets, once per line and in order. Lines without `call ` match
* neither CALL_LINE nor PLAIN_CALL. */
function eachCallLine(chunk: string, each: (start: number, end: number) => void): void {
for (let at = chunk.indexOf("call "); at >= 0;) {
const start = chunk.lastIndexOf("\n", at) + 1;
let end = chunk.indexOf("\n", at);
if (end < 0) end = chunk.length;
each(start, end);
at = chunk.indexOf("call ", end);
}
}
/** The value a bridged call yields on its landing pad: the dummy a
* pending-flag callee returns with an exception pending. */
function dummyValue(type: string): string {
@@ -318,7 +358,7 @@ function dummyValue(type: string): string {
* these functions that emitDirectCall lowered are invokes already. */
export function bridgePlainUnwindCalls(parts: string[]): string[] {
const raisers = new Set<string>();
const split = parts.map((part) => part.split(/\n(?=define )/));
const split = parts.map(splitFunctions);
for (const chunks of split)
for (const chunk of chunks) {
const m = DEFINE_NAME.exec(chunk);
@@ -359,20 +399,29 @@ export function bridgePlainUnwindCalls(parts: string[]): string[] {
}
}
for (const name of invoked) if (!raisers.has(name) && testsFlag.has(name)) violations.add(name);
// Every mention of a raising function outside its `define` line, other
// than as the callee of a call or invoke, found with indexOf.
for (const chunks of split)
for (const chunk of chunks)
for (const line of chunk.split("\n")) {
if (line.startsWith("define ")) continue;
for (const m of line.matchAll(/@([\w.$%-]+)/g)) {
if (!raisers.has(m[1]!)) continue;
const at = m.index!;
if (
/(?:call|invoke) [^@]*$/.test(line.slice(0, at)) &&
line.charAt(at + m[0].length) === "("
)
continue;
violations.add(m[1]!);
for (let at = chunk.indexOf("@"); at >= 0;) {
let end = at + 1;
while (end < chunk.length && isNameCode(chunk.charCodeAt(end))) end++;
const next = chunk.indexOf("@", end);
if (end === at + 1) {
at = next;
continue;
}
const name = chunk.slice(at + 1, end);
if (raisers.has(name)) {
const start = chunk.lastIndexOf("\n", at) + 1;
const line = chunk.slice(start, at);
if (
!line.startsWith("define ") &&
!(/(?:call|invoke) [^@]*$/.test(line) && chunk.charAt(end) === "(")
)
violations.add(name);
}
at = next;
}
if (violations.size > 0) throw new UnwindAbiViolation(violations);
let personality = false;
@@ -381,6 +430,34 @@ export function bridgePlainUnwindCalls(parts: string[]): string[] {
chunks
.map((chunk) => {
if (!DEFINE_NAME.test(chunk) || !chunk.includes("call ")) return chunk;
// A function without a plain call of a raising function only marks
// its calls nounwind: rewrite just its call lines.
let bridged = false;
const marks: { start: number; end: number; line: string }[] = [];
eachCallLine(chunk, (start, end) => {
if (bridged) return;
const line = chunk.slice(start, end);
const m = PLAIN_CALL.exec(line);
const callee = m === null ? undefined : (m[4] ?? m[7]);
if (m !== null && callee !== undefined && raisers.has(callee)) {
bridged = true;
return;
}
if (!CALL_LINE.test(line)) return;
const marked = markNounwind(line);
if (marked !== line) marks.push({ start, end, line: marked });
});
if (!bridged) {
if (marks.length === 0) return chunk;
const pieces: string[] = [];
let from = 0;
for (const mark of marks) {
pieces.push(chunk.slice(from, mark.start), mark.line);
from = mark.end;
}
pieces.push(chunk.slice(from));
return pieces.join("");
}
const lines = chunk.split("\n");
const resume = resumers.has(DEFINE_NAME.exec(chunk)![1]!);
const result: string[] = [];
@@ -1,4 +1,4 @@
import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { chmod, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, expect, test } from "vitest";
@@ -26,6 +26,7 @@ async function fakePackage(
emptyOutput?: boolean;
missingOutput?: boolean;
changePackageDuringEmit?: boolean;
profileReport?: string;
} = {},
) {
const root = await mkdtemp(join(tmpdir(), "scriptc-native-helper-test-"));
@@ -72,6 +73,7 @@ ${
? ":"
: 'for each in $outputs; do cp "$input" "$each"; done'
}
${options.profileReport === undefined ? "" : `printf '%s\\n' '${options.profileReport}'`}
`,
);
await chmod(bin, 0o755);
@@ -196,6 +198,41 @@ test("profiles reach the helper and key the native cache by their bytes", async
expect(calls[2]).toContain("--profile-use /profiles/a.profdata");
});
test("a cached artifact keeps the profile match of the build that produced it", async () => {
const pkg = await fakePackage({
profileReport:
'{"ok":true,"profile":{"annotated":120,"mismatched":7,"missing":40,"unknown_partitions":0}}',
});
const used = {
...request(pkg.root, pkg.packageJson, join(pkg.root, "used.o")),
profile: { use: { path: "/profiles/a.profdata", digest: "a".repeat(64) } },
};
const match = { annotated: 120, mismatched: 7, missing: 40, unknownPartitions: 0 };
expect((await emitNativeArtifact(used)).profileMatch).toEqual(match);
const again = await emitNativeArtifact({ ...used, outputPath: join(pkg.root, "again.o") });
expect(again.profileMatch).toEqual(match);
expect((await readFile(pkg.log, "utf8")).trim().split("\n")).toHaveLength(1);
// Without the cached match, the cached partition's counts are unknown.
const cache = join(pkg.root, "cache", "native-codegen-v1");
const [shard] = await readdir(cache);
for (const name of await readdir(join(cache, shard!)))
if (name.includes(".profile-match")) await rm(join(cache, shard!, name));
const unknown = await emitNativeArtifact({ ...used, outputPath: join(pkg.root, "unknown.o") });
expect(unknown.profileMatch).toEqual({
annotated: 0,
mismatched: 0,
missing: 0,
unknownPartitions: 1,
});
// Builds without a profile carry no match, from the helper or the cache.
const plain = request(pkg.root, pkg.packageJson, join(pkg.root, "plain.o"));
expect((await emitNativeArtifact(plain)).profileMatch).toBeUndefined();
expect(
(await emitNativeArtifact({ ...plain, outputPath: join(pkg.root, "plain-again.o") }))
.profileMatch,
).toBeUndefined();
});
test("chunked LLVM input preserves emitted bytes and the native cache identity", async () => {
const pkg = await fakePackage();
const first = request(pkg.root, pkg.packageJson, join(pkg.root, "chunks.o"));
@@ -2,7 +2,17 @@ import { execFile } from "node:child_process";
import { createHash, type Hash } from "node:crypto";
import { closeSync, constants, openSync, readSync } from "node:fs";
import { createRequire } from "node:module";
import { access, chmod, copyFile, mkdir, readFile, rename, rm, stat } from "node:fs/promises";
import {
access,
chmod,
copyFile,
mkdir,
readFile,
rename,
rm,
stat,
writeFile,
} from "node:fs/promises";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
import {
@@ -436,7 +446,10 @@ export async function emitNativeArtifact(
dependencies: helper.dependencies,
outputPaths: outputs,
} satisfies NativeCodegenArtifact;
if (await installCachedPartitions(cached, outputs)) return artifact;
if (await installCachedPartitions(cached, outputs)) {
if (options.profile?.use === undefined) return artifact;
return { ...artifact, profileMatch: await cachedProfileMatch(cached[0]!, outputs.length) };
}
// Partitioned programs also reuse individual partition artifacts, so an
// edit recompiles only the partitions it affects. The directory names the
// exact helper build and target; the helper keys entries by content.
@@ -459,6 +472,7 @@ export async function emitNativeArtifact(
// The helper reads a private copy: the caller's .ll may be shared with a
// concurrent build of another entry that has the same output stem.
const input = privateSiblingPath(options.outputPath, "native-input");
const matchStage = privateSiblingPath(options.outputPath, "native-profile-match");
try {
if (isLlvmFile(options.llvm)) await copyFile(options.llvm.file, input);
else await writeLlvmSource(input, options.llvm, 0o600);
@@ -493,6 +507,8 @@ export async function emitNativeArtifact(
}
await chmod(stage, artifactMode());
}
const profileMatch =
options.profile?.use === undefined ? null : parseNativeProfileMatch(stdout);
// Cache publication is an optimization boundary. The helper has already
// produced a valid caller artifact, so a read-only/full cache must not
// discard it or turn an otherwise successful build into an exception.
@@ -500,23 +516,68 @@ export async function emitNativeArtifact(
root !== null &&
(await nativeArtifactDependenciesStillMatch(helper.dependencies).catch(() => false))
) {
// The match goes in first: a reader that finds every partition finds it too.
if (profileMatch !== null) {
await writeFile(
matchStage,
`${JSON.stringify({ profile: helperProfileMatch(profileMatch) })}\n`,
{
mode: 0o600,
},
)
.then(() => publishCachedFile(matchStage, cachedProfileMatchPath(cached[0]!)))
.catch(() => undefined);
}
for (let index = 0; index < stages.length; index++)
await publishCachedFile(stages[index]!, cached[index]!).catch(() => undefined);
}
for (let index = 0; index < stages.length; index++)
await rename(stages[index]!, outputs[index]!);
await pruneBuildCache(root);
const profileMatch =
options.profile?.use === undefined ? null : parseNativeProfileMatch(stdout);
return profileMatch === null ? artifact : { ...artifact, profileMatch };
} finally {
await Promise.all([
...stages.map((stage) => rm(stage, { force: true }).catch(() => undefined)),
rm(input, { force: true }).catch(() => undefined),
rm(matchStage, { force: true }).catch(() => undefined),
]);
}
}
/** The cache entry beside a cached artifact's first partition that holds the
* profile match of the build that produced it. */
function cachedProfileMatchPath(firstPartition: string): string {
return `${firstPartition}.profile-match`;
}
/** The match in the helper's own JSON form, as parseNativeProfileMatch reads it. */
function helperProfileMatch(match: NativeProfileMatch): Record<string, number> {
return {
annotated: match.annotated,
mismatched: match.mismatched,
missing: match.missing,
unknown_partitions: match.unknownPartitions,
};
}
/** The profile match of a cached artifact. Without a valid entry, every
* partition's counts are unknown. */
async function cachedProfileMatch(
firstPartition: string,
partitions: number,
): Promise<NativeProfileMatch> {
const path = cachedProfileMatchPath(firstPartition);
const text = (await validCachedFile(path)) ? await readFile(path, "utf8").catch(() => "") : "";
return (
parseNativeProfileMatch(text) ?? {
annotated: 0,
mismatched: 0,
missing: 0,
unknownPartitions: partitions,
}
);
}
/** Install a cached artifact only when every partition is present and valid. */
async function installCachedPartitions(
cached: readonly (string | null)[],
@@ -3,6 +3,7 @@ import { executableLinkInputs } from "./link-plan-core.js";
import {
parseNativeProfileMatch,
nativeProfileHelperArgs,
renderNativeProfileMatch,
nativeProfileKey,
nativeProfileRefusal,
profileGenerateLinkerArgs,
@@ -84,3 +85,49 @@ test("the helper's profile match is read from its output", () => {
expect(parseNativeProfileMatch('{"ok":true}\n')).toBeNull();
expect(parseNativeProfileMatch('{"ok":true,"profile":{"annotated":-1}}\n')).toBeNull();
});
test("the build reports the match, and warns when the profile is stale", () => {
expect(
renderNativeProfileMatch("app.profdata", {
annotated: 9170,
mismatched: 0,
missing: 28636,
unknownPartitions: 0,
}),
).toBe(
"scriptc: profile app.profdata: 9170 functions annotated, 0 mismatched, 28636 without a record\n",
);
// 10% of the described functions changed: at the threshold, no warning.
expect(
renderNativeProfileMatch("app.profdata", {
annotated: 90,
mismatched: 10,
missing: 5,
unknownPartitions: 1,
}),
).toBe(
"scriptc: profile app.profdata: 90 functions annotated, 10 mismatched, 5 without a record, 1 partition without counts\n",
);
expect(
renderNativeProfileMatch("app.profdata", {
annotated: 5208,
mismatched: 3739,
missing: 28859,
unknownPartitions: 2,
}),
).toBe(
"scriptc: profile app.profdata: 5208 functions annotated, 3739 mismatched, 28859 without a record, 2 partitions without counts\n" +
"scriptc: warning: 3739 of the 8947 functions app.profdata describes (41.8%) changed since it was collected and are optimized without their counts; collect the profile again\n",
);
// Nothing described: no share to warn about.
expect(
renderNativeProfileMatch("app.profdata", {
annotated: 0,
mismatched: 0,
missing: 12,
unknownPartitions: 0,
}),
).toBe(
"scriptc: profile app.profdata: 0 functions annotated, 0 mismatched, 12 without a record\n",
);
});
@@ -83,6 +83,32 @@ export function parseNativeProfileMatch(stdout: string): NativeProfileMatch | nu
return null;
}
/** The share of the functions a profile describes whose control flow changed
* since it was collected, above which `scriptc build` warns that the profile
* is stale. A freshly collected profile has none. */
export const STALE_PROFILE_SHARE = 0.1;
/** `scriptc build --profile-use`'s report on stderr: one line with the match,
* and a warning line when more than STALE_PROFILE_SHARE of the functions the
* profile describes changed since it was collected. */
export function renderNativeProfileMatch(profile: string, match: NativeProfileMatch): string {
const unknown =
match.unknownPartitions === 0
? ""
: `, ${match.unknownPartitions} partition${match.unknownPartitions === 1 ? "" : "s"} without counts`;
const lines = [
`scriptc: profile ${profile}: ${match.annotated} functions annotated, ${match.mismatched} mismatched, ${match.missing} without a record${unknown}`,
];
const described = match.annotated + match.mismatched;
if (described > 0 && match.mismatched / described > STALE_PROFILE_SHARE) {
const percent = ((match.mismatched / described) * 100).toFixed(1);
lines.push(
`scriptc: warning: ${match.mismatched} of the ${described} functions ${profile} describes (${percent}%) changed since it was collected and are optimized without their counts; collect the profile again`,
);
}
return `${lines.join("\n")}\n`;
}
/** Cache-key part; empty without a profile so other keys are unchanged. */
export function nativeProfileKey(profile: NativeProfileOptions | undefined): string {
if (profile?.generate !== undefined) return `profile-generate:${profile.generate}`;
@@ -24,6 +24,7 @@ import {
compileLibArchive,
executableSectionEliminationFlags,
executableNativeEnvironmentFingerprint,
implicitDependencyIncludeDirective,
implicitDependencyProbeIncludes,
parseLinkTraceFiles,
resolveBuildCacheRoot,
@@ -624,6 +625,46 @@ test("implicit dependency seeds include separately compiled vendor system header
expect(includes).toContain("<stdatomic.h>");
});
test.skipIf(clangExecutable === undefined)(
"the implicit toolchain probe guards target-specific compiler headers",
async () => {
// clang ships its x86 intrinsic headers (<emmintrin.h>, ...) for every
// target, so __has_include finds them on arm64, where including them is
// an #error, and the same holds for the ARM headers on x86. The probe
// must guard them by target. Only the compiler's own freestanding
// headers are visible here, so the result does not depend on the host's
// C library.
const includes = (await implicitDependencyProbeIncludes(runtimeSrcDir())).filter(
(include) =>
include !== "<intrin.h>" && /intrin\.h>$|^<cpuid\.h>$|^<arm_(neon|acle)\.h>$/.test(include),
);
expect(includes).toContain("<emmintrin.h>");
const resourceDir = execFileSync(clangExecutable!, ["-print-resource-dir"], {
encoding: "utf8",
}).trim();
const source = includes.map(implicitDependencyIncludeDirective).join("");
for (const target of ["aarch64-unknown-linux-gnu", "x86_64-unknown-linux-gnu"]) {
const result = spawnSync(
clangExecutable!,
[
`--target=${target}`,
"-ffreestanding",
"-nostdinc",
"-isystem",
join(resourceDir, "include"),
"-fsyntax-only",
"-x",
"c",
"-",
],
{ input: source, encoding: "utf8" },
);
expect(result.stderr, target).toBe("");
expect(result.status, target).toBe(0);
}
},
);
test.skipIf(
process.platform === "win32" ||
clangExecutable === undefined ||
@@ -26,7 +26,10 @@ export {
export { CcCompileError, subprocessFailureDetail } from "./native/process.js";
export { compileLibArchive } from "./native/library.js";
export { localizeLibraryObjects } from "./native/object-merge.js";
export { implicitDependencyProbeIncludes } from "./native/compiler-fingerprint.js";
export {
implicitDependencyIncludeDirective,
implicitDependencyProbeIncludes,
} from "./native/compiler-fingerprint.js";
export { parseLinkTraceFiles, nativeLinkerDependencyPaths } from "./native/linker-fingerprint.js";
export { clearCcCaches, stageRuntimeObjects } from "./native/runtime-objects.js";
export {
+12 -4
View File
@@ -8,7 +8,12 @@ import {
validateNativeCodegenVersion,
type NativeCodegenOutputKind,
} from "./native-codegen-core.js";
import { nativeProfileHelperArgs, type NativeProfileOptions } from "./native-profile.js";
import {
nativeProfileHelperArgs,
parseNativeProfileMatch,
type NativeProfileMatch,
type NativeProfileOptions,
} from "./native-profile.js";
export function runNativeTool(
executable: string,
@@ -48,10 +53,10 @@ export function emitNativeObject(options: {
partitionCache?: string;
/** Profile-guided optimization: instrumentation or an indexed profile. */
profile?: NativeProfileOptions;
}): string[] {
}): { outputs: string[]; profileMatch: NativeProfileMatch | null } {
verifyNativeHelper(options);
const outputs = nativePartitionPaths(options.outputPath, options.partitions ?? 1);
runNativeTool(options.executable, [
const stdout = runNativeTool(options.executable, [
"emit",
"--input",
options.inputPath,
@@ -74,7 +79,10 @@ export function emitNativeObject(options: {
...nativeProfileHelperArgs(options.profile),
]);
for (const output of outputs) requireNativeArtifact(output);
return outputs;
return {
outputs,
profileMatch: options.profile?.use === undefined ? null : parseNativeProfileMatch(stdout),
};
}
/** Merges raw or indexed IR profiles into one indexed profile. */
@@ -195,15 +195,25 @@ export async function implicitDependencyProbeIncludes(rtDir: string): Promise<st
return [...includes].sort();
}
function implicitDependencyIncludeDirective(include: string): string {
const targetCondition =
include === "<cpuid.h>" || include === "<immintrin.h>"
? " && (defined(__i386__) || defined(__x86_64__) || defined(_M_IX86) || defined(_M_X64))"
: include === "<intrin.h>"
? " && defined(_WIN32)"
: include === "<arm64_neon.h>" || include === "<arm_acle.h>" || include === "<arm_neon.h>"
? " && (defined(__arm__) || defined(__aarch64__) || defined(_M_ARM) || defined(_M_ARM64))"
: "";
/** Compiler-provided x86 headers: clang ships them for every target, so
* __has_include finds them on arm64 too, where including them is an
* #error. Covers the SSE/AVX intrinsic families (<emmintrin.h>,
* <immintrin.h>, ...), <x86intrin.h>, <x86gprintrin.h> and <cpuid.h>. */
function x86OnlyHeader(include: string): boolean {
return include === "<cpuid.h>" || /^<(x86|x86gpr|[a-z]*mm)intrin\.h>$/.test(include);
}
/** One guarded line of the implicit-toolchain probe source: the header is
* included only when the compiler can find it and it belongs to the target
* being probed. */
export function implicitDependencyIncludeDirective(include: string): string {
const targetCondition = x86OnlyHeader(include)
? " && (defined(__i386__) || defined(__x86_64__) || defined(_M_IX86) || defined(_M_X64))"
: include === "<intrin.h>"
? " && defined(_WIN32)"
: include === "<arm64_neon.h>" || include === "<arm_acle.h>" || include === "<arm_neon.h>"
? " && (defined(__arm__) || defined(__aarch64__) || defined(_M_ARM) || defined(_M_ARM64))"
: "";
return `#if __has_include(${include})${targetCondition}\n#include ${include}\n#endif\n`;
}
@@ -1519,6 +1519,8 @@ export async function compileCInternal(
[],
programPublicSymbols,
`${stem}.program`,
undefined,
{ inProcessElf: true },
);
const publishable = entries.filter((entry) => entry.missed);
if (publishable.length > 0) {
@@ -517,6 +517,8 @@ export async function compileLibArchive(opts: LibArchiveOptions): Promise<void>
[],
programPublicSymbols!,
`${stem}.program`,
undefined,
{ inProcessElf: true },
);
// Keep the canonical archive member spelling. The fact that native
// compilation used shards is an implementation detail; consumers and
@@ -5,6 +5,17 @@ import { localizeElfObject, mergeAndLocalizeCoffObjects } from "../object-locali
import { type CcDriver, targetPlatform } from "./driver.js";
import { execFileAsync, subprocessFailureDetail } from "./process.js";
/* Demotes in process (localizeElfObject), rewriting the object in place. */
async function localizeInProcess(object: string, keepSymbols: readonly string[]): Promise<void> {
try {
await writeFile(object, localizeElfObject(await readFile(object), new Set(keepSymbols)));
} catch (err) {
throw new Error(
`ELF symbol localization failed while localizing the library archive's runtime symbols (abi.localize_runtime).\n\n${(err as Error).message}`,
);
}
}
/* Multi-instance library mode's localization step: combine the program
* object with exactly the runtime/vendor members it reaches into ONE
* relocatable object, then demote every scriptc external definition except
@@ -46,7 +57,11 @@ import { execFileAsync, subprocessFailureDetail } from "./process.js";
* multi-archive link from discarding one archive's copies),
* then binutils objcopy --keep-global-symbols localizes every
* other DEFINED global (objcopy leaves undefined symbols
* global by its own rule). The exact historical recipe.
* global by its own rule). The exact historical recipe. With
* `inProcessElf` (program-shard merges), localizeElfObject
* demotes instead of objcopy: a sanitized dev program merges
* into an object with hundreds of thousands of sections, which
* objcopy takes minutes over and localizeElfObject seconds.
* ELF, cross triples from any host — `zig cc -target <triple> -r`
* merges (zig is already the cross driver's hard requirement),
* then localizeElfObject demotes in process and resolves
@@ -68,6 +83,7 @@ export async function localizeLibraryObjects(
keepSymbols: readonly string[],
stem: string,
platform = targetPlatform(driver),
options: { inProcessElf?: boolean } = {},
): Promise<string> {
const combined = join(buildDir, `${stem}.localized.o`);
const staging = join(buildDir, `${stem}.localize-staging.a`);
@@ -79,7 +95,7 @@ export async function localizeLibraryObjects(
const stderr = subprocessFailureDetail(err);
throw new Error(
`${argv[0]} failed while localizing the library archive's runtime symbols (abi.localize_runtime).\n` +
`Runtime symbol localization needs the ${platform === "darwin" ? "host toolchain's ld" : driver.target === null ? "host toolchain's ld and objcopy" : "cross driver's relocatable link"} beside the C compiler.\n\n${stderr}`,
`Runtime symbol localization needs the ${platform === "darwin" || (driver.target === null && options.inProcessElf === true) ? "host toolchain's ld" : driver.target === null ? "host toolchain's ld and objcopy" : "cross driver's relocatable link"} beside the C compiler.\n\n${stderr}`,
);
}
};
@@ -122,7 +138,6 @@ export async function localizeLibraryObjects(
keepFile,
]);
} else if (platform === "linux" && driver.target === null) {
await writeFile(keepFile, keepSymbols.map((s) => `${s}\n`).join(""));
await run([
"ld",
"-r",
@@ -132,7 +147,12 @@ export async function localizeLibraryObjects(
"-o",
combined,
]);
await run(["objcopy", `--keep-global-symbols=${keepFile}`, combined]);
if (options.inProcessElf === true) {
await localizeInProcess(combined, keepSymbols);
} else {
await writeFile(keepFile, keepSymbols.map((s) => `${s}\n`).join(""));
await run(["objcopy", `--keep-global-symbols=${keepFile}`, combined]);
}
} else if (platform === "linux") {
// Cross ELF: the cross driver's own lld performs the relocatable merge
// (with the staging archive's member semantics); demotion and section-
@@ -152,13 +172,7 @@ export async function localizeLibraryObjects(
"-o",
combined,
]);
try {
await writeFile(combined, localizeElfObject(await readFile(combined), new Set(keepSymbols)));
} catch (err) {
throw new Error(
`ELF symbol localization failed while localizing the library archive's runtime symbols (abi.localize_runtime).\n\n${(err as Error).message}`,
);
}
await localizeInProcess(combined, keepSymbols);
} else {
throw new InternalCompilerError(
`runtime symbol localization (abi.localize_runtime) has no ${platform} arm; compileLibrary admits darwin, linux, and win32 builds only`,
@@ -52,13 +52,13 @@ export async function resolveProgramShardMergeIdentity(driver: CcDriver): Promis
}
if (platform === "linux") {
if (driver.target === null) {
const [ld, objcopy] = await Promise.all([resolvedTool("ld"), resolvedTool("objcopy")]);
return ld === null || objcopy === null
// ld -r merges; localizeElfObject demotes in process.
const ld = await resolvedTool("ld");
return ld === null
? null
: rememberFingerprintDependencies(
`program-shard-merge-linux-v1\0${ld.cacheIdentity}\0${objcopy.cacheIdentity}`,
[ld.canonicalPath, objcopy.canonicalPath],
);
: rememberFingerprintDependencies(`program-shard-merge-linux-v2\0${ld.cacheIdentity}`, [
ld.canonicalPath,
]);
}
const arch = driver.target.split("-", 1)[0];
if (arch !== "x86_64" && arch !== "aarch64") return null;
@@ -28,7 +28,10 @@ const SHF_EXECINSTR = 0x4n;
const SHF_GROUP = 0x200n;
const STB_GLOBAL = 1;
const STB_WEAK = 2;
const SHT_SYMTAB_SHNDX = 18;
const SHN_LORESERVE = 0xff00;
const SHN_COMMON = 0xfff2;
const SHN_XINDEX = 0xffff;
interface ElfSymbolSpec {
name: string;
@@ -53,7 +56,11 @@ function buildStrtab(names: string[]): { data: Uint8Array; offsets: Map<string,
/** Assemble an ET_REL ELF64LE: caller supplies content sections; the
* builder appends .symtab, .strtab, .shstrtab (their indices are
* sections.length, +1, +2). Symbols must list locals first. */
* sections.length, +1, +2). Symbols must list locals first. With
* SHN_LORESERVE or more sections the builder uses extended section
* numbering, appending .symtab_shndx (index sections.length + 3) for symbol
* section indices from SHN_LORESERVE up; reserved symbol indices are then
* limited to SHN_COMMON. */
function buildElf(
sections: ElfSectionSpec[],
symbols: ElfSymbolSpec[],
@@ -62,11 +69,19 @@ function buildElf(
const strtab = buildStrtab(symbols.map((s) => s.name).filter((n) => n !== ""));
const symData = new Uint8Array((symbols.length + 1) * 24);
const symView = new DataView(symData.buffer);
const shndxData = new Uint8Array((symbols.length + 1) * 4);
let usesShndx = false;
symbols.forEach((sym, i) => {
const at = (i + 1) * 24;
symView.setUint32(at, sym.name === "" ? 0 : strtab.offsets.get(sym.name)!, true);
symView.setUint8(at + 4, (sym.binding << 4) | 0);
symView.setUint16(at + 6, sym.shndx, true);
if (sym.shndx >= SHN_LORESERVE && sym.shndx !== SHN_COMMON) {
symView.setUint16(at + 6, SHN_XINDEX, true);
new DataView(shndxData.buffer).setUint32((i + 1) * 4, sym.shndx, true);
usesShndx = true;
} else {
symView.setUint16(at + 6, sym.shndx, true);
}
symView.setBigUint64(at + 8, sym.value ?? 0n, true);
});
const all: ElfSectionSpec[] = [
@@ -82,9 +97,22 @@ function buildElf(
},
{ name: ".strtab", type: SHT_STRTAB, data: strtab.data },
{ name: ".shstrtab", type: SHT_STRTAB },
...(usesShndx
? [
{
name: ".symtab_shndx",
type: SHT_SYMTAB_SHNDX,
data: shndxData,
link: sections.length + 1,
entsize: 4n,
},
]
: []),
];
const shstrndx = sections.length + 3;
const extended = all.length >= SHN_LORESERVE;
const shstr = buildStrtab(all.map((s) => s.name).filter((n) => n !== ""));
all[all.length - 1]!.data = shstr.data;
all[shstrndx]!.data = shstr.data;
let offset = 64;
const offsets = all.map((s) => {
const data = s.data ?? new Uint8Array(0);
@@ -106,8 +134,8 @@ function buildElf(
view.setBigUint64(40, BigInt(shoff), true);
view.setUint16(52, 64, true);
view.setUint16(58, 64, true);
view.setUint16(60, all.length, true);
view.setUint16(62, all.length - 1, true);
view.setUint16(60, extended ? 0 : all.length, true);
view.setUint16(62, shstrndx >= SHN_LORESERVE ? SHN_XINDEX : shstrndx, true);
all.forEach((s, i) => {
const at = shoff + i * 64;
const data = s.data ?? new Uint8Array(0);
@@ -116,8 +144,8 @@ function buildElf(
view.setUint32(at + 4, s.type, true);
view.setBigUint64(at + 8, s.flags ?? 0n, true);
view.setBigUint64(at + 24, BigInt(s.type === 0 ? 0 : offsets[i]!), true);
view.setBigUint64(at + 32, BigInt(data.length), true);
view.setUint32(at + 40, s.link ?? 0, true);
view.setBigUint64(at + 32, BigInt(i === 0 && extended ? all.length : data.length), true);
view.setUint32(at + 40, i === 0 && shstrndx >= SHN_LORESERVE ? shstrndx : (s.link ?? 0), true);
view.setUint32(at + 44, s.info ?? 0, true);
view.setBigUint64(at + 48, s.addralign ?? 1n, true);
view.setBigUint64(at + 56, s.entsize ?? 0n, true);
@@ -142,8 +170,10 @@ interface ReadElf {
function readElf(bytes: Uint8Array): ReadElf {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const shoff = Number(view.getBigUint64(40, true));
const shnum = view.getUint16(60, true);
const shstrndx = view.getUint16(62, true);
let shnum = view.getUint16(60, true);
let shstrndx = view.getUint16(62, true);
if (shnum === 0) shnum = Number(view.getBigUint64(shoff + 32, true));
if (shstrndx === SHN_XINDEX) shstrndx = view.getUint32(shoff + 40, true);
const raw = [] as {
nameOff: number;
type: number;
@@ -182,12 +212,16 @@ function readElf(bytes: Uint8Array): ReadElf {
const symtabIdx = raw.findIndex((s) => s.type === SHT_SYMTAB);
const symtab = raw[symtabIdx]!;
const strtab = raw[symtab.link]!;
const shndxTable = raw.find((s) => s.type === SHT_SYMTAB_SHNDX && s.link === symtabIdx);
const symbols = [] as ReadElf["symbols"];
for (let at = symtab.offset; at < symtab.offset + symtab.size; at += 24) {
let shndx = view.getUint16(at + 6, true);
if (shndx === SHN_XINDEX)
shndx = view.getUint32(shndxTable!.offset + ((at - symtab.offset) / 24) * 4, true);
symbols.push({
name: name(strtab, view.getUint32(at, true)),
binding: view.getUint8(at + 4) >> 4,
shndx: view.getUint16(at + 6, true),
shndx,
});
}
return { sections, symbols, symtabInfo: symtab.info };
@@ -241,6 +275,65 @@ describe("localizeElfObject", () => {
expect(info & 0xffffffffn).toBe(2n);
});
test("keeps extended section numbering when the object has SHN_LORESERVE or more sections", () => {
// A sanitized dev program merges into an object with more sections than
// e_shnum and st_shndx can index: section 0 carries the count and the
// section-name table's index, and .symtab_shndx the symbols' indices.
const count = SHN_LORESERVE + 400;
const texts: ElfSectionSpec[] = Array.from({ length: count }, (_, i) => ({
name: `.text.f${i}`,
type: SHT_PROGBITS,
flags: SHF_ALLOC | SHF_EXECINSTR,
data: new Uint8Array(4),
}));
const rela = new Uint8Array(24);
new DataView(rela.buffer).setBigUint64(8, (2n << 32n) | 2n, true); // sym 2 (high)
const object = buildElf(
[
...texts,
{
name: ".rela.text.last",
type: SHT_RELA,
data: rela,
link: count + 2,
info: count,
entsize: 24n,
},
],
[
{ name: "low", binding: STB_GLOBAL, shndx: 1 },
{ name: "high", binding: STB_GLOBAL, shndx: count },
{ name: "main", binding: STB_GLOBAL, shndx: count - 1 },
{ name: "libc_ref", binding: STB_GLOBAL, shndx: 0 },
{ name: "shared_common", binding: STB_GLOBAL, shndx: SHN_COMMON, value: 8n },
],
0,
);
const output = localizeElfObject(object, new Set(["main"]));
const header = new DataView(output.buffer, output.byteOffset, output.byteLength);
expect(header.getUint16(60, true)).toBe(0);
expect(header.getUint16(62, true)).toBe(SHN_XINDEX);
const localized = readElf(output);
expect(localized.sections.length).toBe(count + 6);
expect(localized.sections.filter((s) => s.type === SHT_SYMTAB_SHNDX)).toHaveLength(1);
const byName = new Map(localized.symbols.map((s) => [s.name, s]));
expect(byName.get("low")!.binding).toBe(0);
expect(byName.get("high")!.binding).toBe(0);
expect(localized.sections[byName.get("high")!.shndx]!.name).toBe(`.text.f${count - 1}`);
expect(byName.get("main")!.binding).toBe(STB_GLOBAL);
expect(localized.sections[byName.get("main")!.shndx]!.name).toBe(`.text.f${count - 2}`);
expect(byName.get("libc_ref")!.shndx).toBe(0);
expect(byName.get("shared_common")!.shndx).toBe(SHN_COMMON);
const relaOut = localized.sections.find((s) => s.name === ".rela.text.last")!;
expect(localized.sections[relaOut.info]!.name).toBe(`.text.f${count - 1}`);
const info = new DataView(relaOut.data.buffer, relaOut.data.byteOffset).getBigUint64(8, true);
expect(localized.symbols[Number(info >> 32n)]!.name).toBe("high");
// The output reads back as the same object.
expect(readElf(localizeElfObject(output, new Set(["main"]))).symbols).toEqual(
localized.symbols,
);
});
test("resolves section groups: SHT_GROUP dropped, SHF_GROUP cleared, members and indices intact", () => {
const group = new Uint8Array(8);
const groupView = new DataView(group.buffer);
@@ -84,9 +84,13 @@ interface ElfSymbol {
name: string;
info: number;
other: number;
/** Resolved section index (SHT_SYMTAB_SHNDX applied), or a reserved
* SHN_* value below/at SHN_LORESERVE semantics. */
/** Section index (SHT_SYMTAB_SHNDX applied), or the reserved SHN_* value
* when `reserved`. */
shndx: number;
/** st_shndx holds a reserved value (SHN_ABS, SHN_COMMON, ...), not a
* section index. An index from SHT_SYMTAB_SHNDX can itself be at or above
* SHN_LORESERVE. */
reserved: boolean;
}
/** Demote every defined global/weak symbol not named in `keep` to a local
@@ -143,8 +147,8 @@ export function localizeElfObject(object: Uint8Array, keep: ReadonlySet<string>)
if (strtab === undefined) fail("ELF symbol table names an invalid string table");
// SHT_SYMTAB_SHNDX (only present when section indices overflow st_shndx):
// resolve the real indices up front and drop the section — the rebuilt
// object's section count only shrinks, so inline indices always fit.
// resolve the real indices up front and drop the section. The rebuilt
// object writes a new one when its own section count still overflows.
const shndxSection = sections.find(
(s) => s.type === SHT_SYMTAB_SHNDX && sections[s.link] === symtab,
);
@@ -170,9 +174,11 @@ export function localizeElfObject(object: Uint8Array, keep: ReadonlySet<string>)
for (let i = 0; i < symCount; i++) {
const at = i * 24;
let shndx: number = symView.getUint16(at + 6, true);
let reserved = shndx >= SHN_LORESERVE;
if (shndx === SHN_XINDEX) {
if (shndxView === null) fail("ELF symbol uses SHN_XINDEX without a SHT_SYMTAB_SHNDX table");
shndx = shndxView.getUint32(i * 4, true);
reserved = false;
}
symbols.push({
nameOffset: symView.getUint32(at, true),
@@ -180,6 +186,7 @@ export function localizeElfObject(object: Uint8Array, keep: ReadonlySet<string>)
info: symView.getUint8(at + 4),
other: symView.getUint8(at + 5),
shndx,
reserved,
});
}
@@ -212,11 +219,11 @@ export function localizeElfObject(object: Uint8Array, keep: ReadonlySet<string>)
const symbolKept: boolean[] = [];
for (let i = 0; i < symCount; i++) {
const sym = symbols[i]!;
const regular = sym.shndx > SHN_UNDEF && sym.shndx < SHN_LORESERVE;
const regular = !sym.reserved && sym.shndx !== SHN_UNDEF;
symbolKept.push(!(regular && sectionKept[sym.shndx] !== true));
if (i === 0) continue;
const binding = sym.info >> 4;
const defined = sym.shndx !== SHN_UNDEF && sym.shndx !== SHN_COMMON;
const defined = sym.shndx !== SHN_UNDEF && !(sym.reserved && sym.shndx === SHN_COMMON);
if (binding !== STB_LOCAL && defined && !keep.has(sym.name)) {
sym.info = (STB_LOCAL << 4) | (sym.info & 0xf);
}
@@ -236,6 +243,16 @@ export function localizeElfObject(object: Uint8Array, keep: ReadonlySet<string>)
symbolMap[oldIndex] = newIndex;
});
// Extended section numbering: with SHN_LORESERVE or more sections, an
// index from SHN_LORESERVE up does not fit st_shndx, e_shnum or
// e_shstrndx. Such a symbol's st_shndx is SHN_XINDEX and its index goes in
// a SHT_SYMTAB_SHNDX section (one entry per symbol, appended after the
// kept sections); the counts go in section 0 (sh_size, sh_link).
let keptCount = 0;
for (const keptSection of sectionKept) if (keptSection) keptCount++;
const extended = keptCount >= SHN_LORESERVE;
const shndxOut = extended ? new Uint8Array(symbolOrder.length * 4) : null;
const shndxOutView = shndxOut === null ? null : new DataView(shndxOut.buffer);
const newSymtabData = new Uint8Array(symbolOrder.length * 24);
const newSymtabView = new DataView(newSymtabData.buffer);
symbolOrder.forEach((oldIndex, newIndex) => {
@@ -244,9 +261,14 @@ export function localizeElfObject(object: Uint8Array, keep: ReadonlySet<string>)
newSymtabView.setUint32(at, sym.nameOffset, true);
newSymtabView.setUint8(at + 4, sym.info);
newSymtabView.setUint8(at + 5, sym.other);
const shndx =
sym.shndx > SHN_UNDEF && sym.shndx < SHN_LORESERVE ? sectionMap[sym.shndx]! : sym.shndx;
newSymtabView.setUint16(at + 6, shndx, true);
const regular = !sym.reserved && sym.shndx !== SHN_UNDEF;
const shndx = regular ? sectionMap[sym.shndx]! : sym.shndx;
if (regular && shndx >= SHN_LORESERVE) {
newSymtabView.setUint16(at + 6, SHN_XINDEX, true);
shndxOutView!.setUint32(newIndex * 4, shndx, true);
} else {
newSymtabView.setUint16(at + 6, shndx, true);
}
newSymtabView.setBigUint64(at + 8, symView.getBigUint64(oldIndex * 24 + 8, true), true);
newSymtabView.setBigUint64(at + 16, symView.getBigUint64(oldIndex * 24 + 16, true), true);
});
@@ -278,6 +300,33 @@ export function localizeElfObject(object: Uint8Array, keep: ReadonlySet<string>)
// Rebuild. sh_link is a section index in every use this object can carry;
// sh_info is one only on relocation sections.
const kept = sections.filter((_, i) => sectionKept[i] === true);
const newSymtabIndex = sectionMap[symtabIndex]!;
if (shndxOut !== null) {
// The extended index table goes last, named in the section-name table.
const shstrtab = sections[shstrndx]!;
let nameOffset = shndxSection?.nameOffset;
if (nameOffset === undefined) {
const name = textEncoder.encode(".symtab_shndx\0");
const grown = new Uint8Array(shstrtab.data.length + name.length);
grown.set(shstrtab.data);
grown.set(name, shstrtab.data.length);
nameOffset = shstrtab.data.length;
shstrtab.data = grown;
}
kept.push({
nameOffset,
type: SHT_SYMTAB_SHNDX,
flags: 0n,
addr: 0n,
size: BigInt(shndxOut.length),
link: symtabIndex,
info: 0,
addralign: 4n,
entsize: 4n,
data: shndxOut,
});
}
const newShstrndx = sectionMap[shstrndx]!;
let offset = 64;
const offsets: number[] = [];
for (const section of kept) {
@@ -301,8 +350,8 @@ export function localizeElfObject(object: Uint8Array, keep: ReadonlySet<string>)
outView.setBigUint64(40, BigInt(shoffOut), true);
outView.setUint16(54, 0, true); // e_phentsize
outView.setUint16(56, 0, true); // e_phnum
outView.setUint16(60, kept.length, true);
outView.setUint16(62, sectionMap[shstrndx]!, true);
outView.setUint16(60, extended ? 0 : kept.length, true);
outView.setUint16(62, newShstrndx >= SHN_LORESERVE ? SHN_XINDEX : newShstrndx, true);
kept.forEach((section, i) => {
if (section.type !== SHT_NULL && section.type !== SHT_NOBITS) {
out.set(section.data, offsets[i]!);
@@ -313,12 +362,27 @@ export function localizeElfObject(object: Uint8Array, keep: ReadonlySet<string>)
outView.setBigUint64(at + 8, section.flags, true);
outView.setBigUint64(at + 16, section.addr, true);
outView.setBigUint64(at + 24, BigInt(offsets[i]!), true);
if (i === 0) {
// Section 0 carries the counts that overflow the ELF header.
outView.setBigUint64(at + 32, extended ? BigInt(kept.length) : 0n, true);
outView.setUint32(at + 40, newShstrndx >= SHN_LORESERVE ? newShstrndx : 0, true);
outView.setUint32(at + 44, 0, true);
return;
}
outView.setBigUint64(
at + 32,
section.type === SHT_NOBITS ? section.size : BigInt(section.data.length),
true,
);
outView.setUint32(at + 40, section.link === 0 ? 0 : (sectionMap[section.link] ?? 0), true);
outView.setUint32(
at + 40,
section.type === SHT_SYMTAB_SHNDX
? newSymtabIndex
: section.link === 0
? 0
: (sectionMap[section.link] ?? 0),
true,
);
outView.setUint32(
at + 44,
section.type === SHT_RELA || section.type === SHT_REL
+10
View File
@@ -28,6 +28,7 @@ import {
import { isTypePatchCode } from "../diagnostics/diagnostic.js";
import { isPerformanceCode, renderPerformanceWarnings } from "../diagnostics/dynamic-tier.js";
import { isNativeOptimization } from "../backend/optimization.js";
import { renderNativeProfileMatch } from "../backend/native-profile.js";
import {
hostTargetCpu,
isTargetCpuOption,
@@ -713,6 +714,15 @@ async function main(args: string[], host: CliHost): Promise<number> {
}
}
if (result.artifact.kind === "exe") {
// How the profile matched the program's functions; `run` keeps stderr
// for the program's own output.
const profileUse = values["profile-use"];
if (
command === "build" &&
profileUse !== undefined &&
result.artifact.profileMatch !== undefined
)
process.stderr.write(renderNativeProfileMatch(profileUse, result.artifact.profileMatch));
if (!values["keep-llvm"]) rmSync(result.artifact.translationUnitPath, { force: true });
} else if (result.artifact.kind === "obj") {
nativeLinkInfo = result.artifact.nativeLinkInfo;
+5 -3
View File
@@ -38,7 +38,7 @@ Options:
(native-link-info implies --emit=obj and never links;
diagnostics prints the versioned JSON diagnostics
envelope for build and coverage)
--fail-on <blockers|divergences|dynamic|node-islands|eager-node-islands|island-gaps>
--fail-on <blockers|divergences|dynamic|dynamic-slots|node-islands|eager-node-islands|island-gaps>
coverage only: exit 1 when the entry path has blockers
(a build would fail), or also when it has Node
divergences, likely-hot functions that use untyped
@@ -86,8 +86,10 @@ Options:
code is laid out, inlined and split by how the profiled
runs used it. Code the profile does not describe, or
describes from an older version of the program, is
optimized as without one (release or speed; --emit=exe
only)
optimized as without one. build prints how the profile
matched on stderr, and warns when more than 10% of the
functions it describes changed (release or speed;
--emit=exe only)
--keep-llvm keep generated LLVM IR beside the executable (default)
--no-keep-llvm delete generated LLVM IR after compiling
--emit-ir also write IR beside an executable or library archive;
+5
View File
@@ -59,6 +59,11 @@ export interface CompileBaseOptions {
* merge-profiles`). Code the profile does not describe, or describes
* from an older version of the program, is optimized as without one. */
profileUse?: string;
/** A function layout (scripts/refresh-cli-layout.mjs; gzip-compressed when
* the name ends in .gz): the listed functions are written first, by tier,
* so the code that runs together sits together in the executable. The
* native compiler's build uses it. Executables and LLVM output. */
functionLayout?: string;
/** Eligible packages compile automatically. Explicit names override automatic selection heuristics. */
npmStatic?: readonly string[] | "auto";
+110 -3
View File
@@ -22,9 +22,18 @@ import {
type TiersEnvelope,
} from "../diagnostics/envelope.js";
import { renderDiagnostics } from "../diagnostics/render.js";
import {
dynamicSlotLabel,
isDynamicSlotCode,
type DynamicSlotEntry,
} from "../diagnostics/dynamic-slots.js";
import type { LowerStats } from "../frontend/lowering/lowerer.js";
import type { ProvenanceSources } from "../frontend/provenance-registry.js";
import type { DemotionUnit, TierReport } from "../frontend/tier-analysis.js";
import type {
DemotionUnit,
LocalizedFailureReport,
TierReport,
} from "../frontend/tier-analysis.js";
import type { WasmModuleReport } from "../wasm/discover.js";
import {
describe as describeReason,
@@ -55,6 +64,9 @@ export interface CoverageInput {
* leaves untyped. Never blockers; `--fail-on=dynamic` opts into failing
* on it. */
performance?: ScrDiagnostic[];
/** Localized failure: the checked dynamic slots (SC6104, also in
* `performance`). `--fail-on=dynamic-slots` fails on them. */
dynamicSlots?: DynamicSlotEntry[];
/** Each dependency's native compilation outcome, with the first refusal
* reason for a refused package. */
npmStatic?: NpmStaticStatus[];
@@ -234,6 +246,10 @@ export function renderCoverage(input: CoverageInput, opts: CoverageRenderOptions
` ${c(DIM, `(${skipped} function${skipped === 1 ? "" : "s"} not analyzed — signature blocked; their statements count as not static)`)}`,
);
}
renderDynamicSlots(input.dynamicSlots ?? [], out, c, (loc) => {
const p = locator.position(loc.file, loc.start);
return `${displayPath(loc.file, opts.root)}:${p.line}:${p.column}`;
});
out.push("");
// Report native package compilation and the first reason for each refusal.
@@ -391,7 +407,7 @@ export function renderCoverage(input: CoverageInput, opts: CoverageRenderOptions
renderRows(groupBlockers(divergences), YELLOW);
};
// The performance warning: its message names the functions.
const performance = input.performance ?? [];
const performance = (input.performance ?? []).filter((d) => !isDynamicSlotCode(d.code));
const renderPerformance = () => {
for (const d of performance) {
out.push("");
@@ -525,6 +541,7 @@ export type CoverageFailOn =
| "blockers"
| "divergences"
| "dynamic"
| "dynamic-slots"
| "node-islands"
| "eager-node-islands"
| "island-gaps";
@@ -533,6 +550,7 @@ export const COVERAGE_FAIL_ON: readonly CoverageFailOn[] = [
"blockers",
"divergences",
"dynamic",
"dynamic-slots",
"node-islands",
"eager-node-islands",
"island-gaps",
@@ -548,7 +566,9 @@ export function coveragePasses(input: CoverageInput, failOn?: CoverageFailOn): b
// runtime fences build (the statement throws only if it executes).
if (input.diagnostics.length > 0) return false;
if (failOn === "divergences" && (input.divergences?.length ?? 0) > 0) return false;
if (failOn === "dynamic" && (input.performance?.length ?? 0) > 0) return false;
if (failOn === "dynamic" && (input.performance ?? []).some((d) => !isDynamicSlotCode(d.code)))
return false;
if (failOn === "dynamic-slots" && (input.dynamicSlots?.length ?? 0) > 0) return false;
// The Node island gates (report-only islands count: they are what a
// build would choose). Every island evaluates eagerly until deferred
// evaluation lands, so eager-node-islands equals node-islands for now.
@@ -620,6 +640,19 @@ export function coverageEnvelope(
percentStatic: total === 0 ? 100 : Math.floor((ok / total) * 100),
},
}),
...(input.dynamicSlots !== undefined
? {
dynamicSlots: input.dynamicSlots.map((slot) => ({
file: slot.loc.file,
...locator.position(slot.loc.file, slot.loc.start),
name: slot.name,
kind: slot.slot,
declaredType: slot.declaredType,
code: slot.code,
reason: slot.reason,
})),
}
: {}),
...(input.tiers !== undefined ? { tiers: tiersEnvelope(input.tiers, locator) } : {}),
...(input.wasmModules !== undefined ? { wasmModules: input.wasmModules } : {}),
...(input.nodeIslands !== undefined
@@ -668,6 +701,26 @@ function tiersEnvelope(tiers: TierReport, locator: SourceLocator): TiersEnvelope
...at(r.loc),
})),
units: tiers.units.map((u) => ({ ...unit(u), requests: u.requests, sites: u.sites })),
localizedFailure: {
errors: tiers.localizedFailure.errors,
typeFailures: tiers.localizedFailure.typeFailures,
cascades: tiers.localizedFailure.cascades,
clearedThroughL2: tiers.localizedFailure.clearedThroughL2,
clearedThroughL4: tiers.localizedFailure.clearedThroughL4,
remainingByCode: tiers.localizedFailure.remainingByCode,
slots: tiers.localizedFailure.slots.map((slot) => ({
...at(slot.loc),
slot: slot.slot,
name: slot.name,
declaredType: slot.declaredType,
family: slot.family,
code: slot.code,
message: slot.message,
stage: slot.stage,
...(slot.rejects !== null ? { rejects: slot.rejects } : {}),
cascade: slot.cascade,
})),
},
};
}
@@ -695,6 +748,59 @@ function unitMatches(u: DemotionUnit, query: string): boolean {
);
}
const DYNAMIC_SLOTS_SHOWN = 20;
/** The dynamic slots under the statement counts: each slot's declared
* type, and why it has no static lowering. */
function renderDynamicSlots(
slots: readonly DynamicSlotEntry[],
out: string[],
c: (code: string, s: string) => string,
where: (loc: SrcLoc) => string,
): void {
if (slots.length === 0) return;
out.push(
` dynamic slots ${slots.length} ${c(DIM, "(declared types with no static lowering, held as checked-dynamic values; SC6104)")}`,
);
for (const slot of slots.slice(0, DYNAMIC_SLOTS_SHOWN))
out.push(` ${dynamicSlotLabel(slot)} ${c(DIM, where(slot.loc))}`);
if (slots.length > DYNAMIC_SLOTS_SHOWN)
out.push(` ${c(DIM, `and ${slots.length - DYNAMIC_SLOTS_SHOWN} more`)}`);
}
const SLOTS_SHOWN = 40;
/** The localized-failure section of --explain-dynamic (report-only): the
* committed type failures that would become checked dynamic slots, and
* the cascade each one causes today. */
function renderLocalizedFailure(
report: LocalizedFailureReport,
out: string[],
c: (code: string, s: string) => string,
where: (loc: { file: string; start: number }) => string,
): void {
if (report.typeFailures.degradable === 0 && report.typeFailures.refused === 0) return;
out.push("");
out.push(
` ${report.slots.length} would-be dynamic slot${report.slots.length === 1 ? "" : "s"} ${c(DIM, "(localized failure, report-only: type failures a checked dynamic slot would hold)")}`,
);
out.push(
` ${c(DIM, `${report.errors} errors: ${report.clearedThroughL2} clear with declaration slots (L1-L2), ${report.clearedThroughL4} with member and expression slots too (L3-L4); ${report.cascades} cascade${report.cascades === 1 ? "" : "s"} attributed; ${report.typeFailures.refused} type failure${report.typeFailures.refused === 1 ? " stays" : "s stay"} refused`)}`,
);
for (const slot of report.slots.slice(0, SLOTS_SHOWN)) {
const cascade = slot.cascade > 0 ? `, clears ${slot.cascade}` : "";
out.push(
` ${slot.stage} ${slot.slot} ${slot.name}: ${capMessage(slot.declaredType)} ${c(DIM, `${where(slot.loc)} (${slot.code} ${slot.family}${cascade})`)}`,
);
if (slot.rejects !== null)
out.push(` ${c(DIM, `rejects ${slot.rejects.split(" @")[0]}`)}`);
}
if (report.slots.length > SLOTS_SHOWN)
out.push(
` ${c(DIM, `${report.slots.length - SLOTS_SHOWN} more (--print=diagnostics lists all)`)}`,
);
}
/** `scriptc coverage --explain-dynamic[=<unit>]`: the dynamic tier today
* and the demotion chains the report-only analysis computed. With a query,
* only units whose name matches it (`x`, `Point.x`, `parse:x`). */
@@ -735,6 +841,7 @@ export function renderExplainDynamic(
(excluded > 0 ? c(DIM, `, ${excluded} excluded by policy`) : ""),
);
}
if (query === undefined) renderLocalizedFailure(tiers.localizedFailure, out, c, where);
const units = tiers.units.filter((u) => query === undefined || unitMatches(u, query));
if (query !== undefined && units.length === 0) {
out.push(` no demoted unit matches '${query}'`);
@@ -7,6 +7,7 @@
* still hold. With a semantic matcher (the Node frontend's), edits that
* only touch TypeScript comments also reuse it, with every reported
* location moved to the edited text. */
import type { DynamicSlotEntry } from "../diagnostics/dynamic-slots.js";
import { createHash } from "node:crypto";
import { mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
@@ -23,7 +24,7 @@ import {
type FrontendInputSnapshot,
} from "../frontend/input-tracker.js";
const SCHEMA = "scriptc.coverage-verdict.v3";
const SCHEMA = "scriptc.coverage-verdict.v4";
/** Key-value storage for verdict entries (best effort: failures miss). */
export interface VerdictStore {
@@ -41,6 +42,7 @@ export interface CachedCoverage {
unreached?: { stats: LowerStats; diagnostics: ScrDiagnostic[]; runtimeFences?: ScrDiagnostic[] };
divergences?: ScrDiagnostic[];
performance?: ScrDiagnostic[];
dynamicSlots?: DynamicSlotEntry[];
npmStatic?: NpmStaticStatus[];
wasmModules?: WasmModuleReport[];
nodeIslands?: NodeIslandReport;
@@ -158,6 +160,7 @@ function cacheable(coverage: AnalyzeResult["coverage"]): CachedCoverage | null {
if (coverage.unreached !== undefined) out.unreached = coverage.unreached;
if (coverage.divergences !== undefined) out.divergences = coverage.divergences;
if (coverage.performance !== undefined) out.performance = coverage.performance;
if (coverage.dynamicSlots !== undefined) out.dynamicSlots = coverage.dynamicSlots;
if (coverage.npmStatic !== undefined) out.npmStatic = coverage.npmStatic;
if (coverage.wasmModules !== undefined) out.wasmModules = coverage.wasmModules;
if (coverage.nodeIslands !== undefined) out.nodeIslands = coverage.nodeIslands;
@@ -177,6 +180,7 @@ function restore(cached: CachedCoverage, sources: CachedSource[]): AnalyzeResult
if (cached.unreached !== undefined) coverage.unreached = cached.unreached;
if (cached.divergences !== undefined) coverage.divergences = cached.divergences;
if (cached.performance !== undefined) coverage.performance = cached.performance;
if (cached.dynamicSlots !== undefined) coverage.dynamicSlots = cached.dynamicSlots;
if (cached.npmStatic !== undefined) coverage.npmStatic = cached.npmStatic;
if (cached.wasmModules !== undefined) coverage.wasmModules = cached.wasmModules;
if (cached.nodeIslands !== undefined) coverage.nodeIslands = cached.nodeIslands;
@@ -113,6 +113,64 @@ export interface ScrDiagnostic {
* and class member body: it throws when the module initializes, which is
* program startup for a module the entry imports. */
startup?: true;
/** Localized failure, report-only (stage L0): set where a type with no
* static lowering poisoned a slot (Lowerer.badType and the dyn class
* field policy). Never part of the rendered message. */
typeFailure?: TypeFailure;
/** Localized failure, report-only: the declaration this diagnostic
* rejected, as a failure-group key. Set when a reference flushes a
* deferred declaration's diagnostics, and on the diagnostics of a
* declaration statement whose bindings were blocked. */
rejects?: string;
/** Localized failure, report-only: the failure groups (rejected
* declarations, blocked statements) that the statement committing this
* diagnostic used. A cascade clears when every one of them would
* degrade instead of failing. */
cause?: readonly string[];
}
/** Where a type failure sits (localized-failure design §3.6): a
* declaration slot, a member of a declared type, or an expression's own
* type. */
export type TypeFailureSlot =
| "field"
| "param"
| "return"
| "local"
| "global"
| "loop"
| "member"
| "expression";
/** Which badType branch (or policy fence) refused the type. */
export type TypeFailureFamily =
| "node-types"
| "fetch-ambient"
| "enum-object"
| "stdlib-index"
| "index-signature"
| "package"
| "generic-signature"
| "overloads"
| "intersection"
| "component"
| "stdlib-nominal"
| "record-member"
| "unsupported"
| "dyn-field-policy";
/** A type failure's structured payload: what the commit points of the
* localized-failure design inspect (PoisonError.failure carries the same
* object). `degradable` is the §3.7 classification: a checked-dynamic slot
* would keep the program's meaning. */
export interface TypeFailure {
slot: TypeFailureSlot;
/** The slot's name (`_customTools`, `listAll`, `x`), or the expression
* text for expression slots. */
name: string;
declaredType: string;
family: TypeFailureFamily;
degradable: boolean;
}
/** SC3002–SC3004 — native-helper target, installation, and execution
@@ -0,0 +1,49 @@
/* SC6104, the dynamic-slot performance warning (localized-failure design
* §3.9): a slot whose declared TypeScript type has no static lowering holds
* a checked-dynamic value instead of rejecting the declaration around it.
* Its wording lives here; the slots come from the lowering's commit points
* (Lowerer.degradeAtCommit). Imports nothing heavy, like dynamic-tier.ts. */
import type { SrcLoc } from "../ir/ir.js";
import type { ScrDiagnostic, TypeFailureSlot } from "./diagnostic.js";
export const DYNAMIC_SLOT_CODE = "SC6104";
/** One dynamic slot, as coverage reports it. */
export interface DynamicSlotEntry {
loc: SrcLoc;
name: string;
slot: TypeFailureSlot;
declaredType: string;
/** The diagnostic the slot's declaration would have failed with. */
code: string;
reason: string;
}
const SLOT_WORD: Record<TypeFailureSlot, string> = {
field: "field",
param: "parameter",
return: "return value of",
local: "local",
global: "module global",
loop: "loop variable",
member: "member",
expression: "expression",
};
/** `'_customTools' (declared 'ToolDefinition[]')`, with the slot kind. */
export function dynamicSlotLabel(entry: DynamicSlotEntry): string {
return `${SLOT_WORD[entry.slot]} '${entry.name}' (declared '${entry.declaredType}')`;
}
export function dynamicSlotDiag(entry: DynamicSlotEntry): ScrDiagnostic {
return {
code: DYNAMIC_SLOT_CODE,
message: `${dynamicSlotLabel(entry)} is held as a checked-dynamic value: its type has no static lowering (${entry.reason}); reads into typed code are checked`,
loc: entry.loc,
hint: "restate the declared type inside the compilable set to keep this slot static; scriptc coverage lists dynamic slots, and --fail-on=dynamic-slots fails on them",
};
}
export function isDynamicSlotCode(code: string): boolean {
return code === DYNAMIC_SLOT_CODE;
}
@@ -114,6 +114,27 @@ export interface TiersEnvelope {
message: string;
})[];
units: (TierUnitPosition & { requests: number; sites: number })[];
/** Localized failure (report-only): committed type failures that would
* become checked dynamic slots, and what they would clear. */
localizedFailure: {
errors: number;
typeFailures: { degradable: number; refused: number };
cascades: number;
clearedThroughL2: number;
clearedThroughL4: number;
remainingByCode: Record<string, number>;
slots: (SourcePosition & {
slot: string;
name: string;
declaredType: string;
family: string;
code: string;
message: string;
stage: string;
rejects?: string;
cascade: number;
})[];
};
}
export interface DiagnosticsEnvelope {
@@ -143,6 +164,15 @@ export interface DiagnosticsEnvelope {
};
/** build only: the produced artifact path. */
artifact?: string;
/** coverage only, when there are any: the checked dynamic slots
* (localized failure, SC6104). */
dynamicSlots?: (SourcePosition & {
name: string;
kind: string;
declaredType: string;
code: string;
reason: string;
})[];
/** coverage --explain-dynamic only: the report-only tier assignment. */
tiers?: TiersEnvelope;
/** coverage only: the WebAssembly modules the build can see, with their
@@ -220,7 +220,7 @@ test("early executable cache misses on source and resolution changes", async ()
expect(await readEarlyExecutableCache(f.root, f.options)).toBeNull();
});
test("early executable keys isolate compile modes, paths, implementation, checker, and FFI manifests", async () => {
test("early executable keys isolate compile modes, paths, implementation, checker, function layouts, and FFI manifests", async () => {
const f = await fixture();
const tracker = new FrontendInputTracker();
tracker.run(() => trackedReadFile(f.source));
@@ -242,6 +242,7 @@ test("early executable keys isolate compile modes, paths, implementation, checke
{ ...f.options, cpuDispatch: true },
{ ...f.options, profile: "profile-generate:/tmp/%p.profraw" },
{ ...f.options, profile: "profile-use:digest" },
{ ...f.options, functionLayout: "layout-digest" },
{ ...f.options, checker: "tsc-ts-executable-identity" },
{ ...f.options, npmStatic: "auto" },
{ ...f.options, npmStatic: ["commander"] },
@@ -12,6 +12,7 @@ import {
} from "node:fs/promises";
import { basename, dirname, join, resolve } from "node:path";
import type { ScrDiagnostic } from "../diagnostics/diagnostic.js";
import type { NativeProfileMatch } from "../backend/native-profile.js";
import type { FrontendInputSnapshot } from "../frontend/input-tracker.js";
import { frontendInputsStillMatch, validFrontendInputSnapshot } from "../frontend/input-tracker.js";
import { compilerReleaseVersion } from "../library/sidecar.js";
@@ -129,6 +130,8 @@ interface EarlyExecutableCacheStamp {
native: EarlyExecutableNativeFeatures;
/** The build's performance warnings (SC61xx), which a cache hit repeats. */
performance?: ScrDiagnostic[];
/** How a --profile-use build's profile matched, which a cache hit reports. */
profileMatch?: NativeProfileMatch;
integrity: string;
}
@@ -154,6 +157,8 @@ export interface EarlyExecutableCacheOptions {
/** Omitted without --profile-generate/--profile-use; otherwise the
* pattern, or the digest of the profile's bytes. */
profile?: string;
/** Omitted without a function layout; otherwise the digest of its bytes. */
functionLayout?: string;
/** Omitted for the default tsgo checker; with SCRIPTC_CHECKER=tsc-ts, the
* tsc-ts executable's file identity. */
checker?: string;
@@ -216,12 +221,23 @@ export interface EarlyExecutableCacheHit {
frontend: FrontendInputSnapshot;
/** The performance warnings the build reported (SC61xx), repeated on a hit. */
performance?: ScrDiagnostic[];
/** How a --profile-use build's profile matched, reported again on a hit. */
profileMatch?: NativeProfileMatch;
}
export interface EarlyExecutableCachePublish extends EarlyExecutableCacheHit {
nativeDependencies?: NativeArtifactDependency[];
}
function isProfileMatch(value: unknown): value is NativeProfileMatch {
if (typeof value !== "object" || value === null) return false;
const match = value as Record<string, unknown>;
return ["annotated", "mismatched", "missing", "unknownPartitions"].every((key) => {
const count = match[key];
return typeof count === "number" && Number.isInteger(count) && count >= 0;
});
}
const BOOLEAN_NATIVE_KEYS = [
"regex",
"copying",
@@ -300,6 +316,7 @@ function cacheKey(options: EarlyExecutableCacheOptions): string {
...targetCpuKeyParts(options.targetCpu),
...cpuDispatchKeyParts(options.cpuDispatch),
...(options.profile !== undefined ? [options.profile] : []),
...(options.functionLayout !== undefined ? [`layout:${options.functionLayout}`] : []),
...(options.checker !== undefined ? [`checker:${options.checker}`] : []),
...(options.youngNursery ? ["young-nursery"] : []),
options.npmStatic === null
@@ -344,6 +361,7 @@ function routeKey(options: Omit<EarlyExecutableRouteOptions, "nativeEnvironment"
for (const part of targetCpuKeyParts(options.targetCpu)) hash.update(`${part}\0`);
for (const part of cpuDispatchKeyParts(options.cpuDispatch)) hash.update(`${part}\0`);
if (options.profile !== undefined) hash.update(`${options.profile}\0`);
if (options.functionLayout !== undefined) hash.update(`layout:${options.functionLayout}\0`);
if (options.checker !== undefined) hash.update(`checker:${options.checker}\0`);
if (options.youngNursery) hash.update("young-nursery\0");
hash
@@ -674,6 +692,7 @@ async function readExecutableCache(
...(Array.isArray(stamp.performance) && stamp.performance.length > 0
? { performance: stamp.performance }
: {}),
...(isProfileMatch(stamp.profileMatch) ? { profileMatch: stamp.profileMatch } : {}),
...(ir === null ? {} : { irPath: paths.irPath }),
...(llvmSourcePath === undefined ? {} : { llvmSourcePath }),
...(irSourcePath === undefined ? {} : { irSourcePath }),
@@ -864,6 +883,7 @@ export async function publishEarlyExecutableCache(
...(result.performance === undefined || result.performance.length === 0
? {}
: { performance: result.performance }),
...(result.profileMatch === undefined ? {} : { profileMatch: result.profileMatch }),
};
const stamp: EarlyExecutableCacheStamp = { ...unsigned, integrity: stampIntegrity(unsigned) };
await writeFile(join(stage, "stamp.json"), `${JSON.stringify(stamp)}\n`, { mode: 0o600 });
@@ -132,6 +132,7 @@ export function analyzeWithFrontend(
...(lowered.runtimeFences.length > 0 ? { runtimeFences: lowered.runtimeFences } : {}),
...(lowered.divergences !== undefined ? { divergences: lowered.divergences } : {}),
...(lowered.performance !== undefined ? { performance: lowered.performance } : {}),
...(lowered.dynamicSlots !== undefined ? { dynamicSlots: lowered.dynamicSlots } : {}),
...(lowered.unreached ? { unreached: lowered.unreached } : {}),
...(fe.npmStatic.length > 0 ? { npmStatic: fe.npmStatic } : {}),
// --provenance-sources: the per-package attribution inputs (the
+479
View File
@@ -0,0 +1,479 @@
/* Canonical type normalization (localized-failure design part 2, §4),
* stage N0: report-only. Nothing in the compiler consumes it yet.
*
* `canon(type, env)` resolves a checker type, under the current generic
* bindings, to a small set of canonical shapes: primitive, literal, unit,
* top, object (nominal or structural, with lazily resolved members), array,
* tuple, flat union, unbound parameter, and an opaque leaf for what it
* cannot reduce. The per-form rules of type-mapper.ts (generic aliases,
* equal-member intersections, narrowed parameters, recursive placeholders)
* are what this replaces family by family in later stages; N1 runs it in
* shadow mode beside mapType, N2 as mapType's fallback.
*
* Nodes intern by checker type identity and the bindings of the type
* parameters the walk consulted, so a recursive type closes on itself once
* (a back-reference returns the node under construction). */
import * as ts from "./ts7/adapter.js";
export type CanonPrim = "number" | "string" | "boolean" | "bigint" | "symbol";
export interface CanonProp {
name: string;
optional: boolean;
readonly: boolean;
type: () => Canon;
}
export interface CanonSig {
params: { name: string; optional: boolean; rest: boolean; type: () => Canon }[];
ret: () => Canon;
/** The signature keeps type parameters of its own. */
generic: boolean;
}
export interface CanonIndex {
key: Canon;
value: () => Canon;
}
export interface AliasRef {
name: string;
args: readonly Canon[];
/** The checker type the alias reference came from (identity preserved
* for tables keyed by ts.Type, such as boundAliasInstances). */
tsType: ts.Type;
}
export type Canon =
| { k: "prim"; p: CanonPrim }
| { k: "lit"; value: string | number | boolean }
| { k: "unit"; u: "undefined" | "null" | "void" | "never" }
| { k: "top"; t: "unknown" | "any" | "object" }
| {
k: "obj";
/** Class or standard-library declaration name, when nominal. */
nominal?: string;
/** The type arguments of a nominal generic instantiation. */
typeArgs: () => readonly Canon[];
alias?: AliasRef;
props: () => readonly CanonProp[];
index: () => readonly CanonIndex[];
call: () => readonly CanonSig[];
construct: () => readonly CanonSig[];
}
| { k: "array"; elem: Canon; readonly: boolean }
| { k: "tuple"; elems: readonly { type: Canon; optional: boolean; rest: boolean }[] }
| { k: "union"; arms: readonly Canon[] }
| { k: "param"; name: string }
| { k: "opaque"; ts: ts.Type; reason: string };
/** Type-parameter bindings: the checker type a parameter is bound to. */
export type CanonEnv = ReadonlyMap<ts.Symbol, ts.Type>;
const PRIM_FLAGS: readonly [number, CanonPrim][] = [
[ts.TypeFlags.Number, "number"],
[ts.TypeFlags.String, "string"],
[ts.TypeFlags.Boolean, "boolean"],
[ts.TypeFlags.BigInt, "bigint"],
[ts.TypeFlags.ESSymbol | ts.TypeFlags.UniqueESSymbol, "symbol"],
];
/** A stable key for a canonical node, for deduplication and tests. */
export function canonKey(c: Canon, depth = 0): string {
if (depth > 6) return "…";
switch (c.k) {
case "prim":
return c.p;
case "lit":
return JSON.stringify(c.value);
case "unit":
return c.u;
case "top":
return c.t;
case "param":
return `param ${c.name}`;
case "opaque":
return `opaque(${c.reason})`;
case "array":
return `${c.readonly ? "readonly " : ""}${canonKey(c.elem, depth + 1)}[]`;
case "tuple":
return `[${c.elems.map((e) => `${e.rest ? "..." : ""}${canonKey(e.type, depth + 1)}${e.optional ? "?" : ""}`).join(", ")}]`;
case "union":
return c.arms.map((a) => canonKey(a, depth + 1)).join(" | ");
case "obj": {
if (c.nominal !== undefined) {
const args = c.typeArgs();
return args.length === 0
? c.nominal
: `${c.nominal}<${args.map((a) => canonKey(a, depth + 1)).join(", ")}>`;
}
const props = c
.props()
.map((p) => `${p.name}${p.optional ? "?" : ""}: ${canonKey(p.type(), depth + 1)}`);
const sig = (s: CanonSig): string =>
`(${s.params
.map(
(p) => `${p.rest ? "..." : ""}${p.optional ? "?" : ""}${canonKey(p.type(), depth + 1)}`,
)
.join(", ")}) => ${canonKey(s.ret(), depth + 1)}`;
const calls = c.call().map(sig);
const constructs = c.construct().map((s) => `new ${sig(s)}`);
const index = c
.index()
.map((i) => `[${canonKey(i.key, depth + 1)}]: ${canonKey(i.value(), depth + 1)}`);
return `{ ${[...calls, ...constructs, ...index, ...props].join("; ")} }`;
}
}
}
/** The pairs a comparison takes as equal while it runs, and the order they
* were assumed in, so that a failed comparison withdraws what it assumed. */
interface Assumptions {
pairs: Map<Canon, Set<Canon>>;
trail: [Canon, Canon][];
}
/** Whether two canonical nodes are the same type, structurally: unlike
* canonKey, every part is compared (signature parameters, index
* signatures, type arguments, any depth). A pair met again while it is
* being compared is taken as equal, which closes the comparison of
* recursive types; when the comparison fails, the pairs assumed during it
* are withdrawn. */
export function canonEqual(
a: Canon,
b: Canon,
assumed: Assumptions = { pairs: new Map(), trail: [] },
): boolean {
if (a === b) return true;
if (a.k !== b.k) return false;
let pairs = assumed.pairs.get(a);
if (pairs?.has(b)) return true;
if (pairs === undefined) assumed.pairs.set(a, (pairs = new Set()));
const mark = assumed.trail.length;
pairs.add(b);
assumed.trail.push([a, b]);
const equal = sameCanon(a, b, assumed);
if (!equal)
while (assumed.trail.length > mark) {
const [x, y] = assumed.trail.pop()!;
assumed.pairs.get(x)!.delete(y);
}
return equal;
}
function sameCanon(a: Canon, b: Canon, assumed: Assumptions): boolean {
const eq = (x: Canon, y: Canon): boolean => canonEqual(x, y, assumed);
const sigsEqual = (xs: readonly CanonSig[], ys: readonly CanonSig[]): boolean =>
xs.length === ys.length &&
xs.every((x, i) => {
const y = ys[i]!;
return (
x.generic === y.generic &&
x.params.length === y.params.length &&
x.params.every(
(p, j) =>
p.optional === y.params[j]!.optional &&
p.rest === y.params[j]!.rest &&
eq(p.type(), y.params[j]!.type()),
) &&
eq(x.ret(), y.ret())
);
});
switch (a.k) {
case "prim":
return a.p === (b as typeof a).p;
case "lit":
return a.value === (b as typeof a).value;
case "unit":
return a.u === (b as typeof a).u;
case "top":
return a.t === (b as typeof a).t;
case "param":
return a.name === (b as typeof a).name;
case "opaque":
return a.ts === (b as typeof a).ts;
case "array": {
const o = b as typeof a;
return a.readonly === o.readonly && eq(a.elem, o.elem);
}
case "tuple": {
const o = b as typeof a;
return (
a.elems.length === o.elems.length &&
a.elems.every(
(e, i) =>
e.optional === o.elems[i]!.optional &&
e.rest === o.elems[i]!.rest &&
eq(e.type, o.elems[i]!.type),
)
);
}
case "union": {
const o = b as typeof a;
return (
a.arms.length === o.arms.length &&
a.arms.every((x) => o.arms.some((y) => eq(x, y))) &&
o.arms.every((y) => a.arms.some((x) => eq(x, y)))
);
}
case "obj": {
const o = b as typeof a;
if (a.nominal !== o.nominal) return false;
if (a.nominal !== undefined) {
const ax = a.typeArgs();
const ox = o.typeArgs();
return ax.length === ox.length && ax.every((x, i) => eq(x, ox[i]!));
}
const ap = a.props();
const op = o.props();
if (ap.length !== op.length) return false;
for (const p of ap) {
const q = op.find((x) => x.name === p.name);
if (q === undefined || q.optional !== p.optional || !eq(p.type(), q.type())) return false;
}
const ai = a.index();
const oi = o.index();
return (
ai.length === oi.length &&
ai.every((x, i) => eq(x.key, oi[i]!.key) && eq(x.value(), oi[i]!.value())) &&
sigsEqual(a.call(), o.call()) &&
sigsEqual(a.construct(), o.construct())
);
}
}
}
export class Normalizer {
private readonly interned = new Map<string, Canon>();
/** A number for each type parameter an environment binds: parameters
* with the same name are distinct keys. */
private readonly paramIds = new Map<ts.Symbol, number>();
constructor(private readonly checker: ts.TypeChecker) {}
canon(type: ts.Type, env: CanonEnv = new Map()): Canon {
const id = (type as { id?: number }).id;
const key = id === undefined ? null : `${id}|${this.envKey(env)}`;
if (key !== null) {
const known = this.interned.get(key);
if (known !== undefined) return known;
}
const node = this.build(type, env, key);
if (key !== null && !this.interned.has(key)) this.interned.set(key, node);
return node;
}
private envKey(env: CanonEnv): string {
if (env.size === 0) return "";
const parts: string[] = [];
for (const [param, bound] of env) {
let id = this.paramIds.get(param);
if (id === undefined) this.paramIds.set(param, (id = this.paramIds.size));
parts.push(`${id}=${(bound as { id?: number }).id ?? "?"}`);
}
return parts.sort().join(",");
}
private build(type: ts.Type, env: CanonEnv, key: string | null): Canon {
const flags = type.flags;
if (flags & ts.TypeFlags.Any) return { k: "top", t: "any" };
if (flags & ts.TypeFlags.Unknown) return { k: "top", t: "unknown" };
if (flags & ts.TypeFlags.NonPrimitive) return { k: "top", t: "object" };
if (flags & ts.TypeFlags.Never) return { k: "unit", u: "never" };
if (flags & ts.TypeFlags.Void) return { k: "unit", u: "void" };
if (flags & ts.TypeFlags.Undefined) return { k: "unit", u: "undefined" };
if (flags & ts.TypeFlags.Null) return { k: "unit", u: "null" };
if (flags & ts.TypeFlags.TemplateLiteral) return { k: "prim", p: "string" };
if (flags & (ts.TypeFlags.StringLiteral | ts.TypeFlags.NumberLiteral)) {
const value = (type as { value?: string | number }).value;
if (value !== undefined) return { k: "lit", value };
}
if (flags & ts.TypeFlags.BooleanLiteral)
return { k: "lit", value: this.checker.typeToString(type) === "true" };
if (!type.isUnionType())
for (const [flag, p] of PRIM_FLAGS) if (flags & flag) return { k: "prim", p };
if (flags & ts.TypeFlags.TypeParameter) {
const symbol = type.getSymbol();
const bound = symbol === undefined ? undefined : env.get(symbol);
if (bound !== undefined && bound !== type) return this.canon(bound, env);
return { k: "param", name: symbol === undefined ? "?" : ts.symbolName(symbol) };
}
if (type.isUnionType()) return this.union(ts.constituentTypes(type), env);
if (type.isIntersectionType()) return this.intersection(type, env, key);
if (flags & (ts.TypeFlags.Conditional | ts.TypeFlags.IndexedAccess | ts.TypeFlags.Index))
return { k: "opaque", ts: type, reason: "symbolic type operator" };
if (flags & ts.TypeFlags.Object) return this.object(type, env, key);
return { k: "opaque", ts: type, reason: `flags ${flags}` };
}
/** Flat, deduplicated, never-free; `void` reads as `undefined`. */
private union(parts: readonly ts.Type[], env: CanonEnv): Canon {
const arms: Canon[] = [];
const add = (c: Canon): void => {
if (c.k === "union") {
for (const a of c.arms) add(a);
return;
}
if (c.k === "unit" && c.u === "never") return;
const arm: Canon = c.k === "unit" && c.u === "void" ? { k: "unit", u: "undefined" } : c;
// Objects dedupe by identity (they intern by checker type): comparing
// their members would walk into a recursive type still being built.
if (arms.some((a) => (arm.k === "obj" ? a === arm : canonEqual(a, arm)))) return;
arms.push(arm);
};
for (const part of parts) add(this.canon(part, env));
// Units last, in a fixed order; the rest keep the checker's order.
const unitRank = (c: Canon): number =>
c.k !== "unit" ? 0 : c.u === "null" ? 1 : c.u === "undefined" ? 2 : 3;
arms.sort((a, b) => unitRank(a) - unitRank(b));
if (arms.length === 0) return { k: "unit", u: "never" };
if (arms.length === 1) return arms[0]!;
// `true | false` is `boolean`.
const bools = arms.filter((a) => a.k === "lit" && typeof a.value === "boolean");
if (bools.length === 2) {
const rest = arms.filter((a) => !(a.k === "lit" && typeof a.value === "boolean"));
const merged: Canon[] = [{ k: "prim", p: "boolean" }, ...rest];
return merged.length === 1 ? merged[0]! : { k: "union", arms: merged };
}
return { k: "union", arms };
}
/** Equal constituents collapse; object-like constituents without
* signatures merge through the checker's resolved properties; a
* primitive branded with an object is the primitive. */
private intersection(type: ts.Type, env: CanonEnv, key: string | null): Canon {
const parts = ts.constituentTypes(type).map((t) => this.canon(t, env));
if (parts.every((p) => canonEqual(p, parts[0]!))) return parts[0]!;
// Narrowed parameters: `T & {}` (and `T & ({} | null)`'s object part)
// keep T without its undefined and null arms.
const empty = (p: Canon): boolean =>
p.k === "obj" &&
p.nominal === undefined &&
p.props().length === 0 &&
p.index().length === 0 &&
p.call().length === 0 &&
p.construct().length === 0;
const rest = parts.filter((p) => !empty(p));
if (rest.length === 1 && rest.length < parts.length) {
const only = rest[0]!;
if (only.k !== "union") return only.k === "unit" ? { k: "unit", u: "never" } : only;
const arms = only.arms.filter((a) => a.k !== "unit");
return arms.length === 1 ? arms[0]! : { k: "union", arms };
}
const prims = parts.filter((p) => p.k === "prim" || p.k === "lit");
if (prims.length === 1 && parts.every((p) => p === prims[0] || p.k === "obj")) return prims[0]!;
if (parts.every((p) => p.k === "obj" || (p.k === "top" && p.t === "object")))
return this.object(type, env, key);
return { k: "opaque", ts: type, reason: "intersection" };
}
private object(type: ts.Type, env: CanonEnv, key: string | null): Canon {
const checker = this.checker;
// Arrays and tuples enter the intern table before their elements are
// normalized, so a recursive alias (`type Tree = Tree[] | number`)
// closes on the node under construction.
if (checker.isArrayType(type)) {
const [elem] = checker.getTypeArguments(type as ts.TypeReference);
const node: Canon & { k: "array" } = {
k: "array",
elem: { k: "top", t: "unknown" },
readonly:
type.getSymbol() !== undefined && ts.symbolName(type.getSymbol()!) === "ReadonlyArray",
};
if (key !== null) this.interned.set(key, node);
if (elem !== undefined) node.elem = this.canon(elem, env);
return node;
}
if (checker.isTupleType(type)) {
const ref = type as ts.TupleTypeReference;
// The element flags live on the tuple shape: the type itself, or the
// target of a reference to it.
const shape =
(ref.elementFlags as ts.ElementFlags[] | undefined) !== undefined
? ref
: (ref.getTarget() as ts.TupleType | undefined);
const flags = shape?.elementFlags ?? [];
const elems: { type: Canon; optional: boolean; rest: boolean }[] = [];
const node: Canon = { k: "tuple", elems };
if (key !== null) this.interned.set(key, node);
checker.getTypeArguments(ref).forEach((a, i) => {
const f = flags[i] ?? ts.ElementFlags.Required;
elems.push({
type: this.canon(a, env),
optional: (f & ts.ElementFlags.Optional) !== 0,
rest: (f & (ts.ElementFlags.Rest | ts.ElementFlags.Variadic)) !== 0,
});
});
return node;
}
const symbol = type.getSymbol();
const decl = symbol?.declarations[0];
const nominal =
decl !== undefined && (ts.isClassDeclaration(decl) || ts.isClassExpression(decl))
? ts.symbolName(symbol!)
: decl !== undefined &&
ts.isInterfaceDeclaration(decl) &&
ts.getSourceFileOfNode(decl).isDeclarationFile &&
/\/lib\.[^/]*\.d\.ts$/.test(ts.getSourceFileOfNode(decl).fileName)
? ts.symbolName(symbol!)
: undefined;
const aliasSymbol = type.getAliasSymbol();
const alias: AliasRef | undefined =
aliasSymbol === undefined
? undefined
: {
name: ts.symbolName(aliasSymbol),
args: type.getAliasTypeArguments().map((a) => this.canon(a, env)),
tsType: type,
};
let typeArgs: readonly Canon[] | undefined;
let props: readonly CanonProp[] | undefined;
let index: readonly CanonIndex[] | undefined;
let call: readonly CanonSig[] | undefined;
let construct: readonly CanonSig[] | undefined;
const sigs = (list: readonly ts.Signature[]): CanonSig[] =>
list.map((s) => ({
params: s.getParameters().map((p) => {
const pd = p.declarations[0];
const param = pd !== undefined && ts.isParameter(pd) ? pd : undefined;
return {
name: ts.symbolName(p),
optional: param?.questionToken !== undefined || param?.initializer !== undefined,
rest: param?.dotDotDotToken !== undefined,
type: () => this.canon(checker.getTypeOfSymbol(p), env),
};
}),
ret: () => this.canon(checker.getReturnTypeOfSignature(s), env),
generic: s.getTypeParameters().length > 0,
}));
const node: Canon = {
k: "obj",
...(nominal !== undefined ? { nominal } : {}),
...(alias !== undefined ? { alias } : {}),
typeArgs: () =>
(typeArgs ??=
nominal !== undefined &&
((type as ts.ObjectType).objectFlags & ts.ObjectFlags.Reference) !== 0
? checker.getTypeArguments(type as ts.TypeReference).map((a) => this.canon(a, env))
: []),
props: () =>
(props ??= checker.getPropertiesOfType(type).map((p) => ({
name: ts.symbolName(p),
optional: (p.flags & ts.SymbolFlags.Optional) !== 0,
readonly: false,
type: () => this.canon(checker.getTypeOfSymbol(p), env),
}))),
index: () =>
(index ??= checker.getIndexInfosOfType(type).map((info) => ({
key: this.canon(info.keyType, env),
value: () => this.canon(info.type, env),
}))),
call: () => (call ??= sigs(checker.getCallSignatures(type))),
construct: () => (construct ??= sigs(checker.getConstructSignatures(type))),
};
// Recursion closes here: members resolve lazily, so a back-reference
// reaches this node through the intern table.
if (key !== null) this.interned.set(key, node);
return node;
}
}
@@ -3,8 +3,10 @@ import { InternalCompilerError } from "../../errors.js";
import { dynUndefinedExpr, helperLoc, varRef } from "../../ir/build.js";
import {
BOOL,
DYN,
F64,
type IrExpr,
type IrLocal,
type IrStmt,
type IrType,
isIntegerBytesElem,
@@ -446,6 +448,167 @@ export function appendElement(
];
}
/** The argument a helper passes in place of an element its callback never
* reads (lowerArrayCallback's `unread`): a constant of the parameter's
* type, so the slot stays unread and an element the type cannot hold
* (stored through a dynamic view) passes. Null for a type without one. */
export function unreadArgument(lowerer: Lowerer, type: IrType, loc: SrcLoc): IrExpr | null {
if (type.kind === "f64") return { kind: "numLit", value: NaN, type: F64, loc };
if (type.kind === "bool") return { kind: "boolLit", value: false, type: BOOL, loc };
if (type.kind === "string") return { kind: "strLit", value: "", type: STRING, loc };
if (type.kind === "union" && lowerer.armTag(type.unionId, UNDEFINED_T) >= 0)
return lowerer.wrappedUndefined(type, loc) ?? null;
return null;
}
/** The locals of an element snapshot (arraySlotSnapshot): whether the slot
* held an element outside the element type, the value read otherwise, and
* that element's dynamic value otherwise. */
export interface SlotSnapshot {
isForeign: string;
value: string;
foreign: string;
}
/** The locals a snapshot of an `elem` element uses. */
export function slotSnapshotLocals(lowerer: Lowerer, elem: IrType, ids: SlotSnapshot): IrLocal[] {
return [
{ id: ids.isForeign, name: "foreign", type: BOOL, mutable: false },
{ id: ids.value, name: "value", type: arrayValueType(lowerer, elem), mutable: false },
{ id: ids.foreign, name: "element", type: DYN, mutable: false },
];
}
/** Read the element at `arr[index]` as it is stored, before a callback runs,
* as JavaScript reads it before calling a callback that ignores it: a value
* or a present undefined into `ids.value`, or an element outside the element
* type (stored through a dynamic view) as its dynamic value into
* `ids.foreign`, which `ids.isForeign` tells apart. */
export function arraySlotSnapshot(
lowerer: Lowerer,
arr: IrExpr,
index: IrExpr,
elem: IrType,
ids: SlotSnapshot,
loc: SrcLoc,
): IrStmt[] {
const valueT = arrayValueType(lowerer, elem);
const isForeign: IrExpr = { kind: "varRef", localId: ids.isForeign, type: BOOL, loc };
const missing =
valueT.kind === "dyn" ? dynUndefinedExpr(loc) : lowerer.wrappedUndefined(valueT, loc)!;
return [
{
kind: "varDecl",
localId: ids.isForeign,
init: {
kind: "bin",
op: "===",
left: { kind: "arrayState", arr, index, type: F64, loc },
right: { kind: "numLit", value: 3, type: F64, loc },
type: BOOL,
loc,
},
loc,
},
{
kind: "varDecl",
localId: ids.value,
init: {
kind: "ternary",
cond: isForeign,
then: missing,
else_: arrayValueRead(lowerer, arr, index, elem, loc),
type: valueT,
loc,
},
loc,
},
{
kind: "varDecl",
localId: ids.foreign,
init: {
kind: "ternary",
cond: isForeign,
then: { kind: "libCall", fn: "array.foreign", args: [arr, index], type: DYN, loc },
else_: dynUndefinedExpr(loc),
type: DYN,
loc,
},
loc,
},
];
}
/** Append a snapshot (arraySlotSnapshot) of an element to `out`, an array of
* the same element type, as it was stored: an element outside the element
* type stays one, appended as the view's push appends it. `out` is a
* binding. */
export function arraySnapshotAppend(
lowerer: Lowerer,
out: IrExpr,
elem: IrType,
ids: SlotSnapshot,
loc: SrcLoc,
): IrStmt {
const end: IrExpr = {
kind: "arrIntrinsic",
method: "length",
receiver: out,
args: [],
type: F64,
loc,
};
const value: IrExpr = {
kind: "varRef",
localId: ids.value,
type: arrayValueType(lowerer, elem),
loc,
};
return {
kind: "if",
cond: { kind: "varRef", localId: ids.isForeign, type: BOOL, loc },
then: [
{
kind: "exprStmt",
expr: {
kind: "dynInvoke",
recv: { kind: "dynFrom", value: out, liveRef: true, type: DYN, loc },
method: "push",
calleeName: "push",
args: [{ kind: "varRef", localId: ids.foreign, type: DYN, loc }],
type: DYN,
loc,
},
loc,
},
],
else_: [arrayValueStore(lowerer, out, end, value, elem, loc)],
loc,
};
}
/** A snapshot (arraySlotSnapshot) as a value of `type`: the value read, or
* the element outside the element type through the checked conversion,
* which such an element fails as a typed read does. `typed` converts the
* value read. */
export function arraySnapshotValue(
lowerer: Lowerer,
elem: IrType,
ids: SlotSnapshot,
type: IrType,
typed: (value: IrExpr) => IrExpr,
loc: SrcLoc,
): IrExpr {
return {
kind: "ternary",
cond: { kind: "varRef", localId: ids.isForeign, type: BOOL, loc },
then: lowerer.coerceToExpected({ kind: "varRef", localId: ids.foreign, type: DYN, loc }, type),
else_: typed({ kind: "varRef", localId: ids.value, type: arrayValueType(lowerer, elem), loc }),
type,
loc,
};
}
/** Store a stabilized value without changing the array's element ABI. */
export function arrayValueStore(
lowerer: Lowerer,
@@ -529,6 +692,9 @@ export function lowerSafeIndexRead(
const elem = arr.type.elem;
if (elem.kind === "void") return null;
const resultT = arrayValueType(lowerer, elem);
// An element union that cannot take an undefined arm has no value for a
// missing element.
if (resultT.kind !== "dyn" && lowerer.wrappedUndefined(resultT, loc) === null) return null;
const key = `idxOr:${typeKey(elem)}`;
let name = lowerer.arrHofHelpers.get(key);
if (!name) {
@@ -73,8 +73,7 @@ export function lowerDisposableStackUseCall(
if (valueT?.kind !== "object") return null;
const loc = locOf(call);
const className = valueT.className;
const info = lowerer.classes.get(className);
if (!info) lowerer.flushDeferredClass(className);
const info = lowerer.classes.get(className) ?? lowerer.flushDeferredClass(className);
const found = info ? lowerer.findMethodOn(info, "sym:dispose") : null;
if (!found) return null;
if (found.sig.params.length !== 0) {
@@ -1306,6 +1306,7 @@ export function lowerFsReaddirTypesCall(
);
}
let sawWithFileTypes = false;
let recursive: ts.Expression | null = null;
for (const p of optsNode.properties.nodes) {
const m = optionMember(p);
if (!m) {
@@ -1334,9 +1335,11 @@ export function lowerFsReaddirTypesCall(
"names decode as utf8 (the default); encoding: 'buffer' has no lowering",
);
}
} else if (m.name === "recursive" && !promiseForm) {
recursive = m.value;
} else {
// The options-record stance: recursive (a documented knob with
// no lowering) fences by name; undocumented keys drop like Node.
// no lowering here) fences by name; undocumented keys drop like Node.
fenceOrDropOptionKey(
lowerer,
p,
@@ -1347,6 +1350,26 @@ export function lowerFsReaddirTypesCall(
);
}
}
if (recursive !== null) {
// Only the `true`/`false` keywords fold; any other expression, even one
// typed as a literal, evaluates at run time for its effects.
let flag = recursive;
while (ts.isParenthesizedExpression(flag)) flag = flag.expression;
const literal =
flag.kind === ts.SyntaxKind.TrueKeyword
? "true"
: flag.kind === ts.SyntaxKind.FalseKeyword
? "false"
: null;
if (sawWithFileTypes && literal !== "false") {
lowerer.noLowering(
`${operation} with withFileTypes and recursive`,
recursive,
"recursive: true lists relative names; withFileTypes: true lists one directory's Dirents",
);
}
if (!sawWithFileTypes) return lowerFsReaddirRecursive(lowerer, call, recursive, literal, loc);
}
if (!sawWithFileTypes) {
lowerer.noLowering(
`${operation} with 2 arguments`,
@@ -1388,6 +1411,75 @@ export function lowerFsReaddirTypesCall(
};
}
/** `fs.readdirSync(path, { recursive })` without withFileTypes: the
* relative names of every entry below the path when `recursive` is true
* (scr_fs_readdir_recursive, Node's queue order), the directory's own names
* when it is false. A recursive flag that is not a boolean literal selects
* between the two at run time, after the path is evaluated. */
function lowerFsReaddirRecursive(
lowerer: Lowerer,
call: ts.CallExpression,
recursive: ts.Expression,
literal: string | null,
loc: SrcLoc,
): IrExpr {
const names = arrayOf(STRING);
// @types/node types the call `string[] | Buffer[]` when the options name
// no encoding; the names are strings, so the call is the string[] arm.
const callType = lowerer.mapTypeOf(lowerer.typeOf(call));
if (
!callType ||
!(
typeEquals(callType, names) ||
(callType.kind === "union" && lowerer.armTag(callType.unionId, names) >= 0)
)
) {
lowerer.noLowering(
"readdirSync(path, { recursive }) where the result is not a string array",
call,
"names decode as utf8 strings (the default encoding)",
);
}
return lowerFsReaddirNames(lowerer, call, recursive, literal, names, loc);
}
function lowerFsReaddirNames(
lowerer: Lowerer,
call: ts.CallExpression,
recursive: ts.Expression,
literal: string | null,
names: IrType,
loc: SrcLoc,
): IrExpr {
const path = lowerFsPath(lowerer, call.arguments.nodes[0]!);
const listing = (fn: IrLibFn, arg: IrExpr): IrExpr => ({
kind: "libCall",
fn,
args: [arg],
type: names,
loc,
});
if (literal === "true") return listing("fs.readdirRecursiveSync", path);
if (literal === "false") return listing("fs.readdirSync", path);
const pathLocal = lowerer.declareHiddenLocal("%readdirPath", STRING);
const pathRef = varRef(pathLocal.id, STRING, loc);
const flag = lowerer.lowerExprExpecting(recursive, BOOL);
return {
kind: "seqExpr",
stmts: [{ kind: "varDecl", localId: pathLocal.id, init: path, loc }],
result: {
kind: "ternary",
cond: flag,
then: listing("fs.readdirRecursiveSync", pathRef),
else_: listing("fs.readdirSync", pathRef),
type: names,
loc,
},
type: names,
loc,
};
}
/** fs.statSync / fs.lstatSync's documented option keys. */
const FS_STAT_DOCUMENTED_OPTIONS: ReadonlySet<string> = new Set(["bigint", "throwIfNoEntry"]);
@@ -1,6 +1,6 @@
import { planRecordUnionLift, planRecordUnionWrap } from "../union-retag.js";
import type { WidthLift } from "../width-lift.js";
import type { IrType } from "../../../ir/ir.js";
import type { IrRecordShape, IrType } from "../../../ir/ir.js";
import { canDynCheckTo, isUnitType, typeEquals, UNDEFINED_T } from "../../../ir/ir.js";
import { type ClassInfo, findMethodOn, findGenericMethodOn } from "../lower-classes.js";
import type { Lowerer } from "../lowerer.js";
@@ -104,7 +104,15 @@ export function widthLiftPlan(lowerer: Lowerer, src: IrType, dst: IrType): Width
})
);
});
if (absentOnly.length === 1) selected = absentOnly;
// That layout does not win when it holds the record only by dropping
// fields and another arm keeps every field (`{ kind; type }` beside
// `{ kind; name; adapts?: true; type }`): the value is not its variant.
const dropsFields = (arm: IrType): boolean => !keepsEveryField(lowerer, source, arm);
if (
absentOnly.length === 1 &&
!(dropsFields(absentOnly[0]!.arm) && selected.some(({ arm }) => !dropsFields(arm)))
)
selected = absentOnly;
}
// A record that fits several arms of a discriminated union selects one
// by its discriminant at run time. Without this, an arm that a nested
@@ -131,21 +139,19 @@ export function widthLiftPlan(lowerer: Lowerer, src: IrType, dst: IrType): Width
// A record whose fields some arm keeps EVERY one of, each lifting
// (`{ id; limit; opts?: A | B }` into `{ id; limit; opts?: A | B | C } |
// { id; opts?: C }`): that arm is the value's own layout, widened; the
// others hold it only by dropping fields. Prefer the unique lossless arm.
// others hold it only by dropping fields. Prefer the unique lossless arm
// with the record's own fields, then the unique one that also declares
// optional fields the record lacks, which complete as absent.
if (selected.length > 1 && src.kind === "record") {
const source = lowerer.shapes.get(src.shapeId);
const lossless = selected.filter(({ arm }) => {
const target = arm.kind === "record" ? lowerer.shapes.get(arm.shapeId) : undefined;
return (
source !== undefined &&
target !== undefined &&
!source.tuple &&
!target.tuple &&
target.fields.length === source.fields.length &&
source.fields.every((field) => target.fields.some((kept) => kept.name === field.name))
);
});
if (lossless.length === 1) selected = lossless;
const lossless = selected.filter(({ arm }) => keepsEveryField(lowerer, source, arm));
const exact = lossless.filter(
({ arm }) =>
arm.kind === "record" &&
lowerer.shapes.get(arm.shapeId)?.fields.length === source?.fields.length,
);
if (exact.length === 1) selected = exact;
else if (exact.length === 0 && lossless.length === 1) selected = lossless;
}
if (selected.length !== 1) return null;
return { how: "liftWrap", tag: selected[0]!.tag, arm: selected[0]!.arm };
@@ -241,6 +247,23 @@ export function widthLiftPlan(lowerer: Lowerer, src: IrType, dst: IrType): Width
return null;
}
/** True when the record arm `arm` has a field of every name the record
* `source` has. Tuples keep their own positional layouts. */
function keepsEveryField(
lowerer: Lowerer,
source: IrRecordShape | undefined,
arm: IrType,
): boolean {
const target = arm.kind === "record" ? lowerer.shapes.get(arm.shapeId) : undefined;
return (
source !== undefined &&
target !== undefined &&
!source.tuple &&
!target.tuple &&
source.fields.every((field) => target.fields.some((kept) => kept.name === field.name))
);
}
/** Two record arms a source record fits, where one arm declares a field
* the other lacks and the source types that field `T | undefined`
* (`{ count, errors: errs.length ? errs : undefined }` into `{ count;
@@ -38,6 +38,9 @@ import {
} from "../lower-array-sort.js";
import {
arrayIndexPresent,
arraySlotSnapshot,
arraySnapshotAppend,
arraySnapshotValue,
arrayValueRead,
arrayValueStore,
arrayValueType,
@@ -45,6 +48,9 @@ import {
elementMayBeForeign,
foreignElementThrow,
primitiveElement,
slotSnapshotLocals,
unreadArgument,
type SlotSnapshot,
} from "../array-values.js";
import { typeKey } from "../../type-mapper.js";
import {
@@ -101,9 +107,12 @@ export function lowerArrayHofCall(
}
}
const callback = booleanFilter
? { fnArg: truthyFilterCallback(lowerer, valueT, loc), arity: 1 }
? { fnArg: truthyFilterCallback(lowerer, valueT, loc), arity: 1, unread: false }
: lowerArrayCallback(lowerer, argNode, [valueT], arrayOf(elem));
const { fnArg, arity } = callback;
// Only an array that can hold elements outside its element type needs to
// leave an element its callback ignores unread.
const unread = callback.unread && !present && elementMayBeForeign(lowerer, elem);
const fnRet = fnArg.type.ret;
if (method === "map" && (fnRet.kind === "void" || fnRet.kind === "func")) {
// The result array U[] is unrepresentable here (no void elements;
@@ -133,8 +142,9 @@ export function lowerArrayHofCall(
: null;
const outElem = filtered !== null ? filtered.elem : callbackArrayElement(lowerer, call, fnRet);
// A filter that re-tags its elements keeps the single union-typed visit.
// A callback that never reads its element has no use for a typed one.
const fast =
present || booleanFilter || (method === "filter" && !typeEquals(outElem, elem))
present || booleanFilter || unread || (method === "filter" && !typeEquals(outElem, elem))
? null
: lowerFastCallback(lowerer, argNode, [valueT], 0, elem, arrayOf(elem), callback);
const helper = arrayHofHelper(
@@ -148,6 +158,7 @@ export function lowerArrayHofCall(
callbackSite(fast ?? fnArg),
fast?.type ?? null,
present,
unread,
);
const resultType: IrType = method === "map" || method === "filter" ? arrayOf(outElem) : VOID;
const result: IrExpr = {
@@ -541,12 +552,21 @@ function callbackValueRead(
elem: IrType,
present: boolean,
loc: SrcLoc,
unread = false,
): IrExpr {
if (unread) {
const placeholder = unreadArgument(lowerer, callbackValueType(lowerer, elem, present), loc);
if (placeholder) return placeholder;
}
return present
? { kind: "arrayGet", arr, index, type: elem, loc }
: arrayValueRead(lowerer, arr, index, elem, loc);
}
/** The snapshot locals of the filter and find helpers; `value` is their
* element local `v.0`. */
const FILTER_SNAPSHOT: SlotSnapshot = { isForeign: "vf.0", value: "v.0", foreign: "vd.0" };
/** Interned synthetic loop function for one (method, elem, fnRet, arity)
* combo. Named `%arr.<method>.<n>` ('%' keeps it out of the user
* namespace); rides `liftedFns` into the module like a lifted lambda (it
@@ -562,13 +582,23 @@ function arrayHofHelper(
site = "",
fastT: CallbackType | null = null,
present = false,
unread = false,
): string {
const key = `${method}:${typeKey(elem)}:${typeKey(fnRet)}:${arity}:${typeKey(outElem)}${site}${fastT ? ":split" : ""}${present ? ":present" : ""}`;
const key = `${method}:${typeKey(elem)}:${typeKey(fnRet)}:${arity}:${typeKey(outElem)}${site}${fastT ? ":split" : ""}${present ? ":present" : ""}${unread ? ":unread" : ""}`;
const existing = lowerer.arrHofHelpers.get(key);
if (existing) return existing;
const name = keyedHelperName(lowerer, `%arr.${method}`, key);
lowerer.arrHofHelpers.set(key, name);
lowerer.arrHofInfo.set(name, { method, elem, fnRet, arity, outElem, present, split: !!fastT });
lowerer.arrHofInfo.set(name, {
method,
elem,
fnRet,
arity,
outElem,
present,
split: !!fastT,
unread,
});
const fn = buildArrayHofFn(
lowerer,
name,
@@ -580,6 +610,7 @@ function arrayHofHelper(
outElem,
fastT,
present,
unread,
);
lowerer.liftedFns.push(fastT ? withFastCallback(fn, fastT, elem) : fn);
return name;
@@ -794,6 +825,7 @@ function buildArrayHofFn(
outElem: IrType,
fastT: CallbackType | null = null,
present = false,
unread = false,
): IrFunction {
const arrT = arrayOf(elem);
const valueT = callbackValueType(lowerer, elem, present);
@@ -810,6 +842,19 @@ function buildArrayHofFn(
present,
loc,
);
// What the callback takes: the element, or a placeholder for one it
// ignores (callbackValueRead's `unread`).
const argElem = unread
? callbackValueRead(
lowerer,
varRef("a.0", arrT, loc),
varRef("i.0", F64, loc),
elem,
present,
loc,
true,
)
: getElem;
const hasElem: IrExpr = {
kind: "arrayHas",
arr: varRef("a.0", arrT, loc),
@@ -858,7 +903,7 @@ function buildArrayHofFn(
kind: "if",
cond: hasElem,
then: [
{ kind: "varDecl", localId: "mapped.0", init: callF(getElem), loc },
{ kind: "varDecl", localId: "mapped.0", init: callF(argElem), loc },
arrayValueStore(
lowerer,
varRef("out.0", outT, loc),
@@ -994,6 +1039,19 @@ function buildArrayHofFn(
);
returnType = outT;
const retained = filterRetainedValue(lowerer, varRef("v.0", valueT, loc), elem, outElem, loc);
// A predicate that ignores the element of an array that can hold
// elements outside its element type keeps a snapshot of the slot, read
// before the predicate runs, as stored (arraySlotSnapshot).
const copied =
(arity === 0 || unread) &&
!present &&
typeEquals(outElem, elem) &&
elementMayBeForeign(lowerer, elem);
// `v.0` is declared above; the snapshot adds its other two locals.
if (copied)
locals.push(
...slotSnapshotLocals(lowerer, elem, FILTER_SNAPSHOT).filter((l) => l.id !== "v.0"),
);
body = [
{
kind: "varDecl",
@@ -1006,34 +1064,62 @@ function buildArrayHofFn(
{
kind: "if",
cond: hasElem,
then: [
{ kind: "varDecl", localId: "v.0", init: getElem, loc },
{
kind: "if",
// ToBoolean over the predicate's answer — inert when it already
// returned bool, the per-union helper when it returned a union.
cond: filterCond(callF(varRef("v.0", valueT, loc)), fnRet, loc),
then: [
arrayValueStore(
// A kept element is appended as it was stored, so one the element
// type cannot hold stays in the result.
then: copied
? [
...arraySlotSnapshot(
lowerer,
varRef("out.0", outT, loc),
{
kind: "arrIntrinsic",
method: "length",
receiver: varRef("out.0", outT, loc),
args: [],
type: F64,
loc,
},
retained,
outElem,
varRef("a.0", arrT, loc),
varRef("i.0", F64, loc),
elem,
FILTER_SNAPSHOT,
loc,
),
{
kind: "if",
cond: filterCond(callF(argElem), fnRet, loc),
then: [
arraySnapshotAppend(
lowerer,
varRef("out.0", outT, loc),
elem,
FILTER_SNAPSHOT,
loc,
),
],
else_: null,
loc,
},
]
: [
{ kind: "varDecl", localId: "v.0", init: getElem, loc },
{
kind: "if",
// ToBoolean over the predicate's answer — inert when it already
// returned bool, the per-union helper when it returned a union.
cond: filterCond(callF(varRef("v.0", valueT, loc)), fnRet, loc),
then: [
arrayValueStore(
lowerer,
varRef("out.0", outT, loc),
{
kind: "arrIntrinsic",
method: "length",
receiver: varRef("out.0", outT, loc),
args: [],
type: F64,
loc,
},
retained,
outElem,
loc,
),
],
else_: null,
loc,
},
],
else_: null,
loc,
},
],
else_: null,
loc,
},
@@ -1048,7 +1134,7 @@ function buildArrayHofFn(
{
kind: "if",
cond: hasElem,
then: [{ kind: "exprStmt", expr: callF(getElem), loc }],
then: [{ kind: "exprStmt", expr: callF(argElem), loc }],
else_: null,
loc,
},
@@ -1088,13 +1174,17 @@ export function lowerArrayFindLikeCall(
presentElements(lowerer, call, elem) &&
((method !== "find" && method !== "findLast") ||
(!bindUntyped && lowerer.coercibleValue(elem, lowerer.irTypeOf(call))));
const { fnArg, arity } = lowerArrayCallback(
const lowered = lowerArrayCallback(
lowerer,
argNode,
[callbackValueType(lowerer, elem, present)],
arrT,
bindUntyped,
);
const { fnArg, arity } = lowered;
// Only an array that can hold elements outside its element type needs to
// leave an element its callback ignores unread.
const unread = lowered.unread && !present && elementMayBeForeign(lowerer, elem);
// JS takes the ToBoolean of the predicate's result — allowed wherever
// that ToBoolean has a static answer (bool passes through; f64/string
// by value; a truthy-answerable union by its arm — the
@@ -1109,18 +1199,19 @@ export function lowerArrayFindLikeCall(
method === "findIndex" ||
method === "findLastIndex"
) {
const fast = present
? null
: lowerFastCallback(
lowerer,
argNode,
[arrayValueType(lowerer, elem)],
0,
elem,
arrT,
{ fnArg, arity },
bindUntyped,
);
const fast =
present || unread
? null
: lowerFastCallback(
lowerer,
argNode,
[arrayValueType(lowerer, elem)],
0,
elem,
arrT,
{ fnArg, arity },
bindUntyped,
);
const fastT = fast?.type ?? null;
const helper =
method === "findIndex" || method === "findLastIndex"
@@ -1134,6 +1225,7 @@ export function lowerArrayFindLikeCall(
callbackSite(fast ?? fnArg),
fastT,
present,
unread,
)
: someEveryHelper(
lowerer,
@@ -1145,6 +1237,7 @@ export function lowerArrayFindLikeCall(
callbackSite(fast ?? fnArg),
fastT,
present,
unread,
);
return {
kind: "call",
@@ -1182,6 +1275,7 @@ export function lowerArrayFindLikeCall(
retag,
callbackSite(fnArg),
present,
unread,
);
return { kind: "call", callee: helper, args: [receiver, fnArg], type: resultT, loc };
}
@@ -1275,9 +1369,10 @@ function findHelper(
retag: string | null,
site = "",
present = false,
unread = false,
): string {
const method = last ? "findLast" : "find";
const key = `${method}:${typeKey(elem)}:${typeKey(resultT)}:${typeKey(fnRet)}:${arity}${site}${present ? ":present" : ""}`;
const key = `${method}:${typeKey(elem)}:${typeKey(resultT)}:${typeKey(fnRet)}:${arity}${site}${present ? ":present" : ""}${unread ? ":unread" : ""}`;
const existing = lowerer.arrHofHelpers.get(key);
if (existing) return existing;
const name = keyedHelperName(lowerer, `%arr.${method}`, key);
@@ -1296,28 +1391,62 @@ function findHelper(
type: resultT,
loc,
};
const visitBody: IrStmt[] = [
{
kind: "varDecl",
localId: "v.0",
init: callbackValueRead(
lowerer,
varRef("a.0", arrT, loc),
varRef("i.0", F64, loc),
elem,
present,
loc,
),
const read: IrStmt = {
kind: "varDecl",
localId: "v.0",
init: callbackValueRead(
lowerer,
varRef("a.0", arrT, loc),
varRef("i.0", F64, loc),
elem,
present,
loc,
},
{
kind: "if",
cond: predicateToBoolean(callArrayCallback(arrT, fnT, fnRet, arity, v, loc)),
then: [{ kind: "return", value: found, loc }],
else_: null,
loc,
},
];
),
loc,
};
// A predicate that ignores the element of an array that can hold
// elements outside its element type keeps a snapshot of the slot, read
// before the predicate runs as JavaScript reads it (arraySlotSnapshot):
// the predicate is called without reading it as typed, and a found element
// outside the element type takes the checked conversion into the result.
const ignored = (arity === 0 || unread) && !present && elementMayBeForeign(lowerer, elem);
const placeholder = unread ? unreadArgument(lowerer, valueT, loc) : null;
const visitBody: IrStmt[] = ignored
? [
...arraySlotSnapshot(
lowerer,
varRef("a.0", arrT, loc),
varRef("i.0", F64, loc),
elem,
FILTER_SNAPSHOT,
loc,
),
{
kind: "if",
cond: predicateToBoolean(
callArrayCallback(arrT, fnT, fnRet, arity, placeholder ?? v, loc),
),
then: [
{
kind: "return",
value: arraySnapshotValue(lowerer, elem, FILTER_SNAPSHOT, resultT, () => found, loc),
loc,
},
],
else_: null,
loc,
},
]
: [
read,
{
kind: "if",
cond: predicateToBoolean(callArrayCallback(arrT, fnT, fnRet, arity, v, loc)),
then: [{ kind: "return", value: found, loc }],
else_: null,
loc,
},
];
const visit: IrStmt[] = visitBody;
const loop = last
? reverseArrayLoop(loc, visit)
@@ -1334,6 +1463,9 @@ function findHelper(
locals: [
...arrayCallbackLocals(arrT, fnT),
{ id: "v.0", name: "v", type: valueT, mutable: false },
...(ignored
? slotSnapshotLocals(lowerer, elem, FILTER_SNAPSHOT).filter((l) => l.id !== "v.0")
: []),
],
body,
loc,
@@ -1375,9 +1507,10 @@ function findIndexHelper(
site = "",
fastT: CallbackType | null = null,
present = false,
unread = false,
): string {
const method = last ? "findLastIndex" : "findIndex";
const key = `${method}:${typeKey(elem)}:${typeKey(fnRet)}:${arity}${site}${fastT ? ":split" : ""}${present ? ":present" : ""}`;
const key = `${method}:${typeKey(elem)}:${typeKey(fnRet)}:${arity}${site}${fastT ? ":split" : ""}${present ? ":present" : ""}${unread ? ":unread" : ""}`;
const existing = lowerer.arrHofHelpers.get(key);
if (existing) return existing;
const name = keyedHelperName(lowerer, `%arr.${method}`, key);
@@ -1391,6 +1524,7 @@ function findIndexHelper(
outElem: elem,
present,
split: !!fastT,
unread,
});
const arrT = arrayOf(elem);
const fnT = funcOf([callbackValueType(lowerer, elem, present), F64, arrT].slice(0, arity), fnRet);
@@ -1411,6 +1545,7 @@ function findIndexHelper(
elem,
present,
loc,
unread,
),
loc,
),
@@ -1460,8 +1595,9 @@ function someEveryHelper(
site = "",
fastT: CallbackType | null = null,
present = false,
unread = false,
): string {
const key = `${method}:${typeKey(elem)}:${typeKey(fnRet)}:${arity}${site}${fastT ? ":split" : ""}${present ? ":present" : ""}`;
const key = `${method}:${typeKey(elem)}:${typeKey(fnRet)}:${arity}${site}${fastT ? ":split" : ""}${present ? ":present" : ""}${unread ? ":unread" : ""}`;
const existing = lowerer.arrHofHelpers.get(key);
if (existing) return existing;
const name = keyedHelperName(lowerer, `%arr.${method}`, key);
@@ -1474,6 +1610,7 @@ function someEveryHelper(
outElem: elem,
present,
split: !!fastT,
unread,
});
const arrT = arrayOf(elem);
const fnT = funcOf([callbackValueType(lowerer, elem, present), F64, arrT].slice(0, arity), fnRet);
@@ -1490,6 +1627,7 @@ function someEveryHelper(
elem,
present,
loc,
unread,
),
loc,
),
@@ -1553,12 +1691,16 @@ export function lowerArrayFlatMapCall(
const argNode = call.arguments.nodes[0];
if (!argNode) lowerer.unsupported("SC1090", call, "this call form"); // tsc-guarded
const present = presentElements(lowerer, call, elem);
const { fnArg, arity } = lowerArrayCallback(
const lowered = lowerArrayCallback(
lowerer,
argNode,
[callbackValueType(lowerer, elem, present)],
arrT,
);
const { fnArg, arity } = lowered;
// Only an array that can hold elements outside its element type needs to
// leave an element its callback ignores unread.
const unread = lowered.unread && !present && elementMayBeForeign(lowerer, elem);
const fnRet = fnArg.type.ret;
if (
fnRet.kind === "dyn" ||
@@ -1603,12 +1745,13 @@ export function lowerArrayFlatMapCall(
if (fnRet.kind === "void" || fnRet.kind === "func") lowerer.badType(call, lowerer.typeOf(call));
requireProducedArrayElement(lowerer, call, "'.flatMap()'", fnRet);
const outElem = callbackArrayElement(lowerer, call, fnRet);
const fast = present
? null
: lowerFastCallback(lowerer, argNode, [arrayValueType(lowerer, elem)], 0, elem, arrT, {
fnArg,
arity,
});
const fast =
present || unread
? null
: lowerFastCallback(lowerer, argNode, [arrayValueType(lowerer, elem)], 0, elem, arrT, {
fnArg,
arity,
});
const helper = arrayHofHelper(
lowerer,
"flatMap",
@@ -1620,6 +1763,7 @@ export function lowerArrayFlatMapCall(
callbackSite(fast ?? fnArg),
fast?.type ?? null,
present,
unread,
);
return {
kind: "call",
@@ -1629,12 +1773,13 @@ export function lowerArrayFlatMapCall(
loc,
};
}
const fast = present
? null
: lowerFastCallback(lowerer, argNode, [arrayValueType(lowerer, elem)], 0, elem, arrT, {
fnArg,
arity,
});
const fast =
present || unread
? null
: lowerFastCallback(lowerer, argNode, [arrayValueType(lowerer, elem)], 0, elem, arrT, {
fnArg,
arity,
});
const helper = flatMapHelper(
lowerer,
elem,
@@ -1644,6 +1789,7 @@ export function lowerArrayFlatMapCall(
callbackSite(fast ?? fnArg),
fast?.type ?? null,
present,
unread,
);
return {
kind: "call",
@@ -1943,8 +2089,9 @@ function flatMapHelper(
site = "",
fastT: CallbackType | null = null,
present = false,
unread = false,
): string {
const key = `flatMap:${typeKey(elem)}:${typeKey(fnRet)}:${arity}${site}${fastT ? ":split" : ""}${present ? ":present" : ""}`;
const key = `flatMap:${typeKey(elem)}:${typeKey(fnRet)}:${arity}${site}${fastT ? ":split" : ""}${present ? ":present" : ""}${unread ? ":unread" : ""}`;
const existing = lowerer.arrHofHelpers.get(key);
if (existing) return existing;
const name = keyedHelperName(lowerer, "%arr.flatMap", key);
@@ -1957,6 +2104,7 @@ function flatMapHelper(
outElem: fnRet.elem,
present,
split: !!fastT,
unread,
});
const arrT = arrayOf(elem);
const inner = fnRet.elem;
@@ -2053,6 +2201,7 @@ function flatMapHelper(
elem,
present,
loc,
unread,
),
loc,
),
@@ -2125,7 +2274,7 @@ export function lowerArrayReduceCall(
lowerer.runtimeOptionalReduceTypes.get(call) ??
(hasInit ? lowerer.irTypeOf(call) : arrayValueType(lowerer, elem));
if (accT.kind === "void" || accT.kind === "func") lowerer.badType(call, lowerer.typeOf(call));
const { fnArg, arity } = lowerArrayCallback(
const lowered = lowerArrayCallback(
lowerer,
argNode,
[accT, arrayValueType(lowerer, elem)],
@@ -2133,17 +2282,23 @@ export function lowerArrayReduceCall(
false,
accT,
);
const fast = lowerFastCallback(
lowerer,
argNode,
[accT, arrayValueType(lowerer, elem)],
1,
elem,
arrT,
{ fnArg, arity },
false,
accT,
);
const { fnArg, arity } = lowered;
// Only an array that can hold elements outside its element type needs to
// leave an element its callback ignores unread.
const unread = lowered.unread && elementMayBeForeign(lowerer, elem);
const fast = unread
? null
: lowerFastCallback(
lowerer,
argNode,
[accT, arrayValueType(lowerer, elem)],
1,
elem,
arrT,
{ fnArg, arity },
false,
accT,
);
const helper = reduceHelper(
lowerer,
method,
@@ -2155,6 +2310,7 @@ export function lowerArrayReduceCall(
loc,
callbackSite(fast ?? fnArg),
fast?.type ?? null,
unread,
);
const args: IrExpr[] = [receiver, fnArg];
if (hasInit) args.push(lowerer.lowerExprExpecting(call.arguments.nodes[1]!, accT));
@@ -2185,8 +2341,9 @@ function reduceHelper(
loc: SrcLoc,
site = "",
fastT: CallbackType | null = null,
unread = false,
): string {
const key = `${method}:${typeKey(elem)}:${typeKey(accT)}:${typeKey(fnRet)}:${arity}:${hasInit ? "init" : "seed"}${site}${fastT ? ":split" : ""}`;
const key = `${method}:${typeKey(elem)}:${typeKey(accT)}:${typeKey(fnRet)}:${arity}:${hasInit ? "init" : "seed"}${site}${fastT ? ":split" : ""}${unread ? ":unread" : ""}`;
const existing = lowerer.arrHofHelpers.get(key);
if (existing) return existing;
const name = keyedHelperName(lowerer, `%arr.${method}`, key);
@@ -2201,6 +2358,7 @@ function reduceHelper(
present: false,
split: !!fastT,
hasInit,
unread,
});
const arrT = arrayOf(elem);
const fnT = funcOf([accT, arrayValueType(lowerer, elem), F64, arrT].slice(0, arity), fnRet);
@@ -2210,6 +2368,9 @@ function reduceHelper(
const right = method === "reduceRight";
const at = (index: IrExpr): IrExpr =>
arrayValueRead(lowerer, varRef("a.0", arrT, loc), index, elem, loc);
// The visited element, or a placeholder for a callback that never reads it.
const placeholder = unread ? unreadArgument(lowerer, arrayValueType(lowerer, elem), loc) : null;
const visited = (): IrExpr => placeholder ?? at(i);
const locals: IrLocal[] = [
{ id: "a.0", name: "a", type: arrT, mutable: true },
{ id: "f.0", name: "f", type: fnT, mutable: true },
@@ -2320,7 +2481,7 @@ function reduceHelper(
{
kind: "callValue",
callee: varRef("f.0", fnT, loc),
args: [varRef("acc.0", accT, loc), at(i), i, varRef("a.0", arrT, loc)].slice(
args: [varRef("acc.0", accT, loc), visited(), i, varRef("a.0", arrT, loc)].slice(
0,
arity,
),
@@ -6,6 +6,7 @@ import { AmbientReceiverReaders } from "../../../ir/receiver-readers.js";
import { boolLit, countedFor, numLit, varRef } from "../../../ir/build.js";
import {
BOOL,
DYN,
F64,
VOID,
type IrBindingSource,
@@ -32,9 +33,14 @@ import {
import {
arrayIndexPresent,
arrayValueRead,
arraySlotSnapshot,
arraySnapshotAppend,
arraySnapshotValue,
arrayValueStore,
arrayValueType,
elementMayBeForeign,
unreadArgument,
type SlotSnapshot,
} from "../array-values.js";
import type { Lowerer } from "../lowerer.js";
@@ -62,6 +68,9 @@ export interface ArrayHofInfo {
present: boolean;
split: boolean;
hasInit?: boolean;
/** The callback never reads the element it declares
* (lowerArrayCallback's `unread`). */
unread?: boolean;
}
type FuncType = IrType & { kind: "func" };
@@ -610,7 +619,13 @@ function chainCallbacks(chain: Chain): Stage[] | null {
/** The values a fused loop hands from one stage to the next: a local of a
* known type, or (before the first read) the source slot itself. */
type Flow = { kind: "value"; id: string; type: IrType } | { kind: "slot" };
/** What reaches a stage: a value, the source slot not yet read, or a
* snapshot of the slot read before an earlier callback that ignored it
* (arraySlotSnapshot). */
type Flow =
| { kind: "value"; id: string; type: IrType }
| { kind: "slot" }
| { kind: "snapshot"; ids: SlotSnapshot };
interface Plan {
chain: Chain;
@@ -1595,13 +1610,21 @@ class FusedLoop {
stage: Stage,
k: number,
lead: IrExpr[],
flow: Flow & { kind: "value" },
flow: Flow,
): { pre: IrStmt[]; value: IrExpr } | null => {
const arity = stage.info.arity;
const value = varRef(flow.id, flow.type, loc);
const at = lead.length;
const pick = (): { arg: IrExpr & { type: FuncType }; value: IrExpr | null } | null => {
if (arity <= at) return { arg: stage.slow, value: null };
// A callback that reads the value reaches it read (`read`); one
// that never reads it takes a placeholder for the unread slot.
if (flow.kind !== "value") {
const placeholder = stage.info.unread
? unreadArgument(this.lowerer, stage.slow.type.params[at]!, loc)
: null;
return placeholder ? { arg: stage.slow, value: placeholder } : null;
}
const value = varRef(flow.id, flow.type, loc);
const fast = stage.fast;
if (fast && typeEquals(fast.type.params[at]!, flow.type)) return { arg: fast, value };
const paramT = stage.slow.type.params[at]!;
@@ -1630,11 +1653,62 @@ class FusedLoop {
return { pre: [...before, ...called.pre], value: called.value };
};
/** The stage's callback never reads the element at parameter `at`, and
* a placeholder of its type stands for it (unreadArgument). */
const unreadSlot = (stage: Stage, at: number): boolean =>
stage.info.unread === true &&
unreadArgument(this.lowerer, stage.slow.type.params[at]!, loc) !== null;
const fail = (): IrStmt[] => {
this.failed = true;
return [];
};
/** Bring `flow` to the callback of `stage`, whose element parameter is
* at `at`. A slot is read for a callback that reads it. For one that
* ignores it, the slot of an array that can hold elements outside its
* element type is read as stored before the callback runs (a snapshot,
* as JavaScript reads the element first), so such an element passes the
* callback as it is; other arrays read the slot as usual. A snapshot
* reaching a callback that reads it becomes its value, and an element
* outside the element type takes the ordinary read's checked
* conversion there. */
const reach = (
stage: Stage,
at: number,
flow: Flow,
rest: (flow: Flow) => IrStmt[],
): IrStmt[] => {
const reads = stage.info.arity > at && !unreadSlot(stage, at);
if (flow.kind === "slot") {
if (reads || !elementMayBeForeign(this.lowerer, arrT.elem))
return read(stage, (value) => rest(value));
const ids: SlotSnapshot = {
isForeign: this.local("sf", BOOL),
value: this.local("sv", arrayValueType(this.lowerer, arrT.elem)),
foreign: this.local("sd", DYN),
};
return [
...arraySlotSnapshot(this.lowerer, a, i, arrT.elem, ids, loc),
...rest({ kind: "snapshot", ids }),
];
}
if (flow.kind === "snapshot" && reads) {
const valueT = arrayValueType(this.lowerer, arrT.elem);
const u = this.local("u", valueT);
return [
{
kind: "varDecl",
localId: u,
init: arraySnapshotValue(this.lowerer, arrT.elem, flow.ids, valueT, (v) => v, loc),
loc,
},
...rest({ kind: "value", id: u, type: valueT }),
];
}
return rest(flow);
};
/** Turn the source slot into a value for the callback of `stage`. */
const read = (stage: Stage, rest: (flow: Flow & { kind: "value" }) => IrStmt[]): IrStmt[] => {
if (stage.info.present) {
@@ -1690,11 +1764,12 @@ class FusedLoop {
};
/** The statements that run one value through stages k.. and the terminal. */
const visit = (k: number, flow: Flow): IrStmt[] => {
const visit = (k: number, flow: Flow, reached = false): IrStmt[] => {
if (k === chain.stages.length) return finish(flow);
const stage = chain.stages[k]!;
const info = stage.info;
if (flow.kind === "slot") return read(stage, (value) => visit(k, value));
if (flow.kind !== "value" && !reached)
return reach(stage, 0, flow, (next) => visit(k, next, true));
const called = deliver(stage, k, [], flow);
if (!called) return fail();
const { pre, value: result } = called;
@@ -1791,11 +1866,23 @@ class FusedLoop {
}
};
/** Append a snapshot of the source slot to the collected result: a
* filter whose callback ignored the element keeps it as it was stored
* (arraySnapshotAppend). */
const copySlot = (flow: Flow & { kind: "snapshot" }): IrStmt[] => {
const outT = resultT as IrType & { kind: "array" };
if (holes || !typeEquals(outT.elem, arrT.elem)) return fail();
return [
arraySnapshotAppend(this.lowerer, varRef(out!, resultT, loc), arrT.elem, flow.ids, loc),
];
};
/** The terminal's action for one value. */
const finish = (flow: Flow): IrStmt[] => {
const finish = (flow: Flow, reached = false): IrStmt[] => {
if (terminal.kind === "length") return [increment(count!, loc)];
if (terminal.kind === "collect") {
if (flow.kind !== "value") return fail();
if (flow.kind === "snapshot") return copySlot(flow);
if (flow.kind === "slot")
return read(chain.stages[chain.stages.length - 1]!, (value) => finish(value));
const last = chain.stages[chain.stages.length - 1]!;
const outT = resultT as IrType & { kind: "array" };
if (!typeEquals(outT.elem, last.info.outElem)) return fail();
@@ -1816,7 +1903,9 @@ class FusedLoop {
}
const stage = terminal.stage;
const method = stage.info.method;
if (flow.kind === "slot") return read(stage, (value) => finish(value));
const lead = method === "reduce" ? 1 : 0;
if (flow.kind !== "value" && !reached)
return reach(stage, lead, flow, (next) => finish(next, true));
if (method === "reduce") {
const accT = stage.info.outElem;
const called = deliver(stage, chain.stages.length, [varRef(acc!, accT, loc)], flow);
@@ -53,7 +53,7 @@ export function lowerArrayCallback(
arrT: IrType,
bindUntyped = false,
expectedReturn?: IrType,
): { fnArg: IrExpr & { type: IrType & { kind: "func" } }; arity: number } {
): { fnArg: IrExpr & { type: IrType & { kind: "func" } }; arity: number; unread: boolean } {
// `xs.map(((x) => x))`: a parenthesized callback is the function itself.
while (ts.isParenthesizedExpression(argNode)) argNode = argNode.expression;
// The ambient `Boolean` as the callback (`flags.every(Boolean)`,
@@ -72,7 +72,11 @@ export function lowerArrayCallback(
if (valueT === undefined || valueT.kind === "void" || isUnitType(valueT))
lowerer.badType(argNode, lowerer.typeOf(argNode));
if (valueT.kind === "union") lowerer.requireTruthyUnion(valueT.unionId, argNode);
return { fnArg: truthyFilterCallback(lowerer, valueT, locOf(argNode)), arity: 1 };
return {
fnArg: truthyFilterCallback(lowerer, valueT, locOf(argNode)),
arity: 1,
unread: false,
};
}
// The ambient `String` (`values.map(String)`): String(value) converts its
// first argument as a direct call does, whatever the element type; the
@@ -86,7 +90,7 @@ export function lowerArrayCallback(
const valueT = lead[0];
if (valueT === undefined || valueT.kind === "void")
lowerer.badType(argNode, lowerer.typeOf(argNode));
return { fnArg: stringConversionCallback(lowerer, valueT, argNode), arity: 1 };
return { fnArg: stringConversionCallback(lowerer, valueT, argNode), arity: 1, unread: false };
}
const full = [...lead, F64, arrT];
// A DYN-receiver HOF's callback (`parsed.flatMap((value) => ...)`):
@@ -276,9 +280,32 @@ export function lowerArrayCallback(
return {
fnArg: fnArg as IrExpr & { type: IrType & { kind: "func" } },
arity: fnArg.type.params.length,
unread: !readsParameter(argNode, lead.length - 1),
};
}
/** Whether a callback can observe its parameter at `index`: false for a
* function or arrow expression that declares no such parameter, or binds
* it to a plain name that appears nowhere else in it (any identifier of
* that name counts, including shorthand properties and shadowing
* bindings). Any other callback may. */
function readsParameter(argNode: ts.Expression, index: number): boolean {
if (!ts.isArrowFunction(argNode) && !ts.isFunctionExpression(argNode)) return true;
const param = argNode.parameters.nodes[index];
if (!param) return false;
if (!ts.isIdentifier(param.name) || param.initializer || param.dotDotDotToken) return true;
const name = param.name.text;
const mentions = (node: ts.Node): boolean =>
(node !== param.name &&
ts.isIdentifier(node) &&
(node.text === name || node.text === "arguments")) ||
(ts.forEachChild(node, (child) => (mentions(child) ? true : undefined)) ?? false);
return (
argNode.parameters.nodes.some((other) => other !== param && mentions(other)) ||
mentions(argNode.body)
);
}
/** Preserve the checker-selected payload ABI when a callback's runtime
* return gained only undefined. The array state byte carries that value. */
export function callbackArrayElement(
@@ -1,4 +1,6 @@
/* Element reads and writes (`a[i]`, `a[i] = v`), compound element updates, and keyed record reads. */
import { OBJECT_PROTOTYPE_MEMBERS } from "../../shape-inference/domain.js";
import { lowerUnionTupleRead } from "./union-fields.js";
import * as ts from "../../ts7/adapter.js";
import {
lowerOptionalIndex,
@@ -465,6 +467,12 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre
"tuple indexing with a non-literal index (tuples are fixed-shape — use t[0], t[1], ...)",
);
}
// A union of tuples asserted to one tuple view (`(r as readonly
// [boolean, T | E])[0]`) still holds the union.
if (obj.type.kind === "union") {
const element = lowerUnionTupleRead(lowerer, obj, idx, locOf(expr));
if (element) return lowerer.maybeNarrow(element, expr);
}
const shapeId = obj.type.kind === "record" ? obj.type.shapeId : receiverIr.shapeId;
const actualShape = lowerer.shapes.get(shapeId) ?? shape;
const fieldType = actualShape.fields.find((f) => f.name === String(idx))?.type;
@@ -597,6 +605,15 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre
}
}
}
if (key.type.kind === "f64" && ts.isNumericLiteral(expr.argumentExpression)) {
const element = lowerUnionTupleRead(
lowerer,
value,
Number(expr.argumentExpression.text),
locOf(expr),
);
if (element) return lowerer.maybeNarrow(element, expr);
}
if (key.type.kind === "f64") {
const optional = optionalArmIndexRead(lowerer, value, key, locOf(expr));
if (optional) return lowerer.maybeNarrow(optional, expr);
@@ -1319,6 +1336,21 @@ export function lowerRecordKeyRead(
};
return lowerer.maybeNarrow(get, expr);
}
// A literal key the shape does not declare, on a shape without an index
// signature, names no own property of any of its values: the read
// answers undefined, as Node's does. The checker's key check cannot vouch
// for it here, since the receiver can be typed from a package's
// JavaScript while the checker read its declarations. Object.prototype
// members keep the keyed read below.
if (shape.indexValue === undefined && !OBJECT_PROTOTYPE_MEMBERS.has(litKey)) {
return {
kind: "seqExpr",
stmts: [{ kind: "exprStmt", expr: obj, loc }],
result: dynUndefinedExpr(loc),
type: DYN,
loc,
};
}
}
let key =
litKey !== null
@@ -1489,7 +1521,7 @@ function lowerRecordPropertyKey(lowerer: Lowerer, key: IrExpr, node: ts.Expressi
* exactly the key JS derives; negative/computed keys stay runtime
* expressions and canonicalize through ensureString instead). Null for
* everything else. */
function recordKeyLiteralText(node: ts.Expression): string | null {
export function recordKeyLiteralText(node: ts.Expression): string | null {
if (ts.isStringLiteral(node)) return node.text;
if (ts.isNumericLiteral(node)) return String(Number(node.text));
return null;
@@ -318,10 +318,12 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc:
// that does not exist. The blocked-binding cascade names the use;
// without this the validator's registration check ICEs on the live
// reference (`@this {T}` inference — signature 07).
const unregistered = lowerer.unregisteredClassOf(g.type, new Set());
let unregistered = lowerer.unregisteredClassOf(g.type, new Set());
// The class's own rejection is the root cause the cascade names
// (unless the flush registers it with dynamic slots).
if (unregistered !== null && lowerer.flushDeferredClass(unregistered) !== undefined)
unregistered = lowerer.unregisteredClassOf(g.type, new Set());
if (unregistered !== null) {
// The class's own rejection is the root cause the cascade names.
lowerer.flushDeferredClass(unregistered);
lowerer.pushDiag(blockedBindingUseDiag(expr.text, loc));
throw new PoisonError();
}
@@ -0,0 +1,22 @@
import * as ts from "../../ts7/adapter.js";
const LISTENER_METHODS = new Set([
"on",
"once",
"addListener",
"prependListener",
"prependOnceListener",
]);
/** `<x>.on("message", …)` and the other listener registrations. */
export function isMessageListenerCall(call: ts.CallExpression): boolean {
const callee = call.expression;
const event = call.arguments.nodes[0];
return (
ts.isPropertyAccessExpression(callee) &&
LISTENER_METHODS.has(callee.name.text) &&
event !== undefined &&
ts.isStringLiteral(event) &&
event.text === "message"
);
}
@@ -51,6 +51,7 @@ import { isConstAssertionTypeNode, unitOnlyUnion } from "../../type-mapper.js";
import { assertsGlobalObject, extractionCopiesReference } from "../assertion-identity.js";
import { iterableSlotValue, iterableUnionOf } from "../iterable-values.js";
import { isDeclBoundaryTypeNode } from "../../decl-projection.js";
import { isMessageListenerCall } from "./message-listeners.js";
/** `c ? a : b`. An explicit destination constructs each branch at that
* layout before joining it. Without one, retain the expression's inferred
@@ -1586,7 +1587,7 @@ export function lowerAsExpression(
// contains undefined, so casts over parse results keep failing on
// non-string values with the usual path-annotated TypeError.
if (target.kind === "union" && lowerer.dynConvertible(target)) {
return { kind: "dynCheck", value: inner, type: target, loc: locOf(expr) };
return checkedDynCast(lowerer, expr, inner, target);
}
// Uint8Array targets: the checked-dynamic tree carries a bytes kind now (converted
// stdin chunks) — the extraction validates the kind and copies out.
@@ -1630,9 +1631,145 @@ export function lowerAsExpression(
`number, string, boolean, records, arrays, and unions of those)`,
);
}
return checkedDynCast(lowerer, expr, inner, target);
}
/** The checked conversion of a dynamic value. A record or array built from
* a value another holder may still reference keeps one identity with it
* (adoptingCast); only a source nothing else can hold (freshDynSource)
* converts outright, which keeps the fused `JSON.parse(text) as T`. */
function checkedDynCast(
lowerer: Lowerer,
expr: ts.AsExpression | ts.TypeAssertion,
inner: IrExpr,
target: IrType,
): IrExpr {
if (
holdsReference(lowerer, target) &&
!freshDynSource(lowerer, expr, inner) &&
!resultOnlyRead(lowerer, expr)
) {
return adoptingCast(lowerer, inner, target, locOf(expr), "dyn.adoptShared");
}
return { kind: "dynCheck", value: inner, type: target, loc: locOf(expr) };
}
/** A cast whose result only serves one read of a primitive member
* (`(value as T).name`, `(value as T[]).length`): the converted value is
* never stored, compared, written or called through, and the read yields no
* reference into it, so whether it is the source itself or a copy cannot be
* observed. */
function resultOnlyRead(lowerer: Lowerer, expr: ts.AsExpression | ts.TypeAssertion): boolean {
let node: ts.Node = expr;
while (node.parent !== undefined && ts.isParenthesizedExpression(node.parent)) node = node.parent;
const access = node.parent;
if (
access === undefined ||
!(
(ts.isPropertyAccessExpression(access) || ts.isElementAccessExpression(access)) &&
access.expression === node
)
)
return false;
const primitive =
ts.TypeFlags.StringLike |
ts.TypeFlags.NumberLike |
ts.TypeFlags.BooleanLike |
ts.TypeFlags.BigIntLike |
ts.TypeFlags.Undefined |
ts.TypeFlags.Null;
const read = lowerer.typeOf(access);
const parts = read.isUnionType() ? ts.constituentTypes(read) : [read];
if (!parts.every((part) => (part.flags & primitive) !== 0)) return false;
let use: ts.Node = access;
while (use.parent !== undefined && ts.isParenthesizedExpression(use.parent)) use = use.parent;
const parent = use.parent;
if (parent === undefined) return true;
if (ts.isCallExpression(parent) && parent.expression === use) return false;
if (
ts.isBinaryExpression(parent) &&
parent.left === use &&
ts.isAssignmentOperator(parent.operatorToken.kind)
)
return false;
if (
(ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) &&
(parent.operator === ts.SyntaxKind.PlusPlusToken ||
parent.operator === ts.SyntaxKind.MinusMinusToken)
)
return false;
if (ts.isDeleteExpression(parent)) return false;
return true;
}
/** A cast operand nothing else can hold: a value this expression creates
* (`JSON.parse(text)`, `await response.json()`), or a structured clone the
* runtime delivers once and the program reads at this site only (`workerData`
* read nowhere else; the parameter of the program's one `"message"`
* listener, read nowhere else in it). Anything else is adopted. */
function freshDynSource(
lowerer: Lowerer,
expr: ts.AsExpression | ts.TypeAssertion,
inner: IrExpr,
): boolean {
if (inner.kind === "libCall" && inner.fn === "json.parse") return true;
if (
inner.kind === "awaitExpr" &&
inner.value.kind === "libCall" &&
inner.value.fn === "fetch.responseJson"
)
return true;
const facts = lowerer.structuredCloneReads();
if (inner.kind === "libCall" && inner.fn === "worker.data") return facts.workerData === 1;
let operand: ts.Expression = expr.expression;
while (ts.isParenthesizedExpression(operand)) operand = operand.expression;
if (!ts.isIdentifier(operand) || inner.kind !== "varRef" || facts.messageListeners !== 1)
return false;
const symbol = lowerer.checker.getSymbolAtLocation(operand);
const param = symbol?.valueDeclaration;
if (!param || !ts.isParameter(param) || !ts.isIdentifier(param.name)) return false;
const fn = param.parent;
if (
!(ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) ||
fn.parameters.nodes[0] !== param ||
!isMessageListener(fn)
)
return false;
let reads = 0;
const name = param.name.text;
const visit = (node: ts.Node): void => {
if (ts.isIdentifier(node) && node.text === name && node !== param.name) reads++;
ts.forEachChild(node, visit);
};
ts.forEachChild(fn, visit);
return reads === 1;
}
/** The listener argument of `<x>.on("message", fn)` (also once,
* addListener, prependListener, prependOnceListener) registered once: at
* module level, outside any function, class or loop, so one emitter can
* never hand the same message to two registrations of it. */
function isMessageListener(fn: ts.Node): boolean {
const call = fn.parent;
if (!call || !ts.isCallExpression(call) || call.arguments.nodes[1] !== fn) return false;
if (!isMessageListenerCall(call)) return false;
for (let p = call.parent; p !== undefined && !ts.isSourceFile(p); p = p.parent) {
if (
ts.isFunctionLike(p) ||
ts.isClassDeclaration(p) ||
ts.isClassExpression(p) ||
ts.isClassStaticBlockDeclaration(p) ||
ts.isForStatement(p) ||
ts.isForInStatement(p) ||
ts.isForOfStatement(p) ||
ts.isWhileStatement(p) ||
ts.isDoStatement(p)
)
return false;
}
return true;
}
/** True when a type holds a record or an array somewhere (values whose
* identity can be observed). */
function holdsReference(lowerer: Lowerer, t: IrType, seen = new Set<string>()): boolean {
@@ -1714,6 +1851,24 @@ function declBoundaryCast(
...(originId === undefined ? {} : { origin: originId }),
};
}
return adoptingCast(lowerer, inner, target, loc, "dyn.declAdopt", originId);
}
/** The checked conversion of `inner` to `target` followed by adoption
* (`dyn.declAdopt` at the boundary, the lenient `dyn.adoptShared`
* elsewhere): every source object or array that something else still
* references becomes a live capsule of the native value built from it.
* `originId` names the package declaration a failing check reports
* (decl-origin.ts). */
function adoptingCast(
lowerer: Lowerer,
inner: IrExpr,
target: IrType,
loc: SrcLoc,
fn: "dyn.declAdopt" | "dyn.adoptShared",
originId?: number,
): IrExpr {
const boundary = fn === "dyn.declAdopt";
const source = lowerer.declareHiddenLocal("%boundarySource", DYN);
const value = lowerer.declareHiddenLocal("%boundaryValue", target);
return {
@@ -1726,7 +1881,7 @@ function declBoundaryCast(
init: {
kind: "dynCheck",
value: varRef(source.id, DYN, loc),
adopt: true,
...(boundary ? { adopt: true as const } : {}),
type: target,
loc,
...(originId === undefined ? {} : { origin: originId }),
@@ -1737,7 +1892,7 @@ function declBoundaryCast(
kind: "exprStmt",
expr: {
kind: "libCall",
fn: "dyn.declAdopt",
fn,
args: [
varRef(source.id, DYN, loc),
{
@@ -419,7 +419,12 @@ export function lowerDynObjectLiteral(
: !boxValue &&
(ts.isObjectLiteralExpression(valueExpr) ||
(ts.isArrayLiteralExpression(valueExpr) &&
holdsEvolvingArray(lowerer, valueExpr)))
// An empty literal takes whatever the object's users
// store into it (`{ [kChildren]: [] }`), so it is a
// checked-dynamic array rather than its `never[]` layout.
(valueExpr.elements.nodes.length === 0 ||
holdsEvolvingArray(lowerer, valueExpr) ||
lowerer.untypedLiteralContext(valueExpr))))
? lowerer.lowerExprExpecting(valueExpr, DYN)
: lowerer.lowerExpr(valueExpr as ts.Expression);
raw = fenceClosureProbe(lowerer, valueExpr, undefined, lowerValue) ?? lowerValue();
@@ -3376,6 +3376,18 @@ export function lowerUnionProperty(
expr,
);
}
// An array the checker sees through a union of arrays (`readdirSync`'s
// `string[] | Buffer[]`, whose lowering answers the string[] arm).
if (value.type.kind === "array" && expr.name.text === "length") {
return {
kind: "arrIntrinsic",
method: "length",
receiver: value,
args: [],
type: F64,
loc: locOf(expr),
};
}
if (value.type.kind !== "union") {
lowerer.unsupported(
"SC1090",
@@ -3651,13 +3663,13 @@ export function classFieldTarget(
) {
return { container: "dynamic", obj: represented, field: fieldName, fieldType: DYN };
}
const info = lowerer.classes.get(receiverType.className);
if (!info) {
// A receiver typed as a class whose collection deferred: the
// deferred diagnostics are what explains the miss.
// A receiver typed as a class whose collection deferred: the deferred
// diagnostics are what explains the miss (unless the flush registers
// the class with dynamic slots, retryDeferred).
const info =
lowerer.classes.get(receiverType.className) ??
lowerer.flushDeferredClass(receiverType.className);
return null;
}
if (!info) return null;
const fieldType = info.fields.get(fieldName);
const lowerObjectReceiver = (): IrExpr => {
let obj = represented;
@@ -551,12 +551,13 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc
};
}
if (rhsClassval?.kind === "classval" && !caught) {
const targetInfo = lowerer.classes.get(rhsClassval.className);
const targetInfo =
lowerer.classes.get(rhsClassval.className) ??
lowerer.flushDeferredClass(rhsClassval.className);
if (!targetInfo) {
// The type world names a class the lowering never registered
// (a fenced class expression, a deferred declaration): flush
// its diagnostics and poison the test site, never an ICE.
lowerer.flushDeferredClass(rhsClassval.className);
// (a fenced class expression, a deferred declaration): its
// diagnostics flushed above; poison the test site, never an ICE.
lowerer.unsupported(
"SC1090",
expr,
@@ -281,3 +281,58 @@ export function lowerUnionFieldWrite(
}
return { kind: "seqExpr", stmts: body, result: dispatch, type: result.type, loc };
}
/** Read the element at a literal index from a union of tuples whose element
* types differ (`readonly [true, number] | readonly [false, string]`, index
* 1): each tag reads its own tuple's slot and widens it into the union of
* the arms' element types; the caller narrows the result to the checker's
* flow type. Null when an arm is not a tuple with that position. */
export function lowerUnionTupleRead(
lowerer: Lowerer,
receiver: IrExpr,
index: number,
loc: IrExpr["loc"],
): IrExpr | null {
if (receiver.type.kind !== "union" || !Number.isInteger(index) || index < 0) return null;
const unionId = receiver.type.unionId;
const arms = lowerer.unions.get(unionId)?.arms;
if (!arms || arms.length < 2) return null;
const name = String(index);
const fields: UnionFieldArm[] = [];
const parts: IrType[] = [];
for (const arm of arms) {
if (arm.kind !== "record" || lowerer.shapes.get(arm.shapeId)?.tuple !== true) return null;
const field = unionFieldArm(lowerer, arm, name);
if (!field || field.type.kind === "void") return null;
fields.push(field);
const own =
field.type.kind === "union" ? lowerer.unions.get(field.type.unionId)?.arms : [field.type];
if (!own) return null;
for (const part of own) if (!parts.some((p) => typeEquals(p, part))) parts.push(part);
}
const type: IrType =
parts.length === 1 ? parts[0]! : { kind: "union", unionId: lowerer.unions.intern(parts) };
const object = lowerer.declareHiddenLocal("%unionReadObject", receiver.type);
const objectRef = varRef(object.id, object.type, loc);
const read = (tag: number): IrExpr | null => {
const probe = unionFieldRead(unionId, tag, objectRef, fields[tag]!, name, loc);
const converted = lowerer.coerceToExpected(probe, type);
return typeEquals(converted.type, type) && isWidening(converted, probe) ? converted : null;
};
let dispatch = read(fields.length - 1);
if (!dispatch) return null;
for (let tag = fields.length - 2; tag >= 0; tag--) {
const then = read(tag);
if (!then) return null;
dispatch = {
kind: "ternary",
cond: { kind: "unionIsTag", unionId, tag, value: objectRef, negated: false, type: BOOL, loc },
then,
else_: dispatch,
type,
loc,
};
}
const init: IrStmt = { kind: "varDecl", localId: object.id, init: receiver, loc };
return { kind: "seqExpr", stmts: [init], result: dispatch, type, loc };
}
@@ -10235,8 +10235,8 @@ export function lowerObjectMethodCall(
? lowerer.coerceInto(access.expression, receiver, receiverIr)
: receiver;
};
const info = lowerer.classes.get(receiverIr.className);
if (!info) lowerer.flushDeferredClass(receiverIr.className);
const info =
lowerer.classes.get(receiverIr.className) ?? lowerer.flushDeferredClass(receiverIr.className);
const found = info ? lowerer.findMethodOn(info, access.name.text) : null;
// The zlib stream members (flush/params/close/reset) are declared on
// the zlib classes; their receivers are runtime Transform streams.
@@ -10589,11 +10589,8 @@ function lowerUnionObjectMethodCall(
found: NonNullable<ReturnType<Lowerer["findMethodOn"]>>;
}[] = [];
for (const arm of def.arms as (IrType & { kind: "object" })[]) {
const info = lowerer.classes.get(arm.className);
if (!info) {
lowerer.flushDeferredClass(arm.className);
return null;
}
const info = lowerer.classes.get(arm.className) ?? lowerer.flushDeferredClass(arm.className);
if (!info) return null;
const found = lowerer.findMethodOn(info, method);
if (!found) return null;
if (found.sig.abstract === true && !lowerer.overrideBelow(info, method)) return null;
@@ -117,6 +117,7 @@ import {
requireSpecOf,
} from "../program.js";
import { PoisonError, dynFallbackType, jsFuncNameOf, newFnCtx, own } from "./lowerer.js";
import { declarationGroupKey } from "./type-failure.js";
import { isAssertClassNew } from "./assert-instances.js";
import { isCjsRequireGlobal } from "./lower-node-module.js";
import { lowerArrayConstructor } from "./containers/array-construction.js";
@@ -2305,11 +2306,21 @@ export function collectClassShapeInner(
base = named;
if (!base) {
if (adoptCallableBase(t.expression)) continue;
lowerer.unsupported(
// A base whose own collection deferred can still register later
// (a flush retry with dynamic slots): the derived class retries
// with it (Lowerer.retryDeferred).
const diag = unsupportedDiag(
"SC1090",
t,
locOf(t),
`extending classes not declared in the program ('${t.expression.text}')`,
);
lowerer.retriableDiags.add(diag);
// A base the program declares but rejected: this rejection is its
// cascade (localized failure, report-only).
const rejectedBase = rejectedProgramClassGroup(lowerer, symbol);
if (rejectedBase !== null) diag.cause = [rejectedBase];
lowerer.pushDiag(diag);
throw new PoisonError();
}
}
@@ -2545,13 +2556,13 @@ export function collectClassShapeInner(
if (type.kind === "void") lowerer.badType(member.name, lowerer.typeOf(member.name));
if (
type.kind === "dyn" &&
!lowerer.liftDynFieldPolicy &&
!isJsSourceFile(ts.getSourceFileOfNode(member)) &&
!lowerer.classFieldStoresDyn(lowerer.typeOf(member.name)) &&
(member.type !== undefined ||
(lowerer.typeOf(member.name).flags & ts.TypeFlags.Any) === 0)
) {
lowerer.unsupported(
"SC1090",
lowerer.dynFieldPolicyFence(
member.name,
`${dynFieldTypeSpelling(lowerer, member.name)}-typed static fields`,
);
@@ -3061,14 +3072,16 @@ export function collectClassShapeInner(
if (type.kind === "void") lowerer.badType(member.name, lowerer.typeOf(member.name));
// Bundled JS declares fields without annotations before assigning
// them in the constructor. Use the same native checked-dynamic
// storage as implicit constructor-assigned JS fields.
// storage as implicit constructor-assigned JS fields. In
// TypeScript the refusal is a policy fence that a flush retry lifts
// (Lowerer.liftDynFieldPolicy).
if (
type.kind === "dyn" &&
!lowerer.liftDynFieldPolicy &&
!isJsSourceFile(ts.getSourceFileOfNode(member)) &&
!lowerer.classFieldStoresDyn(lowerer.typeOf(member.name))
) {
lowerer.unsupported(
"SC1090",
lowerer.dynFieldPolicyFence(
member.name,
`${dynFieldTypeSpelling(lowerer, member.name)}-typed class fields`,
);
@@ -3204,9 +3217,12 @@ export function collectClassShapeInner(
const type = shape.bodyType ?? shape.type;
if (type.kind === "void") lowerer.badType(p.name, lowerer.typeOf(p.name));
// The class-field dyn rule verbatim (KEEP NARROW).
if (type.kind === "dyn" && !lowerer.classFieldStoresDyn(lowerer.typeOf(p))) {
lowerer.unsupported(
"SC1090",
if (
type.kind === "dyn" &&
!lowerer.liftDynFieldPolicy &&
!lowerer.classFieldStoresDyn(lowerer.typeOf(p))
) {
lowerer.dynFieldPolicyFence(
p.name,
`${dynFieldTypeSpelling(lowerer, p)}-typed class fields`,
);
@@ -6214,6 +6230,7 @@ function lowerClassValue(
const context = newFnCtx(true, null, null, VOID);
info.localClass.context = context;
lowerer.fnStack.push(context);
const diagsBefore = lowerer.diags.length;
try {
const bodies = lowerClassMembers(lowerer, info);
for (const body of bodies) info.localClass.bodies.set(body.name, body);
@@ -6228,6 +6245,8 @@ function lowerClassValue(
});
}
info.localClass.ready = true;
// A retry of the enclosing statement reuses these bodies.
for (const d of lowerer.diags.slice(diagsBefore)) lowerer.loweredOnceDiags.add(d);
lowerer.onExprClassCollected?.(info);
} finally {
lowerer.fnStack.pop();
@@ -11334,12 +11353,13 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
);
info = lowerer.classes.get(calleeT.className);
}
// The TYPE world names a class the lowering never registered — a
// fenced class expression, an abstract/deferred declaration: flush
// its own diagnostics (they tell the real story, unless the flush
// registers it with dynamic slots) and poison this construction
// site, never an ICE.
if (!info) info = lowerer.flushDeferredClass(calleeT.className);
if (!info) {
// The TYPE world names a class the lowering never registered —
// a fenced class expression, an abstract/deferred declaration:
// flush its own diagnostics (they tell the real story) and
// poison this construction site, never an ICE.
lowerer.flushDeferredClass(calleeT.className);
lowerer.unsupported(
"SC1090",
expr,
@@ -11869,3 +11889,14 @@ function definedThroughReflection(lowerer: Lowerer, decl: ts.ClassLikeDeclaratio
});
return found;
}
/** The failure group of a class the program declares but did not collect
* (its own declaration was rejected), or null for any other symbol. */
function rejectedProgramClassGroup(lowerer: Lowerer, symbol: ts.Symbol | null): string | null {
if (symbol === null) return null;
const recorded = lowerer.rejectedGroups.get(symbol);
if (recorded !== undefined) return recorded;
const decl = symbol.declarations.find((d) => ts.isClassDeclaration(d) || ts.isClassExpression(d));
if (decl === undefined || ts.getSourceFileOfNode(decl).isDeclarationFile) return null;
return declarationGroupKey(symbol);
}
@@ -96,7 +96,8 @@ import {
lowerUnionKeyedRead,
} from "./expressions/property-access.js";
import { lowerNullishPair } from "./expressions/binary.js";
import { lowerElementCompoundParts } from "./expressions/element-access.js";
import { lowerElementCompoundParts, recordKeyLiteralText } from "./expressions/element-access.js";
import { OBJECT_PROTOTYPE_MEMBERS } from "../shape-inference/domain.js";
export {
DEFERRED_READ_MESSAGE,
abstractPropertyDeclOf,
@@ -1964,6 +1965,23 @@ export function lowerAbsenceProbe(lowerer: Lowerer, node: ts.Expression): IrExpr
if (shape && !shape.tuple && shape.indexValue && shape.indexValue.kind !== "dyn") {
return lowerer.lowerRecordKeyRead(expr, receiverT.shapeId, shape, true);
}
// A literal key the shape does not declare is absent from every value
// (lowerRecordKeyRead answers undefined), whatever type the checker gave
// the read. An optional chain keeps its own lowering, which reads a
// nullish receiver as undefined instead of converting it.
const literal = recordKeyLiteralText(expr.argumentExpression);
if (
!expr.questionDotToken &&
!hasOptionalChainGuard(expr.expression) &&
shape &&
!shape.tuple &&
!shape.indexValue &&
literal !== null &&
!OBJECT_PROTOTYPE_MEMBERS.has(literal) &&
!shape.fields.some((field) => field.name === literal)
) {
return lowerer.lowerRecordKeyRead(expr, receiverT.shapeId, shape, true);
}
return null;
}
// `m[2]` on a JavaScript `RegExpExecArray | null` that a previous
@@ -3136,11 +3154,9 @@ export function symbolFieldInfo(
if (lowerer.chainBlocked(expr)) return null;
const receiverIr = lowerer.mapTypeOf(lowerer.typeOf(expr.expression));
if (receiverIr?.kind !== "object") return null;
const info = lowerer.classes.get(receiverIr.className);
if (!info) {
lowerer.flushDeferredClass(receiverIr.className);
return null;
}
const info =
lowerer.classes.get(receiverIr.className) ?? lowerer.flushDeferredClass(receiverIr.className);
if (!info) return null;
const key = classSymbolKeyOf(lowerer, expr.argumentExpression);
if (!key) return null;
const field = info.symbolFields?.get(key.identity);
@@ -1,3 +1,4 @@
import type { TypeFailureSlot } from "../../diagnostics/diagnostic.js";
import { THREADS_MODULE } from "../threads-import.js";
import { positionTag } from "./structural-positions.js";
import { EXPERIMENTAL_MODULE } from "../experimental-import.js";
@@ -178,6 +179,17 @@ export interface AppendGraph {
* the require() edges preflight resolved — a CommonJS file's only edges
* (the compiled CommonJS package a workspace module loaded on demand
* imports, and the sibling packages and files IT requires). */
/** Whether a class declaration has a static field with an initializer,
* which Node runs where the class statement evaluates. */
function hasStaticFieldInitializer(decl: ts.ClassDeclaration): boolean {
return decl.members.nodes.some(
(member) =>
ts.isPropertyDeclaration(member) &&
member.initializer !== undefined &&
member.modifiers?.nodes.some((m) => m.kind === ts.SyntaxKind.StaticKeyword) === true,
);
}
function appendedEdgesOf(
program: ts.Program,
graph: AppendGraph | undefined,
@@ -1035,13 +1047,11 @@ export function moduleArtifacts(
}
while (pendingClasses.length > 0) {
const name = pendingClasses.pop()!;
const info = lowerer.classes.get(name);
if (!info) {
// Referenced by an emitted type but never registered: collection
// deferred its diagnostics — a reached reference makes them count.
lowerer.flushDeferredClass(name);
continue;
}
// Referenced by an emitted type but never registered: collection
// deferred its diagnostics — a reached reference makes them count
// (or registers the class with dynamic slots, retryDeferred).
const info = lowerer.classes.get(name) ?? lowerer.flushDeferredClass(name);
if (!info) continue;
for (const field of info.def.fields) visitType(field.type);
if (info.base) visitClass(info.base.def.name);
if (info.def.genericOf) visitClass(info.def.genericOf);
@@ -2048,7 +2058,15 @@ export function collectGlobals(
// Destructuring declarations register EVERY bound identifier (the
// desugar in the init function assigns the pre-registered globals,
// exactly like plain declarations).
for (const nameNode of boundIdentifiersOf(decl.name)) {
const names = boundIdentifiersOf(decl.name);
// Localized failure: a degradable type failure at a module global
// degrades that type and the same name registers again
// (globalSlotRetry), at most MAX_GLOBAL_RETRIES times.
let retries = 0;
let retriedIndex = -1;
for (let nameIndex = 0; nameIndex < names.length; nameIndex++) {
const nameNode = names[nameIndex]!;
if (nameIndex !== retriedIndex) retries = 0;
const diagsBefore = lowerer.diags.length;
try {
// Checked JavaScript factories can expose inferred callables by
@@ -2700,6 +2718,12 @@ export function collectGlobals(
}
} catch (e) {
if (!(e instanceof PoisonError)) throw e;
if (retries < MAX_GLOBAL_RETRIES && globalSlotRetry(lowerer, sf, e)) {
retries++;
retriedIndex = nameIndex;
nameIndex--;
continue;
}
// diagnostic already recorded; lowering the statement poisons
// too — and in a JS file BOTH defer to the statement's
// runtime fence (deferJsCollectionDiags).
@@ -2710,6 +2734,20 @@ export function collectGlobals(
}
}
/** Localized failure's commit point for module globals (design §3.3):
* collectGlobals commits a global's type failure directly, before any body
* lowers, so it degrades here and the registration runs again. */
function globalSlotRetry(lowerer: Lowerer, sf: ts.SourceFile, e: PoisonError): boolean {
if (isJsSourceFile(sf)) return false;
const slot = lowerer.degradeAtCommit(e, GLOBAL_SLOTS);
if (slot === null) return false;
lowerer.noteDynamicSlot(slot);
return true;
}
const MAX_GLOBAL_RETRIES = 16;
const GLOBAL_SLOTS: ReadonlySet<TypeFailureSlot> = new Set<TypeFailureSlot>(["global"]);
/** Registers the entry-init note for a `var` module global: JS hoists
* module vars to `undefined` at module entry, so an undefined-armed slot
* must hold the interned undefined arm BEFORE the body runs (a function
@@ -3202,7 +3240,19 @@ export function lowerFileInit(
// by position) — exactly when JS evaluates decorators, static
// initializers, and blocks, so code reading an earlier module
// binding sees its assigned value.
const statics = [...lowerer.classes.values()]
const runsStatics = (c: ClassInfo): boolean =>
lowerer.classInitFlags.has(c.def.name) ||
c.callableBase !== undefined ||
c.def.baseValueGlobal !== undefined ||
c.runtimeStatics !== undefined ||
c.staticFields.length > 0 ||
(c.staticBlocks?.length ?? 0) > 0 ||
c.classDecorators !== undefined;
const statics: {
pos: number;
info: ClassInfo | null;
deferred?: { symbol: ts.Symbol; decl: ts.ClassDeclaration };
}[] = [...lowerer.classes.values()]
.filter(
(c) =>
// Class DECLARATIONS only: expression classes run their static
@@ -3214,15 +3264,30 @@ export function lowerFileInit(
ts.isClassDeclaration(c.decl) &&
!c.mixinInstance &&
ts.getSourceFileOfNode(c.decl) === sf &&
(lowerer.classInitFlags.has(c.def.name) ||
c.callableBase !== undefined ||
c.def.baseValueGlobal !== undefined ||
c.runtimeStatics !== undefined ||
c.staticFields.length > 0 ||
(c.staticBlocks?.length ?? 0) > 0 ||
c.classDecorators !== undefined),
runsStatics(c),
)
.map((c) => ({ pos: ts.tokenStartOfNode(c.decl!), info: c }));
// Localized failure: a deferred class with static field initializers
// is reached at its statement, where Node runs them. Its place retries
// it (retryDeferredAtDeclaration), and once it has registered there or
// through an earlier flush, its initializers lower in place.
for (const symbol of lowerer.deferredCollectors.keys())
for (const decl of symbol.declarations)
if (
ts.isClassDeclaration(decl) &&
ts.getSourceFileOfNode(decl) === sf &&
hasStaticFieldInitializer(decl)
)
statics.push({ pos: ts.tokenStartOfNode(decl), info: null, deferred: { symbol, decl } });
const lowerStaticsAt = (entry: (typeof statics)[number]): IrStmt[] => {
let info = entry.info;
if (entry.deferred) {
if (!lowerer.retryDeferredAtDeclaration(entry.deferred.symbol)) return [];
info = lowerer.classes.get(lowerer.classNamer(entry.deferred.decl)) ?? null;
if (info === null || !runsStatics(info)) return [];
}
return withClassInitFlag(lowerer, info!, lowerer.lowerStaticFieldInits(info!));
};
// Statics-bearing MIXIN instantiations whose call evaluates in THIS
// file: their declaration-time code runs when the call does — the
// top-level statement lower-mixins recorded. Insertion order is
@@ -3243,13 +3308,7 @@ export function lowerFileInit(
// declarations never share a kept statement's start, so `<=`
// changes nothing for them.
while (at < statics.length && statics[at]!.pos <= ts.tokenStartOfNode(stmt)) {
body.push(
...withClassInitFlag(
lowerer,
statics[at]!.info,
lowerer.lowerStaticFieldInits(statics[at]!.info),
),
);
body.push(...lowerStaticsAt(statics[at]!));
at++;
}
// A cycle binding's declaration stays at its position: its
@@ -3268,13 +3327,7 @@ export function lowerFileInit(
body.push(...lowerer.pendingClassExprInits.splice(0), ...stmtIr);
}
while (at < statics.length) {
body.push(
...withClassInitFlag(
lowerer,
statics[at]!.info,
lowerer.lowerStaticFieldInits(statics[at]!.info),
),
);
body.push(...lowerStaticsAt(statics[at]!));
at++;
}
for (const { at, g } of unionEntryInits) {
@@ -40,7 +40,9 @@ import { isJsonRequireDecl } from "./json-require.js";
import { isTimeValueDateDeclaration } from "./date-values.js";
import { bindingSource } from "../binding-source.js";
import { lowerConstructorReturn } from "./constructor-returns.js";
import type { Lowerer } from "./lowerer.js";
import type { DynamicSlot, Lowerer } from "./lowerer.js";
import type { ScrDiagnostic, TypeFailureSlot } from "../../diagnostics/diagnostic.js";
import { statementGroupKey } from "./type-failure.js";
import {
cjsExportValueByName,
cjsNamespaceDestructureOf,
@@ -415,8 +417,8 @@ function cleanupCall(
): { call: IrExpr; async: boolean } | null {
const loc = locOf(node);
if (value.type.kind === "object") {
const info = lowerer.classes.get(value.type.className);
if (!info) lowerer.flushDeferredClass(value.type.className);
const info =
lowerer.classes.get(value.type.className) ?? lowerer.flushDeferredClass(value.type.className);
const asyncFound = awaited && info ? lowerer.findMethodOn(info, "sym:asyncDispose") : null;
const syncFound = info ? lowerer.findMethodOn(info, "sym:dispose") : null;
const member = asyncFound ? "sym:asyncDispose" : syncFound ? "sym:dispose" : null;
@@ -750,57 +752,38 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir
if (!lowerer.suppressStats) lowerer.countStatement(stmt, sf?.fileName);
const diagsBefore = lowerer.diags.length;
const sinkMark = lowerer.diagSinkMark();
const touched = new Set<string>();
lowerer.failureTouches.push(touched);
const pendingSlots: DynamicSlot[] = [];
// A declarator that initializes a forward-captured TDZ box takes it
// out of tdzPredeclared; a retry after a later declarator degrades
// must initialize the same box again.
const tdzBefore = new Map(lowerer.ctx.tdzPredeclared);
try {
let lowered: IrStmt | IrStmt[] | null;
let appended = false;
if (
ts.isVariableStatement(stmt) &&
(stmt.declarationList.flags & ts.NodeFlags.Using) !== 0
) {
const registrations = lowerUsingStatement(lowerer, stmt);
const accounting: IrStmt[] = [];
for (const registration of registrations) {
out.push(...registration.acquire);
accounting.push(...registration.acquire, ...registration.cleanup);
if (registration.cleanup.length > 0) {
usingMarkers.push({
start: out.length,
cleanup: registration.cleanup,
loc: registration.loc,
});
}
for (let attempt = 0; ; attempt++) {
try {
lowerOneStatement(lowerer, stmt, out, usingMarkers, diagsBefore, deferFences);
break;
} catch (e) {
// Localized failure's statement commit point (design §3.3): a
// degradable type failure at a local, loop variable or module
// global degrades that type and the statement lowers again.
// A declaration this statement flushed inside a probe (where
// no retry runs) can retry here too.
if (attempt >= MAX_DEGRADE_RETRIES || deferFences) throw e;
const slot = lowerer.degradeAtCommit(e, STATEMENT_SLOTS);
if (slot === null && !lowerer.reopenFlushedAtCommit(diagsBefore)) throw e;
// A failed attempt adds nothing to `out` but the TDZ boxes
// forward captures declared (they stay declared), so the
// retry appends after them.
lowerer.dropAttemptDiags(diagsBefore);
if (slot !== null) pendingSlots.push(slot);
for (const [symbol, local] of tdzBefore)
if (!lowerer.ctx.tdzPredeclared.has(symbol))
lowerer.ctx.tdzPredeclared.set(symbol, local);
}
lowered = accounting;
appended = true;
} else {
lowered = lowerer.lowerStmt(stmt);
}
// The lib-boundary chokepoint (lib-boundary.ts): checked-dynamic
// arguments that reached builtin-call slots without the checked
// coercion get their dynCheck here, and the uncoercible ones
// fence — inside this statement's poison window, so a JS fence
// becomes the statement's runtimeFence exactly like every other
// deferred rejection.
if (lowered) {
const body: IrStmt[] = [];
if (Array.isArray(lowered)) body.push(...lowered);
else body.push(lowered);
const rewritten = lowerSurplusCalls(lowerer, body);
enforceLibBoundary(lowerer, rewritten);
if (!appended) out.push(...rewritten);
}
// A JS statement that lowered CLEAN can still have flushed
// DEFERRED collection diagnostics (a symbol lookup on an alias
// resolves through flushDeferred — informational here, not a
// blocker of THIS statement): those belong to the runtime-fence
// ledger, not the build — real uses of the broken declaration
// meet their own per-site fences.
if (deferFences && lowerer.diagSink === null && lowerer.diags.length > diagsBefore) {
const flushed = lowerer.diags.splice(diagsBefore);
const ice = flushed.filter((d) => d.code === "SC9001");
lowerer.diags.push(...ice);
lowerer.runtimeFences.push(...flushed.filter((d) => d.code !== "SC9001"));
}
for (const slot of pendingSlots) lowerer.noteDynamicSlot(slot);
} catch (e) {
// A Go panic inside tsgo, surfaced by the sync channel as a
// thrown Error (server intact — the prefetch fence's finding):
@@ -813,7 +796,17 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir
throw e;
}
if (!lowerer.suppressStats) lowerer.countStatementFailed(stmt, sf?.fileName);
lowerer.noteBlockedBindings(stmt);
// Localized failure (report-only): the statement's committed
// diagnostics that are not themselves type failures or rejected
// declarations are cascades of the rejected declarations it used.
const committed =
lowerer.diagSink === null && !deferFences ? lowerer.diags.slice(diagsBefore) : [];
if (touched.size > 0) {
const cause = [...touched];
for (const d of committed)
if (d.typeFailure === undefined && d.rejects === undefined) d.cause ??= cause;
}
lowerer.noteBlockedBindings(stmt, committed);
// The runtime-fence conversion (JS sources, direct diagnostics
// only — a capture wrapper in force means collection owns the
// report). The statement's diagnostics move OFF the build and
@@ -837,6 +830,8 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir
const fence = lowerer.capturedRuntimeFence(sinkMark, stmt);
if (fence) out.push(fence);
}
} finally {
lowerer.failureTouches.pop();
}
}
// Each acquisition protects the remainder of its lexical statement
@@ -862,6 +857,81 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir
return out;
}
/** Localized failure: retries a degradable statement-level type failure
* at most this many times (each retry degrades one more type). */
const MAX_DEGRADE_RETRIES = 16;
/** The slot kinds lowerStmts' commit point degrades: locals, loop
* variables and module globals (stage L1), and the parameters and returns
* of the function expressions, arrows and methods a statement holds
* (stage L2). */
const STATEMENT_SLOTS: ReadonlySet<TypeFailureSlot> = new Set<TypeFailureSlot>([
"local",
"global",
"loop",
"param",
"return",
"field",
]);
/** One attempt at lowering a statement of lowerStmts into `out`. */
function lowerOneStatement(
lowerer: Lowerer,
stmt: ts.Statement,
out: IrStmt[],
usingMarkers: UsingMarker[],
diagsBefore: number,
deferFences: boolean,
): void {
let lowered: IrStmt | IrStmt[] | null;
let appended = false;
if (ts.isVariableStatement(stmt) && (stmt.declarationList.flags & ts.NodeFlags.Using) !== 0) {
const registrations = lowerUsingStatement(lowerer, stmt);
const accounting: IrStmt[] = [];
for (const registration of registrations) {
out.push(...registration.acquire);
accounting.push(...registration.acquire, ...registration.cleanup);
if (registration.cleanup.length > 0) {
usingMarkers.push({
start: out.length,
cleanup: registration.cleanup,
loc: registration.loc,
});
}
}
lowered = accounting;
appended = true;
} else {
lowered = lowerer.lowerStmt(stmt);
}
// The lib-boundary chokepoint (lib-boundary.ts): checked-dynamic
// arguments that reached builtin-call slots without the checked
// coercion get their dynCheck here, and the uncoercible ones
// fence — inside this statement's poison window, so a JS fence
// becomes the statement's runtimeFence exactly like every other
// deferred rejection.
if (lowered) {
const body: IrStmt[] = [];
if (Array.isArray(lowered)) body.push(...lowered);
else body.push(lowered);
const rewritten = lowerSurplusCalls(lowerer, body);
enforceLibBoundary(lowerer, rewritten);
if (!appended) out.push(...rewritten);
}
// A JS statement that lowered CLEAN can still have flushed
// DEFERRED collection diagnostics (a symbol lookup on an alias
// resolves through flushDeferred — informational here, not a
// blocker of THIS statement): those belong to the runtime-fence
// ledger, not the build — real uses of the broken declaration
// meet their own per-site fences.
if (deferFences && lowerer.diagSink === null && lowerer.diags.length > diagsBefore) {
const flushed = lowerer.diags.splice(diagsBefore);
const ice = flushed.filter((d) => d.code === "SC9001");
lowerer.diags.push(...ice);
lowerer.runtimeFences.push(...flushed.filter((d) => d.code !== "SC9001"));
}
}
/** Whether a statement list belongs to a function body (at any block
* depth) rather than to a module's top level. */
function enclosedByFunction(stmt: ts.Statement): boolean {
@@ -1302,6 +1372,7 @@ function jsDeclarationExpandsObject(
inferred !== null &&
jsOpenObjectType(decl, inferred, lowerer.shapes, lowerer.unions).kind === "dyn") ||
arms.some((arm) => arm.kind === "record" && jsObjectBindingExpands(lowerer, decl, arm)) ||
arms.some((arm) => arm.kind === "record" && jsObjectLiteralFilledLater(lowerer, decl, arm)) ||
(isJsSourceFile(ts.getSourceFileOfNode(decl)) &&
!decl.type &&
!hasJsTypeAnnotation(decl) &&
@@ -1522,7 +1593,11 @@ function noteEarlyReadVar(lowerer: Lowerer, symbol: ts.Symbol, local: IrLocal):
* lowerVarDecl's salvage path declares the local when it can, and
* resolution checks locals/globals before the fallthroughs consult
* blockedBindings. */
export function noteBlockedBindings(lowerer: Lowerer, stmt: ts.Statement): void {
export function noteBlockedBindings(
lowerer: Lowerer,
stmt: ts.Statement,
diags: readonly ScrDiagnostic[] = [],
): void {
let list: ts.VariableDeclarationList | null = null;
if (ts.isVariableStatement(stmt)) list = stmt.declarationList;
else if (
@@ -1533,12 +1608,20 @@ export function noteBlockedBindings(lowerer: Lowerer, stmt: ts.Statement): void
list = stmt.initializer;
}
if (!list) return;
// Localized failure (report-only): the bindings form one failure group,
// the statement, whose diagnostics are the group's.
const group = statementGroupKey(stmt);
let bound = false;
for (const decl of list.declarations.nodes) {
for (const nameNode of boundIdentifiersOf(decl.name)) {
const symbol = lowerer.checker.getSymbolAtLocation(nameNode);
if (symbol) lowerer.blockedBindings.add(symbol);
if (!symbol) continue;
lowerer.blockedBindings.add(symbol);
if (!lowerer.rejectedGroups.has(symbol)) lowerer.rejectedGroups.set(symbol, group);
bound = true;
}
}
if (bound) for (const d of diags) d.rejects ??= group;
}
/** True when a reference/assignment fell through resolution because the
@@ -1548,12 +1631,13 @@ export function noteBlockedBindings(lowerer: Lowerer, stmt: ts.Statement): void
* flushed by the emit pass). */
export function isBlockedBinding(lowerer: Lowerer, symbol: ts.Symbol | null): boolean {
if (!symbol) return false;
return (
const blocked =
lowerer.blockedBindings.has(symbol) ||
lowerer.deferredDiags.has(symbol) ||
lowerer.flushedSymbols.has(symbol) ||
lowerer.alreadyFlushed.has(symbol)
);
lowerer.alreadyFlushed.has(symbol);
if (blocked) lowerer.touchRejected(symbol);
return blocked;
}
/** Lowers a statement in a fresh lexical scope (if/while/for bodies). */
@@ -1878,7 +1962,16 @@ export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt
}
// Exception-representable values move into the runtime's exception
// cell. `throw f()` of a void call has no value.
let value = lowerer.lowerExpr(stmt.expression);
// A thrown object or array literal has no declared type: it is built as
// a dynamic object, so the catch binding's checked casts adopt it
// (lowerer.untypedLiteralContext).
let literal: ts.Expression = stmt.expression;
while (ts.isParenthesizedExpression(literal)) literal = literal.expression;
let value =
(ts.isObjectLiteralExpression(literal) || ts.isArrayLiteralExpression(literal)) &&
lowerer.untypedLiteralContext(literal)
? lowerer.lowerExprExpecting(stmt.expression, DYN)
: lowerer.lowerExpr(stmt.expression);
if (value.type.kind === "void")
lowerer.badType(stmt.expression, lowerer.typeOf(stmt.expression));
// Preserve primitive reasons and readable record identity across the
@@ -3222,8 +3315,8 @@ export function lowerBindingPattern(
// chain declares keep named fences (a detached method would lose its
// receiver without an error; JS binds the prototype function).
if (srcType.kind === "object") {
const info = lowerer.classes.get(srcType.className);
if (!info) lowerer.flushDeferredClass(srcType.className);
const info =
lowerer.classes.get(srcType.className) ?? lowerer.flushDeferredClass(srcType.className);
if (info) {
for (const el of pattern.elements.nodes) {
if (el.name === undefined) continue;
@@ -6515,6 +6608,41 @@ function lowerVariableInitializer(
}
}
/** A JS object literal that leaves out a required field of the binding's
* inferred record: tsc types `var p = {}; p.id = id;` by the members later
* assignments add, so the object only gains them after its initializer, and
* until then it is not a value of that record. Keep it a checked-dynamic
* object, as an object that gains keys outside the record is. */
function jsObjectLiteralFilledLater(
lowerer: Lowerer,
decl: ts.VariableDeclaration,
type: IrType & { kind: "record" },
): boolean {
if (decl.type || !decl.initializer || !isJsSourceFile(ts.getSourceFileOfNode(decl))) return false;
let initializer = decl.initializer;
while (ts.isParenthesizedExpression(initializer)) initializer = initializer.expression;
if (!ts.isObjectLiteralExpression(initializer) || hasJsTypeAnnotation(decl)) return false;
const shape = lowerer.shapes.get(type.shapeId);
if (!shape || shape.tuple) return false;
const given = new Set<string>();
for (const property of initializer.properties.nodes) {
if (ts.isSpreadAssignment(property)) return false;
const name = property.name;
if (name === undefined) return false;
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name))
given.add(name.text);
else return false;
}
// A field whose type holds undefined reads as it would before the write.
const holdsUndefined = (t: IrType): boolean =>
t.kind === "dyn" ||
t.kind === "undefinedT" ||
(t.kind === "union" && lowerer.armTag(t.unionId, UNDEFINED_T) >= 0);
return shape.fields.some(
(field) => !given.has(field.name) && !field.name.startsWith("%") && !holdsUndefined(field.type),
);
}
/** A JS object literal's inferred fields do not close its later storage.
* Keep the original native object when writes add keys or delete fields;
* converting it to a fixed record would discard that behavior. */
File diff suppressed because it is too large Load Diff
@@ -2223,8 +2223,8 @@ export const BUILTIN_MODULE_FN_ALIASES: Record<
mkdirSync: ["fs.mkdirModeSync", "fs.mkdirRecursiveSync", "fs.mkdirRecursiveModeSync"],
// The { recursive, force } and maxRetries/retryDelay option lowerings.
rmSync: ["fs.rmOptsSync", "fs.rmRetrySync"],
// The { withFileTypes: true } Dirent form.
readdirSync: ["fs.readdirTypesSync"],
// The { withFileTypes: true } Dirent form and the { recursive: true } walk.
readdirSync: ["fs.readdirTypesSync", "fs.readdirRecursiveSync"],
// The { throwIfNoEntry } probe form.
statSync: ["fs.statProbeSync"],
// realpathSync and realpathSync.native read as function values.
@@ -0,0 +1,195 @@
/* Localized failure, stage L0 (report-only).
*
* A type with no static lowering poisons the slot that holds it (a field,
* a parameter, a return, a local), and today the poison rejects the whole
* declaration around the slot: a class, a signature, a declaration
* statement. Every later use of that declaration then fails on its own
* line. The localized-failure design turns such slots into checked
* dynamic slots at the points where the build would otherwise commit the
* error.
*
* This module holds the bookkeeping that stage measures without changing
* any lowering: where a failing type sits (its slot), whether a dynamic
* slot would keep the program's meaning (design §3.7), and the keys that
* tie a cascade diagnostic to the rejected declaration it inherits from.
* The data rides the committed diagnostics as report-only fields
* (ScrDiagnostic.typeFailure, .rejects, .cause); tier-analysis.ts turns it
* into the would-be dynamic slot report of `--explain-dynamic`. */
import type {
TypeFailure,
TypeFailureFamily,
TypeFailureSlot,
} from "../../diagnostics/diagnostic.js";
import * as ts from "../ts7/adapter.js";
/** Families a checked-dynamic slot can hold (design §3.7). The others
* name values the compiler cannot produce (opaque stdlib and @types/node
* types, engine-backed ambient types), package value types that scope B
* routes to Node islands, enum objects with no runtime value, and generic
* implementations without an erased instance. */
const DEGRADABLE_FAMILIES: ReadonlySet<TypeFailureFamily> = new Set<TypeFailureFamily>([
"index-signature",
"overloads",
"intersection",
"component",
"record-member",
"unsupported",
"dyn-field-policy",
]);
function nameText(name: ts.Node | undefined): string | null {
if (name === undefined) return null;
if (ts.isIdentifier(name) || ts.isPrivateIdentifier(name)) return name.text;
if (ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text;
return ts.getTextOfNode(name);
}
function isParameterProperty(param: ts.ParameterDeclaration): boolean {
return (
ts.isConstructorDeclaration(param.parent) &&
(param.modifiers?.nodes ?? []).some(
(m) =>
m.kind === ts.SyntaxKind.PublicKeyword ||
m.kind === ts.SyntaxKind.PrivateKeyword ||
m.kind === ts.SyntaxKind.ProtectedKeyword ||
m.kind === ts.SyntaxKind.ReadonlyKeyword,
)
);
}
function variableSlot(decl: ts.VariableDeclaration): TypeFailureSlot {
const list = decl.parent;
const holder = list?.parent;
if (
holder !== undefined &&
(ts.isForOfStatement(holder) || ts.isForInStatement(holder) || ts.isForStatement(holder))
)
return "loop";
if (holder !== undefined && ts.isVariableStatement(holder) && ts.isSourceFile(holder.parent))
return "global";
return "local";
}
function functionLabel(fn: ts.Node): string {
if (ts.isConstructorDeclaration(fn)) return "constructor";
if (ts.isFunctionLikeDeclaration(fn)) {
const own = nameText(fn.name);
if (own !== null) return own;
const parent = fn.parent;
if (
parent !== undefined &&
(ts.isVariableDeclaration(parent) ||
ts.isPropertyAssignment(parent) ||
ts.isPropertyDeclaration(parent))
) {
const bound = nameText(parent.name);
if (bound !== null) return bound;
}
}
return "<anonymous>";
}
/** The slot a badType blame node denotes. A declaration's own node, its
* name and its type annotation name the declaration's slot; anything
* reached through an expression is an expression slot (the type of a
* cast, a literal, a temporary). */
export function typeFailureSlot(node: ts.Node): { slot: TypeFailureSlot; name: string } {
let current: ts.Node = node;
for (let depth = 0; depth < 64; depth++) {
if (ts.isParameter(current)) {
const name = nameText(current.name) ?? "<parameter>";
return { slot: isParameterProperty(current) ? "field" : "param", name };
}
if (ts.isPropertyDeclaration(current))
return { slot: "field", name: nameText(current.name) ?? "<field>" };
if (ts.isVariableDeclaration(current))
return { slot: variableSlot(current), name: nameText(current.name) ?? "<binding>" };
if (ts.isBindingElement(current)) {
let owner: ts.Node | undefined = current.parent;
while (owner !== undefined && !ts.isVariableDeclaration(owner) && !ts.isParameter(owner))
owner = owner.parent;
const slot: TypeFailureSlot =
owner === undefined
? "local"
: ts.isParameter(owner)
? "param"
: variableSlot(owner as ts.VariableDeclaration);
return { slot, name: nameText(current.name) ?? "<binding>" };
}
if (ts.isPropertySignature(current) || ts.isMethodSignatureDeclaration(current))
return { slot: "member", name: nameText(current.name) ?? "<member>" };
if (ts.isFunctionLikeDeclaration(current))
return { slot: "return", name: functionLabel(current) };
if (ts.isCatchClause(current)) return { slot: "local", name: "<catch binding>" };
const parent: ts.Node | undefined = current.parent;
if (parent === undefined || ts.isSourceFile(parent)) break;
// Climb from a declaration's name or type annotation, or from inside a
// type node; any other position is an expression's own type.
const declared =
(ts.isParameter(parent) ||
ts.isPropertyDeclaration(parent) ||
ts.isVariableDeclaration(parent) ||
ts.isBindingElement(parent) ||
ts.isPropertySignature(parent) ||
ts.isMethodSignatureDeclaration(parent) ||
ts.isFunctionLikeDeclaration(parent)) &&
((parent as { name?: ts.Node }).name === current ||
(parent as { type?: ts.Node }).type === current);
if (!declared && !ts.isTypeNode(current) && !ts.isTypeNode(parent)) break;
current = parent;
}
const text = ts.getTextOfNode(node);
return { slot: "expression", name: text.length > 60 ? `${text.slice(0, 57)}...` : text };
}
/** Program code outside node_modules, in TypeScript: the code holes apply
* to (design §3.3, §3.8). JavaScript keeps its dynamic fallback and its
* runtime fences; npm packages keep island routing. */
function programTypeScript(node: ts.Node): boolean {
const sf = ts.getSourceFileOfNode(node);
const file = sf.fileName;
if (file.includes("/node_modules/")) return false;
if (sf.isDeclarationFile) return false;
return !/\.(?:[cm]?js|jsx)$/.test(file);
}
export function typeFailureOf(
node: ts.Node,
declaredType: string,
family: TypeFailureFamily,
): TypeFailure {
const { slot, name } = typeFailureSlot(node);
return {
slot,
name,
declaredType,
family,
degradable: DEGRADABLE_FAMILIES.has(family) && programTypeScript(node),
};
}
function locKey(node: ts.Node): string {
const sf = ts.getSourceFileOfNode(node);
return `${sf.fileName}:${ts.tokenStartOfNode(node)}`;
}
/** The failure-group key of a deferred declaration (a class shape or a
* function signature whose collection poisoned). */
export function declarationGroupKey(symbol: ts.Symbol): string {
const decl = symbol.declarations[0];
const name = ts.symbolName(symbol);
if (decl === undefined) return `declaration ${name}`;
const kind =
ts.isClassDeclaration(decl) || ts.isClassExpression(decl)
? "class"
: ts.isFunctionLikeDeclaration(decl)
? "function"
: "declaration";
return `${kind} ${name} @${locKey(decl)}`;
}
/** The failure-group key of a poisoned declaration statement whose
* bindings are blocked. */
export function statementGroupKey(stmt: ts.Statement): string {
return `statement @${locKey(stmt)}`;
}
@@ -195,6 +195,15 @@ export function declareTypedArguments(
});
}
/** The binding an identifier reads: its symbol, or for the name of a
* shorthand property (`{ xs }`) the variable whose value the property takes. */
export function bindingSymbolOfRead(lowerer: Lowerer, id: ts.Identifier): ts.Symbol | undefined {
const parent = id.parent;
if (parent && ts.isShorthandPropertyAssignment(parent) && parent.name === id)
return lowerer.checker.getShorthandAssignmentValueSymbol(parent);
return lowerer.checker.getSymbolAtLocation(id);
}
/** Whether every read of a tuple binding in `fn` (identified by `isRoot`)
* only forwards it: `f.apply(t, x)`, `g(...x)` into a program function,
* `x.length`, or a const alias whose own reads only forward. Reads inside
@@ -262,7 +271,7 @@ export function onlyForwarded(
const alias = lowerer.checker.getSymbolAtLocation(p.name);
const aliasName = p.name;
if (alias) {
roots.push((id) => id !== aliasName && lowerer.checker.getSymbolAtLocation(id) === alias);
roots.push((id) => id !== aliasName && bindingSymbolOfRead(lowerer, id) === alias);
return undefined;
}
}
+182 -1
View File
@@ -29,7 +29,12 @@
* Attribution is syntactic and approximate: the site's innermost node, the
* operand it acts on, and that operand's declaration. Stage S6 replaces it
* with requests the failing lowering names exactly. */
import type { ScrDiagnostic } from "../diagnostics/diagnostic.js";
import type {
ScrDiagnostic,
TypeFailureFamily,
TypeFailureSlot,
} from "../diagnostics/diagnostic.js";
import { isWarningCode } from "../diagnostics/render.js";
import type { IrFunction, SrcLoc } from "../ir/ir.js";
import { dynamicOperationSites, type DynamicFunction } from "../ir/tiers.js";
import * as ts from "./ts7/adapter.js";
@@ -85,8 +90,70 @@ export interface TierDynamicFunction extends DynamicFunction {
statements: number;
}
/** The localized-failure stage whose slot positions include a slot kind
* (design §3.6): locals and globals degrade first, declarations next,
* members of declared types with push-down, expression types last. */
export type LocalizedFailureStage = "L1" | "L2" | "L3" | "L4";
export function slotStage(slot: TypeFailureSlot): LocalizedFailureStage {
switch (slot) {
case "local":
case "global":
case "loop":
return "L1";
case "field":
case "param":
case "return":
return "L2";
case "member":
return "L3";
case "expression":
return "L4";
}
}
/** A committed type failure that would become a checked dynamic slot
* (SC6104) instead of an error. */
export interface WouldBeSlot {
loc: SrcLoc;
slot: TypeFailureSlot;
name: string;
declaredType: string;
family: TypeFailureFamily;
code: string;
message: string;
stage: LocalizedFailureStage;
/** The declaration the failure rejects today (a failure-group key), if
* any: a class, a signature, or a declaration statement. */
rejects: string | null;
/** Other committed diagnostics that inherit from this slot's group and
* clear once every group they inherit from degrades (with push-down and
* expression slots, the full design). */
cascade: number;
}
/** Localized failure, report-only (stage L0): what the committed errors
* would become. Two scenarios: through stage L2 (declaration slots) and
* through L4 (members and expressions too). */
export interface LocalizedFailureReport {
/** Reached errors (warnings excluded). */
errors: number;
/** Type failures, by whether a checked dynamic slot keeps meaning. */
typeFailures: { degradable: number; refused: number };
slots: WouldBeSlot[];
/** Diagnostics attributed to a rejected declaration they used. */
cascades: number;
/** Errors that disappear (degraded slots plus cleared cascades). */
clearedThroughL2: number;
clearedThroughL4: number;
/** The errors that remain through L4, by code. */
remainingByCode: Record<string, number>;
}
export interface TierReport {
mode: TierMode;
/** Localized failure (report-only). */
localizedFailure: LocalizedFailureReport;
/** Reached source statements that perform a dynamic operation. */
statementsDynamic: number;
dynamicFunctions: TierDynamicFunction[];
@@ -576,6 +643,119 @@ function demotedUnits(requests: readonly DemotionRequest[]): DemotedUnit[] {
);
}
/* ── localized failure ───────────────────────────────────────────────── */
/** A diagnostic's failure-group bookkeeping (lowerer: badType, deferred
* flushes, blocked statements) turned into the would-be report. A group
* (a rejected declaration or blocked statement) clears in a scenario when
* each of its diagnostics either degrades (a degradable type failure at a
* slot the scenario covers) or is a cascade whose every cause clears. */
export function localizedFailureReport(reached: readonly ScrDiagnostic[]): LocalizedFailureReport {
const errors = reached.filter((d) => !isWarningCode(d.code));
const groups = new Map<string, ScrDiagnostic[]>();
for (const d of errors) {
if (d.rejects === undefined) continue;
const list = groups.get(d.rejects);
if (list === undefined) groups.set(d.rejects, [d]);
else list.push(d);
}
const stages: Record<"L2" | "L4", ReadonlySet<LocalizedFailureStage>> = {
L2: new Set(["L1", "L2"]),
L4: new Set(["L1", "L2", "L3", "L4"]),
};
const clears = (scenario: "L2" | "L4") => {
const covered = stages[scenario];
const memo = new Map<string, boolean>();
const degrades = (d: ScrDiagnostic): boolean =>
d.typeFailure !== undefined &&
d.typeFailure.degradable &&
covered.has(slotStage(d.typeFailure.slot));
const causeClears = (d: ScrDiagnostic): boolean =>
d.cause !== undefined && d.cause.length > 0 && d.cause.every((g) => groupClears(g));
const groupClears = (group: string): boolean => {
const known = memo.get(group);
if (known !== undefined) return known;
memo.set(group, false); // a cycle never clears
const members = groups.get(group) ?? [];
const ok = members.length > 0 && members.every((d) => degrades(d) || causeClears(d));
memo.set(group, ok);
return ok;
};
return { degrades, causeClears, groupClears };
};
const l2 = clears("L2");
const l4 = clears("L4");
const cleared = (c: ReturnType<typeof clears>, d: ScrDiagnostic) =>
c.degrades(d) || c.causeClears(d);
// Each slot's cascade: diagnostics whose causes (transitively) include
// the slot's group and that clear in the full scenario.
const reachesGroup = (d: ScrDiagnostic, group: string, seen: Set<string>): boolean => {
for (const g of d.cause ?? []) {
if (g === group) return true;
if (seen.has(g)) continue;
seen.add(g);
for (const m of groups.get(g) ?? []) if (reachesGroup(m, group, seen)) return true;
}
return false;
};
const slots: WouldBeSlot[] = [];
let degradable = 0;
let refused = 0;
for (const d of errors) {
const f = d.typeFailure;
if (f === undefined) continue;
if (!f.degradable) {
refused++;
continue;
}
degradable++;
const group = d.rejects ?? null;
let cascade = 0;
if (group !== null)
for (const e of errors)
if (
e !== d &&
e.typeFailure === undefined &&
cleared(l4, e) &&
reachesGroup(e, group, new Set())
)
cascade++;
slots.push({
loc: d.loc,
slot: f.slot,
name: f.name,
declaredType: f.declaredType,
family: f.family,
code: d.code,
message: d.message,
stage: slotStage(f.slot),
rejects: group,
cascade,
});
}
slots.sort(
(a, b) =>
b.cascade - a.cascade || a.loc.file.localeCompare(b.loc.file) || a.loc.start - b.loc.start,
);
const remainingByCode: Record<string, number> = {};
let clearedThroughL2 = 0;
let clearedThroughL4 = 0;
for (const d of errors) {
if (cleared(l2, d)) clearedThroughL2++;
if (cleared(l4, d)) clearedThroughL4++;
else remainingByCode[d.code] = (remainingByCode[d.code] ?? 0) + 1;
}
return {
errors: errors.length,
typeFailures: { degradable, refused },
slots,
cascades: errors.filter((d) => d.cause !== undefined && d.typeFailure === undefined).length,
clearedThroughL2,
clearedThroughL4,
remainingByCode,
};
}
export interface TierAnalysisInput {
mode: TierMode;
/** The dynamic-tier IR functions of the reached program. */
@@ -624,6 +804,7 @@ export function analyzeTiers(program: ts.Program, input: TierAnalysisInput): Tie
const dynamic = dynamicFunctionReports(program, input.dynamicFunctions);
return {
mode: input.mode,
localizedFailure: localizedFailureReport(input.reached),
statementsDynamic: dynamic.statements,
dynamicFunctions: dynamic.functions,
requests,
@@ -473,6 +473,29 @@ test("named child access shares node and list identities without materializing s
expect(() => file.namedChild(another, "name")).toThrow("another source file");
});
test("named child getters and text keep answering the first read", () => {
let materialized = 0;
const file = new AstFile(decoded.get("main.ts")!.bytes, () => {
materialized++;
});
let index = 1;
while (file.wire.kind(index) !== AstKind.FunctionDeclaration) index++;
const node = file.node(index);
const name = node.name;
expect(name?.index).toBe(file.wire.namedChild(index, "name"));
const after = materialized;
expect(node.name).toBe(name);
expect(node.childNode("name")).toBe(name);
expect(file.node(name!.index)).toBe(name);
expect(node.typeParameters).toBeUndefined();
expect(materialized).toBe(after);
const text = name!.text;
expect(text).toBe(file.wire.text(name!.index));
expect(name!.text).toBe(text);
expect(node.text).toBeUndefined();
expect(node.text).toBeUndefined();
});
test("warming a named child preserves lazy failures in later malformed sibling links", () => {
const bytes = decoded.get("main.ts")!.bytes;
const wire = new AstFile(bytes).wire;
+39 -8
View File
@@ -83,6 +83,9 @@ export interface AstFileOptions {
writeTargets?: boolean;
}
const NO_CHILD_INDICES = new Uint32Array(0);
const NO_CHILD_NODES: AstNode[] = [];
export class AstFile {
readonly wire: AstWireFile;
readonly root: AstNode;
@@ -94,7 +97,9 @@ export class AstFile {
private readonly structuredNodes = new Map<number, AstNode[]>();
private readonly strings = new Map<number, string[]>();
private lines: number[] | undefined;
private childIndices: Uint32Array | undefined;
/** Resolved named children, eight slots per node; empty until the first
* named child is read. */
private childIndices: Uint32Array = NO_CHILD_INDICES;
/** The root source file's name (AstNode.fileName). */
private rootFileName: string | undefined;
@@ -164,8 +169,8 @@ export class AstFile {
const mask = node.data >>> 30 === 0 ? node.data & 0xff : 0xff;
if ((mask & (1 << order)) === 0) return 0;
const slot = node.index * 8 + order;
const indices = this.childIndices;
if (indices !== undefined) {
let indices = this.childIndices;
if (indices.length !== 0) {
const cached = indices[slot]!;
if (cached !== 0) return cached;
}
@@ -173,9 +178,11 @@ export class AstFile {
// The pinned wire has at most eight named children per node. One packed
// table avoids per-node arrays; allocate it only after successful access
// so malformed siblings still fail lazily in the checked wire decoder.
if (this.childIndices === undefined)
this.childIndices = new Uint32Array(this.wire.nodeCount * 8);
this.childIndices[slot] = index;
if (indices.length === 0) {
indices = new Uint32Array(this.wire.nodeCount * 8);
this.childIndices = indices;
}
indices[slot] = index;
return index;
}
@@ -320,6 +327,13 @@ export class AstNode {
readonly data: number;
private parentResolved = false;
private parentCache: AstNode | undefined;
/** The node's text, read once: the frontend asks the same identifiers
* for their text many times. */
private textResolved = false;
private textCache: string | undefined;
/** Named child nodes by wire slot, filled as they are read; empty until
* the first one is. Slots the child mask leaves empty are never stored. */
private childNodes: AstNode[] = NO_CHILD_NODES;
constructor(
readonly file: AstFile,
@@ -351,7 +365,11 @@ export class AstNode {
return this.parentCache;
}
get text(): string | undefined {
return this.file.wire.text(this.index);
if (!this.textResolved) {
this.textCache = this.file.wire.text(this.index);
this.textResolved = true;
}
return this.textCache;
}
get rawText(): string | undefined {
return this.file.wire.rawText(this.index);
@@ -363,8 +381,21 @@ export class AstNode {
}
/** childNode by wire slot, as the generated getters select it. */
childNodeAt(order: number): AstNode | undefined {
let nodes = this.childNodes;
if (order >= 0 && order < nodes.length) {
const cached = nodes[order];
if (cached !== undefined) return cached;
}
const index = this.file.namedChildAt(this, order);
return index === 0 ? undefined : this.file.node(index);
if (index === 0) return undefined;
const child = this.file.node(index);
if (nodes.length === 0) {
const mask = this.data >>> 30 === 0 ? this.data & 0xff : 0xff;
nodes = new Array<AstNode>(32 - Math.clz32(mask));
this.childNodes = nodes;
}
nodes[order] = child;
return child;
}
childListAt(order: number): AstNodeList | undefined {
const index = this.file.namedChildAt(this, order);
@@ -1282,6 +1282,11 @@ export interface TypeMapperCtx {
* checker type its signature instantiates, or null. Consulted only when
* the symbolic form does not map. */
resolveBoundAliasTs?: TypeParamTsResolver;
/** Localized failure: the lenient mapping of a type whose strict mapping
* failed at a committed slot (Lowerer.degradedTypes), or null. Consulted
* only after the strict mapping (and its bound-alias retry) returns null,
* at every depth, so every occurrence of a degraded type maps alike. */
degradedType?: (type: ts.Type) => IrType | null;
/** GENERIC program classes (monomorphization by flow): the mapping of a
* concrete instantiation reference (`Box<number>`) — the Lowerer
* registers/reuses the instantiation (`Box%0`) and answers its object
@@ -1856,6 +1861,16 @@ export function mapType(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
if (mapped !== null) contextResolutions++;
}
}
// Localized failure: a type that failed at a committed slot maps to its
// lenient form. It counts as a context resolution so neither it nor a
// type containing it is memoized: the strict form may map elsewhere.
if (mapped === null && ctx.degradedType) {
const lenient = ctx.degradedType(type);
if (lenient !== null) {
mapped = lenient;
contextResolutions++;
}
}
if (
mapped !== null &&
memoKey !== null &&
+52 -2
View File
@@ -55,6 +55,7 @@ export type {
import { compilePackedLibrary } from "./backend/library-pack.js";
import { node24FetchCompatProfile } from "./compat/fetch-profile.js";
import { libFnSignatures } from "./ir/builtin-signatures.js";
import { type FunctionLayout, parseFunctionLayout } from "./backend/llvm/function-layout.js";
import { InternalCompilerError } from "./errors.js";
import { frontendProcessExecArgv } from "./frontend-process-args.js";
import { type ChildProcess, spawn } from "node:child_process";
@@ -64,6 +65,7 @@ import { copyFile, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:
import { tmpdir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { gunzipSync } from "node:zlib";
import {
clearCcCaches,
configuredTargetPlatform,
@@ -538,6 +540,14 @@ function performanceWarnings(sink: CompileWarningSink | undefined): {
return performance.length === 0 ? {} : { performance };
}
/** The profile match of a --profile-use build's program object, for its
* artifact and its executable cache entry, which a cache hit reports again. */
function profileMatchField(object: NativeProgramObject | null): {
profileMatch?: NativeProfileMatch;
} {
return object?.profileMatch === undefined ? {} : { profileMatch: object.profileMatch };
}
function withCompileWarnings<T extends CompileRequestResult>(
result: T,
sink: CompileWarningSink,
@@ -802,6 +812,37 @@ async function readNativeProfile(
return { use: { path, digest: createHash("sha256").update(bytes).digest("hex") } };
}
/** The digest of the request's function layout file, which keys the
* executable cache: the layout changes the code's order, not its frontend
* input. */
async function functionLayoutKey(opts: CompileRequestOptions): Promise<string | undefined> {
if (opts.functionLayout === undefined) return undefined;
const path = resolve(opts.functionLayout);
let bytes: Buffer;
try {
bytes = await readFile(path);
} catch (err) {
throw new Error(
`cannot read function layout ${path}: ${err instanceof Error ? err.message : String(err)}`,
);
}
return createHash("sha256").update(bytes).digest("hex");
}
/** The request's function layout. */
async function readFunctionLayout(
opts: CompileRequestOptions,
): Promise<{ functionLayout?: FunctionLayout }> {
if (opts.functionLayout === undefined) return {};
const path = resolve(opts.functionLayout);
const bytes = await readFile(path);
return {
functionLayout: parseFunctionLayout(
(path.endsWith(".gz") ? gunzipSync(bytes) : bytes).toString("utf8"),
),
};
}
function usesPrecompiledRuntimePack(opts: CompileRequestOptions, backend: "llvm"): boolean {
if (backend !== "llvm" || opts.sanitize === true) return false;
return nativeCodegenTarget() !== null;
@@ -875,6 +916,7 @@ async function prepareExecutableInput(
llvm = emitLlvmModuleSource(mod, {
targetTriple: process.env["SCRIPTC_TARGET"] ?? "",
...debugOptions,
...(await readFunctionLayout(opts)),
pointerBits: buildPlatform === "wasi" ? 32 : 64,
wasi: buildPlatform === "wasi",
runtimeAbiMarker: outputKind === "obj",
@@ -949,6 +991,7 @@ async function prepareExecutableInput(
llvmSource = emitLlvmModuleSource(mod, {
targetTriple: process.env["SCRIPTC_TARGET"] ?? "",
...debugOptions,
...(await readFunctionLayout(opts)),
pointerBits: buildPlatform === "wasi" ? 32 : 64,
wasi: buildPlatform === "wasi",
runtimeAbiMarker: useRuntimePack,
@@ -1574,9 +1617,11 @@ async function compileTracked(
platform: buildPlatform,
});
let profile: NativeProfileOptions | undefined;
let functionLayoutDigest: string | undefined;
try {
if (profileRefusal !== null) throw new Error(profileRefusal);
profile = await readNativeProfile(opts);
functionLayoutDigest = await functionLayoutKey(opts);
} catch (err) {
return {
ok: false,
@@ -1612,6 +1657,7 @@ async function compileTracked(
...targetCpuField(opts.targetCpu),
...(opts.cpuDispatch === true ? { cpuDispatch: true as const } : {}),
...(profile === undefined ? {} : { profile: nativeProfileKey(profile) }),
...(functionLayoutDigest === undefined ? {} : { functionLayout: functionLayoutDigest }),
...checkerField(),
...(youngNurseryRequested() ? { youngNursery: true as const } : {}),
npmStatic: opts.npmStatic ?? null,
@@ -1687,6 +1733,7 @@ async function compileTracked(
path: opts.outPath,
translationUnitPath: earlyHit.llvmPath,
backend: earlyHit.native.backend,
...(earlyHit.profileMatch === undefined ? {} : { profileMatch: earlyHit.profileMatch }),
},
binaryPath: opts.outPath,
llvmPath: earlyHit.llvmPath,
@@ -1764,6 +1811,7 @@ async function compileTracked(
executableRestored: true,
nativeDependencies: dependencies,
frontend: earlyHit.frontend,
...profileMatchField(nativeProgramObject),
});
},
nativeProgramObject?.partitionPaths,
@@ -1809,6 +1857,7 @@ async function compileTracked(
path: opts.outPath,
translationUnitPath: earlyHit.llvmPath,
backend: earlyHit.native.backend,
...profileMatchField(nativeProgramObject),
},
binaryPath: opts.outPath,
llvmPath: earlyHit.llvmPath,
@@ -1900,6 +1949,7 @@ async function compileTracked(
frontend: frontendInputs.snapshot(),
...(irSourcePath === undefined ? {} : { irPath: irSourcePath }),
...performanceWarnings(warningSink),
...profileMatchField(nativeProgramObject),
});
publishedExecutable = true;
},
@@ -1919,6 +1969,7 @@ async function compileTracked(
frontend: frontendInputs.snapshot(),
...(irSourcePath === undefined ? {} : { irPath: irSourcePath }),
...performanceWarnings(warningSink),
...profileMatchField(nativeProgramObject),
}).catch(() => undefined);
}
} catch (err) {
@@ -1947,7 +1998,6 @@ async function compileTracked(
}
await pruneBuildCache(cacheRoot);
timing("complete");
const profileMatch = nativeProgramObject?.profileMatch;
return {
ok: true,
artifact: {
@@ -1955,7 +2005,7 @@ async function compileTracked(
path: opts.outPath,
translationUnitPath: llvmPath,
backend,
...(profileMatch === undefined ? {} : { profileMatch }),
...profileMatchField(nativeProgramObject),
},
binaryPath: opts.outPath,
llvmPath,
@@ -765,6 +765,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
"fs.rmSync",
"fs.rmdirSync",
"fs.readdirSync",
"fs.readdirRecursiveSync",
"fs.readdirTypesSync",
"fs.globSync",
"fs.globTypesSync",
@@ -592,6 +592,9 @@ function buildLibFnSignatures() {
"fs.rmSync": { argTypes: [STRING], result: VOID },
"fs.rmdirSync": { argTypes: [STRING], result: VOID },
"fs.readdirSync": { argTypes: [STRING], result: arrayOf(STRING) },
/** `fs.readdirSync(path, { recursive: true })`: every entry below the
* path, relative to it, in Node's queue order. */
"fs.readdirRecursiveSync": { argTypes: [STRING], result: arrayOf(STRING) },
// Result is the call site's Dirent record array (VOID is the
// structure-checked sentinel, the os.networkInterfaces pattern).
/** `fs.readdirSync(path, { withFileTypes: true })` — Dirent rows over
@@ -3997,6 +4000,12 @@ function buildLibFnSignatures() {
* Throws the path TypeError, changing nothing, when a converted object
* is not plain data (a prototype, accessors, symbols, frozen state). */
"dyn.declAdopt": { argTypes: [DYN, DYN], result: VOID },
/** dyn.declAdopt for every other checked cast of a dynamic value to a
* record or array (`value as T`): a shared source object or array
* becomes a live capsule of the value built from it. A node that is not
* plain data, or that the value holds twice, keeps its separate copy.
* Never throws: the conversion already succeeded. */
"dyn.adoptShared": { argTypes: [DYN, DYN], result: VOID },
"dyn.cloneMissing": { argTypes: [], result: DYN },
/** structuredClone with a NON-EMPTY transfer array of static values:
* nothing static is transferable, so the call always throws Node's
+4 -1
View File
@@ -967,7 +967,10 @@ const RUNTIME_FEATURE_RULES = {
/** The declaration-typed boundary's adoption (scr_decl_adopt.c): its
* casts, and closures boxed in a boundary program, which adopt their
* arguments. */
declAdopt: rule({ fns: ["dyn.declAdopt"], initial: (mod) => mod.declBoundary === true }),
declAdopt: rule({
fns: ["dyn.declAdopt", "dyn.adoptShared"],
initial: (mod) => mod.declBoundary === true,
}),
// Checked conversions into package-declared types name the declaration
// in their TypeError (scr_type_origin.c).
typeOrigins: rule({ initial: (mod) => (mod.declOrigins?.length ?? 0) > 0 }),
+84 -3
View File
@@ -107,7 +107,7 @@ test.each([
});
vi.mocked(emitNativeObject).mockImplementation((options) => {
writeFileSync(options.outputPath, "object");
return [options.outputPath];
return { outputs: [options.outputPath], profileMatch: null };
});
const cache = new NativeCache(join(directory, "cache"));
const key = contentDigest("executable link");
@@ -250,7 +250,7 @@ test.each([false, true])(
expect(emitted).toContain("define i32 @main");
if (fail) throw new Error("codegen failed");
writeFileSync(options.outputPath, "new object");
return [options.outputPath];
return { outputs: [options.outputPath], profileMatch: null };
});
const target = MACOS_ARM64_TARGET;
const toolchain: NativeToolchain = {
@@ -341,7 +341,7 @@ test.skipIf(process.platform === "win32").each([false, true])(
});
vi.mocked(emitNativeObject).mockImplementation((options) => {
writeFileSync(options.outputPath, "object");
return [options.outputPath];
return { outputs: [options.outputPath], profileMatch: null };
});
vi.mocked(openNativeExecutableCache).mockReturnValue(null);
const links: string[][] = [];
@@ -378,3 +378,84 @@ test.skipIf(process.platform === "win32").each([false, true])(
if (lldFails) expect(links[1]).not.toContain("-fuse-ld=lld");
},
);
test.skipIf(process.platform === "win32")(
"a --profile-use executable reports the helper's profile match",
() => {
const directory = mkdtempSync(join("/tmp", "scriptc-native-profile-"));
directories.push(directory);
const clang = join(directory, "clang");
writeFileSync(clang, "#!/bin/sh\n");
chmodSync(clang, 0o755);
const entry = join(directory, "entry.ts");
const output = join(directory, "program");
const profile = join(directory, "app.profdata");
writeFileSync(profile, "indexed profile");
const module: IrModule = {
irVersion: 17,
sourceFile: entry,
entry: "%main",
functions: [
{
name: "%main",
params: [],
locals: [],
body: [],
returnType: VOID,
loc: { file: entry, start: 0, end: 0 },
},
],
};
vi.mocked(runNativeFrontend).mockReturnValue({
preflight: [],
entryText: () => "",
entryExports: () => new Map(),
entryContract: () => {
throw new Error("unexpected contract request");
},
sourceTexts: () => new Map(),
npmStatic: [],
npmImportSites: new Map(),
dispose: () => {},
lower: () => ({
module,
diagnostics: [],
runtimeFences: [],
stats: { statementsTotal: 0, statementsFailed: 0, functionsSkipped: 0 },
}),
});
const match = { annotated: 120, mismatched: 7, missing: 40, unknownPartitions: 0 };
vi.mocked(emitNativeObject).mockImplementation((options) => {
expect(options.profile?.use?.path).toBe(profile);
writeFileSync(options.outputPath, "object");
return { outputs: [options.outputPath], profileMatch: match };
});
vi.mocked(openNativeExecutableCache).mockReturnValue(null);
vi.mocked(execFileSync).mockImplementation((_command, args) => {
const list = args as string[];
writeFileSync(list[list.indexOf("-o") + 1]!, "executable");
return "";
});
const target = LINUX_X64_GNU_TARGET;
const compiler = new NativeCompiler({
compilerVersion: "0.0.0",
target,
helper: target.helper,
ts7Executable: "unused",
helperExecutable: "unused",
helperPackageRoot: "unused",
runtimePackRoot: "unused",
linker: clang,
linkerArgs: [],
dsymutil: "unused",
});
const result = compiler.compile(entry, {
outDir: directory,
outPath: output,
backend: "llvm",
optimization: "speed",
profileUse: profile,
});
expect(result).toMatchObject({ ok: true, artifact: { kind: "exe", profileMatch: match } });
},
);
+64 -11
View File
@@ -63,6 +63,8 @@ import { contentDigest, fileDigest, partsDigest, type NativeCache } from "./cach
import {
nativeProfileKey,
nativeProfileRefusal,
parseNativeProfileMatch,
type NativeProfileMatch,
type NativeProfileOptions,
} from "../backend/native-profile.js";
import { splitLlvmLibraryProgram } from "../backend/llvm/split.js";
@@ -78,6 +80,18 @@ function partitionCacheKey(key: string, index: number): string {
return index === 0 ? key : contentDigest(`${key}:${index}`);
}
/** The object cache entry that holds a cached program's profile match. */
function profileMatchCacheKey(key: string): string {
return contentDigest(`${key}:profile-match`);
}
/** A program's native objects, and how a --profile-use build's profile
* matched its functions. */
interface NativeObjects {
paths: string[];
profileMatch?: NativeProfileMatch;
}
function failure(error: unknown, entry: string, sources: Map<string, string>): CompileFailure {
return {
ok: false,
@@ -186,7 +200,7 @@ export class NativeCompiler {
partitions = 1,
profile?: NativeProfileOptions,
llvmDigest: () => string = () => fileDigest(input),
): string[] {
): NativeObjects {
const toolchain = this.toolchain;
const outputs = nativePartitionPaths(output, partitions);
const helperOptions = {
@@ -223,7 +237,20 @@ export class NativeCompiler {
verifyNativeHelper(helperOptions);
if (nativeFileIdentity(toolchain.helperExecutable) === identity) {
cached.forEach((bytes, index) => writeFileSync(outputs[index]!, bytes!));
return outputs;
if (profile?.use === undefined) return { paths: outputs };
// Without the cached match, every partition's counts are unknown.
const match = this.cache.read("object", profileMatchCacheKey(key));
return {
paths: outputs,
profileMatch: (match === null
? null
: parseNativeProfileMatch(match.toString("utf8"))) ?? {
annotated: 0,
mismatched: 0,
missing: 0,
unknownPartitions: outputs.length,
},
};
}
key = null;
}
@@ -235,7 +262,7 @@ export class NativeCompiler {
this.cache === null || identity === null || partitions < 2
? null
: this.cache.partitionDirectory(identity);
emitNativeObject({
const { profileMatch } = emitNativeObject({
executable: toolchain.helperExecutable,
packageRoot: toolchain.helperPackageRoot,
compilerVersion: toolchain.compilerVersion,
@@ -252,15 +279,30 @@ export class NativeCompiler {
});
if (this.cache !== null && key !== null) {
try {
if (nativeFileIdentity(toolchain.helperExecutable) === identity)
if (nativeFileIdentity(toolchain.helperExecutable) === identity) {
// The match goes in first: a reader that finds every partition finds it too.
if (profileMatch !== null)
this.cache.write(
"object",
profileMatchCacheKey(key),
`${JSON.stringify({
profile: {
annotated: profileMatch.annotated,
mismatched: profileMatch.mismatched,
missing: profileMatch.missing,
unknown_partitions: profileMatch.unknownPartitions,
},
})}\n`,
);
outputs.forEach((path, index) =>
this.cache!.write("object", partitionCacheKey(key!, index), readFileSync(path)),
);
}
} catch {
/* An updated helper or unavailable cache only prevents reuse. */
}
}
return outputs;
return profileMatch === null ? { paths: outputs } : { paths: outputs, profileMatch };
}
private emitProgramObject(
@@ -272,7 +314,7 @@ export class NativeCompiler {
library: boolean,
profile?: NativeProfileOptions,
llvmDigest?: () => string,
): string[] {
): NativeObjects {
// Library archives merge dev shards relocatably; executables link the
// helper's program partitions directly.
const llvm =
@@ -303,7 +345,7 @@ export class NativeCompiler {
}
const merged = localizeNativeLibrary(this.toolchain, stage, objects, [], split.publicSymbols);
renameSync(merged, output);
return [output];
return { paths: [output] };
}
compile(entry: string, options: CompileRequestOptions): CompileRequestResult {
@@ -427,6 +469,9 @@ export class NativeCompiler {
const kept = executableCache?.keep(output) ?? false;
const restored = kept || (executableCache?.restore(stagedOutput) ?? false);
timing(restored ? "executable-cache-hit" : "executable-cache-miss");
// The executable cache leaves out --profile-use builds, so this is set
// whenever the build has a profile to report.
let profileMatch: NativeProfileMatch | undefined;
if (!restored) {
// A missing or invalid runtime pack fails before code generation.
if (outputKind === "exe") selectPack();
@@ -440,8 +485,8 @@ export class NativeCompiler {
object,
]);
requireNativeArtifact(object);
} else if (outputKind === "exe")
programObjects = this.emitProgramObject(
} else if (outputKind === "exe") {
const objects = this.emitProgramObject(
llvmInput,
object,
entry,
@@ -451,7 +496,9 @@ export class NativeCompiler {
profile,
digestLlvm,
);
else
programObjects = objects.paths;
profileMatch = objects.profileMatch;
} else
this.emitObject(
llvmInput,
object,
@@ -578,7 +625,13 @@ export class NativeCompiler {
rmSync(output + ".dSYM", { recursive: true, force: true });
return {
ok: true,
artifact: { kind: "exe", path: output, translationUnitPath: llvmPath, backend: "llvm" },
artifact: {
kind: "exe",
path: output,
translationUnitPath: llvmPath,
backend: "llvm",
...(profileMatch === undefined ? {} : { profileMatch }),
},
binaryPath: output,
llvmPath,
backend: "llvm",
@@ -0,0 +1,273 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { canonEqual, canonKey, Normalizer, type Canon } from "../src/frontend/canon.js";
import { loadProgram } from "../src/frontend/program-node.js";
import * as ts from "../src/frontend/ts7/adapter.js";
let directory: string;
let load: ReturnType<typeof loadProgram>;
let checker: ts.TypeChecker;
const aliases = new Map<string, ts.Type>();
const asTypes = new Map<string, { type: ts.Type; params: ts.Symbol[] }>();
const valueTypes = new Map<string, ts.Type>();
const typeParams = new Map<string, ts.Symbol>();
beforeAll(() => {
directory = mkdtempSync(join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-canon-"));
const entry = join(directory, "input.ts");
writeFileSync(
entry,
`
// #2228: an intersection that restates an optional array member.
export interface BuildOptions { cwd: string; contextFiles?: Array<{ path: string; content: string }> }
export type Normalized = BuildOptions & { contextFiles: Array<{ path: string; content: string }> };
export type Same = { x: number[] } & { x: number[] };
// Unions flatten, drop never, read void as undefined, and dedupe.
export type Flat = string | (number | undefined) | never | void | string;
export type Flag = true | false | 0;
// Branded primitive.
export type Id = string & { readonly brand: "id" };
// #2241/#2246: a recursive interface with a generic method.
export interface TreeNode { name: string; child?: TreeNode; send<T>(message: T): string }
// #2227: a generic alias spelled over a generic body's own parameters.
export type Catalog<G, P> = { groups: G[]; provider: P };
export function cast<G, P>(x: unknown) {
return x as Catalog<G, P>;
}
// Narrowed type parameters.
export function present<T>(x: T & {}) {
return x;
}
export const sampleNumber = 1 as number;
export const sampleText = "a" as string;
export const sampleMaybe = undefined as string | undefined;
export class Point { x = 0 }
export type Points = readonly Point[];
export type Pair = [string, number];
// Two type parameters named T, in different functions.
export function first<T>(x: unknown) {
return x as T[];
}
export function second<T>(x: unknown) {
return x as readonly T[];
}
// Distinct callables, and arrays of them.
export type Calls = ((a: string) => void) & ((a: number) => void);
export type CallArrays = ((a: string) => void)[] | ((a: number) => void)[];
// A dictionary with a named property.
export type Dict = { [key: string]: number } & { size: number };
// Recursive array and tuple aliases.
export type Nested = number | Nested[];
export type Chain = [number, Chain] | null;
// Optional and rest tuple elements.
export type Loose = [string, number?, ...boolean[]];
// Arrays of two instantiations of one generic class.
export type Maps = Map<string, number>[] | Map<number, string>[];
`,
);
load = loadProgram(entry);
checker = load.program.getTypeChecker();
ts.walkPreorder(load.entry, (node) => {
if (ts.isTypeAliasDeclaration(node) || ts.isInterfaceDeclaration(node))
aliases.set(
node.name.text,
checker.getTypeAtLocation(ts.isTypeAliasDeclaration(node) ? node.type : node.name),
);
if (ts.isAsExpression(node)) {
let fn: ts.Node | undefined = node.parent;
while (fn !== undefined && !ts.isFunctionDeclaration(fn)) fn = fn.parent;
const params =
fn !== undefined && ts.isFunctionDeclaration(fn)
? (fn.typeParameters?.nodes ?? []).map((p) => checker.getSymbolAtLocation(p.name)!)
: [];
asTypes.set(ts.getTextOfNode(node.type), {
type: checker.getTypeAtLocation(node.type),
params,
});
}
if (ts.isTypeParameterDeclaration(node) && ts.isFunctionDeclaration(node.parent))
typeParams.set(
`${node.parent.name?.text}.${node.name.text}`,
checker.getSymbolAtLocation(node.name)!,
);
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name))
valueTypes.set(node.name.text, checker.getTypeAtLocation(node.name));
if (
ts.isParameter(node) &&
node.type !== undefined &&
ts.isFunctionDeclaration(node.parent) &&
node.parent.name?.text === "present"
)
asTypes.set("present", {
type: checker.getTypeAtLocation(node.type),
params: (node.parent.typeParameters?.nodes ?? []).map((p) =>
checker.getSymbolAtLocation(p.name)!,
),
});
});
});
afterAll(() => {
load?.dispose();
if (directory) rmSync(directory, { recursive: true, force: true });
});
const key = (c: Canon) => canonKey(c);
describe("canon", () => {
test("intersections that restate members resolve to one object (#2228)", () => {
const n = new Normalizer(checker);
expect(key(n.canon(aliases.get("Same")!))).toBe("{ x: number[] }");
expect(key(n.canon(aliases.get("Normalized")!))).toBe(
"{ cwd: string; contextFiles: { path: string; content: string }[] }",
);
});
test("unions are flat, never-free and deduplicated", () => {
const n = new Normalizer(checker);
expect(key(n.canon(aliases.get("Flat")!))).toBe("string | number | undefined");
expect(key(n.canon(aliases.get("Flag")!))).toBe("boolean | 0");
});
test("a branded primitive is the primitive", () => {
expect(key(new Normalizer(checker).canon(aliases.get("Id")!))).toBe("string");
});
test("recursion closes on the node under construction (#2241, #2246)", () => {
const n = new Normalizer(checker);
const tree = n.canon(aliases.get("TreeNode")!);
expect(tree.k).toBe("obj");
if (tree.k !== "obj") return;
const child = tree
.props()
.find((p) => p.name === "child")!
.type();
expect(child.k).toBe("union");
if (child.k !== "union") return;
expect(child.arms).toContain(tree);
const send = tree
.props()
.find((p) => p.name === "send")!
.type();
expect(send.k === "obj" && send.call()[0]!.generic).toBe(true);
});
test("a generic alias in a generic body substitutes the environment (#2227)", () => {
const n = new Normalizer(checker);
const { type, params } = asTypes.get("Catalog<G, P>")!;
expect(key(n.canon(type))).toBe("{ groups: param G[]; provider: param P }");
const bound = n.canon(
type,
new Map([
[params[0]!, valueTypes.get("sampleNumber")!],
[params[1]!, valueTypes.get("sampleText")!],
]),
);
expect(key(bound)).toBe("{ groups: number[]; provider: string }");
expect(bound.k === "obj" && bound.alias?.name).toBe("Catalog");
expect(bound.k === "obj" && bound.alias?.tsType).toBe(type);
});
test("a narrowed parameter drops its unit arms", () => {
const n = new Normalizer(checker);
const { type, params } = asTypes.get("present")!;
expect(key(n.canon(type, new Map([[params[0]!, valueTypes.get("sampleMaybe")!]])))).toBe(
"string",
);
});
test("type parameters with the same name are distinct in the intern key", () => {
const n = new Normalizer(checker);
const { type } = asTypes.get("T[]")!;
const firstT = typeParams.get("first.T")!;
const secondT = typeParams.get("second.T")!;
const num = valueTypes.get("sampleNumber")!;
const text = valueTypes.get("sampleText")!;
expect(
key(
n.canon(
type,
new Map([
[firstT, num],
[secondT, text],
]),
),
),
).toBe("number[]");
expect(
key(
n.canon(
type,
new Map([
[firstT, text],
[secondT, num],
]),
),
),
).toBe("string[]");
});
test("callables and arrays of callables with different parameters stay distinct", () => {
const n = new Normalizer(checker);
const calls = n.canon(aliases.get("Calls")!);
expect(calls.k === "obj" && calls.call().length).toBe(2);
const arms = n.canon(aliases.get("CallArrays")!);
expect(arms.k === "union" && arms.arms.length).toBe(2);
expect(key(arms)).toBe("{ (string) => void }[] | { (number) => void }[]");
});
test("intersecting a dictionary with a named property keeps the index signature", () => {
const dict = new Normalizer(checker).canon(aliases.get("Dict")!);
expect(key(dict)).toBe("{ [string]: number; size: number }");
});
test("recursive array and tuple aliases close on themselves", () => {
const n = new Normalizer(checker);
const nested = n.canon(aliases.get("Nested")!);
expect(nested.k).toBe("union");
if (nested.k !== "union") return;
// The array closes on itself through the union of its element.
const array = nested.arms.find((a) => a.k === "array");
const elem = array?.k === "array" ? array.elem : undefined;
expect(elem?.k === "union" && elem.arms.includes(array!)).toBe(true);
const chain = n.canon(aliases.get("Chain")!);
expect(chain.k).toBe("union");
if (chain.k !== "union") return;
const tuple = chain.arms.find((a) => a.k === "tuple");
const rest = tuple?.k === "tuple" ? tuple.elems[1]!.type : undefined;
expect(rest?.k === "union" && rest.arms.includes(tuple!)).toBe(true);
});
test("a failed comparison withdraws the pairs it assumed equal", () => {
const array = (p: "number" | "string"): Canon => ({
k: "array",
elem: { k: "prim", p },
readonly: false,
});
// number[] fails against string[] before it matches number[]; that
// attempt must not make string[] match later.
const a: Canon = { k: "union", arms: [array("number"), array("number")] };
const b: Canon = { k: "union", arms: [array("string"), array("number")] };
expect(canonEqual(a, b)).toBe(false);
expect(canonEqual(b, a)).toBe(false);
});
test("instantiations of one generic class stay distinct", () => {
const maps = new Normalizer(checker).canon(aliases.get("Maps")!);
expect(maps.k === "union" && maps.arms.length).toBe(2);
expect(key(maps)).toBe("Map<string, number>[] | Map<number, string>[]");
});
test("optional and rest tuple elements keep their flags", () => {
expect(key(new Normalizer(checker).canon(aliases.get("Loose")!))).toBe(
"[string, number | undefined?, ...boolean]",
);
});
test("classes are nominal; arrays and tuples keep their elements", () => {
const n = new Normalizer(checker);
expect(key(n.canon(aliases.get("Points")!))).toBe("readonly Point[]");
expect(key(n.canon(aliases.get("Pair")!))).toBe("[string, number]");
});
});
@@ -0,0 +1,64 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { compile, deserializeModule } from "../src/index.js";
import { emitLlvmModule } from "../src/backend/llvm/emitter.js";
test("a chain of literal stores is one store of a table entry, indexed as on every path", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-constant-choices-"));
try {
const entry = join(dir, "main.ts");
await writeFile(
entry,
`
function bases(arr: Uint8Array): void {
for (let i = 0; i < arr.length; ++i) {
const x = arr[i];
if (x === 97) arr[i] = 0;
else if (x === 116) arr[i] = 1;
else if (x === 99) arr[i] = 2;
else arr[i] = 3;
}
}
function wide(flag: boolean): string {
const a = new Uint8Array(4);
let j = 0;
if (flag) j = 4294967297;
if (j === 0) a[j] = 11;
else if (j === 1) a[j] = 22;
else if (j === 2) a[j] = 33;
else a[j] = 44;
return a.join(",");
}
const sequence = new Uint8Array([97, 116, 99, 103]);
bases(sequence);
console.log(sequence.join(","), wide(true), wide(false));
`,
);
const outPath = join(dir, "main.ir.json");
const lowered = await compile(entry, { outDir: dir, outPath, outputKind: "ir" });
if (!lowered.ok)
throw new Error(lowered.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
const ll = emitLlvmModule(deserializeModule(await readFile(outPath, "utf8")));
const body = (name: string): string =>
ll.match(
new RegExp(`define internal [^\\n]+ @sc_[bf]+_${name}\\([^\\n]*\\) #0 \\{[\\s\\S]*?\\n\\}`),
)![0];
// The cases' span, 97 to 116, with the final else's 3 between the cases.
expect(ll).toMatch(
/@sc_choice_\w+ = private unnamed_addr constant \[20 x i32\] \[i32 0, i32 3, i32 2, (i32 3, ){16}i32 1\]/,
);
expect(body("bases")).toMatch(/load i32, ptr %\S+\n/);
// The index is the compared local. Inside the first arm its range is
// [0, 0], but the one store also runs for 4294967297, so the index
// converts as the local's whole range allows (to i64), not to i32.
const wide = body("wide");
expect(wide).toMatch(/@sc_choice_\w+, i64 0, i64/);
expect(wide).not.toMatch(/fptosi double %\S+ to i32/);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
@@ -244,3 +244,102 @@ test("worker programs poll no loop whose proven ranges bound its runs inside ano
await rm(dir, { recursive: true, force: true });
}
});
test("worker programs read and write shared elements at a strip-mined loop's counter with plain accesses", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-counted-shared-"));
try {
const entry = join(dir, "shared.ts");
await writeFile(
entry,
[
'import { isMainThread, Worker } from "node:worker_threads";',
"export function scale(a: Float64Array, b: Float64Array, n: number): void {",
" for (let i = 0; i < n; i++) b[i] = a[i]! * 2;",
"}",
"export function codes(a: Uint8Array): void {",
" for (let i = 0; i < a.length; ++i) a[i] = a[i] === 97 ? 0 : 3;",
"}",
"export function shift(a: Uint8Array): void {",
" for (let i = 0; i < a.length - 1; i++) a[i] = a[i + 1]!;",
"}",
"export function rows(a: Int32Array, n: number): number {",
" let t = 0;",
" for (let i = 0; i < n; i++) for (let j = 0; j < n; j++) t += a[i]! + a[j]!;",
" return t;",
"}",
"export function waits(a: Int32Array, out: Uint8Array, n: number): number {",
" let spins = 0;",
" for (let i = 0; i < n; i++) {",
" out[i] = 1;",
" while (a[i] === 0) spins++;",
" do spins++; while (a[i] === 1);",
" for (let k = 0; a[i]! > k; k++) spins++;",
" for (let k = 0; k < 3; k += a[i]!) spins++;",
" }",
" return spins;",
"}",
"export function spin(a: Int32Array): number {",
" let n = 0;",
" while (a[0] === 0 && n < 10) n++;",
" return n;",
"}",
"const a = new Float64Array(new SharedArrayBuffer(64)).fill(1);",
"const b = new Float64Array(8);",
"scale(a, b, 8);",
"const c = new Uint8Array(new SharedArrayBuffer(8)).fill(97);",
"codes(c);",
"shift(c);",
"const d = new Int32Array(new SharedArrayBuffer(16)).fill(1);",
"console.log(b[7], c[0], rows(d, 4), spin(d), waits(new Int32Array(new SharedArrayBuffer(16)).fill(2), new Uint8Array(new SharedArrayBuffer(4)), 4));",
"if (!isMainThread) new Worker(new URL(import.meta.url));",
].join("\n"),
);
const outPath = join(dir, "shared.ir.json");
const result = await compile(entry, { outPath, outDir: dir, outputKind: "ir" });
if (!result.ok) throw new Error(result.diagnostics.map((d) => d.message).join("\n"));
const ll = emitLlvmModule(deserializeModule(await readFile(outPath, "utf8")));
const bodies = (name: string): string =>
[
...ll.matchAll(
new RegExp(
`define internal [^\\n]+ @sc_b?f_${name}(?:\\.i32)?\\([^\\n]*\\n([\\s\\S]*?)\\n\\}`,
"g",
),
),
]
.map((match) => match[1]!)
.join("\n");
// Element loads (the poll's flag read is atomic too, from a global).
const elementLoads = (body: string): number =>
body.match(/load atomic \w+, ptr %/g)?.length ?? 0;
// Elements at the counter of a strip-mined loop: plain loads and stores.
for (const name of ["scale", "codes"]) {
const body = bodies(name);
expect(body).toMatch(/^loop\.poll\d+:$/m);
expect(elementLoads(body)).toBe(0);
expect(body).not.toContain("store atomic");
}
// `a[i + 1]` is not at the counter: its read stays atomic, the store
// at the counter is plain.
const shift = bodies("shift");
expect(shift).toMatch(/load atomic i8, ptr %/);
expect(shift).not.toContain("store atomic");
// The outer counter read inside the inner loop reads one element on every
// inner iteration: it stays atomic; the inner counter's read is plain.
const rows = bodies("rows");
expect(rows).toMatch(/load atomic i32, ptr %/);
// A loop that is not counted keeps atomic reads.
expect(bodies("spin")).toMatch(/load atomic i32, ptr %/);
// A nested loop that tests the outer counter's element in its condition
// or steps by it reads it again on every iteration: each read stays
// atomic, so another thread's write ends the wait.
const waits = bodies("waits");
expect(waits).toMatch(/^loop\.poll\d+:$/m);
expect(waits.match(/load atomic i32, ptr %/g)?.length).toBeGreaterThanOrEqual(4);
// The outer loop's write at its counter stays plain (speculation counters
// are atomic stores to globals).
expect(waits).not.toMatch(/store atomic i8 \S+, ptr %/);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
@@ -0,0 +1,183 @@
import { execFileSync } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { gzipSync } from "node:zlib";
import { expect, test, vi } from "vitest";
import { compile } from "../src/index.js";
import {
functionLayoutKey,
layoutTierOf,
parseFunctionLayout,
} from "../src/backend/llvm/function-layout.js";
const source = `
function first(n: number): number { return n + 1; }
function second(n: number): number { return n * 2; }
function third(n: number): number { return n - 3; }
console.log(first(Number(process.argv.length)), second(4), third(5));
`;
async function emitLlvm(layout?: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), "scriptc-function-layout-"));
try {
const entry = join(dir, "main.ts");
const outPath = join(dir, "main.ll");
await writeFile(entry, source);
let functionLayout: string | undefined;
if (layout !== undefined) {
functionLayout = join(dir, "layout.gz");
await writeFile(functionLayout, gzipSync(layout));
}
const result = await compile(entry, {
outDir: dir,
outPath,
outputKind: "llvm",
...(functionLayout === undefined ? {} : { functionLayout }),
});
expect(result.ok, JSON.stringify(result.diagnostics)).toBe(true);
return await readFile(outPath, "utf8");
} finally {
await rm(dir, { recursive: true, force: true });
}
}
/** Each defined function's name with the partition group line it follows. */
function definitions(llvm: string): { name: string; group: string }[] {
const out: { name: string; group: string }[] = [];
let group = "";
for (const line of llvm.split("\n")) {
if (line.startsWith("; scriptc-group")) group = line.slice("; scriptc-group".length).trim();
const name = /^define [^@]*@([\w.$]+)\(/.exec(line)?.[1];
if (name !== undefined) out.push({ name, group });
}
return out;
}
test("a layout writes the functions it lists first, by tier, in their tier's group", async () => {
const plain = definitions(await emitLlvm()).filter((d) => /_(first|second|third)$/.test(d.name));
expect(plain.map((d) => d.name.replace(/^sc_b?f_/, ""))).toEqual(["first", "second", "third"]);
expect(plain.every((d) => !d.group.startsWith("layout-"))).toBe(true);
const names = new Map(plain.map((d) => [d.name.replace(/^sc_b?f_/, ""), d.name]));
const laid = definitions(await emitLlvm(`2 ${names.get("first")}\n1 ${names.get("third")}\n`));
const index = (fn: string) => laid.findIndex((d) => d.name === names.get(fn));
expect(index("third")).toBeLessThan(index("first"));
expect(index("first")).toBeLessThan(index("second"));
expect(laid[index("third")]!.group).toBe("layout-1");
expect(laid[index("first")]!.group).toBe("layout-2");
expect(laid[index("second")]!.group).not.toMatch(/^layout-/);
// Listed functions precede every unlisted definition in the function section.
const firstUnlisted = laid.findIndex(
(d, i) => i > index("third") && !d.group.startsWith("layout-"),
);
expect(firstUnlisted).toBeGreaterThan(index("first"));
});
test("layout keys leave out module numbers", () => {
expect(functionLayoutKey("sc_bf__x25_m447_lowerCall")).toBe(
functionLayoutKey("sc_bf__x25_m12_lowerCall"),
);
expect(functionLayoutKey("sc_f__x25_init_253")).toBe(functionLayoutKey("sc_f__x25_init_7"));
expect(functionLayoutKey("sc_bf__x25_m3_Lexer_scan")).not.toBe(
functionLayoutKey("sc_bf__x25_m3_Lexer_peek"),
);
});
test("a function listed twice keeps its first tier, and malformed lines are skipped", () => {
const layout = parseFunctionLayout("1 sc_f_a\n2 sc_f_a\n2 sc_f_b\nnot a line\n0 sc_f_c\n\n");
expect([...layout]).toEqual([
["sc_f_a", 1],
["sc_f_b", 2],
]);
const text =
"define internal void @sc_f_b() {\n ret void\n}\n\ndefine void @sc_f_a() {\n ret void\n}";
expect(layoutTierOf(layout, text)).toBe(1);
expect(layoutTierOf(layout, "define void @sc_f_z() {\n ret void\n}")).toBeUndefined();
});
test("an executable built with a layout that lists helpers and functions runs unchanged", async () => {
const program = `
class Point { constructor(public x: number, public y: number) {} }
function norm(p: Point): number { return Math.sqrt(p.x * p.x + p.y * p.y); }
const points = [new Point(3, 4), new Point(6, 8)];
console.log(points.map(norm).join(","), points[0]!.x === 3 ? "three" : "other");
`;
const dir = await mkdtemp(join(tmpdir(), "scriptc-function-layout-exe-"));
try {
const entry = join(dir, "main.ts");
await writeFile(entry, program);
const llvmPath = join(dir, "main.ll");
const emitted = await compile(entry, { outDir: dir, outPath: llvmPath, outputKind: "llvm" });
expect(emitted.ok, JSON.stringify(emitted.diagnostics)).toBe(true);
// The generated helpers written before the functions, and one function,
// in two tiers.
const names = definitions(await readFile(llvmPath, "utf8")).map((d) => d.name);
const helpers = names.slice(
0,
names.findIndex((name) => /^sc_b?f_/.test(name)),
);
expect(helpers.length).toBeGreaterThan(0);
const norm = names.find((name) => /^sc_b?f_norm$/.test(name))!;
const layout = join(dir, "layout");
await writeFile(layout, [`1 ${norm}`, ...helpers.map((name) => `2 ${name}`)].join("\n"));
const laid = join(dir, "laid.ll");
const laidOut = await compile(entry, {
outDir: dir,
outPath: laid,
outputKind: "llvm",
functionLayout: layout,
});
expect(laidOut.ok, JSON.stringify(laidOut.diagnostics)).toBe(true);
const order = definitions(await readFile(laid, "utf8"));
expect(order.filter((d) => d.group === "layout-2").map((d) => d.name)).toEqual(helpers);
expect(order.find((d) => d.name === norm)!.group).toBe("layout-1");
const exe = join(dir, "main");
const built = await compile(entry, {
outputKind: "exe",
outDir: dir,
outPath: exe,
functionLayout: layout,
});
expect(built.ok, JSON.stringify(built.diagnostics)).toBe(true);
expect(execFileSync(exe, { encoding: "utf8" })).toBe("5,10 three\n");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("a changed function layout misses the executable cache", async () => {
vi.stubEnv("SCRIPTC_NO_CACHE", undefined);
const dir = await mkdtemp(join(tmpdir(), "scriptc-function-layout-cache-"));
try {
vi.stubEnv("SCRIPTC_CACHE_DIR", join(dir, "cache"));
const entry = join(dir, "main.ts");
await writeFile(entry, source);
const exe = join(dir, "main");
const llvm = join(dir, "main.ll");
const build = async (functionLayout?: string) => {
const built = await compile(entry, {
outputKind: "exe",
outDir: dir,
outPath: exe,
...(functionLayout === undefined ? {} : { functionLayout }),
});
expect(built.ok, JSON.stringify(built.diagnostics)).toBe(true);
return definitions(await readFile(llvm, "utf8"));
};
const plain = await build();
const third = plain.find((d) => /^sc_b?f_third$/.test(d.name))!.name;
const first = plain.find((d) => /^sc_b?f_first$/.test(d.name))!.name;
const layout = join(dir, "layout");
await writeFile(layout, `1 ${third}\n`);
expect((await build(layout)).find((d) => d.name === third)!.group).toBe("layout-1");
// The same path with other contents is another layout.
await writeFile(layout, `1 ${first}\n`);
const edited = await build(layout);
expect(edited.find((d) => d.name === first)!.group).toBe("layout-1");
expect(edited.find((d) => d.name === third)!.group).not.toMatch(/^layout-/);
} finally {
vi.unstubAllEnvs();
await rm(dir, { recursive: true, force: true });
}
});
@@ -0,0 +1,70 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { compile } from "../src/index.js";
async function compileSource(source: string) {
const directory = mkdtempSync(join(tmpdir(), "scriptc-localized-"));
try {
const entry = join(directory, "main.ts");
writeFileSync(entry, source);
return await compile(entry, {
outDir: directory,
outPath: join(directory, "program.ir"),
outputKind: "ir",
});
} finally {
rmSync(directory, { recursive: true, force: true });
}
}
// A statement that lowers again after a later declarator degrades a type
// reuses the member bodies of a function-local class it lowered the first
// time, so the diagnostics of those bodies still report.
test("a degrading retry keeps the diagnostics of a local class lowered before it", async () => {
const result = await compileSource(`type Progress = (done: number, total: number) => void;
type Callbacks = Progress | AbortSignal | Map<string, (x: number) => string> | Set<number>;
function make(): number {
const K = class {
m(): number {
const a = [1, 2];
(a as any).foo = 1;
return a.length;
}
},
size = (cb: Callbacks): number => (cb instanceof Set ? cb.size : 0);
return new K().m() + size(new Set([1]));
}
console.log(make());
`);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected the local class's diagnostic");
expect(result.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: "SC1090",
message: expect.stringContaining("assignment to non-variables"),
}),
]),
);
});
// Without the failing statement, the same program compiles: the arrow's
// parameter is a dynamic slot.
test("the same program without the local class's failure compiles", async () => {
const result = await compileSource(`type Progress = (done: number, total: number) => void;
type Callbacks = Progress | AbortSignal | Map<string, (x: number) => string> | Set<number>;
function make(): number {
const K = class {
m(): number {
return 2;
}
},
size = (cb: Callbacks): number => (cb instanceof Set ? cb.size : 0);
return new K().m() + size(new Set([1]));
}
console.log(make());
`);
expect(result.ok, JSON.stringify(result.ok ? [] : result.diagnostics)).toBe(true);
});
@@ -183,3 +183,47 @@ console.log(key(new Uint8Array([1, 2, 3, 0]), 6));
expect(fast).not.toMatch(/ashr i64 %/);
expect(fast).not.toContain("@llvm.smul.with.overflow.i64");
});
test("a speculative loop reads its views' header fields once, before its copies", async () => {
// A view held by a const local or by a parameter is the same view in
// every iteration. Its data pointer is read before the branch to the fast
// copy, and no copy (ordinary, fast, or the one a failed check resumes
// in) reads it again.
const llvm = await emitLlvm(`
let last = 42;
function fill(array: ArrayBuffer, count: number): void {
const buf = new Float64Array(array);
let l = last;
for (let i = 0; i < count; i++) {
l = (l * 3877 + 29573) % 139968;
buf[i] = l;
}
last = l;
}
function fillInto(buf: Float64Array, count: number): void {
let l = last;
for (let i = 0; i < count; i++) {
l = (l * 3877 + 29573) % 139968;
buf[i] = l;
}
last = l;
}
const array = new ArrayBuffer(8 * 16);
fill(array, 16);
const view = new Float64Array(8);
fillInto(view, 8);
console.log(new Float64Array(array)[15], view[7], last);
`);
const dataReads = /getelementptr inbounds %ScrBytes, ptr %\w+, i64 0, i32 3$/gm;
for (const name of ["fill", "fillInto"]) {
const fn =
new RegExp(`^define internal void @sc_bf_${name}(?:\\.i32)?\\([^]*?^}`, "m").exec(
llvm,
)?.[0] ?? "";
const branch = /^ {2}br i1 %\w+, label %spec\.fast\d+, label %spec\.slow\d+$/m.exec(fn);
expect(branch, name).not.toBeNull();
const copies = fn.slice(branch!.index);
expect(copies.match(dataReads), name).toBeNull();
expect(fn.slice(0, branch!.index).match(dataReads)?.length, name).toBeGreaterThan(0);
}
});
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/5025-lifetime-region-shared-candidate.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8008-string-index-paths.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8014-foreign-element-callbacks.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8332-worker-shared-counted-loops.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/constant-choice-chains.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/dyn-cast-identity-fresh.ts"
],
"diags": []
}

Some files were not shown because too many files have changed in this diff Show More