Merge main

This commit is contained in:
Chris Tate
2026-10-24 21:46:05 -05:00
304 changed files with 16776 additions and 1134 deletions
+5 -2
View File
@@ -539,6 +539,8 @@ jobs:
if: ${{ needs.validation_scope.outputs.full == 'true' && needs.lanes.outputs.linux == 'true' }}
name: test (tsgo and tsc-ts give the same IR)
runs-on: ubuntu-24.04
# The two corpus compiles run back to back and take close to 30 minutes on a
# slow hosted runner, so a 30-minute limit cancels otherwise healthy runs.
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
@@ -633,8 +635,9 @@ jobs:
# Shard 1 additionally runs package/install and link-parity contracts
# before its LLVM differential slice. The slice itself can take ~10
# minutes on a cold hosted runner, so 20 minutes cancels an otherwise
# healthy job before the differential completes.
timeout-minutes: 35
# healthy job before the differential completes. Slow runners need more
# headroom than that, so the limit is 50 minutes.
timeout-minutes: 50
strategy:
fail-fast: false
matrix:
+41
View File
@@ -0,0 +1,41 @@
name: Prune caches
# Pull request caches are scoped to that pull request, so once it is closed no
# run can use them again. Deleting them keeps the repository under its cache
# size limit, so caches that main and open pull requests use are not evicted.
on:
pull_request:
types: [closed]
schedule:
- cron: "17 6 * * *"
workflow_dispatch:
permissions:
actions: write
pull-requests: read
jobs:
prune:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Delete caches of closed pull requests
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
# Read both lists in full before deleting anything: deleting while the cache
# listing pages shifts later pages, and a failed request must stop the job
# instead of leaving open pull requests out of the list.
open=$(gh api --paginate "repos/$REPO/pulls?state=open&per_page=100" --jq '.[].number')
caches=$(gh api --paginate "repos/$REPO/actions/caches?per_page=100" \
--jq '.actions_caches[] | select(.ref | startswith("refs/pull/")) | "\(.id) \(.ref)"')
while read -r id ref; do
[ -n "$id" ] || continue
number=$(echo "$ref" | cut -d/ -f3)
if ! grep -qx "$number" <<<"$open"; then
gh api --method DELETE "repos/$REPO/actions/caches/$id" >/dev/null
echo "deleted cache $id ($ref)"
fi
done <<<"$caches"
+2
View File
@@ -10,6 +10,7 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea
| `atomics-contended` | cpu | four worker threads updating `SharedArrayBuffer` counters with `Atomics`: one shared counter, one counter per worker, a `compareExchange` loop, and `Atomics.wait`/`notify` handoffs |
| `channel-messages` | cpu | 200,000 MessageChannel messages in one thread, half numbers and half small objects, all posted before the port delivers any |
| `csv-numbers` | cpu | `split`, `Number`, `parseInt`, `toFixed` |
| `date-math` | cpu | schedules built with Date setters (local and UTC), weekday and month-end rules through the getters, Dates sorted by time value |
| `cyclic-graphs` | cpu | object graphs with back references built and dropped: trees with parent and sibling links, rings of records sharing a long-lived registry, closures capturing their owner |
| `dictionary-walks` | cpu | `Record<string, number>` dictionaries walked with for-in and `Object.keys`/`values`/`entries` while entries are added, updated and deleted |
| `dynamic-methods` | cpu | untyped JavaScript calling builtin methods on values it does not type: string `slice`/`indexOf`/`toUpperCase`/`padStart`, array `push`/`pop`/`includes`, `Map` and `Set`, and an object's own methods |
@@ -30,6 +31,7 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea
| `regex-logs` | cpu | regex `exec` with groups, `test`, global `replace` |
| `region-builds` | cpu | a watch-mode build loop over `LifetimeRegion`: each build binds a program in a new region and releases the previous one, which a cache keeps referenced for two builds |
| `template-render` | cpu | template literals, escaping, `map`/`join` |
| `throw-chains` | cpu | exceptions caught 2, 5 and 20 recursive calls above the throw, through direct calls and through a callback, and a backtracking parser that throws on a failed alternative |
| `thread-slots`, `thread-slots-cache` | cpu | worker threads reading one published type graph from an `InternTable` and keeping per-thread state for each type they visit: a `ThreadSlots` family, or a direct-mapped cache by id in front of a `Map` by id (the `-cache` entry); under Node.js both are per thread |
| `typed-array-kernels` | cpu | element-wise loops that run to one typed array's length and index others at the same position: `Float64Array` axpy, `Float32Array` blend, `Uint8Array` mask, wrapping `Int32Array` add, `Uint8ClampedArray` brighten, RGBA to gray, a row-major `Float32Array` matrix-vector product, and `reduce`/`some`/`map` callbacks |
| `typed-array-scan`, `typed-array-scan-workers` | cpu | element-by-element scan loops over `Uint8Array`, `Uint16Array`, `Int32Array` and `Float64Array`; the second entry is a worker-enabled executable (it can start a worker), so each access also checks for shared storage |
@@ -0,0 +1,6 @@
{
"entry": "workloads/date-math.ts",
"args": [
"40"
]
}
+52
View File
@@ -0,0 +1,52 @@
// Calendar arithmetic on Dates: schedules built by adjusting Dates with the
// UTC and local setters, weekday and month-end rules read back through the
// getters, Dates compared and sorted by time value, and ISO text for a
// sample. Argument: the number of rounds.
process.env.TZ = "America/New_York";
const rounds = Number(process.argv[2] ?? "40");
let seed = 2026;
function random(): number {
seed = (Math.imul(seed, 1103515245) + 12345) >>> 0;
return seed / 4294967296;
}
/** The next business day at 09:00 local time on or after `from`. */
function nextBusinessDay(from: Date): Date {
const day = new Date(from.getTime());
day.setHours(9, 0, 0, 0);
if (day < from) day.setDate(day.getDate() + 1);
while (day.getDay() === 0 || day.getDay() === 6) day.setDate(day.getDate() + 1);
return day;
}
/** The last day of `date`'s month, in UTC. */
function monthEnd(date: Date): number {
const end = new Date(date.getTime());
end.setUTCDate(1);
end.setUTCMonth(end.getUTCMonth() + 1);
end.setUTCDate(0);
return end.getUTCDate();
}
let checksum = 0;
let sample = "";
for (let round = 0; round < rounds; round++) {
const due: Date[] = [];
const cursor = new Date(Date.UTC(2020 + (round % 7), 0, 1, 12));
for (let i = 0; i < 4000; i++) {
cursor.setUTCMinutes(cursor.getUTCMinutes() + Math.floor(random() * 4000));
const next = nextBusinessDay(cursor);
checksum += next.getDate() + monthEnd(next) + next.getHours();
if (i % 3 === 0) next.setMonth(next.getMonth() + 1, 15);
due.push(next);
}
due.sort((a, b) => +a - +b);
let previous = due[0]!;
for (const date of due) {
checksum += (+date - +previous) / 3600000;
previous = date;
}
sample = due[due.length >> 1]!.toISOString();
}
console.log(Math.round(checksum), sample);
@@ -0,0 +1,6 @@
{
"entry": "workloads/throw-chains.ts",
"args": [
"1"
]
}
@@ -0,0 +1,196 @@
// Exceptions that cross many frames: throws from helpers below a recursion,
// caught 2, 5 and 20 recursive calls up (direct calls, then calls through a
// callback), with each frame holding an array across its call, and a
// backtracking parser that uses exceptions for control flow.
class ParseError extends Error {
readonly at: number;
constructor(at: number, message: string) {
super(message);
this.at = at;
}
}
/** A thrown value that is not an Error (no stack to capture). */
class Stop {
readonly code: number;
constructor(code: number) {
this.code = code;
}
}
const held: number[][] = [];
function fail(n: number, plain: boolean): number {
if (plain) throw new Stop(n);
throw new RangeError("chain " + n);
}
function check(n: number, plain: boolean): number {
return (n & 3) === 0 ? fail(n, plain) : n;
}
function validate(n: number, plain: boolean): number {
return check(n, plain) + (n & 1);
}
function bottom(n: number, plain: boolean): number {
const box = [n, -1];
const r = validate(n, plain);
held[0] = box;
return r + box.length;
}
function walk(n: number, depth: number, plain: boolean): number {
const box = [n, depth];
const r = depth === 0 ? bottom(n, plain) : walk(n, depth - 1, plain);
held[depth] = box;
return r + box.length;
}
function chains(rounds: number): string {
const out: string[] = [];
for (const depth of [2, 5, 20])
for (const plain of [false, true]) {
let caught = 0;
let sum = 0;
for (let i = 0; i < rounds; i++) {
try {
sum += walk(i, depth - 2, plain);
} catch (error) {
caught++;
sum += error instanceof Stop ? error.code & 7 : 1;
}
}
out.push(`depth ${depth} ${plain ? "object" : "error"}: caught ${caught} sum ${sum}`);
}
return out.join("\n");
}
// The same chains through a callback: each level visits the next with a
// closure, as tree visitors do.
function visit(n: number, depth: number, each: (n: number, depth: number) => number): number {
const box = [n, depth];
const r = depth === 0 ? bottom(n, false) : each(n, depth - 1);
held[depth] = box;
return r + box.length;
}
function visits(rounds: number): string {
const out: string[] = [];
const each = (n: number, depth: number): number => visit(n, depth, each);
for (const depth of [2, 5, 20]) {
let caught = 0;
let sum = 0;
for (let i = 0; i < rounds; i++) {
try {
sum += visit(i, depth - 2, each);
} catch (error) {
caught++;
sum += error instanceof RangeError ? 1 : 0;
}
}
out.push(`visit depth ${depth}: caught ${caught} sum ${sum}`);
}
return out.join("\n");
}
// expr := term (("+" | "-") term)*; term := atom ("*" atom)*;
// atom := number | "(" expr ")" | name "(" expr ("," expr)* ")" | name.
// A call and a name start alike: the parser tries the call first and
// backtracks when it fails.
let text = "";
let pos = 0;
function peek(): string {
return pos < text.length ? text[pos]! : "";
}
function expect(c: string): void {
if (peek() !== c) throw new ParseError(pos, `expected ${c}`);
pos++;
}
function name(): string {
const start = pos;
while (pos < text.length && text.charCodeAt(pos) >= 97 && text.charCodeAt(pos) <= 122) pos++;
if (pos === start) throw new ParseError(pos, "expected a name");
return text.slice(start, pos);
}
function call(): number {
const callee = name();
expect("(");
let value = expr() + callee.length;
while (peek() === ",") {
pos++;
value += expr();
}
expect(")");
return value;
}
function atom(): number {
const c = peek();
if (c >= "0" && c <= "9") {
let value = 0;
while (peek() >= "0" && peek() <= "9") value = value * 10 + (text.charCodeAt(pos++) - 48);
return value;
}
if (c === "(") {
pos++;
const value = expr();
expect(")");
return value;
}
const start = pos;
try {
return call();
} catch (error) {
if (!(error instanceof ParseError)) throw error;
pos = start;
return name().length;
}
}
function term(): number {
let value = atom();
while (peek() === "*") {
pos++;
value = (value * atom()) % 1000003;
}
return value;
}
function expr(): number {
let value = term();
while (peek() === "+" || peek() === "-") {
const op = text[pos++];
value = op === "+" ? value + term() : value - term();
}
return value;
}
let seed = 4242;
function random(): number {
seed = (Math.imul(seed, 1103515245) + 12345) >>> 0;
return seed / 4294967296;
}
function source(depth: number): string {
const r = random();
if (depth > 6 || r < 0.25) return String(Math.floor(random() * 100));
if (r < 0.45) return "abcdefgh".slice(0, 1 + Math.floor(random() * 8));
if (r < 0.6) return `(${source(depth + 1)})`;
if (r < 0.8) return `${source(depth + 1)}${random() < 0.5 ? "+" : "*"}${source(depth + 1)}`;
return `f${"xyz".slice(0, Math.floor(random() * 3))}(${source(depth + 1)},${source(depth + 1)})`;
}
function parse(count: number): string {
let total = 0;
let failed = 0;
for (let i = 0; i < count; i++) {
// Every tenth input lacks its last character, which fails deep inside it.
const full = source(0);
text = i % 10 === 0 ? full.slice(0, -1) : full;
pos = 0;
try {
total = (total + expr()) % 1000003;
if (pos !== text.length) throw new ParseError(pos, "trailing input");
} catch (error) {
if (!(error instanceof ParseError)) throw error;
failed++;
}
}
return `parsed ${count}: total ${total} failed ${failed}`;
}
const scale = Number(process.argv[2] ?? "1");
console.log(chains(Math.floor(60000 * scale)));
console.log(visits(Math.floor(60000 * scale)));
console.log(parse(Math.floor(40000 * scale)));
@@ -3,6 +3,8 @@
When `strictEqual` compares function values, `actual` and `expected` read as `undefined`; when `assert.throws` or `assert.rejects` reports an error that is not an instance of the expected class, or that does not match an expected object, `expected` reads as `undefined`.
Values whose type can be cyclic, such as recursive records, also read as `undefined`.
`assert.AssertionError` used as a value outside `instanceof` throws `SC2020`.
The comparison methods, `ok`, `fail` and `ifError` are function values that assert as the methods do; called through a stored value, `ok` without a message reports `value == true`, where Node quotes the call, and `fail` takes at most one argument.
`throws`, `rejects`, `doesNotThrow`, `doesNotReject`, `match` and `doesNotMatch` as values throw `SC2020`.
`assert.deepEqual` and `assert.notDeepEqual` follow Node's loose comparison, including Maps, Sets, Dates, regular expressions, typed arrays, and errors; operands whose type can be cyclic or holds functions inside a structure are refused.
`assert.partialDeepStrictEqual` follows Node's partial comparison over the same values, with the same refusals.
Checked-dynamic values compared deeply have no cycle detection.
@@ -1,6 +1,7 @@
- **Date:** Spread constructor arguments, setters, thrown Date values, and locale formatters are unsupported.
Dates are objects: `===` compares identity, `new Date(date)` makes a new Date, and Dates compile in arrays (`includes`, `indexOf`, `lastIndexOf`, `join`, and default `sort` by `toString()` text), in `Date | undefined` and `Date | null` slots, as Map and Set keys and values, and in records, tuples, class fields, closures, promises and generators.
Unions of a Date with other non-nullish types are unsupported.
Zero-argument, single number/string, and local-time year/month field constructors, `getTime`, `valueOf`, `toISOString`, `toUTCString`, `toJSON` (a `string | null`: null for an invalid date, as in Node), `toString`, `toDateString`, `toTimeString`, `String(date)` and template literal conversion, relational comparison, calendar getters, `getTimezoneOffset`, and single-string `Date.parse` compile.
- **Date:** Spread constructor arguments and locale formatters are unsupported.
Dates are objects with identity: `===` and `==` compare identity, `new Date(date)` makes a new Date, setters change the one Date every reference holds, and Dates compile in arrays (`includes`, `indexOf`, `lastIndexOf`, `join`, and default `sort` by `toString()` text), in unions with other types, as Map and Set keys and values, and in records, tuples, class fields, closures, promises and generators.
Thrown and rejected Dates keep their identity; `structuredClone` and `postMessage` copy them.
Zero-argument, single number/string, and local-time year/month field constructors, `getTime`, `valueOf`, `toISOString`, `toUTCString`, `toJSON` (a `string | null`: null for an invalid date, as in Node), `toString`, `toDateString`, `toTimeString`, the local and UTC calendar getters and setters, `setTime`, `getTimezoneOffset`, and single-string `Date.parse` compile.
Unary `+`, `-` and `Number(date)` read the time value; `String(date)`, template literals and `+` with a string read the `toString()` text; `date[Symbol.toPrimitive](hint)` compiles with a literal hint.
A Date converted to `unknown` is the same Date object.
See [Dates and timezones](#dates-and-timezones) for timezone data.
@@ -1,8 +1,14 @@
- **Prototypes:** Named properties and methods on program class prototypes support replacement, inheritance, and deletion.
Bare prototype reflection, accessor replacement, and prototype storage on local, generic, mixin, or runtime-provided classes are unsupported.
A module-level JavaScript class's prototype can become the prototype of other objects: `Object.create(C.prototype)`, `Object.setPrototypeOf(F.prototype, C.prototype)`, `Object.setPrototypeOf(object, C.prototype)`, and `util.inherits(F, C)` with an ordinary constructor `F`.
The inherited methods and accessors run with the inheriting object as `this`.
Methods that use `#private` members or `super` throw a TypeError for a receiver that is not an instance of the class.
`C.prototype` also reads as a value for `Object.getPrototypeOf` and for `===` and `!==` comparisons.
Changing the prototype of a class prototype (`Object.setPrototypeOf(C.prototype, X)`, `util.inherits(C, F)`, or `Object.setPrototypeOf` on a class prototype reached through an instance) is unsupported.
Other bare prototype reflection, TypeScript class prototypes as values, accessor replacement, and prototype storage on local, generic, mixin, or runtime-provided classes are unsupported.
Plain objects, arrays, functions, class instances, and Date, RegExp, Map, Set, WeakMap, WeakSet, and Promise values inherit `constructor`, `valueOf`, `toString`, `hasOwnProperty`, and `propertyIsEnumerable` from `Object.prototype` as in Node, so `value.constructor === Object` and `value.valueOf === Object.prototype.valueOf` compare identities exactly.
Named reads such as `value.valueOf` answer these inherited members; computed-key reads such as `value[key]` do not.
`Object.prototype`'s own members read as values; its mutation and other reflection, such as `isPrototypeOf` and `__proto__`, are unsupported.
The constructors `Object`, `Function`, `Date`, `RegExp`, `Map`, `Set`, `WeakMap`, `WeakSet`, and `Promise` read as function values; through a stored value only `Object` can be called.
`new` through a stored `Object`, `Date`, `Map`, `Set`, `WeakMap`, or `WeakSet` builds the same value as the `new` expression.
`new` through a stored `RegExp` or `Promise`, `new` of a stored `Object` with a primitive argument, and `instanceof` against these constructors are unsupported.
`instanceof Object` and `instanceof Function` on untyped values follow the prototype chain as in Node, so an object built with `Object.create(null)` is not an `Object`.
+165 -9
View File
@@ -19,6 +19,7 @@
#include "llvm/CodeGen/MachineFunction.h"
#include "llvm/CodeGen/MachineFunctionPass.h"
#include "llvm/CodeGen/MachineModuleInfo.h"
#include "llvm/IR/DiagnosticHandler.h"
#include "llvm/IR/DiagnosticInfo.h"
#include "llvm/IR/DiagnosticPrinter.h"
#include "llvm/IR/LegacyPassManager.h"
@@ -41,11 +42,13 @@
#include "llvm/Support/SourceMgr.h"
#include "llvm/Support/ThreadPool.h"
#include "llvm/Support/Threading.h"
#include "llvm/Support/JSON.h"
#include "llvm/Support/raw_ostream.h"
#include "llvm/Target/TargetMachine.h"
#include "llvm/TargetParser/Triple.h"
#include "llvm/Transforms/Utils/SplitModule.h"
#include <atomic>
#include <mutex>
#include <optional>
#include <string>
@@ -187,6 +190,108 @@ struct UnnameStructTypes : PassInfoMixin<UnnameStructTypes> {
static bool isRequired() { return true; }
};
// How the --profile-use profile matched the functions it annotated, summed
// over partitions: functions that received its counts, functions whose
// control flow changed since it was collected (hash mismatch), and functions
// it has no record of (new, or never run in training: the merged profile is
// sparse). A partition reused from the cache without its counts is counted
// in Unknown.
struct ProfileMatch {
std::atomic<uint64_t> Annotated{0};
std::atomic<uint64_t> Mismatched{0};
std::atomic<uint64_t> Missing{0};
std::atomic<uint64_t> Unknown{0};
void add(const ProfileMatch &Other) {
Annotated += Other.Annotated;
Mismatched += Other.Mismatched;
Missing += Other.Missing;
}
std::string serialize() const {
return formatv("{0} {1} {2}", Annotated.load(), Mismatched.load(),
Missing.load())
.str();
}
bool deserialize(StringRef Text) {
SmallVector<StringRef, 3> Parts;
Text.split(Parts, ' ');
uint64_t Values[3];
if (Parts.size() != 3)
return false;
for (size_t I = 0; I < 3; ++I)
if (Parts[I].getAsInteger(10, Values[I]))
return false;
Annotated += Values[0];
Mismatched += Values[1];
Missing += Values[2];
return true;
}
};
// Counts the profile annotation's hash-mismatch and missing-function
// warnings instead of printing them. Every other diagnostic takes the
// context's default handling.
class ProfileMatchHandler : public DiagnosticHandler {
public:
explicit ProfileMatchHandler(ProfileMatch &Match) : Match(Match) {}
bool handleDiagnostics(const DiagnosticInfo &Info) override {
if (Info.getKind() != DK_PGOProfile)
return false;
std::string Message;
raw_string_ostream Stream(Message);
DiagnosticPrinterRawOStream Printer(Stream);
Info.print(Printer);
if (StringRef(Message).contains("hash mismatch"))
++Match.Mismatched;
else if (StringRef(Message).contains_insensitive("no profile data"))
++Match.Missing;
else
return false;
return true;
}
private:
ProfileMatch &Match;
};
// The annotation warns about functions the profile does not describe only
// when asked; the match counts need those warnings. Process-wide, set once.
void reportMissingProfiles() {
static const bool Reported = [] {
if (cl::Option *Option =
cl::getRegisteredOptions().lookup("pgo-warn-missing-function"))
Option->addOccurrence(0, "pgo-warn-missing-function", "true");
return true;
}();
(void)Reported;
}
// Functions of `M` that the profile annotated with counts.
uint64_t annotatedFunctions(const Module &M) {
uint64_t Count = 0;
for (const Function &F : M) {
if (F.isDeclaration())
continue;
std::optional<Function::ProfileCount> Entry = F.getEntryCount();
if (Entry && !Entry->isSynthetic())
++Count;
}
return Count;
}
// Prints the profile match as the successful emit's one line of JSON.
void reportProfileMatch(const ProfileMatch &Match) {
json::Object Profile{{"annotated", Match.Annotated.load()},
{"mismatched", Match.Mismatched.load()},
{"missing", Match.Missing.load()},
{"unknown_partitions", Match.Unknown.load()}};
json::Object Response{{"ok", true}, {"profile", std::move(Profile)}};
outs() << formatv("{0}\n", json::Value(std::move(Response)));
}
// Reads the profile's header once, before any partition needs it: it must
// hold IR-level counts.
std::optional<EmitFailure> inspectProfile(const EmitOptions &Options) {
@@ -208,6 +313,7 @@ std::optional<EmitFailure> inspectProfile(const EmitOptions &Options) {
// Owns the analysis managers a pipeline needs for one module.
struct Pipeline {
PassInstrumentationCallbacks Callbacks;
LoopAnalysisManager LAM;
FunctionAnalysisManager FAM;
CGSCCAnalysisManager CGAM;
@@ -216,13 +322,26 @@ struct Pipeline {
Pipeline(TargetMachine &Machine, OptimizationLevel Level,
std::optional<PGOOptions> Profile = std::nullopt)
: PB(&Machine, tuningFor(Level), std::move(Profile)) {
: PB(&Machine, tuningFor(Level), std::move(Profile), &Callbacks) {
PB.registerModuleAnalyses(MAM);
PB.registerCGSCCAnalyses(CGAM);
PB.registerFunctionAnalyses(FAM);
PB.registerLoopAnalyses(LAM);
PB.crossRegisterProxies(LAM, FAM, CGAM, MAM);
}
// Adds to Count the functions the profile annotation gave counts to, as
// soon as that pass has run: inlining and dead code elimination later in
// the pipeline delete annotated functions.
void countAnnotated(std::atomic<uint64_t> &Count) {
Callbacks.registerAfterPassCallback(
[&Count](StringRef PassID, Any IR, const PreservedAnalyses &) {
if (PassID != "PGOInstrumentationUse")
return;
if (const auto *M = llvm::any_cast<const Module *>(&IR))
Count += annotatedFunctions(**M);
});
}
};
std::optional<EmitFailure> verify(Module &M, StringRef Code) {
@@ -404,8 +523,15 @@ std::optional<EmitFailure> publish(StringRef TemporaryPath,
std::optional<EmitFailure>
emitModule(Module &M, TargetMachine &Machine, OptimizationLevel Level,
std::optional<PGOOptions> Profile, CodeGenFileType Type,
StringRef OutputPath, std::vector<SmallString<256>> &Temporaries) {
StringRef OutputPath, std::vector<SmallString<256>> &Temporaries,
ProfileMatch &Match) {
bool Annotating = Profile && Profile->Action == PGOOptions::IRUse;
if (Annotating)
M.getContext().setDiagnosticHandler(
std::make_unique<ProfileMatchHandler>(Match));
Pipeline P(Machine, Level, std::move(Profile));
if (Annotating)
P.countAnnotated(Match.Annotated);
P.PB.buildPerModuleDefaultPipeline(Level).run(M, P.MAM);
if (std::optional<EmitFailure> Failure =
verify(M, "post_optimization_verification_failed"))
@@ -572,20 +698,32 @@ std::optional<EmitFailure>
compileOptimizedPartitions(const EmitOptions &Options,
const PartitionPlan &Plan, OptimizationLevel Level,
StringRef Salt, const ArtifactCache &Cache,
std::vector<SmallVector<char, 0>> &Objects) {
std::vector<SmallVector<char, 0>> &Objects,
ProfileMatch &Match) {
size_t Count = Plan.Modules.size();
bool Annotating = !Options.ProfileUse.empty();
std::vector<std::unique_ptr<MemoryBuffer>> Summaries(Count);
SharedFailure Failures;
forEachPartition(Count, [&](size_t I) {
std::string Key = hashParts({Salt, "summary", Plan.Modules[I]});
if ((Summaries[I] = Cache.lookup(Key, "bc")))
if ((Summaries[I] = Cache.lookup(Key, "bc"))) {
if (Annotating) {
std::unique_ptr<MemoryBuffer> Counts = Cache.lookup(Key, "match");
if (!Counts || !Match.deserialize(Counts->getBuffer()))
++Match.Unknown;
}
return;
}
std::string LookupError;
std::unique_ptr<TargetMachine> Machine =
createTargetMachine(Options.Target, Options.OptLevel, LookupError);
if (!Machine)
return Failures.set({"target_machine_failed", LookupError});
LLVMContext Context;
ProfileMatch PartitionMatch;
if (Annotating)
Context.setDiagnosticHandler(
std::make_unique<ProfileMatchHandler>(PartitionMatch));
Expected<std::unique_ptr<Module>> Loaded =
loadPartition(Options, Plan.Modules[I], I, Context, *Machine);
if (!Loaded)
@@ -609,6 +747,8 @@ compileOptimizedPartitions(const EmitOptions &Options,
// Profile instrumentation or annotation happens here, before the thin
// link; the post-link optimization reads the annotated weights.
Pipeline P(*Machine, Level, profileOptions(Options));
if (Annotating)
P.countAnnotated(PartitionMatch.Annotated);
ModulePassManager MPM = P.PB.buildThinLTOPreLinkDefaultPipeline(Level);
MPM.addPass(UnnameStructTypes());
MPM.addPass(BitcodeWriterPass(Stream, /*ShouldPreserveUseListOrder=*/false,
@@ -616,6 +756,11 @@ compileOptimizedPartitions(const EmitOptions &Options,
/*EmitModuleHash=*/true));
MPM.run(Mod, P.MAM);
}
if (Annotating) {
Match.add(PartitionMatch);
// Stored before the summary, so a reused summary finds its counts.
Cache.store(Key, "match", PartitionMatch.serialize());
}
StringRef Contents(Bitcode.data(), Bitcode.size());
Cache.store(Key, "bc", Contents);
Summaries[I] = MemoryBuffer::getMemBufferCopy(Contents);
@@ -762,7 +907,8 @@ compileOptimizedPartitions(const EmitOptions &Options,
std::optional<EmitFailure>
compilePartitions(const EmitOptions &Options, const PartitionPlan &Plan,
std::vector<SmallVector<char, 0>> &Objects) {
std::vector<SmallVector<char, 0>> &Objects,
ProfileMatch &Match) {
// Everything besides a partition's own bytes that determines its
// artifacts. The caller's cache directory already identifies the exact
// helper build; source content and partition count arrive per module.
@@ -796,7 +942,7 @@ compilePartitions(const EmitOptions &Options, const PartitionPlan &Plan,
return compileUnoptimizedPartitions(Options, Plan, Salt, Cache, Objects);
return compileOptimizedPartitions(Options, Plan,
optimizationLevel(Options.OptLevel), Salt,
Cache, Objects);
Cache, Objects, Match);
}
// Publishes in-memory partition objects through private sibling files.
@@ -837,6 +983,8 @@ int emitPartitions(const EmitOptions &Options) {
std::vector<SmallVector<char, 0>> Objects;
std::optional<EmitFailure> Failure;
bool Compiled = false;
// A failed attempt's counts are discarded with its objects.
auto Match = std::make_unique<ProfileMatch>();
{
ErrorOr<std::unique_ptr<MemoryBuffer>> Input =
MemoryBuffer::getFile(Options.Input, /*IsText=*/false,
@@ -848,15 +996,16 @@ int emitPartitions(const EmitOptions &Options) {
Input->reset();
PartitionPlan Plan{std::move(Text->Sources),
std::move(Text->PublicSymbols)};
Compiled = !compilePartitions(Options, Plan, Objects);
Compiled = !compilePartitions(Options, Plan, Objects, *Match);
}
}
}
if (!Compiled) {
Match = std::make_unique<ProfileMatch>();
PartitionPlan Plan;
Failure = splitParsedModule(Options, Plan);
if (!Failure)
Failure = compilePartitions(Options, Plan, Objects);
Failure = compilePartitions(Options, Plan, Objects, *Match);
}
std::vector<SmallString<256>> Temporaries;
if (!Failure)
@@ -869,6 +1018,8 @@ int emitPartitions(const EmitOptions &Options) {
return reportError(Failure->Code, Failure->Message,
Options.DiagnosticFormat);
}
if (!Options.ProfileUse.empty())
reportProfileMatch(*Match);
return 0;
}
@@ -913,6 +1064,8 @@ int emit(const EmitOptions &Options) {
return reportError("invalid_import",
"runtime bitcode import requires an optimized build",
Options.DiagnosticFormat);
if (!Options.ProfileUse.empty())
reportMissingProfiles();
if (std::optional<EmitFailure> Failure = inspectProfile(Options))
return reportError(Failure->Code, Failure->Message,
Options.DiagnosticFormat);
@@ -975,9 +1128,10 @@ int emit(const EmitOptions &Options) {
? CodeGenFileType::ObjectFile
: CodeGenFileType::AssemblyFile;
std::vector<SmallString<256>> Temporaries;
ProfileMatch Match;
std::optional<EmitFailure> Failure =
emitModule(*Mod, *Machine, Level, profileOptions(Options), Type,
Options.Outputs[0], Temporaries);
Options.Outputs[0], Temporaries, Match);
if (!Failure)
Failure = publish(Temporaries[0], Options.Outputs[0]);
if (Failure) {
@@ -986,6 +1140,8 @@ int emit(const EmitOptions &Options) {
return reportError(Failure->Code, Failure->Message,
Options.DiagnosticFormat);
}
if (!Options.ProfileUse.empty())
reportProfileMatch(Match);
return 0;
}
+21
View File
@@ -70,3 +70,24 @@ interface PromiseConstructor {
reject: (reason: Error) => void;
};
}
/* Carriers of the types the lowerer gives untyped JavaScript collections
* whose uses prove them (js-collection-types.ts). Nothing reads them at run
* time. */
declare namespace __scriptcInferredCollections {
const map_string_string: Map<string, string>;
const map_string_number: Map<string, number>;
const map_string_boolean: Map<string, boolean>;
const map_number_string: Map<number, string>;
const map_number_number: Map<number, number>;
const map_number_boolean: Map<number, boolean>;
const map_boolean_string: Map<boolean, string>;
const map_boolean_number: Map<boolean, number>;
const map_boolean_boolean: Map<boolean, boolean>;
const set_string: Set<string>;
const set_number: Set<number>;
const set_boolean: Set<boolean>;
const array_string: string[];
const array_number: number[];
const array_boolean: boolean[];
}
Binary file not shown.
+13 -1
View File
@@ -21,6 +21,7 @@ import {
import { type NativeArtifactDependency } from "./native/contracts.js";
import { RuntimePackError, stageRuntimePackArtifacts } from "./runtime-pack.js";
import type { NativeLinkPlan } from "./link-plan.js";
import { lldLinkerArgs } from "./lld.js";
import {
createDarwinDebugSymbols,
installDarwinDebugSymbols,
@@ -240,8 +241,11 @@ export async function linkNativeExecutable(
try {
const staged = await stageRuntimePackArtifacts(plan.runtimePack);
stagedRoot = staged.root;
// Linux links with lld when the default driver finds it.
const lld = explicitLinker === undefined ? lldLinkerArgs(plan.target.platform, linker) : [];
const args = [
...(explicitLinker === undefined ? plan.target.defaultLinkerArgs : []),
...lld,
...plan.driverFlags,
...plan.inputs.map((input) => staged.replacements.get(input) ?? input),
...plan.systemLibraries.map((name) => `-l${name}`),
@@ -276,7 +280,15 @@ export async function linkNativeExecutable(
...(await snapshotDependencies([...toolchain, ...additionalDependencyPaths])),
])
.catch(() => null);
await execFileAsync(linker, args);
await execFileAsync(linker, args).catch(async (error: unknown) => {
// A failed link with lld runs again with the driver's default linker,
// whose error (if any) is the one reported.
if (lld.length === 0) throw error;
await execFileAsync(
linker,
args.filter((arg) => !lld.includes(arg)),
);
});
const output = await stat(privateOut);
if (!output.isFile() || output.size === 0) throw new Error("linker produced no executable");
if (plan.darwinDebugSymbols) {
+61
View File
@@ -0,0 +1,61 @@
import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, test } from "vitest";
import { driverFindsLld, lldLinkerArgs } from "./lld.js";
const directories: string[] = [];
afterEach(() => {
for (const path of directories.splice(0)) rmSync(path, { recursive: true, force: true });
});
function tool(path: string): string {
writeFileSync(path, "#!/bin/sh\n");
chmodSync(path, 0o755);
return path;
}
function fixture() {
const root = mkdtempSync(join(tmpdir(), "scriptc-lld-"));
directories.push(root);
const llvm = join(root, "llvm", "bin");
const usr = join(root, "usr", "bin");
const other = join(root, "other");
for (const directory of [llvm, usr, other]) mkdirSync(directory, { recursive: true });
return { root, llvm, usr, other };
}
test.skipIf(process.platform === "win32")(
"a driver finds ld.lld beside itself, beside its real executable, or on PATH",
() => {
const f = fixture();
const clang = tool(join(f.llvm, "clang"));
// Without ld.lld anywhere.
expect(driverFindsLld(clang, { PATH: f.other })).toBe(false);
// Beside the driver.
tool(join(f.llvm, "ld.lld"));
expect(driverFindsLld(clang, { PATH: f.other })).toBe(true);
// A PATH entry that links to the driver: clang looks beside its real executable.
symlinkSync(clang, join(f.usr, "clang"));
expect(driverFindsLld("clang", { PATH: f.usr })).toBe(true);
// A driver elsewhere finds ld.lld on PATH, and not otherwise.
const gcc = tool(join(f.other, "gcc"));
expect(driverFindsLld(gcc, { PATH: f.other })).toBe(false);
expect(driverFindsLld(gcc, { PATH: [f.other, f.llvm].join(":") })).toBe(true);
expect(driverFindsLld("missing-driver", { PATH: f.llvm })).toBe(false);
},
);
test.skipIf(process.platform === "win32")(
"Linux links with the default driver use lld when the driver finds it",
() => {
const f = fixture();
const clang = tool(join(f.llvm, "clang"));
tool(join(f.llvm, "ld.lld"));
const env = { PATH: f.other };
expect(lldLinkerArgs("linux", clang, env)).toEqual(["-fuse-ld=lld"]);
expect(lldLinkerArgs("darwin", clang, env)).toEqual([]);
expect(lldLinkerArgs("windows", clang, env)).toEqual([]);
expect(lldLinkerArgs("linux", clang, { ...env, SCRIPTC_LINKER: clang })).toEqual([]);
},
);
+49
View File
@@ -0,0 +1,49 @@
import { realpathSync, statSync } from "node:fs";
import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
function isFile(path: string): boolean {
try {
return statSync(path).isFile();
} catch {
return false;
}
}
/** The executable `command` names: a path, or the first match on PATH. */
function commandFile(command: string, env: NodeJS.ProcessEnv): string | null {
if (command.includes("/")) return isFile(command) ? resolve(command) : null;
for (const directory of (env["PATH"] ?? "/usr/bin:/bin").split(delimiter)) {
const candidate = join(directory || ".", command);
if (isFile(candidate)) return isAbsolute(candidate) ? candidate : resolve(candidate);
}
return null;
}
/**
* Whether the compiler driver `driver` finds ld.lld for `-fuse-ld=lld`: in
* the directory of the driver's executable (where clang looks first) or on
* PATH. Linking still falls back to the driver's default linker when a link
* with lld fails.
*/
export function driverFindsLld(driver: string, env: NodeJS.ProcessEnv = process.env): boolean {
const file = commandFile(driver, env);
if (file === null) return false;
try {
for (const directory of new Set([dirname(file), dirname(realpathSync(file))]))
if (isFile(join(directory, "ld.lld"))) return true;
} catch {
return false;
}
return commandFile("ld.lld", env) !== null;
}
/** `-fuse-ld=lld` for Linux links whose driver finds ld.lld. */
export function lldLinkerArgs(
platform: string,
driver: string,
env: NodeJS.ProcessEnv = process.env,
): string[] {
return platform === "linux" && env["SCRIPTC_LINKER"] === undefined && driverFindsLld(driver, env)
? ["-fuse-ld=lld"]
: [];
}
+13 -1
View File
@@ -9,6 +9,7 @@
* code after it); here the lines are DROPPED instead, and emitStmts skips
* whole statements once the current block is terminated, so no dropped
* definition can leak into a later block. */
import { UNWIND_RESUME, UNWIND_SLOT } from "./unwind-abi.js";
export class BlockBuilder {
private readonly blocks: { label: string; lines: string[]; term: string | null }[] = [];
@@ -86,7 +87,18 @@ export class BlockBuilder {
terminate(s: string): void {
if (this.cur.term === null) {
if (this.returnEpilogue && s.startsWith("ret ")) this.line(this.returnEpilogue);
if (
this.returnEpilogue &&
(s.startsWith("ret ") ||
s.startsWith("call void @scr_unwind_raise()") ||
s === UNWIND_RESUME)
)
this.line(this.returnEpilogue);
if (s === UNWIND_RESUME) {
const exception = this.tmp();
this.line(`${exception} = load { ptr, i32 }, ptr ${UNWIND_SLOT}`);
s = `resume { ptr, i32 } ${exception}`;
}
this.cur.term = ` ${this.withDebugLocation(s)}`;
}
}
@@ -580,7 +580,9 @@ test("the guard tests a string's ASCII bit and the fast copy reads bytes", () =>
const body = /^define internal [^\n]*@sc_bf_kernel[^]*?^}/m.exec(emitLlvmModule(mod))![0];
const guard = body.slice(0, body.search(/^bytes\.version\.slow\d*:/m));
expect(guard).toMatch(/load i32, ptr %\S+\n\s+%\S+ = icmp slt i32 %\S+, 0/);
const fast = body.slice(body.lastIndexOf("bytes.version.fast"));
// The byte copy, up to the units copy that follows it.
const units = body.search(/^bytes\.version\.fast\.units\d+:/m);
const fast = body.slice(body.search(/^bytes\.version\.fast\d+:/m), units < 0 ? undefined : units);
expect(fast).toMatch(/load i8, ptr %\S+\n\s+%\S+ = uitofp i8/);
expect(fast).not.toContain("@sc_str_char_code_at_int");
// The checked copy keeps the inline helper and its fallback.
@@ -12,7 +12,13 @@ import {
} from "./counted-loops.js";
import { emitBytesHeaderLength, emitBytesSharedStorage } from "./expr-bytes.js";
import { f64Lit } from "./common.js";
import { emitStringAsciiFlag, emitStringByteLength } from "./string-reads.js";
import {
emitStringAsciiFlag,
emitStringByteLength,
STRING_CHAR_CODE_AT_DECL,
stringScanType,
} from "./string-reads.js";
import { unitSafeLoop } from "./string-unit-versions.js";
type Node = IrExpr | IrStmt;
type VarRef = IrExpr & { kind: "varRef" };
@@ -73,6 +79,14 @@ export interface BytesLoopVersion {
receivers: { ref: VarRef; global: boolean; string: boolean }[];
/** Receivers the guard requires to have no shared storage. */
ownedReceivers: number[];
/** The string reads among `bounds`. */
stringReads: ReadonlySet<Node>;
/** The version's one string receiver, when the loop may also run over
* its UTF-16 units if it is not ASCII (string-unit-versions.ts). */
unitsReceiver: number | null;
/** The units receiver's `length` reads in the loop. Both fast copies
* read the length the guard took (its UTF-16 length) in their place. */
unitLengths: ReadonlySet<IrExpr>;
fixed: FixedCheck[];
affine: AffineCheck[];
/** While loops: accesses at a counter below a loop-invariant bound. */
@@ -114,6 +128,9 @@ export interface BytesLoopFacts {
/** String bindings held as a slice of another string (string-slices.ts):
* they have no string header for the guard to read. */
scalarStrings: ReadonlySet<string>;
/** The functions a loop over a string's UTF-16 units may call; absent
* or null where emitted code does not read the runtime's scan receiver. */
unitSafeFunctions?: ReadonlySet<string> | null;
}
const MAX_COST = 400;
@@ -670,6 +687,19 @@ export function matchBytesLoopVersion(
// Renumber the receivers the kept checks use.
const order = [...used].sort((a, b) => a - b);
const renumber = new Map(order.map((old, index) => [old, index]));
const stringReads = new Set<Node>();
for (const group of [...fixedKept, ...affineKept])
if (receivers[group.check.receiver]!.string)
for (const node of group.nodes) stringReads.add(node);
for (const group of boundedKept)
if (receivers[group.check.receiver]!.string)
for (const { node } of group.nodes) stringReads.add(node);
const strings = order.filter((index) => receivers[index]!.string);
const unitLengths =
facts.unitSafeFunctions != null && strings.length === 1
? unitSafeLoop(loop, receivers[strings[0]!]!.ref.localId, facts.unitSafeFunctions)
: null;
const unitsReceiver = unitLengths !== null ? renumber.get(strings[0]!)! : null;
const needsBound =
affineKept.length > 0 &&
counted !== null &&
@@ -679,6 +709,9 @@ export function matchBytesLoopVersion(
owned: ownedNodes,
receivers: order.map((index) => receivers[index]!),
ownedReceivers: ownedKept.map(([receiver]) => renumber.get(receiver)!),
stringReads,
unitsReceiver,
unitLengths: unitLengths ?? new Set<IrExpr>(),
fixed: fixedKept.map((g) => ({
receiver: renumber.get(g.check.receiver)!,
end: g.check.end,
@@ -1018,13 +1051,22 @@ function findWhileCounterAccesses(
/** Branches to `fast` when every access the version proves is in bounds for
* every iteration and every view it proves thread-owned has no shared
* storage, and to `slow` otherwise (including a counted loop whose
* integer form is not established, or a module receiver not yet set). */
* integer form is not established, or a module receiver not yet set).
*
* With `unitsFast`, the version's units receiver may instead be a string
* that is not ASCII: the guard has the runtime publish it as the scan
* receiver (one read), tests the same accesses against its UTF-16 length,
* and branches to `unitsFast`. It returns the units pointer that copy
* reads, which stays valid while the loop runs (string-unit-versions.ts),
* and for a version with a units receiver, the receiver's UTF-16 length
* on both fast paths (an i64). */
export function emitBytesLoopVersionGuard(
host: LlvmEmitterContext,
version: BytesLoopVersion,
fast: string,
slow: string,
): void {
unitsFast?: string,
): { units: string | null; length: string | null } | null {
const B = host.B;
const next = (cond: string): void => {
if (cond === "true") return;
@@ -1043,19 +1085,36 @@ export function emitBytesLoopVersionGuard(
}
return value;
});
const lengths = receivers.map((receiver, index) => {
const len = version.receivers[index]!.string
? emitStringByteLength(host, receiver)
: emitBytesHeaderLength(host, receiver);
const unitsIndex = unitsFast !== undefined ? version.unitsReceiver : null;
const units: { read: { pointer: string; taken: string; published: string } | null } = {
read: null,
};
const widen = (len: string): string => {
if (host.sizeType === "i64") return len;
const wide = B.tmp();
B.line(`${wide} = zext ${host.sizeType} ${len} to i64`);
return wide;
};
const lengths = receivers.map((receiver, index) => {
if (index === unitsIndex) {
const read = emitUnitsLength(host, receiver);
units.read = read;
return read.length;
}
return widen(
version.receivers[index]!.string
? emitStringByteLength(host, receiver)
: emitBytesHeaderLength(host, receiver),
);
});
const conditions: string[] = [];
// The published count is the receiver's UTF-16 length only while the
// runtime publishes the receiver itself.
if (units.read) conditions.push(units.read.published);
// A string's byte length is its UTF-16 length when it is ASCII.
version.receivers.forEach(({ string }, index) => {
if (string) conditions.push(emitStringAsciiFlag(host, receivers[index]!));
if (string && index !== unitsIndex)
conditions.push(emitStringAsciiFlag(host, receivers[index]!));
});
for (const index of version.ownedReceivers) {
const owned = B.tmp();
@@ -1158,7 +1217,7 @@ export function emitBytesLoopVersionGuard(
// Unreachable: every term has a proven range. Stay checked.
B.br(slow);
B.startBlock(B.newLabel("bytes.version.dead"));
return;
return null;
}
const wide = widenInteger(host, integer);
const sum = B.tmp();
@@ -1215,8 +1274,70 @@ export function emitBytesLoopVersionGuard(
valid = all;
}
}
if (valid === "true") B.br(fast);
else B.condBr(valid, fast, slow);
const length = version.unitsReceiver !== null ? lengths[version.unitsReceiver]! : null;
const taken = units.read;
const target = taken ? B.newLabel("bytes.version.units") : fast;
if (valid === "true") B.br(target);
else B.condBr(valid, target, slow);
if (!taken) return { units: null, length };
B.startBlock(target);
B.condBr(taken.taken, unitsFast!, fast);
return { units: taken.pointer, length };
}
/** The length of a version's units receiver `s` in UTF-16 units: its byte
* length when it is ASCII; otherwise, after one runtime read publishes it
* as the scan receiver, the published unit count (0 when the runtime does
* not hold its units). Also the units pointer, whether the loop is to
* read it, and whether the published receiver is `s`. */
function emitUnitsLength(
host: LlvmEmitterContext,
s: string,
): { length: string; pointer: string; taken: string; published: string } {
const B = host.B;
const sz = host.sizeType;
const type = stringScanType(sz);
const ascii = emitStringAsciiFlag(host, s);
const bytes = emitStringByteLength(host, s);
const from = B.currentLabel();
const publish = B.newLabel("bytes.version.publish"),
join = B.newLabel("bytes.version.length");
B.condBr(ascii, join, publish);
B.startBlock(publish);
host.declare(STRING_CHAR_CODE_AT_DECL);
const read = B.tmp(),
receiver = B.tmp(),
same = B.tmp(),
pointerSlot = B.tmp(),
pointer = B.tmp(),
countSlot = B.tmp(),
count = B.tmp(),
published = B.tmp();
B.line(`${read} = call double @scr_str_char_code_at(ptr ${s}, double 0.0)`);
B.line(`${receiver} = load ptr, ptr @scr_str_scan`);
B.line(`${same} = icmp eq ptr ${receiver}, ${s}`);
B.line(`${pointerSlot} = getelementptr inbounds ${type}, ptr @scr_str_scan, i32 0, i32 1`);
B.line(`${pointer} = load ptr, ptr ${pointerSlot}`);
B.line(`${countSlot} = getelementptr inbounds ${type}, ptr @scr_str_scan, i32 0, i32 2`);
B.line(`${count} = load ${sz}, ptr ${countSlot}`);
B.line(`${published} = select i1 ${same}, ${sz} ${count}, ${sz} 0`);
const end = B.currentLabel();
B.br(join);
B.startBlock(join);
const length = B.tmp(),
units = B.tmp(),
taken = B.tmp();
B.line(`${length} = phi ${sz} [ ${bytes}, %${from} ], [ ${published}, %${end} ]`);
B.line(`${units} = phi ptr [ null, %${from} ], [ ${pointer}, %${end} ]`);
B.line(`${taken} = phi i1 [ false, %${from} ], [ true, %${end} ]`);
const exact = B.tmp();
B.line(`${exact} = phi i1 [ true, %${from} ], [ ${same}, %${end} ]`);
let wide = length;
if (sz !== "i64") {
wide = B.tmp();
B.line(`${wide} = zext ${sz} ${length} to i64`);
}
return { length: wide, pointer: units, taken, published: exact };
}
function scaleCounter(host: LlvmEmitterContext, value: string, scale: number): string {
@@ -50,8 +50,8 @@ export function emitCountedLoopLimit(
}
/** The run-time test of a guarded counted loop: an unproven limit is within
* the integer form's finite range and an unproven start is an exact safe
* integer. Returns `true` when nothing needs testing. */
* the integer form's finite range and an unproven start is an exact integer
* within the loop's start bound. Returns `true` when nothing needs testing. */
export function emitCountedLoopSafety(
host: LlvmEmitterContext,
loop: IntegerCountedForLoop,
@@ -65,7 +65,11 @@ export function emitCountedLoopSafety(
`${safe} = fcmp ${loop.step > 0 ? "ole" : "oge"} double ${bound.name}, ${f64Lit(loop.guardLimit)}`,
);
}
if (loop.startGuarded) safe = andSafe(host, safe, emitExactStart(host, loop.start));
if (loop.startGuarded) {
const start = host.emitExpr(loop.start);
const bound = loop.startBound;
safe = andSafe(host, safe, emitExactIntegerTest(host, start.name, -bound, bound).ok);
}
for (const follower of loop.followers) {
if (follower.startGuarded) safe = andSafe(host, safe, emitExactStart(host, follower.start));
if (follower.tripGuarded) safe = andSafe(host, safe, emitFollowerReach(host, loop, follower));
@@ -122,6 +122,7 @@ const counted = (
guarded: false,
limitGuarded: false,
startGuarded: false,
startBound: Number.MAX_SAFE_INTEGER,
guardLimit: 0,
followers: [],
...fields,
+28 -17
View File
@@ -43,7 +43,7 @@ import {
isDynTypedRefType,
isRefCounted,
isUnitType,
recordTextCodecClass,
recordHandleCodecClass,
textCodecRecordClass,
typeKey,
} from "../../ir/ir.js";
@@ -98,14 +98,16 @@ export const DYN_HANDLE_TAG_NUM: Record<string, number> = {
/** The handle tags of TextEncoder and TextDecoder records boxed as dyn
* (scr_dyn.h; scr_text_codec_dyn.c boxes and unboxes them). */
export const TEXT_CODEC_TAG_NUM: Record<"TextEncoder" | "TextDecoder", number> = {
export const TEXT_CODEC_TAG_NUM: Record<"TextEncoder" | "TextDecoder" | "StringDecoder", number> = {
TextDecoder: 76,
TextEncoder: 77,
StringDecoder: 78,
};
/** The decoder record fields scr_text_decoder_to_dyn reads, in its
* parameter order. */
const TEXT_DECODER_FIELDS = ["%TextDecoder", "%fatal", "%ignoreBOM", "%state"] as const;
const STRING_DECODER_FIELDS = ["%enc", "%pending"] as const;
/** Handle kinds whose runtime unit boxes them through its own function,
* which installs the tag's ops on first use (scr_crypto.h). Programs that
@@ -1825,11 +1827,18 @@ export class LlDyn {
const codec = textCodecRecordClass(t, (id) => host.recordsById.get(id));
if (codec !== null) {
// The record the codec's handle box holds (+1), or the path TypeError.
host.declare(`declare ptr @scr_text_codec_from_dyn(ptr, i1 zeroext, ptr, ptr)`);
const checked = B.tmp();
B.line(
`${checked} = call ptr @scr_text_codec_from_dyn(ptr %d, i1 ${codec === "TextDecoder"}, ptr %path, ptr ${want})`,
);
if (codec === "StringDecoder") {
host.declare(`declare ptr @scr_string_decoder_from_dyn(ptr, ptr, ptr)`);
B.line(
`${checked} = call ptr @scr_string_decoder_from_dyn(ptr %d, ptr %path, ptr ${want})`,
);
} else {
host.declare(`declare ptr @scr_text_codec_from_dyn(ptr, i1 zeroext, ptr, ptr)`);
B.line(
`${checked} = call ptr @scr_text_codec_from_dyn(ptr %d, i1 ${codec === "TextDecoder"}, ptr %path, ptr ${want})`,
);
}
B.terminate(`ret ptr ${checked}`);
this.defs.push(
`define internal ptr @${name}(ptr %d, ptr %path) ${FN_ATTRS} {`,
@@ -2917,14 +2926,14 @@ export class LlDyn {
return matched;
}
/** A TextEncoder or TextDecoder record boxes as its handle (+1), which
* holds the record by reference. A decoder passes the byte offsets of the
* fields its boxed decode reads. */
/** A TextEncoder, TextDecoder or StringDecoder record boxes as its
* handle (+1), which holds the record by reference. The decoders pass
* the byte offsets of the fields their boxed methods read. */
private textCodecToDyn(
B: BlockBuilder,
t: IrType & { kind: "record" },
shape: IrRecordShape,
codec: "TextEncoder" | "TextDecoder",
codec: "TextEncoder" | "TextDecoder" | "StringDecoder",
): void {
const host = this.host;
const rc = vAdapters(host, t);
@@ -2939,10 +2948,12 @@ export class LlDyn {
const base = host.nullableUnions.fieldBase(t.shapeId);
const start = B.tmp();
B.line(`${start} = ptrtoint ptr %v to ${this.S}`);
const offsets = TEXT_DECODER_FIELDS.map((field) => {
const fields: readonly string[] =
codec === "StringDecoder" ? STRING_DECODER_FIELDS : TEXT_DECODER_FIELDS;
const offsets = fields.map((field) => {
const index = shape.fields.findIndex((f) => f.name === field);
if (index < 0)
throw new InternalCompilerError(`llvm emitter bug: TextDecoder record lacks ${field}`);
throw new InternalCompilerError(`llvm emitter bug: ${codec} record lacks ${field}`);
const p = B.tmp();
const at = B.tmp();
const offset = B.tmp();
@@ -2953,11 +2964,11 @@ export class LlDyn {
B.line(`${offset} = sub ${this.S} ${at}, ${start}`);
return `${this.S} ${offset}`;
});
host.declare(
`declare ptr @scr_text_decoder_to_dyn(ptr, ptr, ptr, ${this.S}, ${this.S}, ${this.S}, ${this.S})`,
);
const fn =
codec === "StringDecoder" ? "scr_string_decoder_to_dyn" : "scr_text_decoder_to_dyn";
host.declare(`declare ptr @${fn}(ptr, ptr, ptr, ${fields.map(() => this.S).join(", ")})`);
B.line(
`${r} = call ptr @scr_text_decoder_to_dyn(ptr %v, ptr ${rc.retain}, ptr ${rc.release}, ${offsets.join(", ")})`,
`${r} = call ptr @${fn}(ptr %v, ptr ${rc.retain}, ptr ${rc.release}, ${offsets.join(", ")})`,
);
}
B.terminate(`ret ptr ${r}`);
@@ -3137,7 +3148,7 @@ export class LlDyn {
const shape = host.recordsById.get(t.shapeId);
if (!shape)
throw new InternalCompilerError(`llvm emitter bug: to-dyn of unknown shape ${t.shapeId}`);
const codec = recordTextCodecClass(shape);
const codec = recordHandleCodecClass(shape);
if (codec !== null) {
this.textCodecToDyn(B, t, shape, codec);
break;
@@ -56,11 +56,14 @@ import { emitIntegerClone, emitIntegerDispatcher } from "./integer-clones.js";
import { beginFunctionLoops, LOOPS } from "./loop-state.js";
import { runningExtremumUpdates } from "./running-extrema.js";
import { findIntegerOrNaNLocals } from "./wrapping-sums.js";
import { UNWIND_PERSONALITY, UNWIND_SLOT } from "./unwind-abi.js";
export function emitFunction(emitter: LlEmitter, fn: IrFunction): string {
const B = new BlockBuilder();
emitter.B = B;
emitter.currentFunctionName = fn.name;
emitter.currentUnwindAbi = emitter.unwindAbi.has(fn.name);
emitter.usesLandingPads = false;
emitter.findInt32Allocations(fn);
emitter.currentTier = fn.tier;
// Fiber switches restore the active exception cell before control
@@ -136,6 +139,7 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string {
emitter.integerTempsEnabled = emitter.debug === null && !fn.async && !fn.generator;
beginFunctionLoops(emitter, fn, numericFn);
emitter.speculativeLoopsEnabled = LOOPS.of(emitter).countedLoopsEnabled;
emitter.loopCopiesEnabled = true;
emitter.speculativeFunction = fn.name;
emitter.speculation = null;
emitter.speculationResume = null;
@@ -159,6 +163,7 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string {
emitter.optionalArrayReads,
emitter.referenceWrites,
emitter.fnByName,
emitter.nodeIndex,
)
: new Set();
emitter.localArrayReads = findLocalArrayReads(
@@ -324,7 +329,7 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string {
const paramIds = new Set(fn.params.map((p) => p.localId));
if (emitter.debug === null) {
for (const id of findIntegerViews(numericFn)) {
for (const id of findIntegerViews(numericFn, emitter.nodeIndex)) {
// An i32 slot is its own view.
if (emitter.int32Locals.has(id)) continue;
const slot = B.slot();
@@ -595,6 +600,8 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string {
B.terminate(cleanup.terminator);
}
if (emitter.usesLandingPads && emitter.currentUnwindAbi)
B.entryAllocas.push(`${UNWIND_SLOT} = alloca { ptr, i32 }`);
if (emitter.logArgSlots > 0) {
B.entryAllocas.push(`%logargs = alloca [${emitter.logArgSlots} x %ScrLogArg]`);
}
@@ -635,7 +642,10 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string {
const debug = emitter.debugScope === null ? "" : ` !dbg ${emitter.debugScope}`;
const symbol = borrowed ? mangleBorrowedFunction(fn.name) : mangleFunction(fn.name);
emitter.privateGlobalSlots.clear();
let body = `define internal ${ret} @${symbol}${int32Abi ? INT32_ABI_SUFFIX : ""}(${params.join(", ")}) ${attrs}${debug} { ; ${fn.name}\n${B.render()}\n}`;
// Landing pads (emitDirectCall) need the personality.
const personality = emitter.usesLandingPads ? ` ${UNWIND_PERSONALITY}` : "";
if (emitter.usesLandingPads) emitter.declare(`declare i32 @__gcc_personality_v0(...)`);
let body = `define internal ${ret} @${symbol}${int32Abi ? INT32_ABI_SUFFIX : ""}(${params.join(", ")}) ${attrs}${personality}${debug} { ; ${fn.name}\n${B.render()}\n}`;
if (int32Abi) body += "\n" + emitter.emitInt32AbiWrapper(fn, symbol, int32Abi);
if (emitter.integerClones.members.has(fn.name)) {
body += "\n" + emitIntegerClone(fn, emitter, emitter.stackChecks?.has(fn.name) === true);
@@ -61,7 +61,7 @@ import { hasLoneSurrogate, wtf8Bytes } from "../literals.js";
import { undefinedArmTag } from "../../ir/analysis.js";
import { NULLABLE_ABSENT, NULLABLE_NULL } from "./nullable-unions.js";
import { literalEqualityDefinition } from "./string-literal-equality.js";
import { STRING_READ_HELPERS, stringScanDecl } from "./string-reads.js";
import { STRING_READ_HELPERS, stringScanDecl, stringScanInline } from "./string-reads.js";
import { emitLibraryIdentityLines } from "../library-identity-markers.js";
import { InternalCompilerError } from "../../errors.js";
import { targetCpuAttribute } from "../target-cpu.js";
@@ -1615,9 +1615,7 @@ export function helperDefs(emitter: LlEmitter): string[] {
// A scan's receiver reads inline too (ScrStrScan): from ordinary global
// storage, or, in worker executables, from the thread's static TLS block,
// one thread-pointer-relative load (see executableTls).
const scanInline =
emitter.executableTls ||
(emitter.mod.workers !== true && emitter.mod.lib?.threadInstances !== true);
const scanInline = stringScanInline(emitter);
let scanDeclared = false;
for (const helper of STRING_READ_HELPERS) {
if (!emitter.decls.has(helper.decl)) continue;
@@ -994,7 +994,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
LOOPS.of(emitter).denseLoopsEnabled = false;
try {
B.startBlock(slow);
emitter.emitStmt(s);
emitSlowCopy(emitter, s);
B.br(done);
B.startBlock(fast);
LOOPS.of(emitter).denseLoop = dense;
@@ -1018,7 +1018,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
LOOPS.of(emitter).byteWindowsEnabled = false;
try {
B.startBlock(slow);
emitter.emitStmt(s);
emitSlowCopy(emitter, s);
B.br(done);
B.startBlock(fast);
emitter.integerRanges = window.ranges;
@@ -1072,7 +1072,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
B.condBr(safe, fast, slow);
B.startBlock(slow);
LOOPS.of(emitter).countedLoopsEnabled = false;
emitter.emitStmt(s);
emitSlowCopy(emitter, s);
LOOPS.of(emitter).countedLoopsEnabled = true;
B.br(countedJoin);
B.startBlock(fast);
@@ -2049,7 +2049,7 @@ function emitCallbackVersionedLoop(
emitter.callbackVersioning.add(s);
try {
B.startBlock(slow);
emitter.emitStmt(s);
emitSlowCopy(emitter, s);
B.br(done);
B.startBlock(fast);
for (const { ref } of bindings) emitter.knownCallbacks.add(ref.localId);
@@ -2062,6 +2062,20 @@ function emitCallbackVersionedLoop(
B.startBlock(done);
}
/** The copy of a versioned loop that runs when its guard fails. Its loops
* get no speculative or int32 kind copies, which would emit each loop again
* for a path that rarely runs. Proven integer loops, which emit one copy,
* are kept. */
function emitSlowCopy(emitter: LlEmitter, s: IrStmt): void {
const copies = emitter.loopCopiesEnabled;
emitter.loopCopiesEnabled = false;
try {
emitter.emitStmt(s);
} finally {
emitter.loopCopiesEnabled = copies;
}
}
/** Entries in a row whose fast copy bailed before a speculative loop site
* stops trying it. */
const SPECULATION_BAILS = 8;
@@ -2082,28 +2096,56 @@ function emitVersionedLoop(
const fast = B.newLabel("bytes.version.fast"),
slow = B.newLabel("bytes.version.slow"),
done = B.newLabel("bytes.version.done");
emitBytesLoopVersionGuard(emitter, version, fast, slow);
const unitsFast =
version.unitsReceiver !== null ? B.newLabel("bytes.version.fast.units") : undefined;
const guard = emitBytesLoopVersionGuard(emitter, version, fast, slow, unitsFast);
const units = guard?.units ?? null;
const originalBounds = emitter.bytesBounds,
originalOwned = emitter.bytesOwned,
originalRanges = emitter.integerRanges;
originalRanges = emitter.integerRanges,
originalUnitReads = emitter.stringUnitReads,
originalLengthReads = emitter.stringLengthReads;
LOOPS.of(emitter).bytesLoopVersioning.add(s);
try {
B.startBlock(slow);
emitter.emitStmt(s);
emitSlowCopy(emitter, s);
B.br(done);
B.startBlock(fast);
emitter.bytesBounds = new Set([...originalBounds, ...version.bounds]);
if (version.owned.size > 0) emitter.bytesOwned = new Set([...originalOwned, ...version.owned]);
if (version.ranges.size > 0)
emitter.integerRanges = new Map([...originalRanges, ...version.ranges]);
if (version.counted?.guarded || version.paired?.loop.guarded)
LOOPS.of(emitter).provenCountedGuards.add(s);
B.startBlock(fast);
emitter.bytesBounds = new Set([...originalBounds, ...version.bounds]);
// Both fast copies read the string's length the guard took.
const length = guard?.length;
if (length != null && version.unitLengths.size > 0)
emitter.stringLengthReads = new Map([
...originalLengthReads,
...[...version.unitLengths].map((node) => [node, length] as const),
]);
emitter.emitStmt(s);
B.br(done);
if (unitsFast !== undefined && units !== null) {
// The string reads take the receiver's UTF-16 units.
B.startBlock(unitsFast);
emitter.bytesBounds = new Set([
...originalBounds,
...[...version.bounds].filter((node) => !version.stringReads.has(node)),
]);
emitter.stringUnitReads = new Map([
...originalUnitReads,
...[...version.stringReads].map((node) => [node, units] as const),
]);
emitter.emitStmt(s);
B.br(done);
}
} finally {
emitter.bytesBounds = originalBounds;
emitter.bytesOwned = originalOwned;
emitter.integerRanges = originalRanges;
emitter.stringUnitReads = originalUnitReads;
emitter.stringLengthReads = originalLengthReads;
LOOPS.of(emitter).provenCountedGuards.delete(s);
LOOPS.of(emitter).bytesLoopVersioning.delete(s);
}
@@ -2120,6 +2162,7 @@ export function emitLoopCopies(
const plan =
allowed &&
emitter.speculativeLoopsEnabled &&
emitter.loopCopiesEnabled &&
emitter.speculation === null &&
emitter.speculationResume === null
? planSpeculativeLoop(
@@ -2147,6 +2190,7 @@ export function emitLoopCopies(
const kinds =
allowed &&
emitter.speculativeLoopsEnabled &&
emitter.loopCopiesEnabled &&
emitter.int32Slots.checkedInt32Arrays.size > 0 &&
emitter.int32Kinds === null &&
emitter.speculation === null &&
@@ -2437,7 +2481,7 @@ function planProvenLoop(
loop: Extract<IrStmt, { kind: "while" | "doWhile" | "for" }>,
): ProvenLoopPlan | null {
if (
loop.kind !== "for" ||
loop.kind === "doWhile" ||
!emitter.speculativeLoopsEnabled ||
emitter.speculation !== null ||
emitter.speculationResume !== null ||
+189 -34
View File
@@ -80,6 +80,7 @@ import {
} from "./emit-journal.js";
import { specializeNumericCalls } from "../../ir/numeric-call-specialization.js";
import { versionLoopBoundParameters } from "../../ir/parameter-versions.js";
import { inlineExpressionFunctions } from "../../ir/expression-inlining.js";
import {
allocatedLiteral,
fillAllocationLength,
@@ -109,6 +110,8 @@ import { dropUnwrittenPropertyBags } from "../../ir/property-bags.js";
import { freezeInitOnlyGlobals } from "../../ir/init-only-globals.js";
import { hoistFinalFieldReads } from "../../ir/field-values.js";
import { holdCharLocalsAsUnits } from "../../ir/char-unit-locals.js";
import { unitSafeFunctions } from "./string-unit-versions.js";
import { stringScanInline } from "./string-reads.js";
import {
findInt32Abis,
INT32_ABI_SUFFIX,
@@ -128,6 +131,16 @@ import {
type FfiCallbackAdapter,
} from "../ffi-callbacks.js";
import { computeMayThrow } from "../may-throw.js";
import {
UNWIND_PERSONALITY,
UNWIND_RAISE,
UNWIND_RAISE_DECLARATION,
UNWIND_RESUME,
UNWIND_SLOT,
UnwindAbiViolation,
bridgePlainUnwindCalls,
unwindAbiFunctions,
} from "./unwind-abi.js";
import {
mangleAsyncInlineAwaitCall,
mangleAsyncSpawn,
@@ -176,7 +189,7 @@ import { type MapEntryFusion } from "./map-entry-fusion.js";
import { emitBorrowedFieldSequence, guardedValue } from "./borrowed-receivers.js";
import { emitBorrowedInputs } from "./borrowed-inputs.js";
import { findWalkBorrows, type WalkBorrowHost } from "./walk-borrows.js";
import { IrNodeIndex } from "../../ir/node-index.js";
import { eachNodeOfKinds, IrNodeIndex, type IrNodeKind } from "../../ir/node-index.js";
import { lastUseRead } from "./local-extents.js";
import { findReboundParameters } from "./rebound-parameters.js";
import { ReferenceEffects } from "./reference-effects.js";
@@ -439,6 +452,12 @@ export interface ForkPrimitives {
export interface LlvmTargetOptions {
/** C ABI target; omitted direct emission uses the host ABI. */
targetTriple?: string;
/** Linux (glibc and musl) executables: functions reached only by direct
* calls propagate exceptions by unwinding the native stack (unwind-abi.ts). */
unwindAbi?: boolean;
/** Functions kept on the pending-flag ABI under `unwindAbi`: the ones an
* earlier emission found breaking its rules (UnwindAbiViolation). */
unwindExcluded?: ReadonlySet<string>;
/** Emit the function bodies in this many contiguous ranges, each by its
* own emitter, and merge them in function order (emit-journal.ts). The
* output is the same as one emitter's. Debug builds (`debugSources`)
@@ -562,13 +581,15 @@ function moduleEmitter(input: IrModule, options: LlvmTargetOptions): LlEmitter {
scalarizeNumericRecords(
specializeCallbackCalls(
specializeNumericCalls(
foldConstantGlobals(
copyStableCaptures(
resolveSelfCaptures(
hoistFinalFieldReads(
freezeInitOnlyGlobals(
holdCharLocalsAsUnits(
dropUnwrittenPropertyBags(runPairedWhileLoopsAsFor(mod)),
inlineExpressionFunctions(
foldConstantGlobals(
copyStableCaptures(
resolveSelfCaptures(
hoistFinalFieldReads(
freezeInitOnlyGlobals(
holdCharLocalsAsUnits(
dropUnwrittenPropertyBags(runPairedWhileLoopsAsFor(mod)),
),
),
),
),
@@ -590,16 +611,36 @@ function moduleEmitter(input: IrModule, options: LlvmTargetOptions): LlEmitter {
return new LlEmitter(emitted, options, null, prepared?.mod === emitted ? prepared : null);
}
/** Emits the module's parts, emitting it again with the functions an
* UnwindAbiViolation names on the pending-flag ABI. */
function emitModuleText(input: IrModule, options: LlvmTargetOptions): string[] {
for (let excluded = options.unwindExcluded ?? new Set<string>(); ;) {
const emitter = moduleEmitter(input, { ...options, unwindExcluded: excluded });
try {
return emitter.emitParts();
} catch (error) {
if (!(error instanceof UnwindAbiViolation)) throw error;
const names = new Set(excluded);
for (const name of emitter.unwindAbi)
for (const symbol of [mangleFunction(name), mangleBorrowedFunction(name)])
if (error.symbols.has(symbol) || error.symbols.has(symbol + INT32_ABI_SUFFIX))
names.add(name);
if (names.size === excluded.size) throw error;
excluded = names;
}
}
}
export function emitLlvmModule(input: IrModule, options: LlvmTargetOptions = {}): string {
return moduleEmitter(input, options).emit();
return emitModuleText(input, options).join("\n");
}
/** emitLlvmModule's text as parts that concatenate to it, without the copy
* that joining them makes: a large program's text is 150 MB. */
export function emitLlvmModuleParts(input: IrModule, options: LlvmTargetOptions = {}): string[] {
return moduleEmitter(input, options)
.emitParts()
.flatMap((part, index) => (index === 0 ? [part] : ["\n", part]));
return emitModuleText(input, options).flatMap((part, index) =>
index === 0 ? [part] : ["\n", part],
);
}
/** Keep large translation units below the host's single-string limit. */
@@ -609,7 +650,7 @@ export function emitLlvmModuleSource(
): string | readonly string[] {
// IR validation, contracts and the frontend's disposal run after lowering without checks.
relieveHeapPressure(true);
const parts = moduleEmitter(input, options).emitParts();
const parts = emitModuleText(input, options);
const length = parts.reduce((total, part) => total + part.length, Math.max(0, parts.length - 1));
if (length <= 256 * 1024 * 1024) return parts.join("\n");
return parts.flatMap((part, index) => (index === 0 ? [part] : ["\n", part]));
@@ -621,6 +662,11 @@ export function llStrBytes(text: string): string {
const NO_BORROWED: ReadonlySet<number> = new Set();
/** The nodes that write a binding by its id (numberGlobalWriters). */
const GLOBAL_WRITE_KINDS: readonly IrNodeKind[] = ["assign", "varDecl", "assignExpr", "incDec"];
/** Runtime library calls (readsLoopMetrics). */
const LIB_CALL_KINDS: readonly IrNodeKind[] = ["libCall"];
/** [A-Za-z0-9_] */
function isIdentifierByte(code: number): boolean {
return (
@@ -1248,6 +1294,10 @@ export class LlEmitter {
/** May-throw analysis (computeMayThrow): pending
* checks are emitted only after calls that can actually raise. */
readonly mayThrow: Set<string>;
/** The functions that propagate exceptions by unwinding (unwind-abi.ts). */
readonly unwindAbi: Set<string>;
/** Whether the function being emitted is one of unwindAbi. */
currentUnwindAbi = false;
readonly indirectMayThrow: boolean;
/** Worker programs: the functions that poll for termination on entry
* (workerTerminationPolls); null outside worker programs. */
@@ -1456,6 +1506,9 @@ export class LlEmitter {
integerTempsEnabled = false;
/** Speculative integer loops (speculative-integers.ts) run in this function. */
speculativeLoopsEnabled = false;
/** False inside the copy of a versioned loop that runs when its guard
* fails: loops there get no speculative or int32 kind copies. */
loopCopiesEnabled = true;
/** The fast copy of a speculative integer loop being emitted: its
* integer-computed writes and where each resumes in the ordinary copy. */
speculation: {
@@ -1510,6 +1563,12 @@ export class LlEmitter {
* (one-unit tests and switches, charAtUnitRead), with the charAt each
* stands for: a loop version's proof for the charAt covers its read. */
readonly charAtUnitReads = new Map<IrExpr, IrExpr>();
/** String reads a loop version proved in bounds of a string's UTF-16
* units, with the units pointer to read (string-unit-versions.ts). */
stringUnitReads: ReadonlyMap<IrExpr | IrStmt, string> = new Map();
/** `length` reads of a loop version's string, with the i64 UTF-16
* length its guard took (bytes-loop-versions.ts). */
stringLengthReads: ReadonlyMap<IrExpr, string> = new Map();
/** Worker programs: typed-array accesses whose views an enclosing version
* guard proved thread-owned. */
bytesOwned: ReadonlySet<IrExpr | IrStmt> = new Set();
@@ -1706,6 +1765,16 @@ export class LlEmitter {
nodes: this.nodeIndex,
});
this.mayThrow = mt.fns;
this.unwindAbi =
options.unwindAbi === true &&
!this.wasi &&
mod.lib === undefined &&
options.debugSources === undefined &&
(options.targetTriple
? /-linux-(?:gnu|musl)/.test(options.targetTriple)
: process.platform === "linux")
? unwindAbiFunctions(mod, this.mayThrow, options.unwindExcluded)
: new Set();
this.workerEntryPolls = mt.workerEntryPolls ?? null;
this.stackChecks = mt.stackChecks ?? null;
// A stackless call's eager prefix borrows its caller's stack; the
@@ -1841,10 +1910,13 @@ export class LlEmitter {
: undefined,
)
: NO_INTEGER_CLONES;
// Integer clones and their dispatchers call each other through their
// own lowering, which keeps plain calls and flag tests.
for (const name of this.integerClones.members) this.unwindAbi.delete(name);
this.stackCallbacks = new StackCallbacks(this.fnByName);
this.receiverReaders = new AmbientReceiverReaders(this.fnByName);
this.receiverReaders = new AmbientReceiverReaders(this.fnByName, this.nodeIndex);
this.stackCaptures = new StackCaptures(this.fnByName, this.callLifetimes, this.stackCallbacks);
this.lazyCaptures = new LazyCaptures(this.fnByName);
this.lazyCaptures = new LazyCaptures(this.fnByName, this.nodeIndex);
const traced = computeTraced(mod, dynamicStores, cycleMutability);
this.tracedShapes = traced.shapes;
this.tracedUnions = traced.unions;
@@ -2120,7 +2192,8 @@ export class LlEmitter {
return this.emitParts().join("\n");
}
emitParts(): string[] {
return emitParts(this);
const parts = emitParts(this);
return this.unwindAbi.size > 0 ? bridgePlainUnwindCalls(parts) : parts;
}
/** The dispose-observer gates the emitted frees and record writers test
@@ -3231,6 +3304,13 @@ export class LlEmitter {
terminator = `br label %${target.label}`;
} else if (this.currentCoro !== null) {
terminator = `br label %${this.currentCoro.finalLabel}`;
} else if (this.currentUnwindAbi && this.inLandingPad) {
// The unwind that entered the landing pad goes on to the caller's.
terminator = UNWIND_RESUME;
} else if (this.currentUnwindAbi) {
// The caller's invoke takes the exception at its landing pad.
this.declare(UNWIND_RAISE_DECLARATION);
terminator = UNWIND_RAISE;
} else {
const t = this.llType(this.currentReturnType);
terminator =
@@ -3261,6 +3341,7 @@ export class LlEmitter {
passThrough !== undefined &&
!target &&
this.currentCoro === null &&
!this.currentUnwindAbi &&
!this.currentInt32Result
) {
const t = this.llType(this.currentReturnType);
@@ -3447,6 +3528,46 @@ export class LlEmitter {
B.startBlock(B.newLabel("exc.dead"));
}
/** Emits `line`, a `call` of `callee`, and reports whether the call
* needs no pending check after it: a call of an unwinding-ABI function
* becomes an `invoke` whose landing pad runs this frame's unwind (the
* same releases and handler as emitPendingCheck's unwind path), and the
* normal path continues in a fresh block. Coroutine bodies keep plain
* calls (unwindAbiFunctions excludes their callees). */
emitDirectCall(line: string, callee: string): boolean {
const B = this.B;
if (!this.unwindAbi.has(callee) || this.currentCoro !== null || B.isTerminated()) {
B.line(line);
return false;
}
const ok = B.newLabel("inv.ok");
const pad = B.newLabel("inv.lp");
this.usesLandingPads = true;
// A trailing comment goes after the invoke's labels.
const comment = line.indexOf(" ; ");
const text = comment < 0 ? line : line.slice(0, comment);
B.terminate(
`${text.replace(/^((?:%[\w.]+ = )?)call /, "$1invoke ")} to label %${ok} unwind label %${pad}`,
);
B.startBlock(pad);
const exception = B.tmp();
B.line(`${exception} = landingpad { ptr, i32 } cleanup`);
if (this.currentUnwindAbi) B.line(`store { ptr, i32 } ${exception}, ptr ${UNWIND_SLOT}`);
this.inLandingPad = true;
this.emitUnwind();
this.inLandingPad = false;
B.startBlock(ok);
return true;
}
/** Whether emitUnwind runs for a landing pad, whose unwind goes on with
* `resume` instead of raising again. */
private inLandingPad = false;
/** Whether the function being emitted has a landing pad (its definition
* needs the personality). */
usesLandingPads = false;
/** A recursive function's entry check: below the running stack's guard,
* throw Node's RangeError and unwind like any other pending exception. */
emitStackCheck(): void {
@@ -3622,15 +3743,14 @@ export class LlEmitter {
set.add(fn.name);
writers.set(localId, set);
};
everyStmtList(fn.body, {
stmt: (s) => {
if (s.kind === "assign" || s.kind === "varDecl") note(s.localId);
return true;
},
expr: (e) => {
if (e.kind === "assignExpr" || e.kind === "incDec") note(e.localId);
return true;
},
eachNodeOfKinds(fn, this.nodeIndex.scan(GLOBAL_WRITE_KINDS), (node) => {
if (
node.kind === "assign" ||
node.kind === "varDecl" ||
node.kind === "assignExpr" ||
node.kind === "incDec"
)
note(node.localId);
});
}
this.globalWriters = writers;
@@ -4364,11 +4484,21 @@ export class LlEmitter {
workers: this.mod.workers === true,
owned: this.bytesOwned,
scalarStrings: new Set(this.stringSlices.keys()),
unitSafeFunctions: this.unitSafeFunctions(),
},
paired,
);
}
private unitSafe: ReadonlySet<string> | null | undefined;
/** The functions a loop over a string's UTF-16 units may call, or null
* where emitted reads do not use the runtime's scan receiver. */
private unitSafeFunctions(): ReadonlySet<string> | null {
if (this.unitSafe === undefined)
this.unitSafe = stringScanInline(this) ? unitSafeFunctions(this.mod) : null;
return this.unitSafe;
}
/** The alias tag of a whole-slot load of a binding (module globals only). */
slotAlias(id: string): string {
const binding = this.binding(id);
@@ -5218,7 +5348,22 @@ export class LlEmitter {
if (fn.captures !== undefined) params.unshift("ptr %sc_env");
const ret = this.llType(fn.returnType);
const call = `call ${ret} @${mangleBorrowedFunction(fn.name)}(${params.join(", ")})`;
B.line(ret === "void" ? call : `%result = ${call}`);
// The owned entry of an unwinding-ABI body releases the borrowed
// parameters on the unwind too, then resumes it to its caller.
const unwinding = this.unwindAbi.has(fn.name) && kept.size === 0;
if (unwinding) {
const ok = B.newLabel("adapt.ok");
const pad = B.newLabel("adapt.lp");
B.terminate(
`${ret === "void" ? "" : "%result = "}invoke ${call.slice(5)} to label %${ok} unwind label %${pad}`,
);
B.startBlock(pad);
const exception = B.tmp();
B.line(`${exception} = landingpad { ptr, i32 } cleanup`);
for (const index of borrowed) this.releaseFrame(`%p${index}`, fn.params[index]!.type);
B.terminate(`resume { ptr, i32 } ${exception}`);
B.startBlock(ok);
} else B.line(ret === "void" ? call : `%result = ${call}`);
// A borrowed result is reachable from the parameters: own it first. A
// throwing body returns a null dummy, which needs no owner (and string
// or array retains do not accept NULL).
@@ -5244,7 +5389,9 @@ export class LlEmitter {
B.terminate(ret === "void" ? "ret void" : `ret ${ret} ${result}`);
const symbol =
kept.size > 0 ? `${mangleBorrowedFunction(fn.name)}.virtual` : mangleFunction(fn.name);
return `define internal ${ret} @${symbol}(${params.join(", ")}) ${FN_ATTRS} {\n${B.render()}\n}`;
if (unwinding) this.declare(`declare i32 @__gcc_personality_v0(...)`);
const personality = unwinding ? ` ${UNWIND_PERSONALITY}` : "";
return `define internal ${ret} @${symbol}(${params.join(", ")}) ${FN_ATTRS}${personality} {\n${B.render()}\n}`;
}
canBorrowCallArgument(value: IrExpr): boolean {
return canBorrowCallArgument(this, value);
@@ -5417,13 +5564,21 @@ export class LlEmitter {
private loopMetricsRead: boolean | undefined;
readsLoopMetrics(): boolean {
if (this.loopMetricsRead !== undefined) return this.loopMetricsRead;
const visitor = {
expr: (e: IrExpr): boolean =>
!(e.kind === "libCall" && (e.fn === "perf.loopStart" || e.fn === "perf.idleTime")),
stmt: (): boolean => true,
};
this.loopMetricsRead = !this.mod.functions.every((fn) => everyStmtList(fn.body, visitor));
return this.loopMetricsRead;
let found = false;
const scan = this.nodeIndex.scan(LIB_CALL_KINDS);
for (const fn of this.mod.functions) {
eachNodeOfKinds(fn, scan, (node) => {
if (
node.kind === "libCall" &&
(node.fn === "perf.loopStart" || node.fn === "perf.idleTime")
)
found = true;
return !found;
});
if (found) break;
}
this.loopMetricsRead = found;
return found;
}
private lastUseMovesAllowed(): boolean {
@@ -244,38 +244,46 @@ export function emitCallExpr(
? INTEGER_DISPATCH_SUFFIX
: "";
const target = `@${borrowed ? mangleBorrowedFunction(e.callee) : host.callTarget(e.callee)}${abi ? INT32_ABI_SUFFIX : ""}${dispatch}`;
// A call of an unwinding-ABI callee is an invoke (emitDirectCall):
// its landing pad unwinds, so no pending check follows it.
if (e.type.kind === "void") {
B.line(`call void ${target}(${argList})`);
if (host.mayThrow.has(e.callee)) host.emitPendingCheck();
const invoked = host.emitDirectCall(`call void ${target}(${argList})`, e.callee);
if (!invoked && host.mayThrow.has(e.callee)) host.emitPendingCheck();
if (borrowed) host.releaseTemps(host.frames.pop()!);
return { name: "", type: e.type };
}
const t = B.tmp();
if (abi?.union) {
B.line(`${t} = call ${SCALAR_UNION_RESULT} ${target}(${argList})`);
if (host.mayThrow.has(e.callee)) host.emitPendingCheck();
const invoked = host.emitDirectCall(
`${t} = call ${SCALAR_UNION_RESULT} ${target}(${argList})`,
e.callee,
);
if (!invoked && host.mayThrow.has(e.callee)) host.emitPendingCheck();
if (borrowed) host.releaseTemps(host.frames.pop()!);
if (stackResult) return host.stackScalarUnion(t, e.type);
return host.own({ name: host.boxScalarUnion(t, e.type), type: e.type });
}
if (abi?.result) {
B.line(`${t} = call i32 ${target}(${argList})`);
const invoked = host.emitDirectCall(`${t} = call i32 ${target}(${argList})`, e.callee);
const out = host.int32Result(t, e.type);
if (host.mayThrow.has(e.callee)) host.emitPendingCheck(out);
if (!invoked && host.mayThrow.has(e.callee)) host.emitPendingCheck(out);
if (borrowed) host.releaseTemps(host.frames.pop()!);
return out;
}
B.line(`${t} = call ${host.llType(e.type)} ${target}(${argList})`);
const invoked = host.emitDirectCall(
`${t} = call ${host.llType(e.type)} ${target}(${argList})`,
e.callee,
);
let out: LlValue;
if (borrowed && host.borrowedReturns.has(e.callee)) {
// A borrowed-return body hands back +0 (a null dummy when it threw):
// test the exception first, then own the result before the
// argument snapshots that keep it reachable are released.
if (host.mayThrow.has(e.callee)) host.emitPendingCheck();
if (!invoked && host.mayThrow.has(e.callee)) host.emitPendingCheck();
out = host.own({ name: host.retainFrame(t, e.type), type: e.type });
} else {
out = host.own({ name: t, type: e.type });
if (host.mayThrow.has(e.callee)) host.emitPendingCheck(out);
if (!invoked && host.mayThrow.has(e.callee)) host.emitPendingCheck(out);
}
if (borrowed) {
host.toFrame(out);
@@ -1366,10 +1374,11 @@ export function emitBorrowedResultCall(host: LlvmEmitterContext, e: ExprOf<"call
)
.join(", ");
const t = host.B.tmp();
host.B.line(
const invoked = host.emitDirectCall(
`${t} = call ${host.llType(e.type)} @${mangleBorrowedFunction(e.callee)}${abi ? INT32_ABI_SUFFIX : ""}(${argList}) ; borrowed result`,
e.callee,
);
if (host.mayThrow.has(e.callee)) host.emitPendingCheck();
if (!invoked && host.mayThrow.has(e.callee)) host.emitPendingCheck();
return { name: t, type: e.type };
}
@@ -52,6 +52,7 @@ import {
STRING_CHAR_CODE_AT_INT_MARKER,
emitAsciiChar,
emitAsciiCharCode,
emitUnitCharCode,
STRING_UTF16_LEN_DECL,
} from "./string-reads.js";
import { exactInteger, widenInteger } from "./integer-values.js";
@@ -321,6 +322,15 @@ export function charAtUnitRead(
return read;
}
/** The UTF-16 units an enclosing loop version proved the index of `e`
* below the count of (string-unit-versions.ts). */
function provenUnitsRead(host: LlvmEmitterContext, e: IrExpr): string | null {
const units = host.stringUnitReads.get(e);
if (units !== undefined) return units;
const origin = host.charAtUnitReads.get(e);
return origin !== undefined ? (host.stringUnitReads.get(origin) ?? null) : null;
}
/** An enclosing loop version proved the string of `e` ASCII and its index
* below the length (bytes-loop-versions.ts). */
function provenStringRead(host: LlvmEmitterContext, e: IrExpr): boolean {
@@ -363,9 +373,14 @@ export function emitStrIntrinsic(
const method = e.method;
switch (method) {
case "length": {
const t = B.tmp();
const length = host.stringLengthReads.get(e);
if (length !== undefined) {
B.line(`${t} = sitofp i64 ${length} to double`);
return { name: t, type: e.type };
}
// sc_str_utf16_len (string-reads.ts) answers proven-ASCII strings inline.
host.declare(STRING_UTF16_LEN_DECL);
const t = B.tmp();
B.line(`${t} = call double @sc_str_utf16_len(ptr ${r.name})`);
return { name: t, type: e.type };
}
@@ -375,6 +390,8 @@ export function emitStrIntrinsic(
const index = e.args[0] ? exactInteger(host, args[0]!, e.args[0]) : null;
if (index) {
const wide = widenInteger(host, index);
const units = provenUnitsRead(host, e);
if (units !== null) return { name: emitUnitCharCode(host, units, wide), type: e.type };
if (provenStringRead(host, e))
return { name: emitAsciiCharCode(host, r.name, wide), type: e.type };
host.declare(STRING_CHAR_CODE_AT_INT_MARKER);
@@ -1460,6 +1477,7 @@ export function emitMapLikeIntrinsic(
: emitStringMapGetScalar(host, r.name, stringKey!);
found = scalar.found;
if (stringKey !== null) rememberMapEntry(host, e, r.name, stringKey, scalar.entry);
else rememberMapEntry(host, e, r.name, numberKey!, scalar.entry, "double");
readHit = () => {
const hit = B.tmp();
B.line(
@@ -1531,7 +1549,13 @@ export function emitMapLikeIntrinsic(
const vAcc = elemAccess(receiverType.value);
if (vAcc === "ref") host.toHeap(v);
const ordinary = (): void => host.mapSet(r.name, kAcc, vAcc, k.name, v.name);
if (kAcc === "f64" && vAcc !== "ref" && host.readNumberMapWrites.has(e)) {
if (
(kAcc === "str" || kAcc === "f64") &&
vAcc !== "ref" &&
emitFusedMapSet(host, e, r.name, k.name, { name: v.name, access: vAcc }, ordinary)
) {
// Written through the read's entry, or inserted.
} else if (kAcc === "f64" && vAcc !== "ref" && host.readNumberMapWrites.has(e)) {
const bits = B.tmp();
B.line(
vAcc === "f64"
@@ -1539,12 +1563,7 @@ export function emitMapLikeIntrinsic(
: `${bits} = zext i1 ${v.name} to i64`,
);
emitNumberMapSetScalar(host, r.name, k.name, bits, ordinary);
} else if (
kAcc !== "str" ||
vAcc === "ref" ||
!emitFusedMapSet(host, e, r.name, k.name, { name: v.name, access: vAcc }, ordinary)
)
ordinary();
} else ordinary();
return { name: "", type: e.type };
}
case "add": {
@@ -30,6 +30,9 @@ export interface WideInteger {
name: string;
below: number;
bound: number;
/** The double is the integer rounded once: the result of one operation on
* operands that always equal their doubles. */
single?: true;
}
export type ExprOf<K extends IrExpr["kind"]> = Extract<IrExpr, { kind: K }>;
@@ -89,7 +89,7 @@ export function emitDynamicExpr(
// Bytes already box their shared mutable storage directly. A second
// capsule would split identity between checked and native views.
const errorRef = v.type.kind === "object" && host.shapeHost.isErrorClass(v.type.className);
// A TextEncoder or TextDecoder record boxes as its own handle, which
// A TextEncoder, TextDecoder or StringDecoder record boxes as its own handle, which
// keeps the record's identity (toDynHelper).
const codecRef = textCodecRecordClass(v.type, (id) => host.recordsById.get(id)) !== null;
if (!errorRef && !codecRef && ((e.liveRef && v.type.kind !== "bytes") || identityRef)) {
@@ -13,13 +13,14 @@ import { emitStringInputs } from "./string-lifetimes.js";
import { emitStringParts, numberPart, stringParts } from "./string-construction.js";
import { emitBorrowedInputs } from "./borrowed-inputs.js";
import {
alwaysExactInteger,
exactCompareOperands,
exactInteger,
int32Number,
integerNumber,
narrowInteger,
wideArithmetic,
widenInteger,
type LlInteger,
} from "./integer-values.js";
import {
bitwiseIdentityOperand,
@@ -45,7 +46,7 @@ import {
slotWidth,
type ArrayAccess,
} from "./dense-array-access.js";
import { emitWrappingSum, markWrappingOperands } from "./wrapping-sums.js";
import { emitWrappingSum, integerOrNaNRange, markWrappingOperands } from "./wrapping-sums.js";
import { emitPowerOfTwoQuotient, isPowerOfTwoCall } from "./lib-filesystem.js";
/** The signed integer predicates of the number compares. */
@@ -58,6 +59,63 @@ const INTEGER_COMPARES: Record<string, string> = {
"!==": "ne",
};
const UNSIGNED_COMPARES: Record<string, string> = {
"<": "ult",
"<=": "ule",
">": "ugt",
">=": "uge",
"===": "eq",
"!==": "ne",
};
/** A number compare of operands that are each an exact integer or an
* integer-or-NaN value (integerOrNaNRange: integer typed-array elements,
* `charCodeAt` results, sums of those), all within int32 or all within
* uint32: their 32-bit views compared, and NaN comparing false (unequal for
* `!==`). The 32-bit view is the value's ToUint32, which holds its bits
* whenever it is not NaN. Null when an operand has no such view. */
function emitIntegerOrNaNCompare(
host: LlvmEmitterContext,
e: ExprOf<"bin">,
l: LlValue,
li: LlInteger | null,
r: LlValue,
ri: LlInteger | null,
): string | null {
const lr = li?.range ?? integerOrNaNRange(host, e.left),
rr = ri?.range ?? integerOrNaNRange(host, e.right);
if (!lr || !rr) return null;
const signed = withinInt32(lr) && withinInt32(rr);
if (!signed && !(withinUint32(lr) && withinUint32(rr))) return null;
const bits = (value: LlValue, integer: LlInteger | null): string | undefined =>
integer ? narrowInteger(host, integer) : value.uint32;
const left = bits(l, li),
right = bits(r, ri);
if (left === undefined || right === undefined) return null;
const B = host.B;
const compare = B.tmp();
B.line(
`${compare} = icmp ${(signed ? INTEGER_COMPARES : UNSIGNED_COMPARES)[e.op]} i32 ${left}, ${right}`,
);
let result = compare;
for (const [value, integer] of [
[l, li],
[r, ri],
] as const) {
if (integer) continue;
const ordered = B.tmp(),
combined = B.tmp();
B.line(`${ordered} = fcmp ord double ${value.name}, 0.0`);
if (e.op === "!==") {
const unordered = B.tmp();
B.line(`${unordered} = xor i1 ${ordered}, true`);
B.line(`${combined} = or i1 ${result}, ${unordered}`);
} else B.line(`${combined} = and i1 ${result}, ${ordered}`);
result = combined;
}
return result;
}
export function emitLiteralExpr(
host: LlvmEmitterContext,
e: ExprOf<"numLit" | "boolLit" | "strLit" | "moduleNsRef" | "unitLit" | "varRef">,
@@ -363,14 +421,16 @@ export function emitOperatorExpr(
return value;
}
} else {
// An operand with only a wide view, such as `i + 1` of a speculated
// counter: compare the integers when both always equal the doubles.
const left = alwaysExactInteger(host, l, li),
right = left === null ? null : alwaysExactInteger(host, r, ri);
if (left !== null && right !== null) {
B.line(`${t} = icmp ${INTEGER_COMPARES[e.op]} i64 ${left}, ${right}`);
// An operand with only a wide view, such as `i + 1` or `2 * i + 1`
// of a speculated counter: compare the integers when that gives the
// doubles' result.
const operands = exactCompareOperands(host, l, li, r, ri);
if (operands) {
B.line(`${t} = icmp ${INTEGER_COMPARES[e.op]} i64 ${operands[0]}, ${operands[1]}`);
return { name: t, type: e.type };
}
const nanCoded = emitIntegerOrNaNCompare(host, e, l, li, r, ri);
if (nanCoded) return { name: nanCoded, type: e.type };
B.line(`${t} = fcmp ${cmp[e.op]} double ${l.name}, ${r.name}`);
}
} else if (bit[e.op] !== undefined) {
@@ -1,7 +1,7 @@
import { typedRefConstructor } from "./shapes.js";
/* Focused LLVM expression emission extracted from emitter.ts. */
import { InternalCompilerError } from "../../errors.js";
import { streamTypedRefEligible, undefinedArmTag } from "../../ir/analysis.js";
import { streamRooted, streamTypedRefEligible, undefinedArmTag } from "../../ir/analysis.js";
import {
type IrRecordShape,
type IrType,
@@ -843,8 +843,7 @@ export function streamTypedRefMaterializeAdapter(
host.declare(`declare ptr @scr_error_dyn_fields(ptr)`);
B.line(`${out} = call ptr @scr_error_dyn_fields(ptr %p)`);
} else if (
meta.def.runtime &&
RUNTIME_STREAM_CLASSES.has(t.className) &&
((meta.def.runtime && RUNTIME_STREAM_CLASSES.has(t.className)) || streamRooted(meta)) &&
!meta.def.instancePrototypeHelper &&
!meta.def.prototypeDataHelper
) {
@@ -111,13 +111,17 @@ export function wideArithmetic(
rv = view(r, ri);
if (!lv || !rv) return null;
let below: number, bound: number;
let single = false;
if (op === "*") {
if (lv.below !== Infinity || rv.below !== Infinity) return null;
below = EXACT_DOUBLES;
bound = productBound(lv.bound, rv.bound);
// Scaling by a power of two never rounds a double that stays finite.
below = powerOfTwo(li) || powerOfTwo(ri) ? Infinity : EXACT_DOUBLES;
single = true;
} else if (lv.below === Infinity && rv.below === Infinity) {
below = EXACT_DOUBLES;
bound = sumBound(lv.bound, rv.bound);
single = true;
} else if (lv.below === Infinity || rv.below === Infinity) {
const [exact, wide] = lv.below === Infinity ? [lv, rv] : [rv, lv];
below = Math.min(wide.below - exact.bound, EXACT_DOUBLES);
@@ -132,7 +136,50 @@ export function wideArithmetic(
host.B.line(
`${name} = ${op === "+" ? "add" : op === "-" ? "sub" : "mul"} nsw i64 ${left}, ${right}`,
);
return { name, below, bound };
return single ? { name, below, bound, single } : { name, below, bound };
}
/** Whether an exact integer is the constant 2^k or -2^k. */
function powerOfTwo(integer: LlInteger | null): boolean {
if (integer === null || integer.range.min !== integer.range.max) return false;
const value = Math.abs(integer.range.min);
// Math.log2 can round to an integer for a value that is not a power of
// two (2^52 - 1), so the candidate power is compared with the value.
return value >= 1 && value <= 2 ** 52 && 2 ** Math.round(Math.log2(value)) === value;
}
/** The i64 operands of a number compare that gives the doubles' result:
* both equal their doubles wherever they can be, or one is an exact integer
* of magnitude at most 2^53 - 1 and the other's double is its integer
* rounded once. Rounding is monotonic, and b - 1, b and b + 1 are doubles
* for a safe integer b, so an integer rounded once compares with b as the
* integer does. Null for any other pair. */
export function exactCompareOperands(
host: LlvmEmitterContext,
l: LlValue,
li: LlInteger | null,
r: LlValue,
ri: LlInteger | null,
): [string, string] | null {
const describe = (value: LlValue, integer: LlInteger | null) => {
if (integer) return { exact: true, single: true, bound: magnitude(integer.range) };
const wide = value.wideInteger;
if (!wide) return null;
return { exact: wide.bound <= wide.below, single: wide.single === true, bound: wide.bound };
};
const a = describe(l, li),
b = describe(r, ri);
if (!a || !b) return null;
const safe = (d: { exact: boolean; bound: number }): boolean =>
d.exact && d.bound <= Number.MAX_SAFE_INTEGER;
const ok =
(a.exact && b.exact) ||
(safe(a) && (b.exact || b.single)) ||
(safe(b) && (a.exact || a.single));
if (!ok) return null;
const operand = (value: LlValue, integer: LlInteger | null): string =>
integer ? widenInteger(host, integer) : value.wideInteger!.name;
return [operand(l, li), operand(r, ri)];
}
/** An i64 index for a consumer that compares it, unsigned, with a length or
@@ -1,6 +1,9 @@
import type { IrExpr, IrFunction, IrStmt } from "../../ir/ir.js";
import { everyExprChild, everyStmtChild } from "../../ir/traverse.js";
import { byteNumberAccess } from "../../ir/byte-numbers.js";
import { eachNodeOfKinds, type IrNodeIndex, type IrNodeKind } from "../../ir/node-index.js";
const LOOP_KINDS: readonly IrNodeKind[] = ["for", "forOf", "while", "doWhile"];
/** Keep a computed ToUint32 view beside an ordinary number when integer
* consumers in loops can reuse it. The number remains authoritative for floating
@@ -14,9 +17,16 @@ import { byteNumberAccess } from "../../ir/byte-numbers.js";
* compare converts the stored number back, and LLVM moves that number's
* conversion into the join of a NaN-coded typed-array read (`phi [sitofp x,
* 0.0]`), where nothing folds it back to the integer. */
export function findIntegerViews(fn: IrFunction): Set<string> {
export function findIntegerViews(fn: IrFunction, nodes?: IrNodeIndex): Set<string> {
const wanted = new Set<string>();
if (fn.async || fn.generator) return wanted;
// Every view is wanted inside a loop: a body without one wants none.
let loops = false;
eachNodeOfKinds(fn, nodes?.scan(LOOP_KINDS), (node, statement) => {
if (statement && LOOP_KINDS.includes(node.kind)) loops = true;
return !loops;
});
if (!loops) return wanted;
const initialized = new Set(fn.params.map((p) => p.localId));
const excluded = new Set(
[...(fn.captures ?? []), ...(fn.classCaptures ?? [])].map((c) => c.localId),
@@ -1,5 +1,8 @@
import type { IrExpr, IrFunction, IrStmt } from "../../ir/ir.js";
import { everyStmtList } from "../../ir/traverse.js";
import { eachNodeOfKinds, type IrNodeIndex, type IrNodeKind } from "../../ir/node-index.js";
const CLOSURE_KINDS: readonly IrNodeKind[] = ["closure"];
/** The local a node rebinds. Writes through the bound reference, such as a
* field store or an array push, leave the binding unchanged. Catch bindings
@@ -37,7 +40,11 @@ export class LazyCaptures {
private readonly pending = new Set<IrFunction>();
private sites: Map<string, { owner: string; captures: string[] }[]> | null = null;
constructor(private readonly functions: ReadonlyMap<string, IrFunction>) {}
constructor(
private readonly functions: ReadonlyMap<string, IrFunction>,
/** The module's node index, read instead of walking the bodies it holds. */
private readonly nodes?: IrNodeIndex,
) {}
/** The captures of closure body `fnName` that hold one value for the
* closure's whole life: at every creation site the captured binding is
@@ -103,16 +110,15 @@ export class LazyCaptures {
private creationSites(): Map<string, { owner: string; captures: string[] }[]> {
if (this.sites !== null) return this.sites;
const sites = new Map<string, { owner: string; captures: string[] }[]>();
const scan = this.nodes?.scan(CLOSURE_KINDS);
for (const fn of this.functions.values()) {
const visit = (node: IrStmt | IrExpr): boolean => {
eachNodeOfKinds(fn, scan, (node) => {
if (node.kind === "closure") {
let list = sites.get(node.fnName);
if (!list) sites.set(node.fnName, (list = []));
list.push({ owner: fn.name, captures: node.captures });
}
return true;
};
everyStmtList(fn.body, { stmt: visit, expr: visit });
});
}
this.sites = sites;
return sites;
@@ -649,6 +649,15 @@ export function emitGenericLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll
if (MAY_THROW_LIB_FNS.has(e.fn) && LIB_FN_SYMS[e.fn] === undefined) {
throw new LlvmUnsupportedError(`libCall:${e.fn}`, e.loc);
}
// A setter's write: the time value into the Date object (ScrDate.ms).
if (e.fn === "date.store") {
const date = emitBorrowedInput(host, e.args[0]!).name;
const ms = host.emitExpr(e.args[1]!).name;
const field = B.tmp();
B.line(`${field} = getelementptr inbounds i8, ptr ${date}, i64 8 ; ->ms`);
B.line(`store double ${ms}, ptr ${field}`);
return { name: ms, type: e.type };
}
// queueMicrotask's checked-dynamic form carries the AsyncLocalStorage
// context like the static one (lib-process.ts).
const sym =
@@ -119,6 +119,13 @@ export function emitChildProcessLibCall(host: LlvmEmitterContext, e: LibCallExpr
B.line(`call void @scr_child_on_error(ptr ${child.name}, ptr ${cb.name}, ptr @${adapter})`);
return { name: "", type: e.type };
}
if (e.fn === "child.hasChannel") {
const child = host.emitExpr(e.args[0]!);
host.declare(`declare zeroext i1 @scr_child_ipc_present(ptr)`);
const out = B.tmp();
B.line(`${out} = call i1 @scr_child_ipc_present(ptr ${child.name})`);
return { name: out, type: e.type };
}
if (e.fn === "child.connected") {
const child = host.emitExpr(e.args[0]!);
host.declare(`declare zeroext i1 @scr_child_ipc_connected(ptr)`);
@@ -37,6 +37,7 @@ export const ASSERT_VERDICT_LIB_FNS: ReadonlyMap<string, number | null> = new Ma
export const LIB_FN_SYMS: Record<string, string> = {
"assert.expectsErrDyn": "scr_assert_expects_err_dyn",
"assert.throwsValidate": "scr_assert_throws_validate",
"insp.jsonDyn": "scr_util_format_j",
"http.reqH2StreamOrThrow": "scr_http_req_h2_stream_or_throw",
"intl.segmenterNew": "scr_intl_segmenter_new",
@@ -82,6 +83,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"util.isDeepStrictEqual": "scr_util_is_deep_strict_equal",
"util.styleText": "scr_util_style_text",
"util.inherits": "scr_util_inherits",
"util.inheritsPrototype": "scr_util_inherits_prototype",
"util.deprecate": "scr_util_deprecate",
"util.callbackify": "scr_util_callbackify",
"util.promisifyDyn": "scr_util_promisify",
@@ -334,6 +336,8 @@ export const LIB_FN_SYMS: Record<string, string> = {
"date.getTime": "scr_date_get_time",
"date.valueOf": "scr_date_get_time",
"date.fromTime": "scr_date_get_time",
"date.setFields": "scr_date_set_fields",
"date.timeClip": "scr_date_new_ms",
"date.parseGetTime": "scr_date_parse",
"date.utc": "scr_date_utc",
"date.newLocal": "scr_date_new_local",
@@ -399,6 +403,9 @@ export const LIB_FN_SYMS: Record<string, string> = {
"assert.eqBool": "scr_assert_eq_bool",
"assert.eqSym": "scr_assert_eq_sym",
"assert.eqDyn": "scr_assert_eq_dyn",
"assert.messageValue": "scr_assert_message_value",
"assert.methodValue": "scr_assert_method_value",
"assert.messageClear": "scr_assert_message_clear",
"assert.looseResult": "scr_assert_loose_result",
"assert.deepResult": "scr_assert_deep_result",
"assert.looseDeepResult": "scr_assert_loose_deep_result",
@@ -1,5 +1,6 @@
import { F64, isRefCounted, type IrExpr, type IrFunction, type IrStmt } from "../../ir/ir.js";
import { everyStmtList, transformStmtList } from "../../ir/traverse.js";
import { eachNodeOfKinds, type IrNodeIndex, type IrNodeKind } from "../../ir/node-index.js";
import type { CallLifetimes } from "./call-lifetimes.js";
import type { ReferenceEffects } from "./reference-effects.js";
import type { OptionalArrayReads } from "./local-array-reads.js";
@@ -57,6 +58,8 @@ export function dynHoleRead(value: IrExpr): (IrExpr & { kind: "arrayGet" }) | nu
return same(cond.left.arr, then.arr) && same(cond.left.index, then.index) ? then : null;
}
const LOOP_KINDS: readonly IrNodeKind[] = ["forOf", "for", "while", "doWhile"];
/** Limit a borrow to a loop whose complete iteration preserves heap edges.
* Initialization and unrelated work elsewhere in the function may mutate.
* The array remains owned by its unchanged local or global binding; the
@@ -72,6 +75,8 @@ export function findLoopArrayBorrows(
optionalReads?: OptionalArrayReads,
writes?: ReferenceWrites | null,
functions?: ReadonlyMap<string, IrFunction>,
/** The module's node index, read instead of walking for the loops. */
nodes?: IrNodeIndex,
): ReadonlySet<IrStmt> {
const result = new Set<IrStmt>();
if (fn.async || fn.generator) return result;
@@ -88,9 +93,10 @@ export function findLoopArrayBorrows(
(!local.boxed && !local.tdz && (bindings?.has(local.id) || parameters.has(local.id)))
);
};
everyStmtList(fn.body, {
expr: () => true,
stmt: (loop) => {
eachNodeOfKinds(fn, nodes?.scan(LOOP_KINDS), (node, statement) => {
if (!statement) return true;
const loop = node as IrStmt;
{
if (loop.kind === "forOf") {
const local = locals.get(loop.localId);
if (
@@ -187,7 +193,7 @@ export function findLoopArrayBorrows(
},
});
return true;
},
}
});
return result;
}
@@ -137,12 +137,20 @@ test("a read followed by a write of the same map pairs, and the write reuses the
}
});
test("number keys keep the write-after-read lookup instead of pairing", () => {
// findReadNumberMapWrites stores such writes into the found entry through
// a read-only probe that LLVM merges with the read's.
const mod = fixture((map, k) => [declare(map, k), set(map, k, increment(F64))], F64, F64);
expect(pairs(mod)).toBe(0);
expect(body(mod)).not.toContain("mapfuse.hit");
test("number keys pair, and the write compares the key's bits with the read's", () => {
for (const value of [F64, BOOL]) {
const mod = fixture((map, k) => [declare(map, k), set(map, k, increment(value))], F64, value);
expect(pairs(mod)).toBe(1);
for (const bits of [32, 64] as const) {
const ir = body(mod, bits);
expect(ir).toContain("mapfuse.hit");
expect(ir).toContain("alloca double ; fused map read: key");
expect(ir).toMatch(/icmp eq i64 %t\d+, %t\d+/);
// The miss still inserts through the runtime.
expect(ir).toMatch(/call void @scr_map_set_f64_(?:f64|bool)\(/);
expect(ir).toContain("@sc_map_entry_f64(");
}
}
});
test("the single-statement form pairs an unconditional nested read", () => {
@@ -14,10 +14,10 @@
* key match by identity implies the SameValueZero match the set would find.
*
* Only scalar values (number, boolean) fuse: overwriting them releases
* nothing, exactly like the runtime's own overwrite. Only string-keyed
* typed maps fuse here: a number-key write after a read of the same key
* already probes through the read-only entry lookup that LLVM merges with
* the read's (findReadNumberMapWrites, map-read-lifetimes.ts).
* nothing, exactly like the runtime's own overwrite. String-keyed and
* number-keyed typed maps fuse. A number key matches when its bits are the
* read's: such keys are SameValueZero-equal, and a key that is equal with
* other bits (-0 for 0) takes the ordinary set.
*/
import type { IrExpr, IrFunction, IrStmt } from "../../ir/ir.js";
import { everyExprChild, everyStmtChild } from "../../ir/traverse.js";
@@ -30,7 +30,7 @@ type MapOp = Extract<IrExpr, { kind: "mapIntrinsic" }>;
/** One fused read/write pair. The slots exist once the read emits through
* an entry path; a write whose read took another path stays ordinary. */
export interface MapEntryPair {
slots: { map: string; key: string; entry: string } | null;
slots: { map: string; key: string; entry: string; keyType: "ptr" | "double" } | null;
}
export interface MapEntryFusion {
@@ -38,13 +38,14 @@ export interface MapEntryFusion {
writes: Map<IrExpr, MapEntryPair>;
}
/** A typed string-keyed Map read/write with a scalar value. */
/** A typed string- or number-keyed Map read/write with a scalar value. */
function fusableOp(e: IrExpr, method: "get" | "set"): e is MapOp {
if (e.kind !== "mapIntrinsic" || e.method !== method) return false;
const receiver = e.receiver.type;
if (receiver.kind !== "map") return false;
if (receiver.kind !== "map" || receiver.key.kind === "union") return false;
if (receiver.value.kind !== "f64" && receiver.value.kind !== "bool") return false;
if (mapKeyAccess(receiver.key) !== "str") return false;
const access = mapKeyAccess(receiver.key);
if (access !== "str" && access !== "f64") return false;
return method === "get" ? borrowsMapReadInputs(e) : borrowsMapMutationReceiver(e);
}
@@ -219,23 +220,25 @@ export function findMapEntryFusions(fn: IrFunction): MapEntryFusion {
return out;
}
/** After a fused read resolved `entry` for the string `key` in `map`: park
* all three for the paired write. */
/** After a fused read resolved `entry` for `key` in `map`: park all three
* for the paired write. `keyType` is the key's LLVM type: a string's ptr or
* a number's double. */
export function rememberMapEntry(
host: LlvmEmitterContext,
read: IrExpr,
map: string,
key: string,
entry: string,
keyType: "ptr" | "double" = "ptr",
): void {
const pair = host.mapEntryFusion?.reads.get(read);
if (!pair) return;
const B = host.B;
if (pair.slots === null) {
const slots = { map: B.slot(), key: B.slot(), entry: B.slot() };
const slots = { map: B.slot(), key: B.slot(), entry: B.slot(), keyType };
B.entryAllocas.push(
`${slots.map} = alloca ptr ; fused map read: map`,
`${slots.key} = alloca ptr ; fused map read: key`,
`${slots.key} = alloca ${keyType} ; fused map read: key`,
`${slots.entry} = alloca ptr ; fused map read: entry`,
`store ptr null, ptr ${slots.entry}`,
);
@@ -243,7 +246,7 @@ export function rememberMapEntry(
}
const slots = pair.slots;
B.line(`store ptr ${map}, ptr ${slots.map}`);
B.line(`store ptr ${key}, ptr ${slots.key}`);
B.line(`store ${slots.keyType} ${key}, ptr ${slots.key}`);
B.line(`store ptr ${entry}, ptr ${slots.entry}`);
}
@@ -270,13 +273,20 @@ export function emitFusedMapSet(
both = B.tmp(),
reuse = B.tmp();
B.line(`${parkedMap} = load ptr, ptr ${slots.map}`);
B.line(`${parkedKey} = load ptr, ptr ${slots.key}`);
B.line(`${parkedKey} = load ${slots.keyType}, ptr ${slots.key}`);
B.line(`${entry} = load ptr, ptr ${slots.entry}`);
// The entry is valid only between its read and this write.
B.line(`store ptr null, ptr ${slots.entry}`);
B.line(`${found} = icmp ne ptr ${entry}, null`);
B.line(`${sameMap} = icmp eq ptr ${parkedMap}, ${map}`);
B.line(`${sameKey} = icmp eq ptr ${parkedKey}, ${key}`);
if (slots.keyType === "ptr") B.line(`${sameKey} = icmp eq ptr ${parkedKey}, ${key}`);
else {
const parkedBits = B.tmp(),
keyBits = B.tmp();
B.line(`${parkedBits} = bitcast double ${parkedKey} to i64`);
B.line(`${keyBits} = bitcast double ${key} to i64`);
B.line(`${sameKey} = icmp eq i64 ${parkedBits}, ${keyBits}`);
}
B.line(`${both} = and i1 ${sameMap}, ${sameKey}`);
B.line(`${reuse} = select i1 ${found}, i1 ${both}, i1 false`);
const hit = B.newLabel("mapfuse.hit"),
@@ -314,6 +314,7 @@ export function emitStackMapRead(host: LlvmEmitterContext, read: LocalMapRead):
// Booleans are stored as 0/1, already the widened union payload.
const scalar = emitNumberMapGetScalar(host, receiver.name, number);
found = scalar.found;
rememberMapEntry(host, read.expr, receiver.name, number, scalar.entry, "double");
B.line(`store i64 ${scalar.bits}, ptr ${payload}`);
}
} else if (string !== null && !isRefCounted(read.value)) {
@@ -75,6 +75,19 @@ export function emitAsciiCharCode(host: LlvmEmitterContext, s: string, index: st
return code;
}
/** Unit `index` (an i64, below their count) of a string's UTF-16 `units`
* (ScrStrScan): one 16-bit load, as a double. */
export function emitUnitCharCode(host: LlvmEmitterContext, units: string, index: string): string {
const B = host.B;
const pointer = B.tmp(),
unit = B.tmp(),
code = B.tmp();
B.line(`${pointer} = getelementptr inbounds i16, ptr ${units}, i64 ${index}`);
B.line(`${unit} = load i16, ptr ${pointer}`);
B.line(`${code} = uitofp i16 ${unit} to double`);
return code;
}
/** The one-character string of unit `index` (an i64) of a proven-ASCII
* string `s` whose length covers it: the immortal `@sc_ascii_chars` entry
* for its byte, as the inline charAt helpers answer. */
@@ -114,6 +127,15 @@ function loadLength(sz: string): string[] {
];
}
/** Whether emitted reads use the runtime's scan receiver: from ordinary
* global storage, or, in worker executables, from the thread's static TLS
* block (executableTls). */
export function stringScanInline(host: LlvmEmitterContext): boolean {
return (
host.executableTls || (host.mod.workers !== true && host.mod.lib?.threadInstances !== true)
);
}
/** The runtime's ScrStrScan record and the declaration naming it. */
export function stringScanType(sz: string): string {
return `{ ptr, ptr, ${sz} }`;
@@ -0,0 +1,126 @@
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 type { IrModule, IrStmt } from "../../ir/ir.js";
import { unitSafeFunctions, unitSafeLoop } from "./string-unit-versions.js";
async function lower(source: string): Promise<IrModule> {
const dir = await mkdtemp(join(tmpdir(), "scriptc-unit-versions-"));
try {
const entry = join(dir, "main.ts");
const outPath = join(dir, "main.ir.json");
await writeFile(entry, source);
const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" });
if (!result.ok)
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
return deserializeModule(await readFile(outPath, "utf8"));
} finally {
await rm(dir, { recursive: true, force: true });
}
}
const loopOf = (mod: IrModule, name: string): IrStmt & { kind: "for" } =>
mod.functions.find((f) => f.name === name)!.body.find((s) => s.kind === "for") as IrStmt & {
kind: "for";
};
test("loops over a string's units call only functions that read no string", async () => {
const mod = await lower(`
function lower(c: number): boolean {
return c >= 97 && c <= 122;
}
function word(c: number): boolean {
return lower(c) || (c >= 48 && c <= 57) || Math.abs(c - 200) < 50;
}
function codeOf(t: string, i: number): number {
return t.charCodeAt(i);
}
function viaCodeOf(t: string): number {
return codeOf(t, 0);
}
function words(s: string): number {
let n = 0;
const starts = [0];
for (let i = 0; i < s.length; i++) {
if (word(s.charCodeAt(i))) n++;
else starts.push(i);
}
return n + starts.length;
}
function pairs(s: string, t: string): number {
let n = 0;
for (let i = 0; i < s.length; i++) n += s.charCodeAt(i) + codeOf(t, i);
return n;
}
function both(s: string, t: string): number {
let n = 0;
for (let i = 0; i < s.length; i++) n += s.charCodeAt(i) + t.charCodeAt(i);
return n;
}
function concatenates(s: string): string {
let out = "";
for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) === 32) out += "-";
return out;
}
console.log(words("ab c"), pairs("ab", "cd"), both("ab", "cd"), concatenates("a b"), viaCodeOf("x"));
`);
const safe = unitSafeFunctions(mod);
expect(safe.has("lower")).toBe(true);
expect(safe.has("word")).toBe(true);
// A string read, directly or through a call, makes a function unsafe.
expect(safe.has("codeOf")).toBe(false);
expect(safe.has("viaCodeOf")).toBe(false);
const param = (fn: string, index = 0): string =>
mod.functions.find((f) => f.name === fn)!.params[index]!.localId;
// A unit-safe loop answers its receiver's length reads: here the
// condition's `s.length`.
const lengths = unitSafeLoop(loopOf(mod, "words"), param("words"), safe);
expect(lengths?.size).toBe(1);
expect([...lengths!].every((e) => e.kind === "strIntrinsic" && e.method === "length")).toBe(true);
expect(unitSafeLoop(loopOf(mod, "pairs"), param("pairs"), safe)).toBeNull();
expect(unitSafeLoop(loopOf(mod, "both"), param("both"), safe)).toBeNull();
expect(unitSafeLoop(loopOf(mod, "concatenates"), param("concatenates"), safe)).toBeNull();
});
test("loops that sort with a comparator or copy an untyped source get no units copy", async () => {
// Either can run program code that indexes other strings, which evicts
// the receiver's units while the loop reads them.
const mod = await lower(`
function sorts(s: string, values: Float64Array, compare: (a: number, b: number) => number): number {
let n = 0;
for (let i = 0; i < s.length; i++) {
n += s.charCodeAt(i);
if (i === 3) values.sort(compare);
}
return n;
}
function copies(s: string, values: Float64Array, source: any): number {
let n = 0;
for (let i = 0; i < s.length; i++) {
n += s.charCodeAt(i);
if (i === 3) values.set(source);
}
return n;
}
function sortsNumbers(s: string, values: Float64Array): number {
let n = 0;
for (let i = 0; i < s.length; i++) {
n += s.charCodeAt(i);
if (i === 3) values.sort();
}
return n;
}
const values = new Float64Array([2, 1]);
console.log(sorts("abcdef", values, (a, b) => a - b), copies("abcdef", values, [4, 3]), sortsNumbers("abcdef", values));
`);
const safe = unitSafeFunctions(mod);
const param = (fn: string): string =>
mod.functions.find((f) => f.name === fn)!.params[0]!.localId;
expect(unitSafeLoop(loopOf(mod, "sorts"), param("sorts"), safe)).toBeNull();
expect(unitSafeLoop(loopOf(mod, "copies"), param("copies"), safe)).toBeNull();
// A numeric sort calls no program code.
expect(unitSafeLoop(loopOf(mod, "sortsNumbers"), param("sortsNumbers"), safe)).not.toBeNull();
});
@@ -0,0 +1,208 @@
import type { IrBytesIntrinsicMethod, IrExpr, IrModule, IrStmt } from "../../ir/ir.js";
import { everyExprChild, everyStmtChild } from "../../ir/traverse.js";
/* Loop versions over the UTF-16 units of a string that is not ASCII.
*
* The runtime publishes the receiver of an indexed scan with its UTF-16
* units (ScrStrScan, string-reads.ts), and drops them when the string is
* released or appended to in place, or when its index entry is evicted:
* the runtime indexes another non-ASCII string. A loop version's string
* receiver is a binding the loop does not write, which keeps it alive and
* unappended for the whole loop. Its fast copy over units therefore only
* needs a body that indexes no other string: the operations below, and
* calls of functions made of the same operations without string reads
* (`unitSafeFunctions`). */
const ARRAY_METHODS = new Set(["push", "pop", "length"]);
/* The typed-array and Buffer methods a units copy may run: those that
* call no program code and read no checked-dynamic value or string. A
* comparator sort (sortCompare) calls back into the program, and a copy
* from a checked-dynamic source (setFromDyn) reads its elements through
* getters; either can index another string, which evicts the receiver's
* units while the loop reads them. */
const UNIT_SAFE_BYTES_METHODS: ReadonlySet<IrBytesIntrinsicMethod> = new Set([
"buffer",
"length",
"byteLength",
"byteOffset",
"get",
"slice",
"subarray",
"copyWithin",
"sortNumeric",
"toReversed",
"with",
"toArray",
"setFrom",
"readNum",
"writeNum",
"readNumVar",
"writeNumVar",
"equals",
"compareBuf",
"indexOfNum",
"lastIndexOfNum",
"includesNum",
"indexOfElem",
"lastIndexOfElem",
"includesElem",
"fill",
"fillNum",
"fillElem",
"copy",
"swap16",
"swap32",
"swap64",
"dataViewNew",
"dvGetUint8",
"dvGetInt8",
"dvGetUint16",
"dvGetInt16",
"dvGetUint32",
"dvGetInt32",
"dvGetFloat16",
"dvGetFloat32",
"dvGetFloat64",
"dvGetBigUint64Number",
"dvGetBigInt64Number",
"dvSetUint8",
"dvSetInt8",
"dvSetUint16",
"dvSetInt16",
"dvSetUint32",
"dvSetInt32",
"dvSetFloat16",
"dvSetFloat32",
"dvSetFloat64",
]);
/** Whether `e` (or statement `s`) uses only operations that index no
* string: numbers, booleans, locals, records and class fields, typed
* arrays, number arrays and Math. `reads` admits the string reads a
* version covers; `calls` the callees. */
function unitSafe(
node: { e: IrExpr } | { s: IrStmt },
reads: (e: IrExpr & { kind: "strIntrinsic" }) => boolean,
calls: (callee: string) => boolean,
): boolean {
const expr = (e: IrExpr): boolean => {
switch (e.kind) {
case "varRef":
case "numLit":
case "boolLit":
case "bin":
case "unary":
case "logical":
case "ternary":
case "toBool":
case "seqExpr":
case "assignExpr":
case "incDec":
case "recordGet":
case "fieldGet":
break;
case "bytesIntrinsic":
if (!UNIT_SAFE_BYTES_METHODS.has(e.method)) return false;
break;
case "arrayGet":
if (e.type.kind !== "f64" && e.type.kind !== "bool") return false;
break;
case "arrIntrinsic": {
// Growing or shrinking an array of numbers allocates; it reads no
// string.
const array = e.receiver.type;
if (
!ARRAY_METHODS.has(e.method) ||
array.kind !== "array" ||
(array.elem.kind !== "f64" && array.elem.kind !== "bool")
)
return false;
break;
}
case "libCall":
if (!e.fn.startsWith("math.")) return false;
break;
case "call":
if (!calls(e.callee)) return false;
break;
case "strIntrinsic":
if (!reads(e)) return false;
break;
default:
return false;
}
return everyExprChild(e, expr, stmt);
};
const stmt = (s: IrStmt): boolean => {
switch (s.kind) {
case "varDecl":
case "assign":
case "exprStmt":
case "if":
case "block":
case "while":
case "doWhile":
case "for":
case "break":
case "continue":
case "return":
case "recordSet":
case "fieldSet":
case "bytesSet":
break;
default:
return false;
}
return everyStmtChild(s, expr, stmt);
};
return "e" in node ? expr(node.e) : stmt(node.s);
}
/** The module's functions a units version may call: synchronous
* functions without captures whose bodies use only unit-safe operations,
* read no string, and call only such functions. */
export function unitSafeFunctions(mod: IrModule): ReadonlySet<string> {
const candidates = new Map(
mod.functions
.filter((fn) => !fn.async && !fn.generator && !(fn.captures?.length ?? 0))
.map((fn) => [fn.name, fn]),
);
const safe = new Set(candidates.keys());
for (let settled = false; !settled;) {
settled = true;
for (const name of safe) {
const fn = candidates.get(name)!;
const ok = fn.body.every((s) =>
unitSafe(
{ s },
() => false,
(callee) => safe.has(callee),
),
);
if (!ok) {
safe.delete(name);
settled = false;
}
}
}
return safe;
}
/** The `length` reads of the binding `receiver` in a loop whose every
* part is unit-safe, reading only charCodeAt and length of `receiver` and
* calling only `safe` functions; null for any other loop. */
export function unitSafeLoop(
loop: IrStmt & { kind: "for" | "while" },
receiver: string,
safe: ReadonlySet<string>,
): ReadonlySet<IrExpr> | null {
const lengths = new Set<IrExpr>();
const reads = (e: IrExpr & { kind: "strIntrinsic" }): boolean => {
if (e.receiver.kind !== "varRef" || e.receiver.localId !== receiver) return false;
if (e.method === "length") lengths.add(e);
return e.method === "charCodeAt" || e.method === "length";
};
const calls = (callee: string): boolean => safe.has(callee);
return unitSafe({ s: loop }, reads, calls) ? lengths : null;
}
@@ -0,0 +1,285 @@
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";
import { UnwindAbiViolation, bridgePlainUnwindCalls } from "./unwind-abi.js";
async function moduleFor(source: string) {
const dir = await mkdtemp(join(tmpdir(), "scriptc-unwind-abi-"));
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 module;
} finally {
await rm(dir, { recursive: true, force: true });
}
}
/** The definition of the source function `name` (its i32 or borrowing entry when it has one). */
function body(ir: string, name: string): string {
return new RegExp(
`^define internal [^\\n]*@sc_b?f_[^\\n(]*${name}(?:\\.i32)?\\([^]*?^}`,
"m",
).exec(ir)![0];
}
const SOURCE = `
function leaf(n: number): number {
if (n === 3) throw new RangeError("three");
return n * 2;
}
function first(n: number): number {
return leaf(n) + 1;
}
function second(n: number): number {
return first(n) * 3;
}
function third(n: number): number {
return second(n) - 2;
}
function mid(n: number): number {
const parts: string[] = ["a" + n];
const r = third(n);
parts.push("b");
return r + parts.length;
}
class Counter {
total = 0;
add(n: number): void { this.total += mid(n); }
}
function outer(n: number): string {
try { return "ok " + mid(n); } catch (e) { return "caught " + (e as Error).message; }
}
const counter = new Counter();
for (let i = 0; i < 5; i++) { console.log(outer(i)); try { counter.add(i); } catch { console.log("add", i); } }
const f = (x: number) => mid(x);
console.log(f(1), counter.total);
`;
const LINUX = { targetTriple: "x86_64-unknown-linux-gnu", unwindAbi: true } as const;
test("directly called functions raise, and their callers invoke them without a pending check", async () => {
const ir = emitLlvmModule(await moduleFor(SOURCE), LINUX);
// leaf throws: the exception starts on the pending flag, and the three
// calls above it test the flag too; mid raises it on.
for (const name of ["leaf", "first", "second", "third"])
expect(body(ir, name)).not.toContain("@scr_unwind_raise");
const mid = body(ir, "mid");
expect(mid).toMatch(/= call double @sc_b?f_[^(]*third\(/);
expect(mid).toContain("call void @scr_unwind_raise()");
// outer invokes mid; the landing pad takes the exception to its handler.
const outer = body(ir, "outer");
expect(outer).toMatch(
/= invoke double @sc_b?f_[^(]*mid[^\n]* to label %inv\.ok\d+ unwind label %inv\.lp\d+/,
);
expect(outer).toMatch(/inv\.lp\d+:\n\s+%t\d+ = landingpad \{ ptr, i32 \} cleanup/);
expect(outer).toContain("personality ptr @__gcc_personality_v0");
expect(ir).toContain("declare i32 @__gcc_personality_v0(...)");
expect(ir).toContain("declare void @scr_unwind_raise() cold");
});
test("a method of a class outside any hierarchy is on the unwinding ABI; closures keep the flag", async () => {
const ir = emitLlvmModule(await moduleFor(SOURCE), LINUX);
// Counter.add invokes mid and resumes the unwind from its landing pad; the
// closure over mid is a pointer target and returns with the flag.
const add = body(ir, "Counter_add");
expect(add).toMatch(/invoke double @sc_b?f_[^(]*mid/);
expect(add).toMatch(/resume \{ ptr, i32 \} %[\w.]+/);
expect(add).not.toContain("@scr_unwind_raise");
const closures = ir
.split(/\n(?=define )/)
.filter((def) => /^define internal [^\n]*@sc_b?f_[^\n(]*fn_/.test(def));
expect(closures.length).toBeGreaterThan(0);
for (const def of closures) expect(def).not.toContain("call void @scr_unwind_raise()");
});
test("recursive functions keep the pending flag, also when they recurse through a callback", async () => {
const source = `
function leaf(n: number): number {
if (n < 0) throw new RangeError("negative");
return n;
}
function one(n: number): number { return leaf(n) + 1; }
function two(n: number): number { return one(n) + 1; }
function three(n: number): number { return two(n) + 1; }
function four(n: number): number {
const parts: string[] = ["a" + n];
const r = three(n);
return r + parts.length;
}
function walk(n: number, depth: number): number {
const parts: string[] = ["w" + n];
const r = depth === 0 ? four(n) : walk(n, depth - 1);
return r + parts.length;
}
function visit(n: number, depth: number, each: (n: number, depth: number) => number): number {
const parts: string[] = ["v" + n];
const r = depth === 0 ? four(n) : each(n, depth - 1);
return r + parts.length;
}
const each = (n: number, depth: number): number => visit(n, depth, each);
for (let i = -1; i < 2; i++) {
try { console.log(walk(i, 3), visit(i, 3, each)); } catch (e) { console.log((e as Error).message); }
}
`;
const ir = emitLlvmModule(await moduleFor(source), LINUX);
expect(body(ir, "four")).toContain("call void @scr_unwind_raise()");
for (const name of ["walk", "visit"]) {
expect(body(ir, name)).not.toContain("@scr_unwind_raise");
expect(body(ir, name)).toMatch(/invoke double @sc_b?f_[^(]*four/);
}
});
test("without the option, and off Linux, emission keeps the pending-flag ABI", async () => {
const mod = await moduleFor(SOURCE);
for (const options of [{}, { targetTriple: "arm64-apple-macosx14.0.0", unwindAbi: true }]) {
const ir = emitLlvmModule(mod, options);
expect(ir).not.toContain("invoke ");
expect(ir).not.toContain("@scr_unwind_raise");
expect(ir).not.toContain("personality");
}
});
test("bridgePlainUnwindCalls keeps the pending-flag contract at plain calls of raising functions", () => {
const raising = [
"define internal ptr @sc_bf_raises(ptr %p) #0 { ; raises",
"entry:",
" call void @scr_unwind_raise()",
" unreachable",
"}",
].join("\n");
const caller = [
"define internal ptr @sc_f_adapter(ptr %p0) #0 {",
"entry:",
" %result = call ptr @sc_bf_raises(ptr %p0) ; borrowed result",
" br label %next",
"next:",
" %v = phi ptr [ %result, %entry ]",
" ret ptr %v",
"}",
].join("\n");
const [out] = bridgePlainUnwindCalls([`${raising}\n${caller}`]);
expect(out).toContain(
"%result.ub = invoke ptr @sc_bf_raises(ptr %p0) to label %ub.n.0 unwind label %ub.lp.0",
);
expect(out).toContain("%result = phi ptr [ %result.ub, %ub.n.0 ], [ null, %ub.lp.0 ]");
// The split block's successor now comes from its last part.
expect(out).toContain("%v = phi ptr [ %result, %ub.ok.0 ]");
expect(out).toMatch(
/define internal ptr @sc_f_adapter\(ptr %p0\) #0 personality ptr @__gcc_personality_v0 \{/,
);
expect(out).toContain("declare i32 @__gcc_personality_v0(...)");
});
test("an invoked wrapper of a raising function resumes the unwind; its address must not escape", () => {
const raising = [
"define internal double @sc_bf_body.i32(i32 %a) #0 {",
"entry:",
" call void @scr_unwind_raise()",
" unreachable",
"}",
].join("\n");
const wrapper = [
"define internal double @sc_bf_body(double %a) #0 {",
"entry:",
" %i = fptosi double %a to i32",
" %r = call double @sc_bf_body.i32(i32 %i)",
" ret double %r",
"}",
].join("\n");
const caller = [
"define internal double @sc_bf_caller(double %a) #0 personality ptr @__gcc_personality_v0 {",
"entry:",
" %r = invoke double @sc_bf_body(double %a) to label %ok unwind label %lp",
"lp:",
" %x = landingpad { ptr, i32 } cleanup",
" call void @scr_unwind_raise()",
" unreachable",
"ok:",
" ret double %r",
"}",
].join("\n");
const [out] = bridgePlainUnwindCalls([[raising, wrapper, caller].join("\n")]);
expect(out).toContain(
"%r = invoke double @sc_bf_body.i32(i32 %i) to label %ub.ok.0 unwind label %ub.lp.0",
);
expect(out).toContain("resume { ptr, i32 } %ub.v.0");
expect(() =>
bridgePlainUnwindCalls([
`${raising}\n@table = internal constant [1 x ptr] [ptr @sc_bf_body.i32]`,
]),
).toThrow(UnwindAbiViolation);
try {
bridgePlainUnwindCalls([
`${raising}\n@table = internal constant [1 x ptr] [ptr @sc_bf_body.i32]`,
]);
} catch (error) {
expect([...(error as UnwindAbiViolation).symbols]).toEqual(["sc_bf_body.i32"]);
}
});
test("an invoked function that never tests the pending flag needs no raise", () => {
const raising = [
"define internal void @sc_bf_raises() #0 {",
"entry:",
" call void @scr_unwind_raise()",
" unreachable",
"}",
].join("\n");
const leaf = [
"define internal double @sc_bf_leaf() #0 {",
"entry:",
" ret double 1.0",
"}",
].join("\n");
const caller = (callee: string) =>
[
"define internal double @sc_bf_caller() #0 personality ptr @__gcc_personality_v0 {",
"entry:",
` %r = invoke double @${callee}() to label %ok unwind label %lp`,
"lp:",
" %x = landingpad { ptr, i32 } cleanup",
" call void @scr_unwind_raise()",
" unreachable",
"ok:",
" ret double %r",
"}",
].join("\n");
expect(() =>
bridgePlainUnwindCalls([[raising, leaf, caller("sc_bf_leaf")].join("\n")]),
).not.toThrow();
const testing = leaf.replace(
" ret double 1.0",
" %f = load i32, ptr @scr_exc_flag\n ret double 1.0",
);
expect(() =>
bridgePlainUnwindCalls([[raising, testing, caller("sc_bf_leaf")].join("\n")]),
).toThrow(UnwindAbiViolation);
});
test("calls that cannot unwind are marked nounwind", () => {
const raising = [
"define internal void @sc_bf_raises(ptr %b) #0 {",
"entry:",
" %n = call i64 @scr_bytes_length(ptr %b) ; length",
" call void @scr_bytes_release(ptr %b), !dbg !4",
" %r = call ptr %b(ptr %b)",
" call void @scr_unwind_raise()",
" unreachable",
"}",
].join("\n");
const [out] = bridgePlainUnwindCalls([raising]);
expect(out).toContain("%n = call i64 @scr_bytes_length(ptr %b) nounwind ; length");
expect(out).toContain("call void @scr_bytes_release(ptr %b) nounwind, !dbg !4");
expect(out).toContain("%r = call ptr %b(ptr %b) nounwind");
expect(out).toContain(" call void @scr_unwind_raise()\n");
});
@@ -0,0 +1,491 @@
/* The functions that propagate exceptions by unwinding instead of the
* pending flag (see unwindAbiFunctions). */
import type { IrExpr, IrModule } from "../../ir/ir.js";
import { everyStmtList } from "../../ir/traverse.js";
/** How many calls above a `throw` functions keep the pending flag. */
const THROW_LEVELS = 3;
/** The call graph's node for calls through values. */
const ANY_FUNCTION = " any function";
/** The nodes of `edges` on a cycle (a recursion), by Tarjan's algorithm
* without recursion of its own: a large module's call chains are deeper
* than the host stack. */
function recursiveFunctions(edges: ReadonlyMap<string, readonly string[]>): Set<string> {
const index = new Map<string, number>();
const low = new Map<string, number>();
const stack: string[] = [];
const onStack = new Set<string>();
const result = new Set<string>();
let counter = 0;
const enter = (name: string): { name: string; next: number } => {
index.set(name, counter);
low.set(name, counter++);
stack.push(name);
onStack.add(name);
return { name, next: 0 };
};
for (const root of edges.keys()) {
if (index.has(root)) continue;
const work = [enter(root)];
while (work.length > 0) {
const top = work[work.length - 1]!;
const out = edges.get(top.name) ?? [];
if (top.next < out.length) {
const callee = out[top.next++]!;
if (!index.has(callee)) work.push(enter(callee));
else if (onStack.has(callee))
low.set(top.name, Math.min(low.get(top.name)!, index.get(callee)!));
continue;
}
work.pop();
const parent = work[work.length - 1];
if (parent !== undefined)
low.set(parent.name, Math.min(low.get(parent.name)!, low.get(top.name)!));
if (low.get(top.name) !== index.get(top.name)) continue;
const members: string[] = [];
let member: string;
do {
member = stack.pop()!;
onStack.delete(member);
members.push(member);
} while (member !== top.name);
if (members.length > 1 || out.includes(top.name)) for (const m of members) result.add(m);
}
}
return result;
}
/** The may-throw functions whose exceptions reach their callers by
* unwinding the native stack (scr_unwind_raise) instead of returning with
* the pending flag set. Their callers call them with `invoke`, so the call
* needs no flag test; the landing pad runs the frame's unwind.
*
* A function qualifies only when every way it can be reached is a direct
* `call` from emitted code that can lower it as an `invoke`: it is not
* async or a generator, not a constructor, not a method a virtual call can
* reach or one a vtable holds (an overridden method), not a closure
* target, not the entry or a library export, not named by any node other
* than a `call`'s callee, and not called from an async or generator body
* (coroutine bodies keep plain calls).
*
* A native unwind costs about 2 µs a raise and 0.1 µs a frame, against a
* few nanoseconds for a flag test, so the functions an exception is likely to
* cross many of keep the flag: those with a `throw` or rethrow of their own
* and those up to THROW_LEVELS calls above one (a handler near the throw
* takes it without a native unwind), and the recursive ones (deep stacks
* are recursions). */
export function unwindAbiFunctions(
mod: IrModule,
mayThrow: ReadonlySet<string>,
excluded: ReadonlySet<string> = new Set(),
): Set<string> {
const names = new Set(mod.functions.map((fn) => fn.name));
const blocked = new Set<string>(excluded);
blocked.add(mod.entry);
// The runtime calls the error property readers through the pointers the
// module installs (emit-module.ts).
blocked.add("%error.name.read");
blocked.add("%error.message.read");
for (const entry of mod.lib?.exports ?? []) blocked.add(entry.fnName);
// Methods a virtual call can reach, and the overridden methods of class
// hierarchies (whose vtables list them), are called through pointers; the
// others are called directly, like functions. A virtual call reaches its listed
// targets, or else the method in its receiver class's hierarchy; a
// receiver that is no class of the module (an interface) may reach any
// class's method of that name.
const classes = new Map((mod.classes ?? []).map((cls) => [cls.name, cls]));
const anyClass = new Set<string>();
for (const fn of mod.functions)
everyStmtList(fn.body, {
expr: (e: IrExpr) => {
if (e.kind !== "virtualCall") return true;
if (e.targets !== undefined) for (const target of e.targets) blocked.add(target);
else if (classes.has(e.className)) blocked.add(`%${e.className}.${e.method}`);
else anyClass.add(e.method);
return true;
},
stmt: () => true,
});
// A hierarchy's vtables hold the methods declared by more than one of its
// classes (an override and what it overrides).
const rootOf = (name: string): string => {
let cls = classes.get(name);
while (cls?.base !== undefined && classes.has(cls.base)) cls = classes.get(cls.base);
return cls?.name ?? name;
};
const declarers = new Map<string, number>();
for (const cls of mod.classes ?? [])
for (const m of cls.methods ?? []) {
const key = `${rootOf(cls.name)}\0${m}`;
declarers.set(key, (declarers.get(key) ?? 0) + 1);
}
for (const cls of mod.classes ?? []) {
blocked.add(`%${cls.name}.constructor`);
blocked.add(`%${cls.name}.construct`);
const root = rootOf(cls.name);
for (const m of cls.methods ?? [])
if (anyClass.has(m) || declarers.get(`${root}\0${m}`)! > 1) blocked.add(`%${cls.name}.${m}`);
}
// Any function a node names other than as a call's callee may be reached
// through a pointer.
const referenced = new Set<string>();
const noteNames = (node: object, skip: string | undefined): void => {
for (const key in node) {
if (key === skip) continue;
const value = (node as Record<string, unknown>)[key];
if (typeof value === "string" && names.has(value)) referenced.add(value);
}
};
// The call graph, for the functions near a throw and the recursive ones.
// Calls through values (closures, class values, dynamic calls, callbacks
// passed to the runtime) reach ANY_FUNCTION, which reaches every function
// a value can hold.
const throws = new Set<string>();
const callers = new Map<string, string[]>();
const callees = new Map<string, string[]>();
const edge = (from: string, to: string): void => {
let list = callers.get(to);
if (list === undefined) callers.set(to, (list = []));
list.push(from);
let out = callees.get(from);
if (out === undefined) callees.set(from, (out = []));
out.push(to);
};
const methodsNamed = new Map<string, string[]>();
for (const cls of mod.classes ?? [])
for (const m of cls.methods ?? []) {
let list = methodsNamed.get(m);
if (list === undefined) methodsNamed.set(m, (list = []));
list.push(`%${cls.name}.${m}`);
}
for (const fn of mod.functions) {
const coroutine = fn.async === true || fn.generator !== undefined;
everyStmtList(fn.body, {
expr: (e: IrExpr) => {
const kind = e.kind as string;
if (kind === "throw" || kind === "rethrow") throws.add(fn.name);
if (e.kind === "call") {
edge(fn.name, e.callee);
// The entry's callees (the module initializers) run once: on the
// flag they keep landing pads out of the top-level code inlined
// into the entry.
if (coroutine || fn.name === mod.entry) blocked.add(e.callee);
noteNames(e, "callee");
return true;
}
noteNames(e, undefined);
if (e.kind === "virtualCall")
for (const target of e.targets ?? methodsNamed.get(e.method) ?? []) edge(fn.name, target);
else if (e.kind === "new") {
edge(fn.name, `%${e.className}.constructor`);
edge(fn.name, `%${e.className}.construct`);
} else if (kind === "callValue" || kind === "dynCall" || kind === "newValue")
edge(fn.name, ANY_FUNCTION);
else if ("args" in e) {
const args: unknown = e.args;
if (Array.isArray(args))
for (const arg of args as IrExpr[])
if (arg.type.kind === "func") edge(fn.name, ANY_FUNCTION);
}
return true;
},
stmt: (s) => {
const kind = s.kind as string;
if (kind === "throw" || kind === "rethrow") throws.add(fn.name);
noteNames(s, undefined);
return true;
},
});
}
for (const name of referenced) {
blocked.add(name);
edge(ANY_FUNCTION, name);
}
// A throw starts on the pending flag, and so do the functions up to
// THROW_LEVELS calls above it. The union narrowing helpers' throws (a
// failed narrowing) don't count for their callers.
for (const name of throws) blocked.add(name);
let level = [...throws].filter((name) => !name.startsWith("%union."));
const near = new Set(level);
for (let i = 0; i < THROW_LEVELS; i++) {
const next: string[] = [];
for (const name of level)
for (const caller of callers.get(name) ?? [])
if (!near.has(caller)) {
near.add(caller);
next.push(caller);
}
level = next;
}
for (const name of near) blocked.add(name);
for (const name of recursiveFunctions(callees)) blocked.add(name);
const result = new Set<string>();
for (const fn of mod.functions) {
if (!mayThrow.has(fn.name) || blocked.has(fn.name)) continue;
if (fn.async === true || fn.generator !== undefined) continue;
result.add(fn.name);
}
return result;
}
/** What an unwinding-ABI function's unwind ends with when no handler of
* its own takes the exception. */
export const UNWIND_RAISE = "call void @scr_unwind_raise()\n unreachable";
/** What the unwind of a landing pad ends with when no handler of the
* function takes the exception: the unwind goes on (`resume`) with the
* exception the landing pad stored in UNWIND_SLOT. The block builder
* expands it (BlockBuilder.terminate). */
export const UNWIND_RESUME = "resume { ptr, i32 } <unwind slot>";
/** The function's slot for the exception its landing pads take. */
export const UNWIND_SLOT = "%unw.exc";
/** scr_unwind_raise's declaration. It is not `noreturn`: sanitized builds
* would call the sanitizer's no-return hook before it, which fails on fiber
* stacks; scr_unwind_raise clears the unwound frames' shadow itself. */
export const UNWIND_RAISE_DECLARATION = "declare void @scr_unwind_raise() cold";
/** The personality of functions with landing pads: the landing pads are
* cleanups that scr_unwind_raise's forced unwind runs. */
export const UNWIND_PERSONALITY = "personality ptr @__gcc_personality_v0";
/** An unwind leaving the function: a raise, or a landing pad's `resume`. */
const UNWINDS = /call void @scr_unwind_raise\(\)|\n\s+resume /;
/** A pending-flag test: the thread's exception word, the active cell, or the runtime query. */
const PENDING_TEST = /@scr_exc_flag\b|%exc\.cell\b|@scr_exc_active\b|@scr_exc_pending\(/;
/** Functions emission found breaking the unwinding ABI's rules
* (bridgePlainUnwindCalls), by symbol: the caller emits the module again
* with them on the pending-flag ABI. */
export class UnwindAbiViolation extends Error {
readonly symbols: ReadonlySet<string>;
constructor(symbols: ReadonlySet<string>) {
super(`unwinding ABI: ${[...symbols].slice(0, 3).join(", ")} must keep the pending flag`);
this.symbols = symbols;
}
}
const DEFINE_NAME = /^define [^@\n]*@([\w.$%-]+)\(/;
const PLAIN_CALL = /^(\s*)(%[\w.$-]+) = call (.*?)@([\w.$%-]+)\(|^(\s*)call (.*?)@([\w.$%-]+)\(/;
const RETURN_ATTRIBUTES =
/\b(?:zeroext|signext|noalias|nonnull|noundef|inreg|dereferenceable(?:_or_null)?\(\d+\)|align \d+)\s+/g;
/** A call instruction's line. */
const CALL_LINE = /^\s*(?:%[\w.$-]+ = )?(?:(?:tail|musttail|notail) )?call /;
/** A call's callee (a function or a register) and the parenthesis that
* opens its arguments. */
const CALLEE = /[@%][\w.$%-]+\(/;
/** The index of the parenthesis that closes the argument list opened at `open`. */
function argumentsEnd(line: string, open: number): number {
let depth = 0;
let close = open;
for (; close < line.length; close++) {
const c = line.charAt(close);
if (c === "(") depth++;
else if (c === ")" && --depth === 0) break;
}
return close;
}
/** A call that cannot unwind, marked so: only scr_unwind_raise and the
* raising functions propagate an unwind (runtime code and every function
* reached through a pointer return normally), so every other call is
* `nounwind`, and the inliner keeps it a call where it inlines a raising
* function at an invoke. */
function markNounwind(line: string): string {
const call = line.indexOf("call ");
const callee = CALLEE.exec(line.slice(call));
if (callee === null || callee[0] === "@scr_unwind_raise(") return line;
const close = argumentsEnd(line, call + callee.index + callee[0].length - 1);
return close >= line.length
? line
: `${line.slice(0, close + 1)} nounwind${line.slice(close + 1)}`;
}
/** 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 {
if (type === "ptr") return "null";
if (type === "double" || type === "float") return "0.0";
if (/^i\d+$/.test(type)) return "0";
return "zeroinitializer";
}
/** Rewrites every remaining plain call of an unwinding-ABI function (one
* that raises, scr_unwind_raise, or resumes an unwind) in `parts`, the module's text, into an
* invoke whose landing pad continues after the call with the exception
* pending and the call's dummy result: the pending-flag contract those
* call sites were emitted for, whichever lowering emitted them. Calls of
* 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 )/));
for (const chunks of split)
for (const chunk of chunks) {
const m = DEFINE_NAME.exec(chunk);
if (m !== null && UNWINDS.test(chunk)) raisers.add(m[1]!);
}
if (raisers.size === 0) return parts;
// An invoke's callee must raise or never test the pending flag: one that
// returned with the flag pending would pass the exception by its invoking
// caller unseen. A wrapper an invoke reaches (an int32 or borrowed-ABI
// entry forwarding to the body) propagates its callee's unwind instead
// (`resume`), so it raises too. A raising function must be reached only by
// calls: a pointer to it (a vtable or table entry, a runtime callback)
// could be called without a landing pad. Functions that break either rule
// go back to the pending-flag ABI (UnwindAbiViolation).
const violations = new Set<string>();
const invoked = new Set<string>();
const testsFlag = new Set<string>();
for (const chunks of split)
for (const chunk of chunks) {
const m = DEFINE_NAME.exec(chunk);
if (m !== null && PENDING_TEST.test(chunk)) testsFlag.add(m[1]!);
for (const c of chunk.matchAll(/ invoke [^@\n]*@([\w.$%-]+)\(/g)) invoked.add(c[1]!);
}
const resumers = new Set<string>();
for (let grew = true; grew;) {
grew = false;
for (const chunks of split)
for (const chunk of chunks) {
const m = DEFINE_NAME.exec(chunk);
const name = m?.[1];
if (name === undefined || raisers.has(name) || !invoked.has(name)) continue;
if (![...chunk.matchAll(/ call [^@\n]*@([\w.$%-]+)\(/g)].some((c) => raisers.has(c[1]!)))
continue;
if (testsFlag.has(name)) continue;
raisers.add(name);
resumers.add(name);
grew = true;
}
}
for (const name of invoked) if (!raisers.has(name) && testsFlag.has(name)) violations.add(name);
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]!);
}
}
if (violations.size > 0) throw new UnwindAbiViolation(violations);
let personality = false;
let counter = 0;
const out = split.map((chunks) =>
chunks
.map((chunk) => {
if (!DEFINE_NAME.test(chunk) || !chunk.includes("call ")) return chunk;
const lines = chunk.split("\n");
const resume = resumers.has(DEFINE_NAME.exec(chunk)![1]!);
const result: string[] = [];
// The label a block's terminator now follows, for phi operands.
const moved = new Map<string, string>();
let block = "";
let changed = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
const label = /^([\w.$-]+):/.exec(line);
if (label !== null) block = label[1]!;
const m = PLAIN_CALL.exec(line);
const callee = m === null ? undefined : (m[4] ?? m[7]);
if (m === null || callee === undefined || !raisers.has(callee)) {
const marked = CALL_LINE.test(line) ? markNounwind(line) : line;
changed ||= marked !== line;
result.push(marked);
continue;
}
// The call's text from its callee to the end of the argument list.
const close = argumentsEnd(line, line.indexOf(`@${callee}(`) + callee.length + 1);
const head = line.slice(line.indexOf("call ") + 5, close + 1);
const tail = line.slice(close + 1);
const meta = /,\s*!/.exec(tail);
const attrs = (
meta === null ? tail.replace(/;.*$/, "") : tail.slice(0, meta.index)
).trimEnd();
const dbg = meta === null ? "" : tail.slice(meta.index).replace(/\s;.*$/, "");
const n = counter++;
if (block === "") {
result.splice(1, 0, "entry.ub:");
block = "entry.ub";
}
const indent = m[1] ?? m[5] ?? " ";
const ok = `ub.ok.${n}`;
const pad = `ub.lp.${n}`;
if (resume) {
// The wrapper holds nothing to release: the unwind goes on to
// its caller's landing pad.
const assign = m[2] !== undefined ? `${m[2]} = ` : "";
result.push(
`${indent}${assign}invoke ${head}${attrs} to label %${ok} unwind label %${pad}${dbg}`,
);
result.push(
`${pad}:`,
`${indent}%ub.v.${n} = landingpad { ptr, i32 } cleanup`,
`${indent}resume { ptr, i32 } %ub.v.${n}`,
);
result.push(`${ok}:`);
} else if (m[2] !== undefined) {
const type = (m[3] ?? "").replace(RETURN_ATTRIBUTES, "").trim();
const normal = `ub.n.${n}`;
result.push(
`${indent}${m[2]}.ub = invoke ${head}${attrs} to label %${normal} unwind label %${pad}${dbg}`,
);
result.push(`${normal}:`, `${indent}br label %${ok}`);
result.push(
`${pad}:`,
`${indent}%ub.v.${n} = landingpad { ptr, i32 } cleanup`,
`${indent}br label %${ok}`,
);
result.push(
`${ok}:`,
`${indent}${m[2]} = phi ${type} [ ${m[2]}.ub, %${normal} ], [ ${dummyValue(type)}, %${pad} ]`,
);
} else {
result.push(
`${indent}invoke ${head}${attrs} to label %${ok} unwind label %${pad}${dbg}`,
);
result.push(
`${pad}:`,
`${indent}%ub.v.${n} = landingpad { ptr, i32 } cleanup`,
`${indent}br label %${ok}`,
);
result.push(`${ok}:`);
}
moved.set(block, ok);
changed = true;
}
if (!changed) return chunk;
if (moved.size === 0) return result.join("\n");
personality = true;
const header = result[0]!;
const brace = header.lastIndexOf(" {");
if (!header.includes("personality") && brace >= 0) {
// Before the function's metadata attachments (`!dbg`), if any.
const dbg = header.slice(0, brace).search(/\s!dbg !\d+$/);
const at = dbg >= 0 ? dbg : brace;
result[0] = `${header.slice(0, at)} ${UNWIND_PERSONALITY}${header.slice(at)}`;
}
// A split block's successors now come from its last part.
if (moved.size > 0)
for (let i = 0; i < result.length; i++) {
const line = result[i]!;
if (!line.includes(" phi ")) continue;
result[i] = line.replace(/, %([\w.$-]+) \]/g, (all, name: string) => {
const to = moved.get(name);
return to === undefined ? all : `, %${to} ]`;
});
}
return result.join("\n");
})
.join("\n"),
);
if (personality && !out.some((part) => part.includes("@__gcc_personality_v0(...)")))
out[0] = `declare i32 @__gcc_personality_v0(...)\n${out[0]}`;
return out;
}
@@ -4,13 +4,15 @@ import { integerArithmeticRange, type IntegerRange } from "../../ir/integer-rang
import type { LlValue, LlvmEmitterContext } from "./expr-context.js";
import type { LlInteger } from "./integer-values.js";
/** Marks the `+` and `-` operands whose number only reaches ToInt32/ToUint32
* consumers (`| 0`, the other bitwise operators, `Math.imul`, `Math.clz32`),
* directly or through further sums, in the emitter's `wrappingSums`. Called
* by the consumer before its operands emit. */
/** Marks the `+`, `-` and `*` operands whose number only reaches
* ToInt32/ToUint32 consumers (`| 0`, the other bitwise operators,
* `Math.imul`, `Math.clz32`), directly or through further sums and
* products, in the emitter's `wrappingSums`. Called by the consumer before
* its operands emit. */
export function markWrappingOperands(host: LlvmEmitterContext, ...operands: IrExpr[]): void {
for (const operand of operands) {
if (operand.kind !== "bin" || (operand.op !== "+" && operand.op !== "-")) continue;
if (operand.kind !== "bin" || (operand.op !== "+" && operand.op !== "-" && operand.op !== "*"))
continue;
if (operand.type.kind !== "f64" || host.wrappingSums.has(operand)) continue;
host.wrappingSums.add(operand);
markWrappingOperands(host, operand.left, operand.right);
@@ -128,12 +130,15 @@ function wrappingOperand(
return { low, valid };
}
/** ToUint32 of `left op right` (`+` or `-`) in 32-bit arithmetic when the
* expression only reaches ToInt32/ToUint32 consumers and both operands are
* exact integers or NaN with an exact result. Both conversions are
* arithmetic mod 2^32, so the wrapping i32 sum of the operands' low halves
* is the converted exact sum; a NaN operand makes the sum NaN, which
* converts to 0. Returns undefined, emitting nothing, for any other sum. */
/** ToUint32 of `left op right` (`+`, `-` or `*`) in 32-bit arithmetic when
* the expression only reaches ToInt32/ToUint32 consumers and both operands
* are exact integers or NaN with an exact result. Both conversions are
* arithmetic mod 2^32, so the wrapping i32 sum or product of the operands'
* low halves is the converted exact result; a NaN operand makes the result
* NaN, which converts to 0. A product is exact when the operands' magnitude
* bounds multiply to at most 2^53; it may be -0 (`0 * -1`), which converts
* to 0 like the integer 0. Returns undefined, emitting nothing, for any
* other expression. */
export function emitWrappingSum(
host: LlvmEmitterContext,
e: IrExpr & { kind: "bin" },
@@ -142,15 +147,23 @@ export function emitWrappingSum(
li: LlInteger | null,
ri: LlInteger | null,
): string | undefined {
if ((e.op !== "+" && e.op !== "-") || !host.wrappingSums.has(e)) return undefined;
if ((e.op !== "+" && e.op !== "-" && e.op !== "*") || !host.wrappingSums.has(e)) return undefined;
const leftRange = li ? li.range : integerOrNaNRange(host, e.left);
const rightRange = leftRange && (ri ? ri.range : integerOrNaNRange(host, e.right));
if (!rightRange || !integerArithmeticRange(e.op, leftRange!, rightRange)) return undefined;
if (!rightRange) return undefined;
if (
e.op === "*"
? !exactProduct(leftRange!, rightRange)
: !integerArithmeticRange(e.op, leftRange!, rightRange)
)
return undefined;
const B = host.B;
const left = wrappingOperand(host, l, li, leftRange!);
const right = wrappingOperand(host, r, ri, rightRange);
const sum = B.tmp();
B.line(`${sum} = ${e.op === "+" ? "add" : "sub"} i32 ${left.low}, ${right.low}`);
B.line(
`${sum} = ${e.op === "+" ? "add" : e.op === "-" ? "sub" : "mul"} i32 ${left.low}, ${right.low}`,
);
let valid = left.valid ?? right.valid;
if (valid === null) return sum;
if (left.valid && right.valid) {
@@ -162,6 +175,17 @@ export function emitWrappingSum(
return out;
}
/** Whether every product of a value in `left` and one in `right` is an
* integer of magnitude below 2^53, which the double product holds exactly.
* The bound's own product is computed in a double: a bound of at least
* 2^53 never rounds below 2^53, while 2^53 + 1 (3 * 3002399751580331)
* rounds to 2^53, so the test is strict. */
function exactProduct(left: IntegerRange, right: IntegerRange): boolean {
const magnitude = (range: IntegerRange): number =>
Math.max(Math.abs(range.min), Math.abs(range.max));
return magnitude(left) * magnitude(right) < 2 ** 53;
}
/** The number locals of `fn` that only hold exact integers (never -0) or NaN, with the range of
* those integers: locals declared once with an initializer, neither parameters nor captured or
* boxed, whose initializer and every later write `integerOrNaNRange` covers. A local joins once
@@ -30,6 +30,8 @@ import { compilerReleaseVersion } from "../library/sidecar.js";
import {
nativeProfileHelperArgs,
nativeProfileKey,
parseNativeProfileMatch,
type NativeProfileMatch,
type NativeProfileOptions,
} from "./native-profile.js";
import { writeTextParts } from "../text-parts.js";
@@ -69,6 +71,8 @@ export interface NativeCodegenArtifact {
dependencies: NativeArtifactDependency[];
/** Every emitted file, starting with the requested output path. */
outputPaths: string[];
/** How the build's profile matched, when it used one and the helper ran. */
profileMatch?: NativeProfileMatch;
}
const resolvedHelperCache = new Map<string, Promise<ResolvedHelper>>();
@@ -458,7 +462,7 @@ export async function emitNativeArtifact(
try {
if (isLlvmFile(options.llvm)) await copyFile(options.llvm.file, input);
else await writeLlvmSource(input, options.llvm, 0o600);
await invoke(helper.binaryPath, [
const { stdout } = await invoke(helper.binaryPath, [
"emit",
"--input",
input,
@@ -502,7 +506,9 @@ export async function emitNativeArtifact(
for (let index = 0; index < stages.length; index++)
await rename(stages[index]!, outputs[index]!);
await pruneBuildCache(root);
return artifact;
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)),
@@ -1,6 +1,7 @@
import { expect, test } from "vitest";
import { executableLinkInputs } from "./link-plan-core.js";
import {
parseNativeProfileMatch,
nativeProfileHelperArgs,
nativeProfileKey,
nativeProfileRefusal,
@@ -71,3 +72,15 @@ test("only the instrumented link adds the profile runtime", () => {
"-fprofile-generate",
);
});
test("the helper's profile match is read from its output", () => {
expect(
parseNativeProfileMatch(
'{"ok":true,"profile":{"annotated":120,"mismatched":7,"missing":40,"unknown_partitions":1}}\n',
),
).toEqual({ annotated: 120, mismatched: 7, missing: 40, unknownPartitions: 1 });
// A helper that predates the report prints nothing.
expect(parseNativeProfileMatch("")).toBeNull();
expect(parseNativeProfileMatch('{"ok":true}\n')).toBeNull();
expect(parseNativeProfileMatch('{"ok":true,"profile":{"annotated":-1}}\n')).toBeNull();
});
@@ -44,6 +44,45 @@ export function nativeProfileHelperArgs(profile: NativeProfileOptions | undefine
return [];
}
/** How an optimized build's profile matched the program's functions, as the
* helper reports it: functions that took the profile's counts, functions
* whose control flow changed since the profile was collected, functions the
* profile has no record of (new, or never run while it was collected), and
* partitions reused from the cache without their counts. */
export interface NativeProfileMatch {
annotated: number;
mismatched: number;
missing: number;
unknownPartitions: number;
}
/** The profile match in a successful helper emit's output, or null when the
* helper reports none (a helper that predates the report). */
export function parseNativeProfileMatch(stdout: string): NativeProfileMatch | null {
for (const line of stdout.split("\n")) {
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch {
continue;
}
const profile = (parsed as { profile?: Record<string, unknown> } | null)?.profile;
if (typeof profile !== "object" || profile === null) continue;
const count = (key: string): number | null => {
const value = profile[key];
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
};
const annotated = count("annotated");
const mismatched = count("mismatched");
const missing = count("missing");
const unknownPartitions = count("unknown_partitions");
if (annotated === null || mismatched === null || missing === null || unknownPartitions === null)
continue;
return { annotated, mismatched, missing, unknownPartitions };
}
return null;
}
/** 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}`;
@@ -43,6 +43,7 @@ import {
cacheTargetIdentity,
isZigDriver,
isMuslTarget,
type CcDriver,
} from "./driver.js";
import {
resolveProgramShardMergeIdentity,
@@ -821,6 +822,7 @@ export async function compileCInternal(
// generated/native input that references inflate symbols. Cross
// and targetless Zig builds use vendored zlib objects in the input section above.
...(((opts.zlib ?? false) || nativeFetch) && !isZigDriver(driver) ? ["-lz"] : []),
...unwinderLinkArgs(driver),
// glibc keeps libm separate from libc. This must trail the generated
// program and every native FFI input because GNU ld resolves archives
// from left to right.
@@ -1068,6 +1070,7 @@ export async function compileCInternal(
...(mbedtls && targetPlatform(driver) === "win32" ? ["-lbcrypt"] : []),
...(tlsCa && targetPlatform(driver) === "win32" ? ["-lcrypt32"] : []),
...(((opts.zlib ?? false) || nativeFetch) && !isZigDriver(driver) ? ["-lz"] : []),
...unwinderLinkArgs(driver),
...driver.linkArgs,
...executableLinkFlags,
];
@@ -1080,6 +1083,7 @@ export async function compileCInternal(
...(mbedtls && targetPlatform(driver) === "win32" ? ["-lbcrypt"] : []),
...(tlsCa && targetPlatform(driver) === "win32" ? ["-lcrypt32"] : []),
...(((opts.zlib ?? false) || nativeFetch) && !isZigDriver(driver) ? ["-lz"] : []),
...unwinderLinkArgs(driver),
...driver.linkArgs,
...executableLinkFlags,
];
@@ -1689,3 +1693,11 @@ export async function compileC(opts: CcOptions): Promise<void> {
await rm(`${opts.outPath}.dSYM`, { recursive: true, force: true });
}
}
/** Linux runtimes raise through the native unwinder (scr_unwind_raise):
* Zig's libunwind, or the toolchain's libgcc_eh. Both are static archives,
* so programs load no shared library for them. */
function unwinderLinkArgs(driver: CcDriver): string[] {
if (targetPlatform(driver) !== "linux") return [];
return isZigDriver(driver) ? ["-lunwind"] : ["-lgcc_eh"];
}
@@ -53,6 +53,7 @@ export const EXECUTABLE_RUNTIME_SOURCES = [
"scr_object.c",
"scr_union.c",
"scr_exception.c",
"scr_unwind.c",
"scr_error.c",
"scr_console.c",
"scr_lib.c",
@@ -1,12 +1,39 @@
import { createHash } from "node:crypto";
import {
mkdirSync,
readFileSync,
renameSync,
statSync,
symlinkSync,
unlinkSync,
utimesSync,
writeFileSync,
} from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, test } from "vitest";
import { afterEach, expect, test, vi } from "vitest";
import { nativeCodegenDiag } from "../diagnostics/diagnostic.js";
import { compilerReleaseVersion } from "../library/sidecar.js";
import type { NativeLinkFeatures } from "./native-link-info.js";
import { RuntimePackError } from "./runtime-pack-core.js";
import { selectNativeRuntimePack } from "./runtime-pack-native.js";
import { WASM32_WASI_TARGET } from "./targets.js";
import { RuntimePackError, type RuntimePackManifest } from "./runtime-pack-core.js";
import {
runtimePackManifest,
selectNativeRuntimePack,
stageNativeRuntimeSelection,
type NativeRuntimeSelection,
type RuntimePackStore,
} from "./runtime-pack-native.js";
import { MACOS_ARM64_TARGET, WASM32_WASI_TARGET } from "./targets.js";
vi.mock("node:crypto", async (original) => {
const crypto = await original<typeof import("node:crypto")>();
return { ...crypto, createHash: vi.fn(crypto.createHash) };
});
vi.mock("node:fs", async (original) => {
const fs = await original<typeof import("node:fs")>();
return { ...fs, readFileSync: vi.fn(fs.readFileSync) };
});
const dirs: string[] = [];
afterEach(async () =>
@@ -45,3 +72,171 @@ test("a missing native helper keeps the reinstall hint", () => {
"reinstall scriptc with optional dependencies enabled for this host",
);
});
/** A runtime pack with one runtime object and a license, and a store that
* keeps entries in memory. */
async function packFixture() {
const root = await mkdtemp(join(tmpdir(), "scriptc-runtime-pack-store-"));
dirs.push(root);
const target = MACOS_ARM64_TARGET;
const version = compilerReleaseVersion();
writeFileSync(
join(root, "package.json"),
JSON.stringify({ name: target.runtimePackPackage, version }),
);
mkdirSync(join(root, "artifacts"));
const object = join(root, "artifacts", "base.o");
writeFileSync(object, "base object");
writeFileSync(join(root, "license.txt"), "license");
const unit = {
source: "scr_base.c",
predicate: true,
variants: [
{
id: "default",
when: {},
defines: [],
path: "artifacts/base.o",
sha256: createHash("sha256").update("base object").digest("hex"),
size: Buffer.byteLength("base object"),
},
],
};
const manifest: RuntimePackManifest = {
schema: "scriptc.runtime-pack.v1",
format: 1,
package: target.runtimePackPackage,
version,
target: {
name: target.name,
llvm_triple: target.llvmTriple,
architecture: target.architecture,
object_format: target.objectFormat,
minimum_os: target.minimumOs,
},
runtime_abi: { version: 37, marker: "scr_runtime_abi_v37" },
compiler: { command: "clang", identity: "fixture clang", target: target.llvmTriple },
macros: {
executable: [],
excluded: ["SCR_LIB", "SCR_THREAD_INSTANCES", "SCR_RC_AUDIT"],
sanitizer: "external-toolchain-required",
},
flavors: {
release: { optimization: "-O2", runtime_units: [unit] },
dev: { optimization: "-O0", runtime_units: [unit] },
},
archives: [],
system_libraries: [{ name: "System", predicate: true }],
licenses: [{ path: "license.txt", license: "fixture" }],
};
const manifestText = JSON.stringify(manifest);
writeFileSync(join(root, "runtime-pack.json"), manifestText);
const entries = new Map<string, string>();
const store: RuntimePackStore = {
read: (key) => entries.get(key) ?? null,
write: (key, text) => void entries.set(key, text),
};
const select = () =>
selectNativeRuntimePack(
root,
target,
version,
{} as NativeLinkFeatures,
"release",
"executable",
store,
);
let stages = 0;
const stage = (selection: NativeRuntimeSelection) => {
const directory = join(root, `stage-${stages++}`);
mkdirSync(directory);
return stageNativeRuntimeSelection(selection, directory, store);
};
return { root, object, manifestText, entries, select, stage };
}
test("a stored selection answers while the pack's files keep their identities", async () => {
const f = await packFixture();
const first = f.select();
expect(first.selected.systemLibraries).toEqual(["System"]);
expect(f.entries.size).toBe(1);
// The next selection reads the stored answer instead of the manifest.
const [key, text] = [...f.entries][0]!;
const stored = JSON.parse(text);
stored.selected.systemLibraries = ["Stored"];
f.entries.set(key, JSON.stringify(stored));
const second = f.select();
expect(second.selected.systemLibraries).toEqual(["Stored"]);
expect(second.licenses).toEqual(first.licenses);
expect(second.manifest).toBeUndefined();
expect(runtimePackManifest(second).flavors.release).toEqual(first.manifest!.flavors.release);
// Writing the manifest again, with the same text, gives it a new identity.
writeFileSync(join(f.root, "runtime-pack.json"), f.manifestText);
expect(f.select().selected.systemLibraries).toEqual(["System"]);
});
test.each(["replaced", "rewritten in place"])(
"staging hashes an artifact again when it is %s, even at the same size and mtime",
async (change) => {
const f = await packFixture();
const selection = f.select();
f.stage(selection);
const before = statSync(f.object);
const changed = change === "replaced" ? f.object + ".new" : f.object;
writeFileSync(changed, "x".repeat(before.size));
utimesSync(changed, before.atime, before.mtime);
if (changed !== f.object) renameSync(changed, f.object);
expect(() => f.stage(selection)).toThrow(
"runtime pack artifact hash mismatch: artifacts/base.o",
);
},
);
test("staging does not hash an unchanged artifact again", async () => {
const f = await packFixture();
const selection = f.select();
const hashes = () => vi.mocked(createHash).mock.calls.length;
let start = hashes();
f.stage(selection);
const firstStage = hashes() - start;
start = hashes();
const staged = f.stage(selection);
// Only the store key is hashed; the staged copy has the verified bytes.
expect(hashes() - start).toBe(firstStage - 1);
expect(readFileSync(staged.runtimeObjects[0]!, "utf8")).toBe("base object");
});
test.skipIf(process.platform === "win32")(
"staging reads an artifact's bytes from the file whose identity it checks",
async () => {
const f = await packFixture();
// The artifact path is a symbolic link to the verified file.
const real = f.object + ".real";
renameSync(f.object, real);
symlinkSync(real, f.object);
const selection = f.select();
f.stage(selection);
// During the next read the link names another file of the same size,
// and it names the verified file again before the read ends.
const other = f.object + ".other";
writeFileSync(other, "x".repeat(statSync(real).size));
const read = vi.mocked(readFileSync);
const original = read.getMockImplementation()!;
read.mockImplementation(((source: Parameters<typeof readFileSync>[0], ...rest: unknown[]) => {
if (typeof source === "string" && !source.endsWith("base.o"))
return (original as (...args: unknown[]) => unknown)(source, ...rest);
read.mockImplementation(original);
unlinkSync(f.object);
symlinkSync(other, f.object);
try {
return (original as (...args: unknown[]) => unknown)(source, ...rest);
} finally {
unlinkSync(f.object);
symlinkSync(real, f.object);
}
}) as typeof readFileSync);
const staged = f.stage(selection);
expect(read.getMockImplementation()).toBe(original);
expect(readFileSync(staged.runtimeObjects[0]!, "utf8")).toBe("base object");
},
);
@@ -1,16 +1,21 @@
/** Synchronous runtime-pack host for the statically compiled driver. Each
* selected artifact is hashed and copied from the same read into private
* storage. This host has no persistent cache or mutable installed link inputs. */
* selected artifact is verified and copied from the same read into private
* storage. With a store, a pack's selections and the identities of the
* artifacts it verified are kept between builds: an artifact whose file
* identity is unchanged since it was hashed is not hashed again. */
import type { NativeOptimization } from "./optimization.js";
import { createHash } from "node:crypto";
import {
closeSync,
constants,
existsSync,
fstatSync,
mkdirSync,
openSync,
readFileSync,
statSync,
writeSync,
type Stats,
} from "node:fs";
import { dirname, join } from "node:path";
import type { NativeLinkFeatures } from "./native-link-info.js";
@@ -34,13 +39,66 @@ export interface NativeRuntimePack {
export interface NativeRuntimeSelection {
root: string;
manifest: RuntimePackManifest;
/** The parsed manifest; a selection read from a store has none until
* runtimePackManifest parses it. */
manifest?: RuntimePackManifest;
licenses: RuntimePackManifest["licenses"];
packageText: string;
manifestText: string;
selected: RuntimePackArtifacts;
flavor: NativeOptimization;
}
/** Text entries kept between builds (the native compiler's cache). */
export interface RuntimePackStore {
read(key: string): string | null;
write(key: string, text: string): void;
}
interface FileIdentity {
dev: number;
ino: number;
size: number;
mtime: number;
ctime: number;
}
function fileIdentity(path: string): FileIdentity {
return statsIdentity(statSync(path));
}
function statsIdentity(info: Stats): FileIdentity {
return {
dev: info.dev,
ino: info.ino,
size: info.size,
mtime: info.mtimeMs,
ctime: info.ctimeMs,
};
}
function sameIdentity(a: FileIdentity, b: FileIdentity): boolean {
return (
a.dev === b.dev &&
a.ino === b.ino &&
a.size === b.size &&
a.mtime === b.mtime &&
a.ctime === b.ctime
);
}
function storeKey(text: string): string {
return createHash("sha256").update(text).digest("hex");
}
/** A stored selection: what selecting from the pack's files, with these
* identities, returned. */
interface StoredSelection {
schema: 1;
licenses: RuntimePackManifest["licenses"];
selected: RuntimePackArtifacts;
}
export function selectNativeRuntimePack(
root: string,
target: NativeTargetSpec,
@@ -48,38 +106,204 @@ export function selectNativeRuntimePack(
features: NativeLinkFeatures,
flavor: NativeOptimization,
mode: RuntimePackMode = "executable",
store?: RuntimePackStore,
): NativeRuntimeSelection {
if (!existsSync(join(root, "package.json"))) {
const packagePath = join(root, "package.json");
const manifestPath = join(root, "runtime-pack.json");
if (!existsSync(packagePath)) {
throw new RuntimePackError(
`runtime pack for ${target.name} is not installed; install ${target.runtimePackPackage}@${compilerVersion} in your project or set SCRIPTC_RUNTIME_PACK to its directory`,
"missing",
"scriptc includes the runtime pack for its own platform only; other targets need their runtime pack package in the project",
);
}
const packageText = readFileSync(join(root, "package.json"), "utf8");
const manifestText = readFileSync(join(root, "runtime-pack.json"), "utf8");
// The pack's files by identity, with everything else selection reads.
let key: string | null = null;
let identities: FileIdentity[] = [];
if (store !== undefined) {
try {
identities = [fileIdentity(packagePath), fileIdentity(manifestPath)];
key = storeKey(
JSON.stringify({
schema: 1,
root,
identities,
target: target.name,
compilerVersion,
features,
flavor,
mode,
}),
);
const stored = readStoredSelection(store.read(key));
if (stored !== null) {
const packageText = readFileSync(packagePath, "utf8");
const manifestText = readFileSync(manifestPath, "utf8");
// The texts read are the ones with the stored identities.
if (
sameIdentity(fileIdentity(packagePath), identities[0]!) &&
sameIdentity(fileIdentity(manifestPath), identities[1]!)
) {
return {
root,
licenses: stored.licenses,
packageText,
manifestText,
selected: stored.selected,
flavor,
};
}
}
} catch {
key = null;
}
}
const packageText = readFileSync(packagePath, "utf8");
const manifestText = readFileSync(manifestPath, "utf8");
const identity = JSON.parse(packageText) as { name?: string; version?: string };
const manifest = parseRuntimePackManifest(JSON.parse(manifestText));
validateRuntimePackIdentity(manifest, identity.name, identity.version, target, compilerVersion);
const selected = selectRuntimePackArtifacts(manifest, features, flavor, process.env, mode);
return { root, manifest, packageText, manifestText, selected, flavor };
if (store !== undefined && key !== null) {
try {
// Stored only when the files selected from still have the identities
// in the key.
if (
sameIdentity(fileIdentity(packagePath), identities[0]!) &&
sameIdentity(fileIdentity(manifestPath), identities[1]!)
) {
const entry: StoredSelection = { schema: 1, licenses: manifest.licenses, selected };
store.write(key, JSON.stringify(entry));
}
} catch {
/* A failed write is a later miss. */
}
}
return {
root,
manifest,
licenses: manifest.licenses,
packageText,
manifestText,
selected,
flavor,
};
}
function stageArtifact(root: string, stage: string, artifact: RuntimePackArtifact): string {
/** The selection's parsed manifest. */
export function runtimePackManifest(selection: NativeRuntimeSelection): RuntimePackManifest {
return (selection.manifest ??= parseRuntimePackManifest(JSON.parse(selection.manifestText)));
}
function readStoredSelection(text: string | null): StoredSelection | null {
if (text === null) return null;
const value: unknown = JSON.parse(text);
if (value === null || typeof value !== "object") return null;
const entry = value as Partial<StoredSelection>;
if (
entry.schema !== 1 ||
!Array.isArray(entry.licenses) ||
entry.selected === undefined ||
entry.selected === null ||
typeof entry.selected !== "object" ||
!Array.isArray(entry.selected.runtime) ||
!Array.isArray(entry.selected.archives) ||
!Array.isArray(entry.selected.systemLibraries)
)
return null;
return entry as StoredSelection;
}
/** An artifact path's identity when its bytes last matched the manifest's
* size and digest. */
interface VerifiedArtifact {
path: string;
sha256: string;
dev: number;
ino: number;
size: number;
mtime: number;
ctime: number;
}
interface StoredVerification {
schema: number;
artifacts: VerifiedArtifact[];
}
function readVerified(
store: RuntimePackStore | undefined,
key: string,
): Map<string, VerifiedArtifact> {
const verified = new Map<string, VerifiedArtifact>();
try {
const text = store?.read(key) ?? null;
if (text === null) return verified;
const stored = JSON.parse(text) as StoredVerification;
if (stored.schema === 1)
for (const artifact of stored.artifacts) verified.set(artifact.path, artifact);
} catch {
/* A malformed entry verifies everything again. */
}
return verified;
}
function stageArtifact(
root: string,
stage: string,
artifact: RuntimePackArtifact,
verified: Map<string, VerifiedArtifact>,
newlyVerified: VerifiedArtifact[],
): string {
const source = join(root, artifact.path);
const destination = join(stage, artifact.path);
let bytes: Buffer;
let before: FileIdentity;
let after: FileIdentity;
try {
bytes = readFileSync(source);
// The identities are those of the open file the bytes come from, also
// when the path is a symbolic link that changes during the read.
const input = openSync(source, constants.O_RDONLY);
try {
before = statsIdentity(fstatSync(input));
bytes = readFileSync(input);
after = statsIdentity(fstatSync(input));
} finally {
closeSync(input);
}
} catch {
throw new RuntimePackError(`runtime pack artifact is missing: ${artifact.path}`, "invalid");
}
// The bytes are those last hashed when the file kept the identity it had
// then for the whole read.
const known = verified.get(artifact.path);
const unchanged = sameIdentity(before, after);
const trusted =
known !== undefined &&
known.sha256 === artifact.sha256 &&
known.size === artifact.size &&
unchanged &&
known.dev === before.dev &&
known.ino === before.ino &&
known.size === before.size &&
known.mtime === before.mtime &&
known.ctime === before.ctime;
if (
bytes.length !== artifact.size ||
createHash("sha256").update(bytes).digest("hex") !== artifact.sha256
(!trusted && createHash("sha256").update(bytes).digest("hex") !== artifact.sha256)
) {
throw new RuntimePackError(`runtime pack artifact hash mismatch: ${artifact.path}`, "invalid");
}
if (!trusted && unchanged)
newlyVerified.push({
path: artifact.path,
sha256: artifact.sha256,
dev: before.dev,
ino: before.ino,
size: before.size,
mtime: before.mtime,
ctime: before.ctime,
});
mkdirSync(dirname(destination), { recursive: true });
const fd = openSync(
destination,
@@ -123,15 +347,19 @@ export function stageNativeRuntimePack(
export function stageNativeRuntimeSelection(
selection: NativeRuntimeSelection,
stageRoot: string,
store?: RuntimePackStore,
): NativeRuntimePack {
const { root, manifest, selected, packageText, manifestText } = selection;
const { root, licenses, selected, packageText, manifestText } = selection;
const packagePath = join(root, "package.json");
const manifestPath = join(root, "runtime-pack.json");
const runtimeObjects = selected.runtime.map((artifact) =>
stageArtifact(root, stageRoot, artifact),
);
const archives = selected.archives.map((artifact) => stageArtifact(root, stageRoot, artifact));
for (const license of manifest.licenses) {
const key = storeKey(JSON.stringify({ schema: 1, verified: root }));
const verified = readVerified(store, key);
const newlyVerified: VerifiedArtifact[] = [];
const stage = (artifact: RuntimePackArtifact): string =>
stageArtifact(root, stageRoot, artifact, verified, newlyVerified);
const runtimeObjects = selected.runtime.map(stage);
const archives = selected.archives.map(stage);
for (const license of licenses) {
try {
readFileSync(join(root, license.path));
} catch {
@@ -144,5 +372,14 @@ export function stageNativeRuntimeSelection(
) {
throw new RuntimePackError("runtime pack changed while staging verified artifacts", "invalid");
}
if (store !== undefined && newlyVerified.length > 0) {
for (const artifact of newlyVerified) verified.set(artifact.path, artifact);
const entry: StoredVerification = { schema: 1, artifacts: [...verified.values()] };
try {
store.write(key, JSON.stringify(entry));
} catch {
/* A failed write only means hashing again next time. */
}
}
return { runtimeObjects, archives, systemLibraries: selected.systemLibraries };
}
+3
View File
@@ -2,6 +2,7 @@ import type { TargetCpu } from "./backend/target-cpu.js";
import type { WindowsSubsystem } from "./backend/targets.js";
import type { NativeLinkInfo } from "./backend/native-link-info.js";
import type { NativeOptimization } from "./backend/optimization.js";
import type { NativeProfileMatch } from "./backend/native-profile.js";
import type { ScrDiagnostic } from "./diagnostics/diagnostic.js";
import type { CoverageInput } from "./coverage/report.js";
import type { TierMode } from "./frontend/tier-analysis.js";
@@ -100,6 +101,8 @@ export type CompileArtifact =
path: string;
translationUnitPath: string;
backend: "llvm";
/** How a `profileUse` build's profile matched the program's functions. */
profileMatch?: NativeProfileMatch;
};
export type CompileFailure = {
@@ -622,7 +622,7 @@ export function unsupportedTypeDiag(typeText: string, loc: SrcLoc): ScrDiagnosti
code: "SC2001",
message:
`values of type '${typeText}' cannot be compiled yet ` +
`(supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown')`,
`(supported: number, string, boolean, arrays, Maps, Sets, RegExp, Dates, functions, classes, records, unions of those, and 'unknown')`,
loc,
milestone: "M2",
};
@@ -270,3 +270,49 @@ test("synchronous tracking restores parents after throws and propagates unstable
trackedFileExists(join(dir, "outside.ts"));
expect(parent.snapshot()).toEqual(before);
});
test("a settled file is checked by its identity, and any write invalidates it", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-inputs-"));
scratch.push(dir);
const source = join(dir, "entry.ts");
await writeFile(source, "export const answer = 1;\n");
const written = fs.statSync(source);
// The file was last changed long enough before the read.
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(Date.now() + 5000);
const tracker = new FrontendInputTracker();
try {
tracker.run(() => trackedReadFile(source));
} finally {
vi.useRealTimers();
}
const snapshot = tracker.snapshot();
const probe = snapshot.probes.find((entry) => entry.op === "file");
expect(probe?.op === "file" && probe.identity !== undefined).toBe(true);
expect(validFrontendInputSnapshot(snapshot)).toBe(true);
const reads = vi.mocked(fs.readFileSync);
reads.mockClear();
expect(frontendInputsStillMatch(snapshot)).toBe(true);
expect(reads.mock.calls.filter(([path]) => path === source)).toHaveLength(0);
// A same-size rewrite that restores the modification time still changes
// the change time.
await writeFile(source, "export const answer = 2;\n");
fs.utimesSync(source, written.atime, written.mtime);
expect(frontendInputsStillMatch(snapshot)).toBe(false);
});
test("a file changed shortly before the read is hashed on every check", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-inputs-"));
scratch.push(dir);
const source = join(dir, "entry.ts");
await writeFile(source, "export const answer = 1;\n");
const tracker = new FrontendInputTracker();
tracker.run(() => trackedReadFile(source));
const snapshot = tracker.snapshot();
const probe = snapshot.probes.find((entry) => entry.op === "file");
expect(probe?.op === "file" && probe.identity).toBeUndefined();
const reads = vi.mocked(fs.readFileSync);
reads.mockClear();
expect(frontendInputsStillMatch(snapshot)).toBe(true);
expect(reads.mock.calls.filter(([path]) => path === source)).toHaveLength(1);
});
@@ -12,7 +12,8 @@ import { observeSourceText } from "./ts7/ast-node.js";
* behind an old resolution answer.
*/
export type FrontendInputProbe =
| { op: "file"; path: string; digest: string }
/** `identity` (fileIdentity) lets a later check skip hashing the file. */
| { op: "file"; path: string; digest: string; identity?: string }
| { op: "read-error"; path: string }
| { op: "kind"; path: string; kind: "file" | "directory" | "other" | "missing" }
| { op: "entries"; path: string; files: string[]; directories: string[] }
@@ -53,6 +54,36 @@ function frontendSourceDigest(text: string): string {
return createHash("sha256").update(text).digest("hex");
}
/** How long a file must have been unchanged before a read for its identity
* to stand for its content: a later write then gets a newer change time
* even on file systems whose timestamps tick once a second or every two. */
const IDENTITY_SETTLE_MS = 2000;
/** The device, inode, size, modification time and change time of `path`. A
* write changes the change time, which no tool can set back, so an equal
* identity means the content is the one hashed with it. */
function fileIdentity(path: string): string | null {
try {
const info = statSync(path);
return `${info.dev}:${info.ino}:${info.size}:${info.mtimeMs}:${info.ctimeMs}`;
} catch {
return null;
}
}
/** The text of `path` with its identity when the identity was the same before
* and after the read and the file had been unchanged for IDENTITY_SETTLE_MS. */
function readWithIdentity(path: string): { text: string; identity: string | null } {
const readAt = Date.now();
const before = statSync(path);
const text = readFileSync(path, "utf8");
const after = fileIdentity(path);
const identity = `${before.dev}:${before.ino}:${before.size}:${before.mtimeMs}:${before.ctimeMs}`;
const settled =
before.mtimeMs <= readAt - IDENTITY_SETTLE_MS && before.ctimeMs <= readAt - IDENTITY_SETTLE_MS;
return { text, identity: settled && after === identity ? identity : null };
}
function pathKind(path: string): Extract<FrontendInputProbe, { op: "kind" }>["kind"] {
try {
const info = statSync(path);
@@ -199,8 +230,11 @@ export function trackedReadFile(path: string): string | null {
const virtual = virtualSources.get(path);
if (virtual !== undefined) return virtual;
try {
const text = readFileSync(path, "utf8");
record({ op: "file", path, digest: frontendSourceDigest(text) });
const { text, identity } = readWithIdentity(path);
const digest = frontendSourceDigest(text);
record(
identity === null ? { op: "file", path, digest } : { op: "file", path, digest, identity },
);
return text;
} catch {
record({ op: "read-error", path });
@@ -337,6 +371,9 @@ export function frontendInputsStillMatch(
}
switch (probe.op) {
case "file": {
// An unchanged identity stands for the content hashed with it.
if (probe.identity !== undefined && fileIdentity(probe.path) === probe.identity)
return true;
try {
return frontendSourceDigest(readFileSync(probe.path, "utf8")) === probe.digest;
} catch {
@@ -443,6 +480,11 @@ export function frontendInputsSemanticallyMatch(
probes.push(probe);
continue;
}
if (probe.identity !== undefined && fileIdentity(probe.path) === probe.identity) {
currentSources.set(probe.path, previous);
probes.push(probe);
continue;
}
let current: string;
try {
current = readFileSync(probe.path, "utf8");
@@ -460,7 +502,7 @@ export function frontendInputsSemanticallyMatch(
if (rawText.has(probe.path) || !isSemanticEquivalent(probe.path, previous, current))
return null;
changed.push({ path: probe.path, previous, current });
probes.push({ ...probe, digest: currentDigest });
probes.push({ op: "file", path: probe.path, digest: currentDigest });
}
const adjusted: FrontendInputSnapshot = { ...snapshot, probes };
if (!frontendInputsStillMatch(adjusted, exclusions)) return null;
@@ -488,7 +530,11 @@ export function validFrontendInputSnapshot(snapshot: unknown): snapshot is Front
if (typeof value.path !== "string" || typeof value.op !== "string") return false;
switch (value.op) {
case "file":
return typeof value.digest === "string" && /^[0-9a-f]{64}$/.test(value.digest);
return (
typeof value.digest === "string" &&
/^[0-9a-f]{64}$/.test(value.digest) &&
(value.identity === undefined || typeof value.identity === "string")
);
case "read-error":
return true;
case "kind":
@@ -5,6 +5,7 @@ import {
DYN,
DYN_HANDLE_KINDS,
F64,
STRING,
type IrExpr,
type IrStmt,
type IrType,
@@ -285,6 +286,43 @@ export function lowerAbstractEquality(
};
return negated ? { kind: "unary", op: "!", operand: result, type: BOOL, loc } : result;
}
// Two Dates are equal when they are the same object, as with ===.
if (left.type.kind === "date" && right.type.kind === "date")
return { kind: "bin", op: negated ? "!==" : "===", left, right, type: BOOL, loc };
// A Date and a string or number compare through the Date's default
// primitive, its toString() text.
if (left.type.kind === "date" || right.type.kind === "date") {
const otherType = left.type.kind === "date" ? right.type : left.type;
if (otherType.kind === "string" || otherType.kind === "f64") {
// Both operands evaluate before the Date converts.
const l = lowerer.declareHiddenLocal("%looseA", left.type);
const r = lowerer.declareHiddenLocal("%looseB", right.type);
const stmts: IrStmt[] = [
{ kind: "varDecl", localId: l.id, init: left, loc },
{ kind: "varDecl", localId: r.id, init: right, loc },
];
const primitive = (value: IrExpr): IrExpr => {
if (value.type.kind !== "date") return value;
const text: IrExpr = {
kind: "libCall",
fn: "date.toString",
args: [value],
type: STRING,
loc,
};
return otherType.kind === "string"
? text
: { kind: "libCall", fn: "num.fromString", args: [text], type: F64, loc };
};
const a = primitive(varRef(l.id, left.type, loc));
const b = primitive(varRef(r.id, right.type, loc));
const result: IrExpr =
otherType.kind === "string"
? { kind: "strEq", negated, left: a, right: b, type: BOOL, loc }
: { kind: "bin", op: negated ? "!==" : "===", left: a, right: b, type: BOOL, loc };
return { kind: "seqExpr", stmts, result, type: BOOL, loc };
}
}
if (!supportsPair(lowerer, left.type, right.type)) return null;
if (left.kind === "strLit" && right.kind === "strLit") {
return { kind: "boolLit", value: (left.value === right.value) !== negated, type: BOOL, loc };
@@ -2,7 +2,13 @@ import * as ts from "../../ts7/adapter.js";
import { type Lowerer } from "../lowerer.js";
import { locOf } from "../../program.js";
import { type ClassInfo } from "../lower-classes.js";
import { DATE_METHOD_HINT, dateJsonType, dateValueMethod } from "./dates.js";
import {
DATE_METHOD_HINT,
dateJsonType,
dateSetterTime,
dateValueMethod,
isDateSetter,
} from "./dates.js";
import { dynUndefinedExpr, varRef } from "../../../ir/build.js";
import {
BOOL,
@@ -248,6 +254,22 @@ export function lowerInheritedDateMethodCall(
type: result.type,
loc,
});
if (isDateSetter(name) && name !== "setTime") {
// The field setters change the Date the class's time slot holds once
// the arguments are evaluated: an argument that calls setTime on the
// instance gives the slot a new Date.
const date = lowerer.declareHiddenLocal("%dateTime", DATE_T);
const current = varRef(date.id, DATE_T, loc);
const time: IrExpr = { kind: "libCall", fn: "date.getTime", args: [current], type: F64, loc };
const next = dateSetterTime(lowerer, call, name, stmts, time);
stmts.push({
kind: "varDecl",
localId: date.id,
init: timeOf(lowerer, value, root, loc),
loc,
});
return done({ kind: "libCall", fn: "date.store", args: [current, next], type: F64, loc });
}
if (name === "setTime") {
// Every argument is evaluated before setTime converts the first one.
let ms: IrExpr = { kind: "numLit", value: Number.NaN, type: F64, loc };
@@ -2,12 +2,15 @@ import * as ts from "../../ts7/adapter.js";
import { type Lowerer, own } from "../lowerer.js";
import { locOf } from "../../program.js";
import { tryLowerExpression } from "../expressions/try-lower-expression.js";
import { lowerToNumberArguments } from "../lower-exprs.js";
import { programDeclaresDateClass } from "./date-classes.js";
import {
BOOL,
DATE_T,
F64,
type IrExpr,
type IrLibFn,
type IrLocal,
type IrStmt,
type IrType,
NULL_T,
@@ -36,6 +39,32 @@ const DATE_GETTER_FNS: Readonly<Record<string, IrLibFn>> = {
getTimezoneOffset: "date.getTimezoneOffset",
};
/** The setters that change calendar fields: the field code
* scr_date_set_fields takes (scr_number.h SCR_DATE_SET_*; 8 marks the UTC
* forms) and the most arguments each takes. */
const DATE_FIELD_SETTERS: ReadonlyMap<string, { code: number; max: number }> = new Map([
["setMilliseconds", { code: 0, max: 1 }],
["setSeconds", { code: 1, max: 2 }],
["setMinutes", { code: 2, max: 3 }],
["setHours", { code: 3, max: 4 }],
["setDate", { code: 4, max: 1 }],
["setMonth", { code: 5, max: 2 }],
["setFullYear", { code: 6, max: 3 }],
["setUTCMilliseconds", { code: 8, max: 1 }],
["setUTCSeconds", { code: 9, max: 2 }],
["setUTCMinutes", { code: 10, max: 3 }],
["setUTCHours", { code: 11, max: 4 }],
["setUTCDate", { code: 12, max: 1 }],
["setUTCMonth", { code: 13, max: 2 }],
["setUTCFullYear", { code: 14, max: 3 }],
]);
/** Whether `name` is a Date setter scriptc lowers: setTime or a field
* setter. */
export function isDateSetter(name: string): boolean {
return name === "setTime" || DATE_FIELD_SETTERS.has(name);
}
const DATE_TEXT_FNS: ReadonlyMap<string, IrLibFn> = new Map([
["toString", "date.toString"],
["toDateString", "date.toDateString"],
@@ -43,11 +72,11 @@ const DATE_TEXT_FNS: ReadonlyMap<string, IrLibFn> = new Map([
]);
export const DATE_METHOD_HINT =
"getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), toString(), toDateString(), toTimeString(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale formatters have no lowering";
"getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), toString(), toDateString(), toTimeString(), the local/UTC calendar getters and setters, setTime(), and getTimezoneOffset() are supported; locale formatters have no lowering";
/** The Date slice: statics plus read-only Date values backed by one
* TimeClip'd millisecond scalar. Construction lives in lowerNew;
* identity and mutating methods remain fenced. */
/** Date statics and methods on Date objects (a TimeClip'd millisecond
* time value with identity): getters, text forms and setters.
* Construction lives in lowerNew. */
export function lowerDateCall(
lowerer: Lowerer,
call: ts.CallExpression,
@@ -67,16 +96,18 @@ export function lowerDateCall(
if (lowerer.mapTypeOf(lowerer.typeOf(access.expression))?.kind !== "date") return null;
if (!lowerer.isStdlibMember(access)) return null;
const name = access.name.text;
// A Date a JavaScript file constructs is a mutable object; setTime on
// it takes the dynamic method call. So does every Date method on a
// A Date a JavaScript file constructs is a checked-dynamic value; a
// setter on it takes the dynamic method call, which changes the Date the
// handle holds. So does every Date method on a
// checked-dynamic receiver when the program declares a class that extends
// Date: the value can be an instance, whose class may declare the method.
if (
(name === "setTime" || programDeclaresDateClass(lowerer)) &&
(isDateSetter(name) || programDeclaresDateClass(lowerer)) &&
tryLowerExpression(lowerer, access.expression)?.type.kind === "dyn"
)
return null;
if (name === "toJSON") return lowerDateToJson(lowerer, call, access);
if (isDateSetter(name)) return lowerDateSetter(lowerer, call, access, name);
if (call.arguments.nodes.length !== 0) {
lowerer.noLowering(`Date.prototype.${name} with arguments`, call, DATE_METHOD_HINT);
}
@@ -253,3 +284,151 @@ export function isDateJsonSource(lowerer: Lowerer, node: ts.Node): boolean {
lowerer.isStdlibMember(access)
);
}
/** `d.setTime(t)` and the field setters (`d.setHours(h, m?, s?, ms?)` ...)
* on a Date object: the receiver, then the arguments in order, then the
* new time value (scr_date_set_fields over the time value the Date holds
* once the arguments have run) stored into the Date and answered. Missing
* optional arguments keep their fields; arguments past a setter's last
* one are evaluated and ignored. A Date local that holds its time value
* (date-values.ts) takes the new time value itself. */
function lowerDateSetter(
lowerer: Lowerer,
call: ts.CallExpression,
access: ts.PropertyAccessExpression,
name: string,
): IrExpr {
const loc = locOf(call);
const nodes = call.arguments.nodes;
if (nodes.some((node) => ts.isSpreadElement(node)))
lowerer.noLowering(`Date.prototype.${name} with spread arguments`, call, DATE_METHOD_HINT);
const timeLocal = timeValueLocalOf(lowerer, access.expression);
const stmts: IrStmt[] = [];
let receiver: IrExpr | null = null;
if (timeLocal === null) {
// A checked-dynamic receiver takes the dynamic method call
// (lowerDateCall), so the value here is a Date object.
const value = lowerer.lowerExpr(access.expression);
if (value.type.kind !== "date")
lowerer.badType(access.expression, lowerer.typeOf(access.expression));
const date = lowerer.declareHiddenLocal("%date", DATE_T);
stmts.push({ kind: "varDecl", localId: date.id, init: value, loc });
receiver = { kind: "varRef", localId: date.id, type: DATE_T, loc };
}
const time: IrExpr =
receiver === null
? { kind: "varRef", localId: timeLocal!.id, type: F64, loc }
: { kind: "libCall", fn: "date.getTime", args: [receiver], type: F64, loc };
const next = dateSetterTime(lowerer, call, name, stmts, time);
if (receiver === null) {
stmts.push({ kind: "assign", localId: timeLocal!.id, value: next, loc });
return {
kind: "seqExpr",
stmts,
result: { kind: "varRef", localId: timeLocal!.id, type: F64, loc },
type: F64,
loc,
};
}
return {
kind: "seqExpr",
stmts,
result: { kind: "libCall", fn: "date.store", args: [receiver, next], type: F64, loc },
type: F64,
loc,
};
}
/** The new time value of setter `name` called with `call`'s arguments on a
* Date whose time value `time` reads: the arguments evaluate into
* temporaries appended to `stmts` (ToNumber of each, an undefined one is
* NaN), and the result reads `time` after them. A missing first argument
* is NaN; missing optional arguments keep their fields; arguments past a
* setter's last one are evaluated and ignored. */
export function dateSetterTime(
lowerer: Lowerer,
call: ts.CallExpression,
name: string,
stmts: IrStmt[],
time: IrExpr,
): IrExpr {
const loc = locOf(call);
const converted = lowerToNumberArguments(
lowerer,
call.arguments.nodes as readonly ts.Expression[],
);
stmts.push(...converted.stmts);
const args: IrExpr[] = converted.values.map((value) => {
if (value.kind === "numLit") return value;
const arg = lowerer.declareHiddenLocal("%dateArg", F64);
stmts.push({ kind: "varDecl", localId: arg.id, init: value, loc });
return { kind: "varRef", localId: arg.id, type: F64, loc };
});
const number = (value: number): IrExpr => ({ kind: "numLit", value, type: F64, loc });
if (name === "setTime")
return {
kind: "libCall",
fn: "date.timeClip",
args: [args[0] ?? number(Number.NaN)],
type: F64,
loc,
};
const setter = DATE_FIELD_SETTERS.get(name)!;
// The first argument is required: a missing one is undefined, so NaN.
const used = args.length === 0 ? [number(Number.NaN)] : args.slice(0, setter.max);
const operands = [...used];
while (operands.length < 4) operands.push(number(0));
return {
kind: "libCall",
fn: "date.setFields",
args: [time, number(setter.code), number(used.length), ...operands],
type: F64,
loc,
};
}
/** The local a Date binding that holds its time value lowers to, for an
* identifier `node` that names one (date-values.ts); null otherwise. */
function timeValueLocalOf(lowerer: Lowerer, node: ts.Expression): IrLocal | null {
while (ts.isParenthesizedExpression(node)) node = node.expression;
if (!ts.isIdentifier(node)) return null;
const local = lowerer.resolveLocal(node);
return local !== undefined && local !== null && lowerer.timeValueDateLocals.has(local)
? local
: null;
}
/** `d[Symbol.toPrimitive](hint)` on a Date with a literal hint: "number"
* answers the time value, "string" and "default" the toString() text (a
* Date's default hint is a string). Null for other calls. */
export function lowerDateToPrimitiveCall(
lowerer: Lowerer,
call: ts.CallExpression,
access: ts.ElementAccessExpression,
): IrExpr | null {
const key = access.argumentExpression;
if (
!ts.isPropertyAccessExpression(key) ||
key.name.text !== "toPrimitive" ||
!ts.isIdentifier(key.expression) ||
!lowerer.isStdlibGlobal(key.expression, "Symbol") ||
lowerer.mapTypeOf(lowerer.typeOf(access.expression))?.kind !== "date"
)
return null;
const loc = locOf(call);
const hint = call.arguments.nodes.length === 1 ? call.arguments.nodes[0]! : null;
const text = hint !== null && ts.isStringLiteralLike(hint) ? hint.text : null;
if (text !== "number" && text !== "string" && text !== "default")
return lowerer.noLowering(
'Date.prototype[Symbol.toPrimitive] without a "number", "string" or "default" literal hint',
call,
);
const raw = lowerer.lowerExpr(access.expression);
const date: IrExpr =
raw.type.kind === "dyn"
? { kind: "libCall", fn: "date.checkedValue", args: [raw], type: DATE_T, loc }
: raw;
return text === "number"
? { kind: "libCall", fn: "date.valueOf", args: [date], type: F64, loc }
: { kind: "libCall", fn: "date.toString", args: [date], type: STRING, loc };
}
@@ -185,6 +185,32 @@ export function builtinMemberRequireDecl(
return spec !== null && canonicalBuiltinModule(spec) !== null;
}
/** The member's own symbol for a variable bound to a builtin module's
* member (`const SD = require("string_decoder").StringDecoder`,
* builtinMemberRequireDecl) when the member is a class, so the variable
* resolves as a named import of the class does. The symbol itself
* otherwise. */
export function builtinMemberBindingTarget(
lowerer: Lowerer,
symbol: ts.Symbol | null,
): ts.Symbol | null {
const decl = symbol?.valueDeclaration;
if (
decl === undefined ||
!ts.isVariableDeclaration(decl) ||
!builtinMemberRequireDecl(decl.name, decl.initializer)
)
return symbol;
const init = decl.initializer as ts.PropertyAccessExpression;
let member = lowerer.checker.getSymbolAtLocation(init.name);
if (member && member.flags & ts.SymbolFlags.Alias)
member = lowerer.checker.getAliasedSymbol(member);
// Only a class member: `require("events").EventEmitter` is a static
// property of the exported class, which builtinEmitterInfoOf follows
// from the variable itself.
return member?.declarations.some(ts.isClassDeclaration) ? member : symbol;
}
/** The supported builtin module when `decl` destructures a builtin
* NAMESPACE binding (`const crypto = require('crypto'); const {
* createSign, sign: mySign } = crypto;` — test/common/crypto.js's
@@ -302,6 +302,15 @@ function interfaceClassModule(lowerer: Lowerer, node: ts.Expression): string | n
: null;
}
/** `new readline.Interface(...)` and `new readlinePromises.Interface(...)`:
* the constructor createInterface calls, with the same arguments. Null for
* other constructors. */
export function lowerReadlineInterfaceNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr | null {
const module = interfaceClassModule(lowerer, expr.expression);
if (module === null) return null;
return lowerReadlineCreateCall(lowerer, expr, locOf(expr), module === "readline/promises");
}
/** `rl instanceof readline.Interface` and `rl instanceof
* readlinePromises.Interface` over a readline Interface: an interface is
* an instance of the class of the module that created it (Node's
@@ -1059,10 +1068,12 @@ function lowerOutput(lowerer: Lowerer, node: ts.Expression): IrExpr | "stdout" |
* goes through rl.terminal with the completer. */
export function lowerReadlineCreateCall(
lowerer: Lowerer,
expr: ts.CallExpression,
expr: ts.CallExpression | ts.NewExpression,
loc: SrcLoc,
promises: boolean,
): IrExpr {
const argNodes = expr.arguments?.nodes ?? [];
const what = ts.isNewExpression(expr) ? "new Interface" : "createInterface";
let input: IrExpr | null = null;
let output: IrExpr | "stdout" | "none" = "none";
let crlf: IrExpr = numLit(0, loc);
@@ -1112,39 +1123,38 @@ export function lowerReadlineCreateCall(
});
terminal = { kind: "varRef", localId: local.id, type: BOOL, loc };
};
const optsNode = expr.arguments.nodes[0];
const optsNode = argNodes[0];
if (optsNode === undefined) {
lowerer.noLowering(
"createInterface without arguments",
`${what} without arguments`,
expr,
"pass { input: process.stdin, output: process.stdout } or createInterface(input, output)",
);
}
if (!ts.isObjectLiteralExpression(optsNode)) {
// createInterface(input, output?, completer?, terminal?)
if (expr.arguments.nodes.length > 4) {
if (argNodes.length > 4) {
lowerer.noLowering(
`createInterface with ${expr.arguments.nodes.length} arguments`,
expr.arguments.nodes[4]!,
`${what} with ${argNodes.length} arguments`,
argNodes[4]!,
"pass createInterface(input, output, completer, terminal) or one options object",
);
}
input = lowerInput(lowerer, optsNode);
if (expr.arguments.nodes[1] !== undefined)
output = lowerOutput(lowerer, expr.arguments.nodes[1]);
const completerNode = expr.arguments.nodes[2];
if (argNodes[1] !== undefined) output = lowerOutput(lowerer, argNodes[1]);
const completerNode = argNodes[2];
if (
completerNode !== undefined &&
!(ts.isIdentifier(completerNode) && completerNode.text === "undefined")
) {
setCompleter(completerNode);
}
if (expr.arguments.nodes[3] !== undefined) setTerminal(expr.arguments.nodes[3]);
if (argNodes[3] !== undefined) setTerminal(argNodes[3]);
} else {
if (expr.arguments.nodes.length > 1) {
if (argNodes.length > 1) {
lowerer.noLowering(
"createInterface with more than one argument",
expr.arguments.nodes[1]!,
`${what} with more than one argument`,
argNodes[1]!,
"pass every option in the one options object",
);
}
@@ -54,6 +54,7 @@ import {
import {
classPrototypeData,
hasClassPrototypeData,
prototypeReflectedFlag,
observedPrototypeMethod,
reflectClassPrototype,
} from "./class-prototypes.js";
@@ -4180,6 +4181,103 @@ export class ClassDynamicDispatch {
return { kind: "call", callee: name, args: [bag, key], type: BOOL, loc };
}
/** `%dyn.class.bagHasOwn`: whether an instance's property bag holds `key`
* as its own property. False before the instance has a bag. */
private readBagHasOwn(lowerer: Lowerer, bag: IrExpr, key: IrExpr, loc: SrcLoc): IrExpr {
const name = "%dyn.class.bagHasOwn";
let helper = lowerer.liftedFnNamed(name);
if (!helper) {
const value = varRef("p.0", DYN, loc);
helper = {
name,
params: [
{ localId: "p.0", name: "bag", type: DYN },
{ localId: "p.1", name: "key", type: STRING },
],
returnType: BOOL,
locals: [
{ id: "p.0", name: "bag", type: DYN, mutable: false },
{ id: "p.1", name: "key", type: STRING, mutable: false },
],
body: [
{
kind: "if",
cond: { kind: "dynTest", test: "undefined", value, type: BOOL, loc },
then: [
{ kind: "return", value: { kind: "boolLit", value: false, type: BOOL, loc }, loc },
],
else_: null,
loc,
},
{
kind: "return",
value: {
kind: "libCall",
fn: "dyn.hasOwn",
args: [value, varRef("p.1", STRING, loc)],
type: BOOL,
loc,
},
loc,
},
],
loc,
};
lowerer.liftedFns.push(helper);
}
this.generated.add(helper);
return { kind: "call", callee: name, args: [bag, key], type: BOOL, loc };
}
/** The `constructor` an instance of `info` inherits when it has no own
* one. The class's prototype view answers its own `constructor` (a
* replacement, or the class once reflection installed it). A view
* without one answers the class itself unless reflection had populated it
* (prototypeReflectedFlag, set when the view initializes): then the
* program deleted the property, and the read continues to the base
* class's prototype and past the last one to Object.prototype, as in
* Node. A prototype's own `constructor` is read with the instance
* `receiver` as `this`, so an accessor's getter runs on the instance. */
private inheritedConstructor(
lowerer: Lowerer,
info: ClassInfo,
key: IrExpr,
receiver: IrExpr,
loc: SrcLoc,
): IrExpr {
const cls = lowerer.coerceToExpected(classValueRef(lowerer, info, info.decl!), DYN);
const prototype = hasClassPrototypeData(info) ? classPrototypeData(lowerer, info, loc) : null;
if (!prototype) return cls;
const base = info.base;
const read = (): IrExpr => ({
kind: "libCall",
fn: "dyn.reflectGet",
args: [prototype, lowerer.coerceToExpected(key, DYN), receiver],
type: DYN,
loc,
});
// Past the last class prototype, the read continues to Object.prototype's data property.
const next: IrExpr =
base && !base.builtinError && base.decl && !base.generic && !base.localClass
? this.inheritedConstructor(lowerer, base, key, receiver, loc)
: { kind: "dynKeyGet", value: prototype, key, type: DYN, loc };
return {
kind: "ternary",
cond: { kind: "libCall", fn: "dyn.hasOwn", args: [prototype, key], type: BOOL, loc },
then: read(),
else_: {
kind: "ternary",
cond: varRef(prototypeReflectedFlag(lowerer, info), BOOL, loc),
then: next,
else_: cls,
type: DYN,
loc,
},
type: DYN,
loc,
};
}
/** The hints for which some boxed class declares a conversion method. A
* string conversion calls toString first, so without a declared toString
* or Symbol.toPrimitive it answers Object.prototype.toString's
@@ -5938,15 +6036,19 @@ export class ClassDynamicDispatch {
!info.classDecorators
) {
this.constructors.set(info.def.name, info);
const cls = lowerer.coerceToExpected(classValueRef(lowerer, info, info.decl), DYN);
const bag = this.branchPropertyBag(lowerer, info, receiver, false, loc);
const key: IrExpr = { kind: "strLit", value: name, type: STRING, loc };
// `constructor` is an own property of every class prototype, so an
// ancestor's prototype answers for an instance of this class only
// after the program deletes the class's own: the instance's own
// property answers, then the class's prototype (inheritedConstructor).
const answered = this.readBagHasOwn(lowerer, bag, key, loc);
return [
{
kind: "return",
value: {
kind: "ternary",
cond: this.readBagHas(lowerer, "dyn.hasKey", bag, key, loc),
cond: answered,
then: {
kind: "libCall",
fn: "dyn.bagGet",
@@ -5954,7 +6056,13 @@ export class ClassDynamicDispatch {
type: DYN,
loc,
},
else_: cls,
else_: this.inheritedConstructor(
lowerer,
info,
key,
lowerer.coerceToExpected(receiver, DYN),
loc,
),
type: DYN,
loc,
},
@@ -13,7 +13,7 @@ import {
type IrStmt,
type SrcLoc,
} from "../../ir/ir.js";
import { locOf } from "../program.js";
import { isJsSourceFile, locOf } from "../program.js";
import {
classMemberNameOf,
classValueRef,
@@ -299,6 +299,23 @@ function declaresPrototypeMembers(decl: ts.ClassLikeDeclaration): boolean {
);
}
/** The global that is true once the class's prototype view has been
* populated by reflection (reflectClassPrototype): the view then holds an
* own `constructor` unless the program deleted it. A view that was never
* reflected leaves `constructor` implicit. */
export function prototypeReflectedFlag(lowerer: Lowerer, info: ClassInfo): string {
if (info.prototypeReflectedFlag) return info.prototypeReflectedFlag;
const id = `%g.%prototype.data.${info.def.name}.reflected`;
lowerer.globalsList.push({
id,
name: `${info.def.jsName ?? info.def.name}.prototype.reflected`,
type: BOOL,
mutable: true,
});
info.prototypeReflectedFlag = id;
return id;
}
/** Populate the shared prototype view before exposing it to reflection.
* Methods and accessors are non-enumerable and retain declaration identity. */
export function reflectClassPrototype(
@@ -399,6 +416,12 @@ export function reflectClassPrototype(
{ key: "value", value: classValueRef(lowerer, info, info.decl) },
writable,
]),
{
kind: "assign",
localId: prototypeReflectedFlag(lowerer, info),
value: { kind: "boolLit", value: true, type: BOOL, loc },
loc,
},
);
const context = newFnCtx(false, null, null, DYN);
lowerer.fnStack.push(context);
@@ -503,6 +526,69 @@ export function isErrorPrototypeValue(
);
}
/** Whether a bare `C.prototype` read becomes the prototype of another object
* or is only compared or inspected: the argument of `Object.create` or
* `Object.getPrototypeOf` / `Reflect.getPrototypeOf`, the prototype argument
* of `Object.setPrototypeOf` / `Reflect.setPrototypeOf`, or an operand of
* `===` / `!==`. In these positions the complete view of the
* prototype (reflectClassPrototype) answers. Objects that inherit from it
* run the class's methods with themselves as the receiver, which the
* methods' receiver-independent lowering (reflectedClassMethodValue)
* supports. Other positions, such as `Object.setPrototypeOf(C.prototype, X)`,
* which would relink the class, keep the diagnostic. */
function prototypeViewPosition(lowerer: Lowerer, expr: ts.PropertyAccessExpression): boolean {
let node: ts.Node = expr;
let parent = node.parent;
while (parent && ts.isParenthesizedExpression(parent)) {
node = parent;
parent = parent.parent;
}
if (!parent) return false;
if (ts.isCallExpression(parent) && ts.isPropertyAccessExpression(parent.expression)) {
const args = parent.arguments.nodes;
const objectMethod = lowerer.stdlibGlobalMember(parent.expression, "Object");
const reflectMethod = lowerer.stdlibGlobalMember(parent.expression, "Reflect");
if (
objectMethod === "create" ||
objectMethod === "getPrototypeOf" ||
reflectMethod === "getPrototypeOf"
)
return args[0] === node;
if (objectMethod === "setPrototypeOf" || reflectMethod === "setPrototypeOf")
return args[1] === node;
return false;
}
return (
ts.isBinaryExpression(parent) &&
(parent.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken ||
parent.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken)
);
}
/** A module-level JavaScript class named directly (not a class value that
* may hold a subclass at run time): its prototype has a complete view whose
* methods accept any receiver. */
function viewableClass(lowerer: Lowerer, info: ClassInfo, receiver: ts.Expression): boolean {
return (
info.decl !== null &&
info.localClass === undefined &&
isJsSourceFile(ts.getSourceFileOfNode(info.decl)) &&
exactClassOfReceiver(lowerer, receiver) === info
);
}
/** The complete prototype view of the class `node` names, when it is a
* viewable class (util.inherits's superCtor); null otherwise. */
export function classPrototypeView(
lowerer: Lowerer,
node: ts.Expression,
loc: SrcLoc,
): IrExpr | null {
const info = exactClassOfReceiver(lowerer, node);
if (!info || !viewableClass(lowerer, info, node)) return null;
return reflectClassPrototype(lowerer, info, loc, classValueRef(lowerer, info, node));
}
/** Named data and method slots have native descriptors. Bare reflection and
* accessor replacement still require a complete view of the prototype. */
export function lowerClassPrototypeData(
@@ -566,6 +652,12 @@ export function lowerClassPrototypeData(
});
return reflectClassPrototype(lowerer, info, locOf(expr), receiver);
}
if (
name === null &&
prototypeViewPosition(lowerer, expr) &&
viewableClass(lowerer, info, expr.expression)
)
return reflectClassPrototype(lowerer, info, locOf(expr), classValueRef(lowerer, info, expr));
if (name === null || (isCompiledPrototypeMember(lowerer, info, name) && !method)) {
lowerer.unsupported(
"SC1090",
@@ -1,5 +1,6 @@
import * as ts from "../ts7/adapter.js";
import type { Lowerer } from "./lowerer.js";
import { isDateSetter } from "./builtins/dates.js";
/** Date methods whose receiver is only read for its time value: a Date
* local used as their receiver can store the time value instead of a Date
@@ -41,12 +42,15 @@ const RELATIONAL = new Set([
/** True when a local `const` (or a `let` that is never assigned again)
* initialized with `new Date(...)` can keep the Date's time value instead
* of a Date object: no use observes the Date's identity. Every use reads the time value as
* a Date method receiver, a String() or template operand, a relational or
* string `+` operand, or `new Date(d)`'s argument, inside the declaring
* function, or inside the block or loop that scopes a module-level
* binding (no closure captures it). Top-level module bindings keep the
* object. Other uses box the time value into a new Date at the use. */
* of a Date object: no use observes the Date's identity. Every use reads
* the time value as a Date method receiver (a setter replaces it; the
* arguments of a method that reads it call no setter on it), a
* String(), Number() or template operand, a unary + or - operand, a
* relational or string `+` operand, or `new Date(d)`'s argument, inside
* the declaring function, or inside the block or loop that scopes a
* module-level binding (no closure captures it). Top-level module bindings
* keep the object. Other uses box the time value into a new Date at the
* use. */
export function isTimeValueDateDeclaration(
lowerer: Lowerer,
decl: ts.VariableDeclaration,
@@ -94,17 +98,21 @@ function decideTimeValueDate(lowerer: Lowerer, decl: ts.VariableDeclaration): bo
const symbol = lowerer.checker.getSymbolAtLocation(decl.name);
if (!symbol) return false;
let ok = true;
let sets = false;
const visit = (node: ts.Node): void => {
if (!ok) return;
if (ts.isIdentifier(node) && node !== decl.name && node.text === decl.name.text) {
if (lowerer.checker.getSymbolAtLocation(node) === symbol) {
if (enclosingFunction(node) !== fn || !readsTimeValue(lowerer, node)) ok = false;
const use = enclosingFunction(node) === fn ? timeValueUse(lowerer, node, symbol) : null;
if (use === null) ok = false;
else if (use === "set") sets = true;
}
return;
}
ts.forEachChild(node, visit);
};
visit(scope);
if (ok && sets) lowerer.timeValueDateWrites.add(decl);
return ok;
}
@@ -164,29 +172,75 @@ function enclosingFunction(node: ts.Node): ts.FunctionLikeDeclaration | null {
return null;
}
/** A use of the binding that reads only its time value. */
function readsTimeValue(lowerer: Lowerer, id: ts.Identifier): boolean {
/** How a use of the binding uses its time value: "read" for a use that
* reads only the time value, "set" for a setter call that replaces it, or
* null for any other use. */
function timeValueUse(
lowerer: Lowerer,
id: ts.Identifier,
symbol: ts.Symbol,
): "read" | "set" | null {
let node: ts.Node = id;
while (ts.isParenthesizedExpression(node.parent)) node = node.parent;
const parent = node.parent;
if (parent === undefined) return null;
if (ts.isPropertyAccessExpression(parent) && parent.expression === node) {
const call = parent.parent;
return (
!parent.questionDotToken &&
ts.isCallExpression(call) &&
call.expression === parent &&
!call.questionDotToken &&
TIME_VALUE_METHODS.has(parent.name.text) &&
lowerer.isStdlibMember(parent)
);
if (
parent.questionDotToken ||
!ts.isCallExpression(call) ||
call.expression !== parent ||
call.questionDotToken ||
!lowerer.isStdlibMember(parent)
)
return null;
if (isDateSetter(parent.name.text)) return "set";
// The method reads the time value after its arguments are evaluated:
// a setter there (`d.toJSON(d.setTime(0))`) changes what it reads.
if (call.arguments.nodes.some((arg) => callsSetterOf(lowerer, arg, symbol))) return null;
return TIME_VALUE_METHODS.has(parent.name.text) ? "read" : null;
}
return readsTimeValue(lowerer, node, parent, symbol) ? "read" : null;
}
/** True when `node` calls a setter on the binding `symbol` names. */
function callsSetterOf(lowerer: Lowerer, node: ts.Node, symbol: ts.Symbol): boolean {
if (
ts.isCallExpression(node) &&
ts.isPropertyAccessExpression(node.expression) &&
isDateSetter(node.expression.name.text)
) {
let receiver = node.expression.expression;
while (ts.isParenthesizedExpression(receiver)) receiver = receiver.expression;
if (ts.isIdentifier(receiver) && lowerer.checker.getSymbolAtLocation(receiver) === symbol)
return true;
}
return (
ts.forEachChild(node, (child) => callsSetterOf(lowerer, child, symbol) || undefined) ?? false
);
}
/** A use of the binding, other than a method call, that reads only its
* time value. */
function readsTimeValue(
lowerer: Lowerer,
node: ts.Node,
parent: ts.Node,
symbol: ts.Symbol,
): boolean {
if (ts.isTemplateSpan(parent)) return parent.expression === node;
// Unary + and -, the number hint.
if (ts.isPrefixUnaryExpression(parent))
return (
parent.operator === ts.SyntaxKind.PlusToken || parent.operator === ts.SyntaxKind.MinusToken
);
if (ts.isCallExpression(parent)) {
return (
parent.arguments.nodes.length === 1 &&
parent.arguments.nodes[0] === node &&
ts.isIdentifier(parent.expression) &&
lowerer.isStdlibGlobal(parent.expression, "String")
(lowerer.isStdlibGlobal(parent.expression, "String") ||
lowerer.isStdlibGlobal(parent.expression, "Number"))
);
}
if (ts.isNewExpression(parent)) {
@@ -198,6 +252,9 @@ function readsTimeValue(lowerer: Lowerer, id: ts.Identifier): boolean {
);
}
if (ts.isBinaryExpression(parent)) {
// A left operand converts after the right one is evaluated: a setter
// there changes the time value the comparison or concatenation reads.
if (parent.left === node && callsSetterOf(lowerer, parent.right, symbol)) return false;
const other = parent.left === node ? parent.right : parent.left;
const otherKind = lowerer.mapTypeOf(lowerer.typeOf(other))?.kind;
if (RELATIONAL.has(parent.operatorToken.kind)) return otherKind === "date";
@@ -1600,6 +1600,19 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr
loc,
};
}
// A Date against an untyped value: the same Date object, whose
// checked-dynamic handle the untyped value would hold.
if (dynSide.type.kind === "dyn" && scalarSide.type.kind === "date") {
const boxed = lowerer.coerceToExpected(scalarSide, DYN);
return {
kind: "dynScalarEq",
left: scalarSide === left ? boxed : left,
right: scalarSide === right ? boxed : right,
...(negated ? { negated: true as const } : {}),
type: BOOL,
loc,
};
}
if (
dynSide.type.kind === "dyn" &&
(scalarSide.type.kind === "f64" ||
@@ -2024,6 +2037,28 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr
loc,
};
if (left.type.kind === "string" || right.type.kind === "string") {
// A Date on the left converts after the right operand is
// evaluated, which can run its setters.
if (left.type.kind === "date" && !isSafeToRepeat(right)) {
const date = lowerer.declareHiddenLocal("%plus", left.type);
const other = lowerer.declareHiddenLocal("%plus", right.type);
return {
kind: "seqExpr",
stmts: [
{ kind: "varDecl", localId: date.id, init: left, loc },
{ kind: "varDecl", localId: other.id, init: right, loc },
],
result: {
kind: "strConcat",
left: concatOperand(lowerer, varRef(date.id, left.type, loc), expr.left),
right: concatOperand(lowerer, varRef(other.id, right.type, loc), expr.right),
type: STRING,
loc,
},
type: STRING,
loc,
};
}
return {
kind: "strConcat",
left: concatOperand(lowerer, left, expr.left),
@@ -112,6 +112,7 @@ import { dynUndefinedExpr, nodeThrowExpr, numLit, strLit, varRef } from "../../.
import { typedArrayLacksMember } from "../builtins/typed-array-members.js";
import { TEMPORAL_GLOBAL } from "../../builtin-modules.js";
import { tryLowerExpression } from "./try-lower-expression.js";
import { DATE_METHOD_HINT } from "../builtins/dates.js";
import {
cjsClassExprWholeExportOf,
cjsExportAssignmentOf,
@@ -2055,6 +2056,9 @@ export function lowerIntrinsicProperty(
);
}
if (kind === "date") {
// A Date held as a checked-dynamic value (a Date a JavaScript file
// constructs) answers its method values at run time.
if (tryLowerExpression(lowerer, expr.expression)?.type.kind === "dyn") return null;
const methods = new Set([
"getTime",
"valueOf",
@@ -2083,7 +2087,7 @@ export function lowerIntrinsicProperty(
lowerer.noLowering(
`Date.prototype.${name}`,
expr,
"getTime(), valueOf(), toISOString(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale/string formatters have no lowering",
DATE_METHOD_HINT,
lowerer.checker.getSymbolAtLocation(expr.name),
);
}
@@ -96,6 +96,8 @@ const NO_CLASS_INSTANCE_KINDS: ReadonlySet<IrType["kind"]> = new Set([
"nullT",
]);
const PRIMITIVE_WRAPPERS = ["Boolean", "Number", "String", "BigInt", "Symbol"] as const;
/** `x instanceof C` for a program-declared class C. When x's static
* class and C are both in extends-hierarchies the test is dynamic — the
* O(1) preorder-interval check against the vtable (`instanceOf` node).
@@ -187,6 +189,20 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc
loc,
};
}
// The primitive wrapper constructors: a compiled program never creates a
// boxed primitive (`new Boolean(...)` and friends have no lowering), and
// a primitive is never an instance, so the test is false for every value
// (typebox's IsBoolean/IsNumber/IsString guards). The operand still runs.
if (PRIMITIVE_WRAPPERS.some((name) => lowerer.isStdlibGlobal(expr.right, name))) {
const value = lowerer.lowerExpr(expr.left);
return {
kind: "seqExpr",
stmts: [{ kind: "exprStmt", expr: value, loc }],
result: { kind: "boolLit", value: false, type: BOOL, loc },
type: BOOL,
loc,
};
}
if (lowerer.isStdlibGlobal(expr.right, "FinalizationRegistry")) {
const value = lowerer.coerceInto(expr.left, lowerer.lowerExpr(expr.left), DYN);
return { kind: "libCall", fn: "finalizationRegistry.is", args: [value], type: BOOL, loc };
@@ -288,6 +304,16 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc
if (!target) {
const date = lowerer.isStdlibGlobal(expr.right, "Date");
const url = lowerer.isStdlibGlobal(expr.right, "URL");
// A catch binding holds a thrown Date as its checked-dynamic handle.
const caughtDate = date ? lowerer.caughtLocalOf(expr.left) : null;
if (caughtDate)
return {
kind: "libCall",
fn: "dyn.nativeDateIs",
args: [{ kind: "caughtToDyn", value: varRef(caughtDate.id, CAUGHT, loc), type: DYN, loc }],
type: BOOL,
loc,
};
if ((date || url) && !lowerer.caughtLocalOf(expr.left)) {
const value = lowerer.lowerExpr(expr.left);
if (value.type.kind === "dyn")
@@ -1902,6 +1928,30 @@ export function lowerInExpression(
};
}
}
// A child process has `send`, `disconnect` and `channel` only when it was
// created with an IPC channel (fork, or an "ipc" stdio entry), and keeps
// them after the channel closes: the runtime answers from the child.
if (recv.type.kind === "child" && CHILD_CHANNEL_MEMBERS.has(key))
return { kind: "libCall", fn: "child.hasChannel", args: [recv], type: BOOL, loc };
// A native Node handle (an http.Server, a socket, a child process...):
// a member Node's declarations give the class is on its prototype, so
// `"closeAllConnections" in server` answers true. Undeclared names can
// still be real (internal `_`-prefixed fields), so they keep the fence.
if (
NATIVE_HANDLE_KINDS.has(recv.type.kind) &&
nodeDeclaredMember(lowerer, lowerer.typeOf(expr.right), key)
) {
const answer: IrExpr = { kind: "boolLit", value: true, type: BOOL, loc };
return isSafeToDiscard(recv)
? answer
: {
kind: "seqExpr",
stmts: [{ kind: "exprStmt", expr: recv, loc }],
result: answer,
type: BOOL,
loc,
};
}
if (recv.type.kind !== "record") {
lowerer.unsupported(
"SC1090",
@@ -2178,24 +2228,43 @@ function lowerRuntimeKeyIn(
return { kind: "call", callee: helper, args: [keyIr, recv], type: BOOL, loc };
}
/** Standard-library constructors the untyped instanceof helper has no
* instance test for: an untyped `x instanceof Object` always refuses. */
const DYN_INSTANCEOF_REFUSED: ReadonlySet<string> = new Set(["Object", "Function"]);
/** The SC1090 message `x instanceof C` throws, with the site's position,
* when `C` is a runtime constructor with no instance test. A right-hand
* side that names one of DYN_INSTANCEOF_REFUSED always reaches that
* refusal, so the site is listed with the runtime fences. */
* when `C` is a runtime constructor with no instance test. */
function dynInstanceOfRefusal(lowerer: Lowerer, expr: ts.BinaryExpression): string {
const diag = unsupportedDiag("SC1090", locOf(expr), "instanceof against this native constructor");
const symbol = lowerer.checker.getSymbolAtLocation(expr.right);
if (
symbol !== undefined &&
lowerer.isStdlibSymbol(symbol) &&
DYN_INSTANCEOF_REFUSED.has(ts.symbolName(symbol))
)
lowerer.runtimeFences.push(diag);
const sf = ts.getSourceFileOfNode(expr);
const line = ts.getLineAndCharacterOfPosition(sf, diag.loc.start).line + 1;
return `${diag.message} [${diag.code} at ${diag.loc.file}:${line}]`;
}
/** IR kinds of native Node handles whose members come from Node's own
* declarations. */
const NATIVE_HANDLE_KINDS: ReadonlySet<IrType["kind"]> = new Set([
"netServer",
"netSocket",
"httpReq",
"httpRes",
"httpClientReq",
"child",
"dgramSocket",
"fsWatcher",
"http2Session",
"http2Stream",
]);
/** Child process members that exist only on a child with an IPC channel. */
const CHILD_CHANNEL_MEMBERS: ReadonlySet<string> = new Set(["send", "disconnect", "channel"]);
/** Whether every constituent of `t` declares a required member `key` in
* a declaration file (Node's @types/node). */
function nodeDeclaredMember(lowerer: Lowerer, t: ts.Type, key: string): boolean {
const parts = t.isUnionType() ? ts.constituentTypes(t) : [t];
return parts.every((part) => {
const prop = lowerer.checker.getPropertyOfType(part, key);
if (prop === undefined || (prop.flags & ts.SymbolFlags.Optional) !== 0) return false;
return (
prop.declarations.length > 0 &&
prop.declarations.every((d) => ts.getSourceFileOfNode(d).isDeclarationFile)
);
});
}
@@ -35,6 +35,7 @@ import { isExecExceptionType, refuseExecExceptionCode } from "../builtins/errors
import { InternalCompilerError } from "../../../errors.js";
import { TEMPORAL_GLOBAL } from "../../builtin-modules.js";
import { isTemporalGlobalRoot } from "../lower-namespaces.js";
import { isF64HandleTyped } from "../f64-handles.js";
export function lowerTypeofExpr(lowerer: Lowerer, expr: ts.TypeOfExpression, loc: SrcLoc): IrExpr {
const webAssembly = lowerWebAssemblyTypeof(lowerer, expr.expression);
@@ -281,13 +282,18 @@ export function lowerTypeofExpr(lowerer: Lowerer, expr: ts.TypeOfExpression, loc
promise: "object",
bytes: "object",
regex: "object",
date: "object",
generator: "object",
classval: "function",
moduleNs: "object",
undefinedT: "undefined",
nullT: "object",
};
const folded = FOLD[operand.type.kind];
// An f64 handle (a readline Interface, a Timeout) is an object.
const folded =
operand.type.kind === "f64" && isF64HandleTyped(lowerer, expr.expression)
? "object"
: FOLD[operand.type.kind];
if (folded) {
const result: IrExpr = { kind: "strLit", value: folded, type: STRING, loc };
return isSafeToDiscard(operand)
@@ -120,6 +120,11 @@ function coercedUnion(lowerer: Lowerer, type: IrType): boolean {
return arms.some((arm) => arm.kind !== "f64" && !isUnitType(arm));
}
/** ToNumber of a Date: its time value (valueOf, the number hint). */
function dateTimeValue(date: IrExpr, loc: SrcLoc): IrExpr {
return { kind: "libCall", fn: "date.valueOf", args: [date], type: F64, loc };
}
/** ToNumber of a boolean: 1 for true, 0 for false. */
function booleanToNumber(value: IrExpr, loc: SrcLoc): IrExpr {
return {
@@ -145,23 +150,25 @@ export function lowerPrefixUnary(lowerer: Lowerer, expr: ts.PrefixUnaryExpressio
const operand =
raw.type.kind === "bool"
? booleanToNumber(raw, loc)
: coercedUnion(lowerer, raw.type)
? ({
kind: "libCall",
fn: "dyn.toNumberCoerce",
args: [{ kind: "dynFrom", value: raw, type: DYN, loc }],
type: F64,
loc,
} satisfies IrExpr)
: raw.type.kind === "dyn" && isJsSourceFile(ts.getSourceFileOfNode(expr))
: raw.type.kind === "date"
? dateTimeValue(raw, loc)
: coercedUnion(lowerer, raw.type)
? ({
kind: "libCall",
fn: "dyn.toNumberCoerce",
args: [raw],
args: [{ kind: "dynFrom", value: raw, type: DYN, loc }],
type: F64,
loc,
} satisfies IrExpr)
: lowerOptionalNumber(lowerer, raw, loc, expr.operand);
: raw.type.kind === "dyn" && isJsSourceFile(ts.getSourceFileOfNode(expr))
? ({
kind: "libCall",
fn: "dyn.toNumberCoerce",
args: [raw],
type: F64,
loc,
} satisfies IrExpr)
: lowerOptionalNumber(lowerer, raw, loc, expr.operand);
if (operand.type.kind !== "f64") operatorUnsupported(lowerer, expr, "-", [raw.type]);
if (operand.kind === "numLit") return { ...operand, value: -operand.value, loc };
return { kind: "unary", op: "-", operand, type: F64, loc };
@@ -185,6 +192,8 @@ export function lowerPrefixUnary(lowerer: Lowerer, expr: ts.PrefixUnaryExpressio
return { kind: "libCall", fn: "num.fromString", args: [raw], type: F64, loc };
}
if (raw.type.kind === "bool") return booleanToNumber(raw, loc);
// A Date's number hint answers its time value.
if (raw.type.kind === "date") return dateTimeValue(raw, loc);
const classNumber = classToNumberConversion(lowerer, raw, expr.operand, "unary plus");
if (classNumber) return classNumber;
const operand = lowerOptionalNumber(lowerer, raw, loc, expr.operand);
@@ -0,0 +1,15 @@
import * as ts from "../ts7/adapter.js";
import type { Lowerer } from "./lowerer.js";
/** Whether `node`'s value is an f64 handle: an object whose class
* type-mapper.ts represents as an f64 id into a runtime registry (a
* readline Interface, a Timeout or Immediate, an AsyncLocalStorage, a
* diagnostics_channel Channel). Its checker type is an object type that
* maps to f64; the Number wrapper interface maps to f64 for primitives. */
export function isF64HandleTyped(lowerer: Lowerer, node: ts.Node): boolean {
const type = lowerer.checker.getTypeAtLocation(node);
if ((type.flags & ts.TypeFlags.Object) === 0) return false;
const symbol = type.getSymbol();
if (symbol !== undefined && ts.symbolName(symbol) === "Number") return false;
return lowerer.mapTypeOf(type)?.kind === "f64";
}
@@ -0,0 +1,533 @@
/* Types of untyped JavaScript collections that their uses prove.
*
* In a JavaScript file, `const m = new Map()`, `new Set()` and `[]` type as
* Map<any, any>, Set<any> and any[] to the checker, so every use lowers to
* the checked-dynamic collection operations. When every reference of such a
* `const` is a direct use of the collection, the value never leaves the
* binding. If its keys, values or elements are then each one primitive type
* wherever the program writes and looks them up, the binding lowers as the
* typed Map<K, V>, Set<K> or T[], as a JSDoc @type would make it:
*
* - Map: `m.get(k)`, `m.has(k)`, `m.delete(k)`, `m.clear()`, `m.size`, and
* `m.set(k, v)` as a statement (its result is the map).
* - Set: `s.add(k)` as a statement, `s.has(k)`, `s.delete(k)`, `s.clear()`,
* `s.size`.
* - Array: `a.push(...)`, `a.pop()`, `a.shift()`, `a.at(i)`, `a.includes(v)`,
* `a.indexOf(v)`, `a.join(separator)`, `a.length`.
*
* Any other reference (passing, returning, storing, exporting, iterating,
* spreading, inspecting the collection, an optional call), a direct `eval`
* in the file, or any doubt about a type keeps the binding untyped.
*
* The types come from carrier declarations in scriptc-overrides.d.ts, which
* only the lowering program includes. */
import * as ts from "../ts7/adapter.js";
import { isJsSourceFile } from "../program.js";
import { overridesDtsPath } from "../dts-paths.js";
import type { Lowerer } from "./lowerer.js";
type Primitive = "string" | "number" | "boolean";
const PRIMITIVES: readonly Primitive[] = ["string", "number", "boolean"];
type Collection = "map" | "set" | "array";
interface Candidate {
readonly kind: Collection;
readonly declaration: ts.VariableDeclaration;
/** Every identifier that names the binding, its declaration's included. */
readonly names: ts.Identifier[];
/** Map keys or Set members written by set/add. */
readonly keyWrites: ts.Expression[];
/** Map values written by set. Array elements written by push. */
readonly valueWrites: ts.Expression[];
/** Arguments compared with keys or elements: get/has/delete, includes/indexOf. */
readonly lookups: ts.Expression[];
/** Array indexes passed to at(). */
readonly indexes: ts.Expression[];
/** Separators passed to join(). */
readonly separators: ts.Expression[];
key: Primitive | undefined;
value: Primitive | undefined;
}
const MAP_LOOKUPS = new Set(["get", "has", "delete"]);
const SET_LOOKUPS = new Set(["has", "delete"]);
const ARRAY_READS = new Set(["pop", "shift"]);
const ARRAY_SEARCHES = new Set(["includes", "indexOf"]);
/** The expression lowering refuses deeper nesting (SC1090). */
const MAX_DEPTH = 200;
/** The inferred type of the binding a `new Map()` or `new Set()` initializes,
* which the construction adopts. */
export function initializedCollectionType(
lowerer: Lowerer,
expr: ts.NewExpression,
): ts.Type | null {
const declaration = expr.parent;
return declaration !== undefined &&
ts.isVariableDeclaration(declaration) &&
declaration.initializer === expr &&
ts.isIdentifier(declaration.name)
? inferredCollectionType(lowerer, declaration.name)
: null;
}
/** The inferred collection type of an identifier that names a proven
* binding, or null. Computed for every JavaScript file of the program on
* first use. */
export function inferredCollectionType(lowerer: Lowerer, node: ts.Identifier): ts.Type | null {
const types = (lowerer.inferredCollectionTypes ??= inferProgram(lowerer));
return types.size === 0 ? null : (types.get(node) ?? null);
}
function inferProgram(lowerer: Lowerer): Map<ts.Node, ts.Type> {
const out = new Map<ts.Node, ts.Type>();
const carriers = carrierTypes(lowerer);
if (carriers === null) return out;
for (const file of lowerer.program.getSourceFiles()) {
if (file.isDeclarationFile || !isJsSourceFile(file)) continue;
for (const candidate of inferFile(lowerer, file)) {
const name =
candidate.kind === "map"
? `map_${candidate.key}_${candidate.value}`
: candidate.kind === "set"
? `set_${candidate.key}`
: `array_${candidate.value}`;
const type = carriers.get(name);
if (type === undefined) continue;
for (const id of candidate.names) out.set(id, type);
}
}
return out;
}
/** The carrier declarations' types, by name (`map_string_number`,
* `set_number`, `array_string`, ...), or null when the lowering program does
* not include them. */
function carrierTypes(lowerer: Lowerer): Map<string, ts.Type> | null {
const file = lowerer.program.getSourceFile(overridesDtsPath());
if (file === undefined) return null;
const out = new Map<string, ts.Type>();
for (const statement of file.statements.nodes) {
if (
!ts.isModuleDeclaration(statement) ||
!ts.isIdentifier(statement.name) ||
statement.name.text !== "__scriptcInferredCollections" ||
statement.body === undefined ||
!ts.isModuleBlock(statement.body)
)
continue;
for (const member of statement.body.statements.nodes) {
if (!ts.isVariableStatement(member)) continue;
for (const declaration of member.declarationList.declarations.nodes)
if (ts.isIdentifier(declaration.name))
out.set(declaration.name.text, lowerer.checker.getTypeAtLocation(declaration.name));
}
}
return out.size > 0 ? out : null;
}
function inferFile(lowerer: Lowerer, file: ts.SourceFile): Candidate[] {
const candidates = new Map<ts.Symbol, Candidate>();
let directEval = false;
const declarations = (node: ts.Node): void => {
if (
ts.isCallExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === "eval"
)
directEval = true;
if (ts.isVariableDeclaration(node)) {
const kind = candidateKind(lowerer, node);
const symbol = kind !== null ? lowerer.checker.getSymbolAtLocation(node.name) : undefined;
if (kind !== null && symbol !== undefined)
candidates.set(symbol, {
kind,
declaration: node,
names: [node.name as ts.Identifier],
keyWrites: [],
valueWrites: [],
lookups: [],
indexes: [],
separators: [],
key: undefined,
value: undefined,
});
}
};
ts.walkPreorder(file, declarations);
if (directEval || candidates.size === 0) return [];
const names = new Set(
[...candidates.values()].map((c) => (c.declaration.name as ts.Identifier).text),
);
const refused = new Set<ts.Symbol>();
const references = (node: ts.Node): void => {
if (ts.isIdentifier(node) && names.has(node.text)) {
const symbol = referencedSymbol(lowerer, node);
const candidate = symbol !== undefined ? candidates.get(symbol) : undefined;
if (symbol !== undefined && candidate !== undefined && node !== candidate.declaration.name) {
if (recordUse(candidate, node)) candidate.names.push(node);
else refused.add(symbol);
}
}
};
ts.walkPreorder(file, references);
for (const symbol of refused) candidates.delete(symbol);
const proven = [...candidates.values()].filter(
(c) => c.valueWrites.length > 0 || c.keyWrites.length > 0,
);
solve(lowerer, proven);
return proven.filter((c) =>
c.kind === "map" ? c.key && c.value : c.kind === "set" ? c.key : c.value,
);
}
/** The collection a `const` declaration starts empty, when the checker
* types it as an untyped Map, Set or array. */
function candidateKind(lowerer: Lowerer, declaration: ts.VariableDeclaration): Collection | null {
if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined) return null;
const list = declaration.parent;
if (!ts.isVariableDeclarationList(list) || (list.flags & ts.NodeFlags.Const) === 0) return null;
const statement = list.parent;
if (
ts.isVariableStatement(statement) &&
ts.getModifiers(statement)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)
)
return null;
const init = declaration.initializer;
let kind: Collection | null = null;
if (ts.isArrayLiteralExpression(init) && init.elements.nodes.length === 0) kind = "array";
else if (
ts.isNewExpression(init) &&
init.typeArguments === undefined &&
(init.arguments?.nodes.length ?? 0) === 0
) {
if (lowerer.isStdlibGlobal(init.expression, "Map")) kind = "map";
else if (lowerer.isStdlibGlobal(init.expression, "Set")) kind = "set";
}
if (kind === null) return null;
// A JSDoc type, or anything else the checker already knows, stands.
const type = lowerer.checker.getTypeAtLocation(declaration.name);
const args =
kind === "array"
? lowerer.checker.isArrayType(type)
? lowerer.checker.getTypeArguments(type as ts.TypeReference)
: []
: lowerer.checker.getTypeArguments(type as ts.TypeReference);
const arity = kind === "map" ? 2 : 1;
if (args.length !== arity || !args.every((a) => (a.flags & ts.TypeFlags.Any) !== 0)) return null;
return kind;
}
function referencedSymbol(lowerer: Lowerer, id: ts.Identifier): ts.Symbol | undefined {
const parent = id.parent;
if (parent !== undefined && ts.isShorthandPropertyAssignment(parent) && parent.name === id)
return lowerer.checker.getShorthandAssignmentValueSymbol(parent);
const symbol = lowerer.checker.getSymbolAtLocation(id);
// `export { m }` names the export, an alias of the local binding.
if (
symbol !== undefined &&
parent !== undefined &&
ts.isExportSpecifier(parent) &&
parent.propertyName === undefined &&
(symbol.flags & ts.SymbolFlags.Alias) !== 0
)
return lowerer.checker.getAliasedSymbol(symbol);
return symbol;
}
/** Records a reference's use, or answers false when the reference is not
* one of the collection uses this inference proves. */
function recordUse(candidate: Candidate, id: ts.Identifier): boolean {
const access = id.parent;
if (
access === undefined ||
!ts.isPropertyAccessExpression(access) ||
access.expression !== id ||
access.questionDotToken !== undefined
)
return false;
const name = access.name.text;
const call = access.parent;
if (call !== undefined && ts.isCallExpression(call) && call.expression === access) {
if (call.questionDotToken !== undefined) return false;
const args = call.arguments.nodes;
if (args.some(ts.isSpreadElement)) return false;
switch (candidate.kind) {
case "map":
if (MAP_LOOKUPS.has(name) && args.length === 1)
return (candidate.lookups.push(args[0]!), true);
if (name === "clear") return args.length === 0;
if (name === "set" && args.length === 2 && isStatement(call)) {
candidate.keyWrites.push(args[0]!);
candidate.valueWrites.push(args[1]!);
return true;
}
return false;
case "set":
if (SET_LOOKUPS.has(name) && args.length === 1)
return (candidate.lookups.push(args[0]!), true);
if (name === "clear") return args.length === 0;
if (name === "add" && args.length === 1 && isStatement(call))
return (candidate.keyWrites.push(args[0]!), true);
return false;
case "array":
if (name === "push") return (candidate.valueWrites.push(...args), true);
if (ARRAY_READS.has(name)) return args.length === 0;
if (name === "at" && args.length === 1) return (candidate.indexes.push(args[0]!), true);
if (ARRAY_SEARCHES.has(name) && args.length === 1)
return (candidate.lookups.push(args[0]!), true);
if (name === "join" && args.length <= 1) return (candidate.separators.push(...args), true);
return false;
}
}
return (candidate.kind === "array" ? name === "length" : name === "size") && isPlainRead(access);
}
function isStatement(call: ts.CallExpression): boolean {
let node: ts.Node = call;
while (node.parent !== undefined && ts.isParenthesizedExpression(node.parent)) node = node.parent;
return node.parent !== undefined && ts.isExpressionStatement(node.parent);
}
/** A read that neither writes nor deletes the property. */
function isPlainRead(access: ts.PropertyAccessExpression): boolean {
let node: ts.Node = access;
while (node.parent !== undefined && ts.isParenthesizedExpression(node.parent)) node = node.parent;
const parent = node.parent;
if (parent === undefined) return false;
if (
ts.isBinaryExpression(parent) &&
parent.left === node &&
isAssignment(parent.operatorToken.kind)
)
return false;
if (
(ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) &&
(parent.operator === ts.SyntaxKind.PlusPlusToken ||
parent.operator === ts.SyntaxKind.MinusMinusToken)
)
return false;
return !ts.isDeleteExpression(parent);
}
function isAssignment(kind: ts.SyntaxKind): boolean {
return kind >= ts.SyntaxKind.FirstAssignment && kind <= ts.SyntaxKind.LastAssignment;
}
/** Decides each candidate's key and value types: a fixed point in which a
* decided candidate's reads type the expressions built from them. */
function solve(lowerer: Lowerer, candidates: Candidate[]): void {
const bySymbol = new Map<ts.Symbol, Candidate>();
for (const c of candidates) {
const symbol = lowerer.checker.getSymbolAtLocation(c.declaration.name);
if (symbol) bySymbol.set(symbol, c);
}
for (let changed = true; changed;) {
changed = false;
for (const c of candidates) {
if (c.kind !== "array" && c.key === undefined) {
const key = uniform(lowerer, bySymbol, c.keyWrites);
if (key !== null) {
c.key = key;
changed = true;
}
}
if (c.kind !== "set" && c.value === undefined) {
// Each candidate type is assumed for this collection's own reads.
const accepted = PRIMITIVES.filter((assumed) => {
c.value = assumed;
const found = uniform(lowerer, bySymbol, c.valueWrites);
c.value = undefined;
return found === assumed;
});
if (accepted.length === 1) {
c.value = accepted[0];
changed = true;
}
}
}
}
for (const c of candidates) {
const element = c.kind === "array" ? c.value : c.key;
// Lookups compare with the stored type; an index is a number.
const lookupsAgree =
element !== undefined &&
c.lookups.every((e) => primitiveOf(lowerer, bySymbol, e, new Set()) === element) &&
c.indexes.every((e) => primitiveOf(lowerer, bySymbol, e, new Set()) === "number") &&
c.separators.every((e) => primitiveOf(lowerer, bySymbol, e, new Set()) === "string");
if (!lookupsAgree) {
c.key = undefined;
c.value = undefined;
}
}
}
/** The one primitive type of every expression, or null. */
function uniform(
lowerer: Lowerer,
bySymbol: Map<ts.Symbol, Candidate>,
exprs: readonly ts.Expression[],
): Primitive | null {
let found: Primitive | null = null;
for (const e of exprs) {
const p = primitiveOf(lowerer, bySymbol, e, new Set());
if (p === null || (found !== null && p !== found)) return null;
found = p;
}
return found;
}
function checkerPrimitive(type: ts.Type): Primitive | null {
const flags = type.flags;
if (flags & (ts.TypeFlags.String | ts.TypeFlags.StringLiteral)) return "string";
if (flags & (ts.TypeFlags.Number | ts.TypeFlags.NumberLiteral)) return "number";
if (flags & (ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral)) return "boolean";
return null;
}
/** The primitive type `e` always evaluates to, or null. The checker answers
* where it knows; where it types `e` as `any`, the value's structure does:
* a read of a decided candidate, arithmetic and comparisons over such
* values, and constants defined from them. An expression nested deeper than
* the lowering's nesting limit answers null. */
function primitiveOf(
lowerer: Lowerer,
bySymbol: Map<ts.Symbol, Candidate>,
e: ts.Expression,
visiting: Set<ts.Node>,
depth = 0,
): Primitive | null {
if (depth > MAX_DEPTH) return null;
const type = lowerer.checker.getTypeAtLocation(e);
if ((type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) === 0) return checkerPrimitive(type);
const of = (x: ts.Expression): Primitive | null =>
primitiveOf(lowerer, bySymbol, x, visiting, depth + 1);
if (ts.isParenthesizedExpression(e)) return of(e.expression);
if (ts.isPrefixUnaryExpression(e)) {
if (e.operator === ts.SyntaxKind.ExclamationToken) return "boolean";
return e.operator !== ts.SyntaxKind.PlusPlusToken &&
e.operator !== ts.SyntaxKind.MinusMinusToken &&
of(e.operand) === "number"
? "number"
: null;
}
if (ts.isConditionalExpression(e)) {
const yes = of(e.whenTrue);
return yes !== null && yes === of(e.whenFalse) ? yes : null;
}
if (ts.isBinaryExpression(e)) {
const op = e.operatorToken.kind;
switch (op) {
case ts.SyntaxKind.LessThanToken:
case ts.SyntaxKind.GreaterThanToken:
case ts.SyntaxKind.LessThanEqualsToken:
case ts.SyntaxKind.GreaterThanEqualsToken:
case ts.SyntaxKind.EqualsEqualsEqualsToken:
case ts.SyntaxKind.ExclamationEqualsEqualsToken:
case ts.SyntaxKind.EqualsEqualsToken:
case ts.SyntaxKind.ExclamationEqualsToken:
return "boolean";
case ts.SyntaxKind.QuestionQuestionToken:
case ts.SyntaxKind.BarBarToken: {
// `m.get(k) ?? d` and `a.pop() || d`: the stored type or a default of it.
const stored = storedRead(lowerer, bySymbol, e.left);
const fallback = of(e.right);
return stored !== null && stored === fallback ? stored : null;
}
case ts.SyntaxKind.PlusToken: {
const left = of(e.left);
const right = of(e.right);
if (left === "number" && right === "number") return "number";
return left !== null && right !== null && (left === "string" || right === "string")
? "string"
: null;
}
case ts.SyntaxKind.MinusToken:
case ts.SyntaxKind.AsteriskToken:
case ts.SyntaxKind.SlashToken:
case ts.SyntaxKind.PercentToken:
case ts.SyntaxKind.AsteriskAsteriskToken:
case ts.SyntaxKind.BarToken:
case ts.SyntaxKind.AmpersandToken:
case ts.SyntaxKind.CaretToken:
case ts.SyntaxKind.LessThanLessThanToken:
case ts.SyntaxKind.GreaterThanGreaterThanToken:
case ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken:
return of(e.left) === "number" && of(e.right) === "number" ? "number" : null;
default:
return null;
}
}
if (ts.isCallExpression(e) || ts.isPropertyAccessExpression(e))
return candidateRead(lowerer, bySymbol, e);
// A constant defined from such an expression.
if (ts.isIdentifier(e) && !visiting.has(e)) {
const symbol = lowerer.checker.getSymbolAtLocation(e);
const declaration = symbol?.valueDeclaration;
if (
declaration !== undefined &&
ts.isVariableDeclaration(declaration) &&
declaration.initializer !== undefined &&
ts.isVariableDeclarationList(declaration.parent) &&
(declaration.parent.flags & ts.NodeFlags.Const) !== 0
) {
visiting.add(e);
const p = primitiveOf(lowerer, bySymbol, declaration.initializer, visiting, depth + 1);
visiting.delete(e);
return p;
}
}
return null;
}
/** The candidate a use reads, when `e` is `c.member` or `c.member(...)` of a
* candidate `c`. */
function candidateOf(
lowerer: Lowerer,
bySymbol: Map<ts.Symbol, Candidate>,
e: ts.Expression,
): { candidate: Candidate; member: string } | null {
const access = ts.isCallExpression(e) ? e.expression : e;
if (!ts.isPropertyAccessExpression(access) || !ts.isIdentifier(access.expression)) return null;
const symbol = lowerer.checker.getSymbolAtLocation(access.expression);
const candidate = symbol !== undefined ? bySymbol.get(symbol) : undefined;
return candidate !== undefined ? { candidate, member: access.name.text } : null;
}
/** The type of a candidate's read that may answer undefined: `m.get(k)`,
* `a.pop()`, `a.shift()`, `a.at(i)`. */
function storedRead(
lowerer: Lowerer,
bySymbol: Map<ts.Symbol, Candidate>,
e: ts.Expression,
): Primitive | null {
while (ts.isParenthesizedExpression(e)) e = e.expression;
if (!ts.isCallExpression(e)) return null;
const read = candidateOf(lowerer, bySymbol, e);
if (read === null) return null;
const { candidate, member } = read;
if (candidate.kind === "map" && member === "get") return candidate.value ?? null;
if (candidate.kind === "array" && (ARRAY_READS.has(member) || member === "at"))
return candidate.value ?? null;
return null;
}
/** The type of a candidate's read that always answers a value: size,
* length, has, includes, indexOf, push. */
function candidateRead(
lowerer: Lowerer,
bySymbol: Map<ts.Symbol, Candidate>,
e: ts.Expression,
): Primitive | null {
const read = candidateOf(lowerer, bySymbol, e);
if (read === null) return null;
const { candidate, member } = read;
if (!ts.isCallExpression(e)) return member === "size" || member === "length" ? "number" : null;
if (member === "has" || member === "includes" || member === "delete") return "boolean";
if (member === "indexOf" || (candidate.kind === "array" && member === "push")) return "number";
if (candidate.kind === "array" && member === "join") return "string";
return null;
}
@@ -51,11 +51,15 @@ import {
STRING,
type SrcLoc,
VOID,
isDynTypedRefType,
isUnitType,
typeEquals,
typeKey,
} from "../../ir/ir.js";
import { boolLit, countedFor, dynUndefinedExpr, numLit, strLit, varRef } from "../../ir/build.js";
import { everyExpr } from "../../ir/traverse.js";
import { checkedClassInstanceOf } from "./class-construction.js";
import { isJsSourceFile } from "../program.js";
/** node:assert/strict binds the loose NAMES to the strict comparisons —
* Node's own aliasing (`strict.equal === assert.strictEqual`). */
@@ -73,6 +77,63 @@ function canonicalAssertMember(module: string, member: string): string {
return member;
}
/** The assert methods that are function values (scr_assert_method_value). */
const ASSERT_FUNCTION_VALUES: ReadonlySet<string> = new Set([
"ok",
"fail",
"equal",
"notEqual",
"strictEqual",
"notStrictEqual",
"deepEqual",
"notDeepEqual",
"deepStrictEqual",
"notDeepStrictEqual",
"partialDeepStrictEqual",
"ifError",
]);
/** The assert method a `const` binding holds (`const ok = assert.ok`,
* `const eq = strictEqual`), or null. */
export function assertFunctionAliasOf(
lowerer: Lowerer,
ident: ts.Identifier,
): { module: string; member: string } | null {
const decl = lowerer.checker.getSymbolAtLocation(ident)?.valueDeclaration;
if (
!decl ||
!ts.isVariableDeclaration(decl) ||
!ts.isIdentifier(decl.name) ||
!ts.isVariableDeclarationList(decl.parent) ||
(decl.parent.flags & ts.NodeFlags.Const) === 0 ||
!decl.initializer
)
return null;
let init = decl.initializer;
while (ts.isParenthesizedExpression(init)) init = init.expression;
const bi = ts.isPropertyAccessExpression(init)
? lowerer.builtinMemberOf(init)
: ts.isIdentifier(init)
? lowerer.builtinImportOf(init)
: null;
if (!bi || (bi.module !== "assert" && bi.module !== "assert/strict")) return null;
return ASSERT_FUNCTION_VALUES.has(canonicalAssertMember(bi.module, bi.member)) ? bi : null;
}
/** An assert method read as a value (`emitter.on("x", assert.fail)`,
* `const eq = assert.strictEqual`): an untyped function that asserts as
* the method does, the same value for every read. Null for other
* members. */
export function assertFunctionValue(
bi: { module: string; member: string },
loc: SrcLoc,
): IrExpr | null {
if (bi.module !== "assert" && bi.module !== "assert/strict") return null;
const member = canonicalAssertMember(bi.module, bi.member);
if (!ASSERT_FUNCTION_VALUES.has(member)) return null;
return { kind: "libCall", fn: "assert.methodValue", args: [strLit(member, loc)], type: DYN, loc };
}
/* Node's AssertionError carries actual, expected and operator. Where the
* verdict is computed in compiled code, the failure branch records them
* (assert.record) before the throwing call, so a passing assertion boxes
@@ -166,28 +227,156 @@ function requireStatementPosition(lowerer: Lowerer, call: ts.CallExpression, wha
);
}
/** The message the runtime reads from a registered value
* (SCR_ASSERT_VALUE_MESSAGE in scr_assert.h). */
const VALUE_MESSAGE = "\u0001scriptc:assert-message-value";
/** A message argument that is not a string, met while one assertion
* lowers (withValueMessage). `fail` marks assert.fail's message, which
* Node uses for every value but null and undefined. */
let valueMessage: { value: IrExpr; has: string; text: string; fail: boolean } | null = null;
/** The optional trailing message argument: Node accepts a string or an
* Error (throwing the Error itself) — the STRING form lowers; a literal
* `undefined` counts as omitted (Node's `message == null` test). Returns
* the libCall's (msg, hasMsg) pair — a "" dummy when omitted, so the
* runtime can distinguish an omitted message from an empty one. */
* runtime can distinguish an omitted message from an empty one. Any other
* value that crosses into the checked-dynamic tree (an untyped
* `failures.join('')`, an Error) is registered with the runtime at the call
* instead (withValueMessage). */
function lowerMessageArg(
lowerer: Lowerer,
node: ts.Expression | undefined,
loc: SrcLoc,
fail = false,
): { msg: IrExpr; hasMsg: IrExpr } {
if (!node || (ts.isIdentifier(node) && node.text === "undefined")) {
return { msg: { kind: "strLit", value: "", type: STRING, loc }, hasMsg: boolLit(false, loc) };
}
const msg = lowerer.lowerExpr(node);
if (msg.type.kind !== "string") {
if (msg.type.kind === "string") return { msg, hasMsg: boolLit(true, loc) };
const value =
valueMessage === null && (msg.type.kind === "dyn" || lowerer.dynConvertible(msg.type))
? lowerer.coerceToExpected(msg, DYN)
: null;
if (value === null || value.type.kind !== "dyn") {
lowerer.noLowering(
`assert messages of type '${lowerer.fmt(msg.type)}'`,
node,
"only string messages lower (Node also accepts an Error to throw — construct and throw it directly)",
);
}
return { msg, hasMsg: boolLit(true, loc) };
const has = lowerer.declareHiddenLocal("assert.hasMessage", BOOL);
const text = lowerer.declareHiddenLocal("assert.messageText", STRING);
valueMessage = { value, has: has.id, text: text.id, fail };
return { msg: varRef(text.id, STRING, loc), hasMsg: varRef(has.id, BOOL, loc) };
}
/** Lowers one assertion; a message value met on the way is evaluated after
* the assertion's other arguments, as Node evaluates them. A null or
* undefined message is omitted (Node's `message == null` test); a truthy
* one is registered with the runtime, which the failure reads; any other
* value is the empty message, which Node's comparison diffs treat as no
* message too (createErrDiff tests the message's truthiness). */
function withValueMessage(
lowerer: Lowerer,
expr: ts.CallExpression,
loc: SrcLoc,
lower: () => IrExpr | null,
): IrExpr | null {
const outer = valueMessage;
valueMessage = null;
try {
const lowered = lower();
const message = valueMessage as {
value: IrExpr;
has: string;
text: string;
fail: boolean;
} | null;
if (lowered === null || message === null) return lowered;
// assert.ok's statements evaluate the value first; its call is last.
const before = lowered.kind === "seqExpr" ? lowered.stmts : [];
const call = lowered.kind === "seqExpr" ? lowered.result : lowered;
if (call.kind !== "libCall") {
lowerer.noLowering(
"assert messages that are not strings in this assertion form",
expr,
"pass a string message",
);
}
const stmts: IrStmt[] = [...before];
// An argument computed from the message (assert.fail's generated flag)
// reads it after the declarations below; the others are evaluated first.
const readsMessage = (arg: IrExpr): boolean =>
!everyExpr(arg, {
stmt: () => true,
expr: (e) =>
!(e.kind === "varRef" && (e.localId === message.has || e.localId === message.text)),
});
const args = call.args.map((arg, index) =>
readsMessage(arg) ? arg : stableValue(lowerer, stmts, arg, `assert.arg${index}`, loc),
);
const local = lowerer.declareHiddenLocal("assert.message", DYN);
const value = varRef(local.id, DYN, loc);
// The runtime reports a registered value: a truthy one for the other
// methods, any value but null and undefined for assert.fail (Node's
// `actual == null` test), whose other values read "Failed".
const registered: IrExpr = message.fail
? varRef(message.has, BOOL, loc)
: { kind: "dynTest", test: "truthy", value, type: BOOL, loc };
stmts.push(
{ kind: "varDecl", localId: local.id, init: message.value, loc },
{
kind: "varDecl",
localId: message.has,
init: { kind: "dynTest", test: "nullish", negated: true, value, type: BOOL, loc },
loc,
},
{
kind: "varDecl",
localId: message.text,
init: {
kind: "ternary",
cond: registered,
then: strLit(VALUE_MESSAGE, loc),
else_: strLit(message.fail ? "Failed" : "", loc),
type: STRING,
loc,
},
loc,
},
{
kind: "if",
cond: registered,
then: [
{
kind: "exprStmt",
expr: {
kind: "libCall",
fn: "assert.messageValue",
args: [value, { kind: "dynTest", test: "error", value, type: BOOL, loc }],
type: VOID,
loc,
},
loc,
},
],
else_: null,
loc,
},
{ kind: "exprStmt", expr: { ...call, args }, loc },
);
return {
kind: "seqExpr",
stmts,
result: { kind: "libCall", fn: "assert.messageClear", args: [], type: VOID, loc },
type: VOID,
loc,
};
} finally {
valueMessage = outer;
}
}
/** The module-function dispatch — the spoke's entry, called from both the
@@ -201,6 +390,15 @@ export function lowerAssertModuleCall(
loc: SrcLoc,
): IrExpr | null {
if (bi.module !== "assert" && bi.module !== "assert/strict") return null;
return withValueMessage(lowerer, expr, loc, () => lowerAssertMember(lowerer, expr, bi, loc));
}
function lowerAssertMember(
lowerer: Lowerer,
expr: ts.CallExpression,
bi: { module: string; member: string },
loc: SrcLoc,
): IrExpr | null {
switch (canonicalAssertMember(bi.module, bi.member)) {
case "ok":
return lowerAssertOk(lowerer, expr, loc);
@@ -880,7 +1078,7 @@ export function lowerAssertDirectCall(
'use the default import: import assert from "node:assert")',
);
}
return lowerAssertOk(lowerer, expr, loc);
return withValueMessage(lowerer, expr, loc, () => lowerAssertOk(lowerer, expr, loc));
}
/** assert(value[, message]) / assert.ok(value[, message]): the truthiness
@@ -889,7 +1087,24 @@ export function lowerAssertDirectCall(
* the same bytes at compile time). */
function lowerAssertOk(lowerer: Lowerer, expr: ts.CallExpression, loc: SrcLoc): IrExpr {
requireStatementPosition(lowerer, expr, ts.getTextOfNode(expr.expression));
if (expr.arguments.nodes.length < 1 || expr.arguments.nodes.length > 2) {
if (expr.arguments.nodes.length === 0) {
// Node's message for a call without arguments.
const text = "No value argument passed to `assert.ok()`";
return {
kind: "seqExpr",
stmts: [recordOnFailure(lowerer, boolLit(true, loc), null, boolLit(true, loc), "==", loc)],
result: {
kind: "libCall",
fn: "assert.ok",
args: [boolLit(false, loc), strLit(text, loc), boolLit(true, loc)],
type: VOID,
loc,
},
type: VOID,
loc,
};
}
if (expr.arguments.nodes.length > 2) {
lowerer.noLowering(
`${ts.getTextOfNode(expr.expression)} with ${expr.arguments.nodes.length} arguments`,
expr,
@@ -953,22 +1168,26 @@ function lowerAssertFail(lowerer: Lowerer, expr: ts.CallExpression, loc: SrcLoc)
generated = boolLit(true, loc);
} else {
// Node's AssertionError sets generatedMessage to !message, so an
// empty custom message counts as generated.
msg = stableValue(
lowerer,
stmts,
lowerMessageArg(lowerer, msgNode, loc).msg,
"%assertFailMessage",
loc,
);
generated = {
kind: "strEq",
negated: false,
left: msg,
right: strLit("", loc),
type: BOOL,
loc,
};
// empty custom message counts as generated. A message that is not a
// string is read from the hidden local that withValueMessage declares
// after these statements, so it is used as is.
const { msg: lowered, hasMsg } = lowerMessageArg(lowerer, msgNode, loc, true);
if (lowered.kind === "varRef" && hasMsg.kind === "varRef") {
// A value message: generated when Node reads "Failed" for null or
// undefined; the runtime sets the flag for a registered value.
msg = lowered;
generated = { kind: "unary", op: "!", operand: hasMsg, type: BOOL, loc };
} else {
msg = stableValue(lowerer, stmts, lowered, "%assertFailMessage", loc);
generated = {
kind: "strEq",
negated: false,
left: msg,
right: strLit("", loc),
type: BOOL,
loc,
};
}
}
const falseLit: IrExpr = { kind: "boolLit", value: false, type: BOOL, loc };
stmts.push(recordOnFailure(lowerer, boolLit(true, loc), null, null, "fail", loc));
@@ -1056,9 +1275,22 @@ function lowerAssertEqual(
// would answer false where Node can answer true (the value's own
// earlier crossing) — a miscompare, never lowered. Bytes fence both
// ways (the dyn copy cannot carry the Buffer/Uint8Array brand the
// checker type knows); class instances and everything non-convertible
// keep the fence too.
if (a.type.kind === "dyn" || b.type.kind === "dyn") {
// checker type knows); everything non-convertible keeps the fence too.
// A class instance crosses as a reference to the instance itself, so
// strict compares are honest reference equality, between two instances
// too (assert.strictEqual(e, e.setMaxListeners(1))).
const instanceType = (t: IrType): boolean =>
isDynTypedRefType(t) && !lowerer.isSubclassOf(t.className, "%Error");
const instance = (e: IrExpr): boolean =>
!deep &&
(instanceType(e.type) ||
(e.type.kind === "union" &&
(lowerer.unions
.get(e.type.unionId)
?.arms.every((arm) => instanceType(arm) || isUnitType(arm)) ??
false))) &&
lowerer.dynConvertible(e.type);
if (a.type.kind === "dyn" || b.type.kind === "dyn" || (instance(a) && instance(b))) {
const box = (e: IrExpr, node: ts.Expression): IrExpr => {
if (e.type.kind === "dyn") return e;
const k = e.type.kind;
@@ -1067,6 +1299,18 @@ function lowerAssertEqual(
k === "f64" ||
k === "string" ||
k === "bool" ||
// A union of scalars and units crosses by value, like its arms.
(e.type.kind === "union" &&
(lowerer.unions
.get(e.type.unionId)
?.arms.every(
(arm) =>
arm.kind === "f64" ||
arm.kind === "string" ||
arm.kind === "bool" ||
isUnitType(arm),
) ??
false)) ||
(k === "func" && lowerer.dynConvertible(e.type)) ||
// Runtime handles cross by REFERENCE (identity preserved), so the
// strict compare is honest: assert.strictEqual(this, server) —
@@ -1082,6 +1326,9 @@ function lowerAssertEqual(
// same). The deep walk compares the retained error data without
// re-encoding and losing identity (SEMANTICS.md).
(k === "object" && e.type.kind === "object" && e.type.className === "%Error") ||
instance(e) ||
// A symbol crosses as itself, so strict compares are identity.
(!deep && k === "symbol") ||
(deep && k !== "bytes" && k !== "object" && lowerer.dynConvertible(e.type)) ||
// Under skipPrototype the prototype a class instance or Buffer
// brand would carry is not compared.
@@ -1470,17 +1717,29 @@ function classifyThrowsExpected(
displayName: rec ? rec.lib : info.def.name.replace(/^%/, ""),
};
}
if (
(surface === "assert.doesNotThrow" || surface === "assert.doesNotReject") &&
expectedT?.kind === "func"
) {
if (expectedT?.kind === "func") {
const value = lowerer.lowerExpr(node);
// An untyped value there (a dynamic-tier function's result) is checked
// at run time like any untyped expected value.
if (value.type.kind === "jsval" || value.type.kind === "dyn") {
const boxed = lowerer.coerceToExpected(value, DYN);
if (boxed.type.kind === "dyn") return { form: "errValue", value: boxed };
}
// A JavaScript function accepts any argument; a typed one must take
// unknown values (the caught value need not be an Error).
if (
value.type.kind === "func" &&
value.type.params.every((param) => param.kind === "dyn") &&
(value.type.params.every((param) => param.kind === "dyn") ||
isJsSourceFile(ts.getSourceFileOfNode(node))) &&
lowerer.dynConvertible(value.type)
) {
return { form: "predicate", value: lowerer.coerceToExpected(value, DYN) };
// The boxed function carries its source name (Node's message names
// a named validation function).
const fnName = jsFuncNameOf(node);
return {
form: "predicate",
value: { kind: "dynFrom", value, ...(fnName !== null ? { fnName } : {}), type: DYN, loc },
};
}
lowerer.noLowering(
`${surface} with this validation callback`,
@@ -1546,7 +1805,9 @@ function classifyThrowsExpected(
// tracingChannel suite's shape): the value crosses as a dyn error and
// the runtime compares per Node's expectsError key walk. A
// checked-dynamic expected rides directly; a static %Error boxes.
if (expectedT?.kind === "dyn") {
// An untyped JavaScript value (a parameter) too: a function there is
// checked at run time (assertThrowsHelper).
if (expectedT?.kind === "dyn" || expectedT === null) {
const value = lowerer.lowerExpr(node);
if (value.type.kind === "dyn") return { form: "errValue", value };
}
@@ -1943,6 +2204,46 @@ function assertThrowsHelper(
return { e: strLit("", loc), has: false };
})();
// expectedException's function branch: a function with a prototype that
// the caught value is an instance of passes; anything else is called as
// a validation function, whose result must be true.
const validateStmts = (fn: IrExpr): IrStmt[] => {
const actual = (): IrExpr => ({ kind: "caughtToDyn", value: caughtRef(), type: DYN, loc });
const prototype: IrExpr = {
kind: "dynKeyGet",
value: fn,
key: strLit("prototype", loc),
optional: true,
type: DYN,
loc,
};
return [
ifStmt(
{ kind: "dynTest", test: "undefined", negated: true, value: prototype, type: BOOL, loc },
[
ifStmt(
checkedClassInstanceOf(
lowerer,
actual(),
fn,
"assert.throws against this native constructor is not supported yet",
loc,
),
[doReturn],
),
],
),
lib("assert.throwsValidate", [
actual(),
fn,
strLit(mode === "rejects" ? "rejects" : "throws", loc),
m(),
hm(),
]),
doReturn,
];
};
let catchBody: IrStmt[];
if (mode === "dnr" || mode === "dnt") {
const actual = (): IrExpr => ({ kind: "caughtToDyn", value: caughtRef(), type: DYN, loc });
@@ -2116,7 +2417,13 @@ function assertThrowsHelper(
// caught value crosses as a dyn value (identity-preserving) and
// the runtime walks the expected's keys — a mismatch throws the
// deep-equal AssertionError.
// A function there is a validation function (or an error class
// value) as in the predicate form.
catchBody = [
ifStmt(
{ kind: "dynTest", test: "function", value: varRef("ev.0", DYN, loc), type: BOOL, loc },
validateStmts(varRef("ev.0", DYN, loc)),
),
lib("assert.expectsErrDyn", [
{ kind: "caughtToDyn", value: caughtRef(), type: DYN, loc },
varRef("ev.0", DYN, loc),
@@ -2127,7 +2434,8 @@ function assertThrowsHelper(
];
break;
case "predicate":
throw new InternalCompilerError("assert validation callback only enters a no-error helper");
catchBody = validateStmts(varRef("predicate.0", DYN, loc));
break;
}
}
@@ -1,5 +1,7 @@
import { assertFunctionAliasOf } from "./lower-assert.js";
import { keyedHelperName } from "./stable-names.js";
import { perKeyOrder } from "./key-order-witness.js";
import { lowerDateToPrimitiveCall } from "./builtins/dates.js";
import { isForwardedTupleSpread, lowerForwardingApply } from "./typed-arguments.js";
import { guardNullProto, nullProtoRecordShape } from "./null-prototype.js";
import { lowerDynStreamMemberCall } from "./dyn-streams.js";
@@ -1052,6 +1054,13 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr {
}
const bi = lowerer.builtinImportOf(expr.expression);
if (bi) return lowerBuiltinCall(lowerer, expr, bi, loc);
// A constant bound to an assert method (`const ok = assert.ok`) called
// directly is that assertion, with the message Node quotes the call for.
{
const alias = assertFunctionAliasOf(lowerer, expr.expression);
const served = alias ? lowerer.lowerAssertModuleCall(expr, alias, loc) : null;
if (served) return served;
}
// The assert module binding called DIRECTLY (`assert(x)` — a default
// import or the CJS `const assert = require("assert")`): Node's
// module object IS assert.ok; namespace-import bindings fence inside
@@ -1816,6 +1825,8 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr {
// The element spelling of an unsupported native Web method must fence
// before lowering the callee into a checked-dynamic keyed read.
if (ts.isElementAccessExpression(expr.expression)) {
const datePrimitive = lowerDateToPrimitiveCall(lowerer, expr, expr.expression);
if (datePrimitive) return datePrimitive;
const timersDispose = lowerMockTimersElementCall(lowerer, expr, expr.expression);
if (timersDispose) return timersDispose;
const symbolMethod = lowerClassSymbolMethodCall(lowerer, expr);
@@ -2456,6 +2467,9 @@ function lowerNumberConstructorValue(
};
const optionalNumber = lowerOptionalStringNumber(lowerer, arg, loc);
if (optionalNumber) return optionalNumber;
// A Date's number hint answers its time value.
if (arg.type.kind === "date")
return { kind: "libCall", fn: "date.valueOf", args: [arg], type: F64, loc };
// A program class instance converts through its own methods.
const classNumber = classToNumberConversion(lowerer, arg, argNode);
if (classNumber) return classNumber;
@@ -8703,7 +8717,12 @@ function lowerObjectStaticCall(
(probed && isDynTypedRefType(probed.type)) ||
(probed?.type.kind === "object" && lowerer.errorHierarchyClassOf(probed.type.className)) ||
(probed && constructor && (constructor.callableBase || hasRuntimeStatics(constructor))) ||
(probed?.type.kind === "func" && lowerer.dynConvertible(probed.type))
(probed?.type.kind === "func" && lowerer.dynConvertible(probed.type)) ||
// null, undefined and unions of untyped JavaScript values: the same
// probe, whose ToObject throws Node's TypeError on nullish.
(probed &&
(isUnitType(probed.type) || probed.type.kind === "union") &&
lowerer.dynConvertible(probed.type))
) {
const loc = locOf(call);
const receiver = lowerer.coerceToExpected(probed, DYN);
@@ -18,8 +18,11 @@ import {
superIntoResultSlot,
} from "./constructor-returns.js";
import { checkedIterableSpread } from "./checked-iterable-spread.js";
import { lowerReadlineUiNew } from "./builtins/readline.js";
import { builtinMemberRequireDecl } from "./builtins/module-bindings.js";
import { lowerReadlineInterfaceNew, lowerReadlineUiNew } from "./builtins/readline.js";
import {
builtinMemberBindingTarget,
builtinMemberRequireDecl,
} from "./builtins/module-bindings.js";
import { checkedClassUnionAssertion } from "./class-assertions.js";
import { collectClassMethodMutations } from "./class-method-mutations.js";
import { InternalCompilerError } from "../../errors.js";
@@ -113,6 +116,7 @@ import { PoisonError, dynFallbackType, jsFuncNameOf, newFnCtx, own } from "./low
import { isAssertClassNew } from "./assert-instances.js";
import { isCjsRequireGlobal } from "./lower-node-module.js";
import { lowerArrayConstructor } from "./containers/array-construction.js";
import { initializedCollectionType } from "./js-collection-types.js";
import { lowerMapSeedNew, lowerSetSeedNew } from "./containers/collection-methods.js";
import { lowerObjectConstructor } from "./containers/indexed-objects.js";
import { knownBufEncoding, lowerBufferConstruct } from "./containers/bytes.js";
@@ -481,6 +485,10 @@ export interface ClassInfo {
localPrototypeData?: true;
prototypeMethodValues?: Map<string, IrExpr>;
prototypeReflectionReady?: true;
/** The global that records at run time that the prototype view was
* populated by reflection, so it holds its own `constructor` until the
* program deletes it (prototypeReflectedFlag). */
prototypeReflectedFlag?: string;
prototypeObservedMethodsReady?: true;
/** The globals holding each observed method's original prototype value
* (set when the prototype data is built), by method name. A typed call
@@ -9819,7 +9827,7 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
}
const worker = lowerWorkerNew(lowerer, expr);
if (worker) return worker;
const readlineUi = lowerReadlineUiNew(lowerer, expr);
const readlineUi = lowerReadlineUiNew(lowerer, expr) ?? lowerReadlineInterfaceNew(lowerer, expr);
if (readlineUi) return readlineUi;
const channel = lowerMessageChannelNew(lowerer, expr) ?? lowerBroadcastChannelNew(lowerer, expr);
if (channel) return channel;
@@ -10119,6 +10127,102 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
const agent = lowerHttpAgentNew(lowerer, expr);
if (agent) return agent;
}
// `new crypto.X509Certificate(data)` — the Dirent-style data record:
// the certificate's lowered members (fingerprint — the SHA-1 of the
// DER, uppercase colon-separated — plus the validFrom/validTo
// validity window in Node's ASN1_TIME_print shape) compute AT
// CONSTRUCTION, when Node parses too, so unparseable input throws
// Node's exact PEM error here (ERR_OSSL_PEM_NO_START_LINE) and the
// handle never exists. Both import forms (`crypto.X509Certificate`
// through the namespace, named `X509Certificate`); Buffer input only
// — the readFileSync idiom. mapType interns the matching record, so
// locals and the composed member reads all flow. The data argument
// feeds THREE field computations, so construction goes through an
// interned helper whose parameter evaluates it exactly once.
// It runs before the standard-library constructor fence below, which
// also sees `const X = require("crypto").X509Certificate` as the
// builtin class.
{
const callee = expr.expression;
const isX509 =
(ts.isPropertyAccessExpression(callee) &&
callee.name.text === "X509Certificate" &&
lowerer.builtinNamespaceModuleOf(callee.expression) === "crypto") ||
(ts.isIdentifier(callee) &&
(() => {
const bi = lowerer.builtinImportOf(callee);
return bi?.module === "crypto" && bi.member === "X509Certificate";
})());
if (isX509) {
const args: readonly ts.Expression[] = expr.arguments?.nodes ?? [];
if (args.length !== 1) {
lowerer.noLowering(
"X509Certificate with this argument shape",
expr,
"the supported form is new X509Certificate(readFileSync(path))",
);
}
const data = lowerer.lowerExpr(args[0]!);
const isBytes = data.type.kind === "bytes" && data.type.elem === "u8";
if (!isBytes && data.type.kind !== "string") {
lowerer.noLowering(
`X509Certificate over '${lowerer.fmt(data.type)}' data`,
args[0]!,
"pass the certificate Buffer or PEM string (an fs.readFileSync result)",
);
}
const t = lowerer.mapTypeOf(lowerer.typeOf(expr));
if (t?.kind !== "record") lowerer.badType(expr, lowerer.typeOf(expr));
const key = `x509.record:${isBytes ? "bytes" : "str"}`;
let helper = lowerer.valueHelpers.get(key);
if (!helper) {
helper = keyedHelperName(lowerer, "%x509.record", key);
lowerer.valueHelpers.set(key, helper);
const dataT = data.type;
const dRef: IrExpr = { kind: "varRef", localId: "d.0", type: dataT, loc };
const field = (
name: string,
fn:
| "crypto.x509Fingerprint"
| "crypto.x509FingerprintStr"
| "crypto.x509ValidFrom"
| "crypto.x509ValidFromStr"
| "crypto.x509ValidTo"
| "crypto.x509ValidToStr",
): { name: string; value: IrExpr } => ({
name,
value: { kind: "libCall", fn, args: [dRef], type: STRING, loc },
});
lowerer.liftedFns.push({
name: helper,
params: [{ localId: "d.0", name: "d", type: dataT }],
returnType: t,
locals: [{ id: "d.0", name: "d", type: dataT, mutable: false }],
body: [
{
kind: "return",
value: {
kind: "recordLit",
fields: [
field(
"fingerprint",
isBytes ? "crypto.x509Fingerprint" : "crypto.x509FingerprintStr",
),
field("validFrom", isBytes ? "crypto.x509ValidFrom" : "crypto.x509ValidFromStr"),
field("validTo", isBytes ? "crypto.x509ValidTo" : "crypto.x509ValidToStr"),
],
type: t,
loc,
},
loc,
},
],
loc,
});
}
return { kind: "call", callee: helper, args: [data], type: t, loc };
}
}
if (
ts.isIdentifier(expr.expression) ||
(ts.isPropertyAccessExpression(expr.expression) &&
@@ -10132,7 +10236,12 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
// (a no-op for every non-import= binding).
if (ts.isIdentifier(expr.expression)) fenceEarlyAliasUse(lowerer, expr.expression, expr);
const identifier = ts.isIdentifier(expr.expression) ? expr.expression : expr.expression.name;
const symbol = ts.isIdentifier(identifier) ? lowerer.resolveValueSymbol(identifier) : null;
// `const SD = require("string_decoder").StringDecoder; new SD()`
// constructs the member, as a named import of it does.
const symbol = builtinMemberBindingTarget(
lowerer,
ts.isIdentifier(identifier) ? lowerer.resolveValueSymbol(identifier) : null,
);
if (ts.isIdentifier(identifier) && lowerer.isStdlibGlobal(identifier, "Array")) {
return lowerArrayConstructor(lowerer, expr, expr.arguments?.nodes ?? []);
}
@@ -10437,6 +10546,29 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
const time: IrExpr = { kind: "libCall", fn: "date.getTime", args: [arg], type: F64, loc };
return { kind: "libCall", fn: "date.newMs", args: [time], type: DATE_T, loc };
}
// A union (`Date | string | number`) or checked-dynamic value: the
// runtime's constructor reads a Date's time value, parses a string
// and converts anything else to a primitive and a number.
if (
(arg.type.kind === "union" || arg.type.kind === "dyn") &&
lowerer.dynConvertible(arg.type)
) {
const value: IrExpr = {
kind: "libCall",
fn: "date.nativeNewValue",
args: [
{
kind: "dynArrLit",
elems: [lowerer.coerceToExpected(arg, DYN)],
type: DYN,
loc,
},
],
type: DYN,
loc,
};
return { kind: "libCall", fn: "date.checkedValue", args: [value], type: DATE_T, loc };
}
lowerer.noLowering(
`new Date of '${lowerer.fmt(arg.type)}' values`,
args[0]!,
@@ -10568,7 +10700,11 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
// expression). The CONTEXTUAL type carries the annotation — adopt
// it when it is a supported map. TS type arguments keep winning:
// their expression type already maps.
if (mapped?.kind !== "map" || contextualSeed) {
const inferred = initializedCollectionType(lowerer, expr);
if (inferred) {
tsType = inferred;
mapped = lowerer.mapTypeOf(inferred);
} else if (mapped?.kind !== "map" || contextualSeed) {
const ctx = lowerer.checker.getContextualType(expr);
const ctxMapped = ctx ? lowerer.mapTypeOf(ctx) : null;
if (ctx && ctxMapped?.kind === "map") {
@@ -10708,7 +10844,8 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
// `new Set<T>()`: construct + bulk add from supported values,
// including live collection cursors. Duplicates collapse in insertion order.
if (ts.isIdentifier(identifier) && lowerer.isStdlibGlobal(identifier, "Set")) {
const tsType = lowerer.typeOf(expr);
const inferred = initializedCollectionType(lowerer, expr);
const tsType = inferred ?? lowerer.typeOf(expr);
let mapped = lowerer.mapTypeOf(tsType);
const fieldType = assignedThisFieldType(lowerer, expr);
if (mapped?.kind !== "set" && fieldType?.kind === "set") mapped = fieldType;
@@ -10899,99 +11036,6 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
);
}
}
// `new crypto.X509Certificate(data)` — the Dirent-style data record:
// the certificate's lowered members (fingerprint — the SHA-1 of the
// DER, uppercase colon-separated — plus the validFrom/validTo
// validity window in Node's ASN1_TIME_print shape) compute AT
// CONSTRUCTION, when Node parses too, so unparseable input throws
// Node's exact PEM error here (ERR_OSSL_PEM_NO_START_LINE) and the
// handle never exists. Both import forms (`crypto.X509Certificate`
// through the namespace, named `X509Certificate`); Buffer input only
// — the readFileSync idiom. mapType interns the matching record, so
// locals and the composed member reads all flow. The data argument
// feeds THREE field computations, so construction goes through an
// interned helper whose parameter evaluates it exactly once.
{
const callee = expr.expression;
const isX509 =
(ts.isPropertyAccessExpression(callee) &&
callee.name.text === "X509Certificate" &&
lowerer.builtinNamespaceModuleOf(callee.expression) === "crypto") ||
(ts.isIdentifier(callee) &&
(() => {
const bi = lowerer.builtinImportOf(callee);
return bi?.module === "crypto" && bi.member === "X509Certificate";
})());
if (isX509) {
const args: readonly ts.Expression[] = expr.arguments?.nodes ?? [];
if (args.length !== 1) {
lowerer.noLowering(
"X509Certificate with this argument shape",
expr,
"the supported form is new X509Certificate(readFileSync(path))",
);
}
const data = lowerer.lowerExpr(args[0]!);
const isBytes = data.type.kind === "bytes" && data.type.elem === "u8";
if (!isBytes && data.type.kind !== "string") {
lowerer.noLowering(
`X509Certificate over '${lowerer.fmt(data.type)}' data`,
args[0]!,
"pass the certificate Buffer or PEM string (an fs.readFileSync result)",
);
}
const t = lowerer.mapTypeOf(lowerer.typeOf(expr));
if (t?.kind !== "record") lowerer.badType(expr, lowerer.typeOf(expr));
const key = `x509.record:${isBytes ? "bytes" : "str"}`;
let helper = lowerer.valueHelpers.get(key);
if (!helper) {
helper = keyedHelperName(lowerer, "%x509.record", key);
lowerer.valueHelpers.set(key, helper);
const dataT = data.type;
const dRef: IrExpr = { kind: "varRef", localId: "d.0", type: dataT, loc };
const field = (
name: string,
fn:
| "crypto.x509Fingerprint"
| "crypto.x509FingerprintStr"
| "crypto.x509ValidFrom"
| "crypto.x509ValidFromStr"
| "crypto.x509ValidTo"
| "crypto.x509ValidToStr",
): { name: string; value: IrExpr } => ({
name,
value: { kind: "libCall", fn, args: [dRef], type: STRING, loc },
});
lowerer.liftedFns.push({
name: helper,
params: [{ localId: "d.0", name: "d", type: dataT }],
returnType: t,
locals: [{ id: "d.0", name: "d", type: dataT, mutable: false }],
body: [
{
kind: "return",
value: {
kind: "recordLit",
fields: [
field(
"fingerprint",
isBytes ? "crypto.x509Fingerprint" : "crypto.x509FingerprintStr",
),
field("validFrom", isBytes ? "crypto.x509ValidFrom" : "crypto.x509ValidFromStr"),
field("validTo", isBytes ? "crypto.x509ValidTo" : "crypto.x509ValidToStr"),
],
type: t,
loc,
},
loc,
},
],
loc,
});
}
return { kind: "call", callee: helper, args: [data], type: t, loc };
}
}
// `new X(...)` through a class VALUE (a classval-typed binding, array
// element, map read, param): the newValue dispatch through the class
// object's construct thunk. Arguments complete against the STATIC
@@ -2339,6 +2339,7 @@ export function typeofAnswer(arm: IrType): string | null {
case "set":
case "generator":
case "regex":
case "date":
case "bytes":
case "url":
case "searchParams":
@@ -183,13 +183,9 @@ export function lowerGenMethodCall(
);
}
arg = lowerer.lowerExpr(argNode);
if (arg.type.kind === "date") {
lowerer.unsupported(
"SC1090",
argNode,
".throw() with a Date value (the exception channel cannot preserve Date's object kind; throw an Error or primitive instead)",
);
}
// A Date throws as its checked-dynamic handle, the same Date object
// (the throw statement's rule).
if (arg.type.kind === "date") arg = lowerer.coerceToExpected(arg, DYN);
if (arg.type.kind === "void" || arg.type.kind === "caught" || isUnitType(arg.type)) {
lowerer.unsupported(
"SC1090",
@@ -1,5 +1,6 @@
import { keyedHelperName } from "./stable-names.js";
import { perKeyOrder } from "./key-order-witness.js";
import { classPrototypeView } from "./class-prototypes.js";
import {
dynUndefinedExpr,
boolLit,
@@ -3527,8 +3528,14 @@ function lowerUtilCallableCall(
member === "inherits" && expr.arguments.nodes[1] !== undefined
? builtinClassKindOf(lowerer, expr.arguments.nodes[1])
: null;
// A superCtor class whose prototype has a complete view (a module-level
// JavaScript class named directly) passes that view to the runtime.
const superView =
member === "inherits" && builtinBase === null && expr.arguments.nodes[1] !== undefined
? classPrototypeView(lowerer, expr.arguments.nodes[1], loc)
: null;
for (const [index, arg] of expr.arguments.nodes.slice(0, classArgs).entries()) {
if (index === 1 && builtinBase !== null) continue;
if (index === 1 && (builtinBase !== null || superView !== null)) continue;
if (lowerer.mapTypeOf(lowerer.typeOf(arg))?.kind === "classval")
lowerer.noLowering(
`util.${member} over a class constructor`,
@@ -3560,7 +3567,9 @@ function lowerUtilCallableCall(
for (const arg of expr.arguments.nodes.slice(arity))
stmts.push({ kind: "exprStmt", expr: lowerer.lowerExpr(arg), loc });
const type = fn === "util.inherits" ? VOID : DYN;
const result: IrExpr = { kind: "libCall", fn, args, type, loc };
const result: IrExpr = superView
? { kind: "libCall", fn: "util.inheritsPrototype", args: [...args, superView], type, loc }
: { kind: "libCall", fn, args, type, loc };
return { kind: "seqExpr", stmts, result, type: result.type, loc };
}
@@ -1630,16 +1630,22 @@ export function lowerStaticAbortControllerCall(
call: ts.CallExpression,
): IrExpr | null {
const access = call.expression;
// An optional chain (`controller?.abort()`) lowers the call again once it
// has narrowed the receiver (Lowerer.chainHandled).
if (
call.questionDotToken ||
(call.questionDotToken && !lowerer.chainHandled.has(call)) ||
call.arguments.nodes.some((argument) => ts.isSpreadElement(argument)) ||
(!ts.isPropertyAccessExpression(access) && !ts.isElementAccessExpression(access)) ||
access.questionDotToken ||
(access.questionDotToken && !lowerer.chainHandled.has(access)) ||
staticResponseMemberName(lowerer, access) !== "abort"
) {
return null;
}
const receiverTs = lowerer.checker.getBaseTypeOfLiteralType(lowerer.typeOf(access.expression));
const receiverTs = lowerer.checker.getBaseTypeOfLiteralType(
access.questionDotToken
? lowerer.checker.getNonNullableType(lowerer.typeOf(access.expression))
: lowerer.typeOf(access.expression),
);
const symbol = receiverTs.getAliasSymbol() ?? receiverTs.getSymbol();
if (
symbol === undefined ||
@@ -1624,8 +1624,47 @@ const THROWN_AS_DYN: ReadonlySet<IrType["kind"]> = new Set([
"bytes",
"func",
"regex",
"date",
]);
/** A `return` at the top level of a CommonJS module (preflight admits it
* there only): it ends the module's evaluation, and its value is
* evaluated and discarded, as Node's module wrapper does. Top-level
* bindings and export assignments after it would be left uninitialized
* where Node leaves them in their temporal dead zone or absent, so a
* module with any keeps a refusal. Null for a return inside a function. */
function lowerModuleLevelReturn(lowerer: Lowerer, stmt: ts.ReturnStatement): IrStmt | null {
let top: ts.Node = stmt;
while (top.parent !== undefined && !ts.isSourceFile(top.parent)) {
if (ts.isFunctionLike(top.parent) || ts.isClassStaticBlockDeclaration(top.parent)) return null;
top = top.parent;
}
const sf = top.parent;
if (sf === undefined || !ts.isSourceFile(sf)) return null;
const stmts = sf.statements.nodes;
const later = stmts.slice(stmts.indexOf(top as ts.Statement) + 1);
if (
later.some(
(s) =>
ts.isVariableStatement(s) || ts.isClassDeclaration(s) || cjsExportAssignmentOf(s) !== null,
)
) {
lowerer.unsupported(
"SC1090",
stmt,
"a top-level return in a CommonJS module that declares variables or classes, or assigns exports, after it",
);
}
const loc = locOf(stmt);
const ret: IrStmt = { kind: "return", value: null, loc };
if (!stmt.expression) return ret;
return {
kind: "block",
body: [{ kind: "exprStmt", expr: lowerer.lowerExpr(stmt.expression), loc }, ret],
loc,
};
}
export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt[] | null {
if (ownPrototypeResetStatement(lowerer, stmt))
return { kind: "block", body: [], loc: locOf(stmt) };
@@ -1735,6 +1774,8 @@ export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt
if (ts.isForOfStatement(stmt)) return lowerer.lowerForOf(stmt);
if (ts.isLabeledStatement(stmt)) return lowerLabeled(lowerer, stmt);
if (ts.isReturnStatement(stmt)) {
const moduleReturn = lowerModuleLevelReturn(lowerer, stmt);
if (moduleReturn) return moduleReturn;
// Implicit-any instance RETURN INFERENCE (function-returns.ts
// resolveInferredReturn): the value lowers BARE and the statement
// records itself — the post-pass settles the instance's return type
@@ -1828,9 +1869,7 @@ export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt
return { kind: "rethrow", localId: caughtLocal.id, loc: locOf(stmt) };
}
// Exception-representable values move into the runtime's exception
// cell. `throw f()` of a void call has no value; Date has numeric
// storage internally, but cannot preserve its object kind through the
// untyped catch boundary. Reject both rather than changing JS identity.
// cell. `throw f()` of a void call has no value.
let value = lowerer.lowerExpr(stmt.expression);
if (value.type.kind === "void")
lowerer.badType(stmt.expression, lowerer.typeOf(stmt.expression));
@@ -1842,19 +1881,12 @@ export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt
} else if (value.type.kind === "record" && lowerer.dynConvertible(value.type)) {
value = { kind: "dynFrom", value, liveRef: true, type: DYN, loc: locOf(stmt.expression) };
} else if (THROWN_AS_DYN.has(value.type.kind) && lowerer.dynConvertible(value.type)) {
// Arrays, Maps, Sets, typed arrays, Buffers, functions, regexes and
// unions over them keep their identity and contents the same way:
// the catch binding, `typeof`, `instanceof`, `Array.isArray`,
// inspection and typed views read the thrown object.
// Arrays, Maps, Sets, typed arrays, Buffers, functions, regexes,
// Dates and unions over them keep their identity and contents the
// same way: the catch binding, `typeof`, `instanceof`,
// `Array.isArray`, inspection and typed views read the thrown object.
value = lowerer.coerceInto(stmt.expression, value, DYN);
}
if (value.type.kind === "date") {
lowerer.unsupported(
"SC1090",
stmt.expression,
"throwing Date values (catch bindings cannot preserve Date's object kind; throw an Error or primitive instead)",
);
}
// A dyn throw rides the REF cell arm (scr_throw_ref over the checked-dynamic tree
// node — identity preserved through catch bindings, caughtToDyn
// passes it back by reference). The JS-lane traced-call shape:
@@ -5450,7 +5482,12 @@ export function lowerVarDecl(
if (isTimeValueDateDeclaration(lowerer, decl)) {
const loc = locOf(decl);
const date = lowerer.lowerExprExpecting(decl.initializer!, DATE_T);
const local = lowerer.declareLocal(decl.name, decl.name.text, F64, isLet);
const local = lowerer.declareLocal(
decl.name,
decl.name.text,
F64,
isLet || lowerer.timeValueDateWrites.has(decl),
);
lowerer.timeValueDateLocals.add(local);
const init: IrExpr = { kind: "libCall", fn: "date.getTime", args: [date], type: F64, loc };
return { kind: "varDecl", localId: local.id, init, loc };
@@ -239,7 +239,7 @@ test("optional union widening keeps refused and missing contracts current", () =
unionId: lowerer.unions.recursiveRef(recursive, () => "Recursive"),
};
expect(lowerer.withUndefinedArmOf(pending)).not.toBeNull();
lowerer.unions.finalizeRecursive(recursive, [{ kind: "date" }, STRING]);
lowerer.unions.finalizeRecursive(recursive, [{ kind: "promise", inner: F64 }, STRING]);
expect(lowerer.withUndefinedArmOf(pending)).toBeNull();
expect(lowerer.withUndefinedArmOf(pending)).toBeNull();
@@ -156,6 +156,7 @@ import {
trackClassFieldCreation,
} from "./class-dynamic-dispatch.js";
import { guardClassAttributeWrites } from "./class-attributes.js";
import { inferredCollectionType } from "./js-collection-types.js";
import { guardFrozenWrites, markSloppyRecordWrites } from "./frozen-records.js";
import { applyHandleProperties } from "./handle-properties.js";
import { installBufferValue } from "./containers/bytes.js";
@@ -593,7 +594,11 @@ import {
lowerTestMethodCall,
lowerTestCtxProperty,
} from "./lower-test.js";
import { lowerAssertModuleCall, lowerAssertDirectCall } from "./lower-assert.js";
import {
assertFunctionValue,
lowerAssertModuleCall,
lowerAssertDirectCall,
} from "./lower-assert.js";
import type { AssertInstanceOptions } from "./assert-instances.js";
import { lowerUtilModuleCall } from "./lower-inspect.js";
import { lowerSystemErrorMapValue } from "./lower-system-errors.js";
@@ -2362,6 +2367,9 @@ export class Lowerer {
* Date's time value instead of a Date object (date-values.ts). */
readonly timeValueDateDecisions = new Map<ts.Node, boolean>();
readonly timeValueDateLocals = new Set<IrLocal>();
/** Declarations of those locals that a setter call writes
* (date-values.ts). */
readonly timeValueDateWrites = new Set<ts.VariableDeclaration>();
/** Record bindings whose flow class reaches storage outside the class:
* they keep the declared layout, so an initializer from unchecked reads
* converts once instead of the binding adopting the widened variant. */
@@ -2846,6 +2854,9 @@ export class Lowerer {
/** Parameters read as `any` in the error-path copy of an error-first
* callback. */
readonly anySymbols = new Set<ts.Symbol>();
/** The collection types that uses prove for untyped JavaScript bindings,
* by every identifier naming one (js-collection-types.ts). */
inferredCollectionTypes: Map<ts.Node, ts.Type> | undefined = undefined;
/** overload-choice's answers per call, binding and function. */
readonly dynamicOverloadAnswers = new Map<ts.Node, boolean>();
/** Error parameters of an error-first callback with a known value in
@@ -3910,7 +3921,8 @@ export class Lowerer {
/** Declared class fields may store a DYN value only when its static type
* is `unknown` or `object` (also with `null` or `undefined`, or an array
* of those), names checked protocol identity, is an owned runtime
* handle (a Worker or MessagePort, a fetch Headers/Request/Response, or
* handle (a Worker or MessagePort, a fetch Headers/Request/Response or
* AbortController/AbortSignal, or
* a node:crypto ECDH, DiffieHellman or DiffieHellmanGroup), or is a
* WeakMap/WeakSet (native weak collections live in checked-dynamic
* storage, as locals of those types do); `any` fields stay refused. */
@@ -9067,6 +9079,10 @@ export class Lowerer {
}
typeOf(node: ts.Node): ts.Type {
if (ts.isIdentifier(node)) {
const inferred = inferredCollectionType(this, node);
if (inferred) return inferred;
}
if (this.anySymbols.size > 0 && this.readsAnySymbol(node)) return this.checker.getAnyType();
// A checked-dynamic argument that another overload could take at run
// time leaves the resolved overload's return a guess, and JS bindings
@@ -13985,6 +14001,8 @@ export class Lowerer {
* deliberately narrower than table membership because many table rows
* are only dispatch sentinels for call-site-specific lowering. */
lowerBuiltinCallableValue(bi: { module: string; member: string }, loc: SrcLoc): IrExpr | null {
const assertion = assertFunctionValue(bi, loc);
if (assertion) return assertion;
if (bi.module === "util" && bi.member === "types") return utilTypesModuleValue(this, loc);
if (bi.module === "util/types" && builtinModuleFnOf(this, bi.module, bi.member))
return lowerUtilTypeValue(this, bi.member, loc);
@@ -873,6 +873,12 @@ export const OBJECT_CALLABLE_VALUES: Record<string, BuiltinModuleFn | undefined>
result: DYN,
valueParams: exactValueParams(DYN, DYN),
},
hasOwn: {
fn: "dyn.hasOwnComputed",
params: [DYN, DYN],
result: BOOL,
valueParams: exactValueParams(DYN, DYN),
},
keys: { fn: "dyn.objKeys", params: [DYN], result: DYN, valueParams: exactValueParams(DYN) },
values: { fn: "dyn.objValues", params: [DYN], result: DYN, valueParams: exactValueParams(DYN) },
entries: { fn: "dyn.objEntries", params: [DYN], result: DYN, valueParams: exactValueParams(DYN) },
@@ -2338,6 +2344,13 @@ export const AMBIENT_SURFACE_FNS: readonly AmbientSurfaceRow[] = [
],
note: "zero arguments, one milliseconds/date-string argument, or local-time year/month fields; values are the read-only TimeClip scalar slice",
},
{
id: "stdlib.date.setters",
kind: "stdlib",
name: "Date.prototype setters",
fns: ["date.setFields", "date.timeClip", "date.store"],
note: "setTime and the local and UTC field setters, with their optional arguments",
},
{
id: "stdlib.date.UTC",
kind: "stdlib",
+154 -112
View File
@@ -11,6 +11,7 @@ import { npmStaticPackageOfPath } from "./npm-static.js";
import { trackedReadFile } from "./input-tracker.js";
import { npmPackageNameOf } from "./workspace-registry.js";
import { typeOnlyNamedImport } from "./type-only-imports.js";
import { moduleSourceCandidates } from "./module-source-candidates.js";
export function isPrunedNpmReexport(program: ts.Program, stmt: ts.ExportDeclaration): boolean {
return program.analysis.prunedNpmReexports.has(stmt);
@@ -117,6 +118,21 @@ function pureExternalPackage(
type Demand = Set<string> | null; // null requests the entire namespace
/** One module edge of a package file, resolved once per plan. */
interface ModuleEdge {
spec: string;
dep: ts.SourceFile | null;
}
/** A file's runtime module edges: its import declarations with the names
* they request (null: the whole module), its `export … from` declarations,
* and its `import()` and `require()` calls with one string argument. */
interface ModuleEdges {
imports: (ModuleEdge & { names: readonly string[] | null })[];
reexports: (ModuleEdge & { stmt: ts.ExportDeclaration })[];
calls: ModuleEdge[];
}
/** Package files outside the demanded module graph are absent from the
* executable program. All ordinary project files retain their preflight
* behavior, including files not imported by the entry. */
@@ -151,6 +167,70 @@ export function planNpmStaticReexports(
}
};
// The demand walk visits a file again whenever another of its names is
// requested, and every purity check walks the files it reaches: both read
// these edges instead of resolving the file's syntax each time.
const edgeMemo = new Map<ts.SourceFile, ModuleEdges>();
const edgesOf = (sf: ts.SourceFile): ModuleEdges => {
const cached = edgeMemo.get(sf);
if (cached !== undefined) return cached;
const edges: ModuleEdges = { imports: [], reexports: [], calls: [] };
for (const stmt of sf.statements.nodes) {
if (ts.isImportDeclaration(stmt)) {
if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue;
const clause = stmt.importClause;
if (clause?.phaseModifier === ts.SyntaxKind.TypeKeyword) continue;
const spec = stmt.moduleSpecifier.text;
let names: string[] | null = null;
if (
clause !== undefined &&
(clause.namedBindings === undefined ||
!(
ts.isNamespaceImport(clause.namedBindings) ||
(ts.isNamedImports(clause.namedBindings) &&
clause.namedBindings.elements.nodes.length === 0)
)) &&
// `import { type A }` still loads the module, like `import {}`.
!typeOnlyNamedImport(stmt)
) {
names = [];
if (clause.name !== undefined) names.push("default");
if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) {
for (const element of clause.namedBindings.elements.nodes) {
if (!element.isTypeOnly) names.push((element.propertyName ?? element.name).text);
}
}
}
edges.imports.push({ spec, dep: resolveEdge(sf, spec), names });
} else if (ts.isExportDeclaration(stmt)) {
// `export { type A } from` still loads the module under Node's
// type stripping; only `export type … from` erases.
if (
stmt.isTypeOnly ||
stmt.moduleSpecifier === undefined ||
!ts.isStringLiteral(stmt.moduleSpecifier)
)
continue;
const spec = stmt.moduleSpecifier.text;
edges.reexports.push({ spec, dep: resolveEdge(sf, spec), stmt });
}
}
for (const node of moduleSourceCandidates(program, sf).calls) {
if (node.arguments.nodes.length !== 1) continue;
const arg = node.arguments.nodes[0];
if (arg === undefined || !ts.isStringLiteralLike(arg)) continue;
const kind =
node.expression.kind === ts.SyntaxKind.ImportKeyword
? "import"
: ts.isIdentifier(node.expression) && node.expression.text === "require"
? "require"
: null;
if (kind !== null) edges.calls.push({ spec: arg.text, dep: resolveEdge(sf, arg.text, kind) });
}
edgeMemo.set(sf, edges);
return edges;
};
for (const sf of files) {
if (npmStaticPackageOfPath(sf.fileName) === null) request(sf, null);
}
@@ -178,57 +258,65 @@ export function planNpmStaticReexports(
return false;
}
};
// An edge this program does not resolve: the conservative
// whole-dependency promise of the external package it names.
const externalPure = (sf: ts.SourceFile, spec: string): boolean => {
const resolved = resolveBareModule(sf.fileName, spec, "js-only");
const pkg = resolved === null ? null : npmPackageNameOf(resolved.typesFile);
const root =
pkg === null || resolved === null
? null
: packageRootJsonPath(resolved.typesFile, pkg, packageJsons);
return root !== null && pureExternalPackage(root, packageJsons);
};
// Check the actual imported closure: metadata for one file never vouches
// for its side-effectful descendants, even across a package or a cycle.
const pureModuleTree = (root: ts.SourceFile): boolean => {
const cached = pureMemo.get(root);
if (cached !== undefined) return cached;
const visited = new Set<ts.SourceFile>();
const visit = (sf: ts.SourceFile): boolean => {
if (visited.has(sf)) return true;
if (pureMemo.get(sf) === false || !filePure(sf)) return false;
visited.add(sf);
const edges: { spec: string; kind?: "import" | "require" }[] = [];
for (const stmt of sf.statements.nodes) {
if (
ts.isImportDeclaration(stmt) &&
stmt.importClause?.phaseModifier !== ts.SyntaxKind.TypeKeyword &&
ts.isStringLiteral(stmt.moduleSpecifier)
)
edges.push({ spec: stmt.moduleSpecifier.text });
else if (
ts.isExportDeclaration(stmt) &&
!stmt.isTypeOnly &&
stmt.moduleSpecifier &&
ts.isStringLiteral(stmt.moduleSpecifier)
)
edges.push({ spec: stmt.moduleSpecifier.text });
// A file is pure when every file it reaches is. The walk finds the
// strongly connected components of the module graph (Tarjan): a finished
// component is pure as a whole. On the first impure file the walk stops,
// and every file still on its stack reaches that file, so each is impure.
const order = new Map<ts.SourceFile, number>();
const stack: ts.SourceFile[] = [];
/** The lowest order the file's walk reaches (Infinity for a finished
* component), or -1 once it reaches an impure file. */
const visit = (sf: ts.SourceFile): number => {
const known = pureMemo.get(sf);
if (known !== undefined) return known ? Infinity : -1;
const seen = order.get(sf);
if (seen !== undefined) return seen;
if (!filePure(sf)) {
pureMemo.set(sf, false);
return -1;
}
const own = order.size;
order.set(sf, own);
stack.push(sf);
let low = own;
const edges = edgesOf(sf);
for (const list of [edges.imports, edges.reexports, edges.calls]) {
for (const edge of list) {
const reached =
edge.dep !== null ? visit(edge.dep) : externalPure(sf, edge.spec) ? low : -1;
if (reached < 0) return -1;
if (reached < low) low = reached;
}
ts.walkPreorder(sf, (node) => {
if (!ts.isCallExpression(node) || node.arguments.nodes.length !== 1) return;
const arg = node.arguments.nodes[0];
if (!arg || !ts.isStringLiteralLike(arg)) return;
if (node.expression.kind === ts.SyntaxKind.ImportKeyword)
edges.push({ spec: arg.text, kind: "import" });
else if (ts.isIdentifier(node.expression) && node.expression.text === "require")
edges.push({ spec: arg.text, kind: "require" });
});
return edges.every((edge) => {
const dep = resolveEdge(sf, edge.spec, edge.kind);
if (dep !== null) return visit(dep);
const resolved = resolveBareModule(sf.fileName, edge.spec, "js-only");
const pkg = resolved === null ? null : npmPackageNameOf(resolved.typesFile);
const root =
pkg === null || resolved === null
? null
: packageRootJsonPath(resolved.typesFile, pkg, packageJsons);
return root !== null && pureExternalPackage(root, packageJsons);
});
};
const pure = visit(root);
if (pure) for (const sf of visited) pureMemo.set(sf, true);
else pureMemo.set(root, false);
return pure;
}
if (low === own) {
for (;;) {
const member = stack.pop()!;
order.delete(member);
pureMemo.set(member, true);
if (member === sf) break;
}
}
return low;
};
const pureModuleTree = (root: ts.SourceFile): boolean => {
if (visit(root) >= 0) return true;
for (const sf of stack) pureMemo.set(sf, false);
stack.length = 0;
order.clear();
return false;
};
const canPrune = (
sf: ts.SourceFile,
@@ -245,78 +333,32 @@ export function planNpmStaticReexports(
return names !== undefined && names !== null && !names.has(stmt.exportClause.name.text);
};
// A file is queued again only when its demand grows. Its imports and
// calls request the same modules every time; only a namespace re-export
// the new demand names stops being prunable.
const walked = new Set<ts.SourceFile>();
while (queue.length > 0) {
const sf = queue.shift()!;
for (const stmt of sf.statements.nodes) {
if (ts.isImportDeclaration(stmt)) {
if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue;
const clause = stmt.importClause;
if (clause?.phaseModifier === ts.SyntaxKind.TypeKeyword) continue;
const dep = resolveEdge(sf, stmt.moduleSpecifier.text);
if (
clause === undefined ||
(clause.namedBindings !== undefined &&
(ts.isNamespaceImport(clause.namedBindings) ||
(ts.isNamedImports(clause.namedBindings) &&
clause.namedBindings.elements.nodes.length === 0))) ||
// `import { type A }` still loads the module, like `import {}`.
typeOnlyNamedImport(stmt)
) {
request(dep, null);
} else {
if (clause.name !== undefined) request(dep, "default");
if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) {
for (const element of clause.namedBindings.elements.nodes) {
if (!element.isTypeOnly) request(dep, (element.propertyName ?? element.name).text);
}
}
}
} else if (ts.isExportDeclaration(stmt)) {
// `export { type A } from` still loads the module under Node's
// type stripping; only `export type … from` erases.
if (
stmt.isTypeOnly ||
stmt.moduleSpecifier === undefined ||
!ts.isStringLiteral(stmt.moduleSpecifier)
)
continue;
const dep = resolveEdge(sf, stmt.moduleSpecifier.text);
if (canPrune(sf, stmt, dep)) continue;
request(dep, null);
const edges = edgesOf(sf);
const first = !walked.has(sf);
walked.add(sf);
if (first) {
for (const edge of edges.imports) {
if (edge.names === null) request(edge.dep, null);
else for (const name of edge.names) request(edge.dep, name);
}
}
ts.walkPreorder(sf, (node) => {
if (!ts.isCallExpression(node) || node.arguments.nodes.length !== 1) return undefined;
const arg = node.arguments.nodes[0];
if (arg === undefined || !ts.isStringLiteralLike(arg)) return undefined;
if (
node.expression.kind === ts.SyntaxKind.ImportKeyword ||
(ts.isIdentifier(node.expression) && node.expression.text === "require")
) {
request(
resolveEdge(
sf,
arg.text,
node.expression.kind === ts.SyntaxKind.ImportKeyword ? "import" : "require",
),
null,
);
}
return undefined;
});
for (const edge of edges.reexports) {
if (canPrune(sf, edge.stmt, edge.dep)) continue;
request(edge.dep, null);
}
if (first) for (const edge of edges.calls) request(edge.dep, null);
}
const pruned = new Set<ts.ExportDeclaration>();
for (const sf of demanded.keys()) {
for (const stmt of sf.statements.nodes) {
if (
!ts.isExportDeclaration(stmt) ||
stmt.moduleSpecifier === undefined ||
!ts.isStringLiteral(stmt.moduleSpecifier)
)
continue;
if (canPrune(sf, stmt, resolveEdge(sf, stmt.moduleSpecifier.text))) pruned.add(stmt);
}
for (const edge of edgesOf(sf).reexports)
if (canPrune(sf, edge.stmt, edge.dep)) pruned.add(edge.stmt);
}
program.analysis.prunedNpmReexports.clear();
for (const stmt of pruned) program.analysis.prunedNpmReexports.add(stmt);
+32 -27
View File
@@ -140,6 +140,7 @@ import {
} from "./type-only-imports.js";
import { moduleSourceCandidates } from "./module-source-candidates.js";
import { planDeclProjection, type DeclProjection } from "./decl-projection.js";
import { reportShapes } from "./shape-inference/index.js";
import { inferredJsDiagnosticSuppressed } from "./inferred-js-diagnostics.js";
import {
containsJsx,
@@ -487,43 +488,33 @@ function visitNpmStaticDeclarationClosure(
visit(entryPath);
}
function npmStaticDeclarationOverloadsOf(
/** The overloads and properties of the classes a package's declaration
* closure declares, read from one parse of each declaration file. */
function npmStaticDeclarationMetadataOf(
services: FrontendServices,
entryPath: string,
): NpmStaticDeclarationOverloads {
const classes = new Map<string, Map<string, readonly NpmStaticOverloadSignature[]>>();
): { overloads: NpmStaticDeclarationOverloads; properties: NpmStaticDeclarationProperties } {
const overloads = new Map<string, Map<string, readonly NpmStaticOverloadSignature[]>>();
const properties = new Map<string, Map<string, string>>();
visitNpmStaticDeclarationClosure(services, entryPath, (path, source) => {
for (const [className, methods] of parseNpmStaticDeclarationOverloads(
services.parse(path, source, "ts"),
)) {
const sourceFile = services.parse(path, source, "ts");
for (const [className, methods] of parseNpmStaticDeclarationOverloads(sourceFile)) {
const target =
classes.get(className) ?? new Map<string, readonly NpmStaticOverloadSignature[]>();
classes.set(className, target);
overloads.get(className) ?? new Map<string, readonly NpmStaticOverloadSignature[]>();
overloads.set(className, target);
for (const [methodName, signatures] of methods) {
if (!target.has(methodName)) target.set(methodName, signatures);
}
}
});
return classes;
}
function npmStaticDeclarationPropertiesOf(
services: FrontendServices,
entryPath: string,
): NpmStaticDeclarationProperties {
const classes = new Map<string, Map<string, string>>();
visitNpmStaticDeclarationClosure(services, entryPath, (path, source) => {
for (const [className, properties] of parseNpmStaticDeclarationProperties(
services.parse(path, source, "ts"),
)) {
const target = classes.get(className) ?? new Map<string, string>();
classes.set(className, target);
for (const [propertyName, type] of properties) {
for (const [className, fields] of parseNpmStaticDeclarationProperties(sourceFile)) {
const target = properties.get(className) ?? new Map<string, string>();
properties.set(className, target);
for (const [propertyName, type] of fields) {
if (!target.has(propertyName)) target.set(propertyName, type);
}
}
});
return classes;
return { overloads, properties };
}
/** See LoadResult.startupCrash: Node's exact error message, the IR error
@@ -1095,8 +1086,7 @@ export function loadProgram(
) {
continue;
}
const overloads = npmStaticDeclarationOverloadsOf(services, resolved.typesFile);
const properties = npmStaticDeclarationPropertiesOf(services, resolved.typesFile);
const { overloads, properties } = npmStaticDeclarationMetadataOf(services, resolved.typesFile);
const classNames = new Set([...overloads.keys(), ...properties.keys()]);
if (classNames.size === 0) continue;
const runtime = resolveBareModule(entryPath, pkg, "js-only");
@@ -1260,6 +1250,10 @@ export function loadProgram(
npmStaticPackages.length > 0,
projector,
);
// Shape inference, stage S1: a report only (SCRIPTC_DEBUG_SHAPES).
const shapes = process.env["SCRIPTC_DEBUG_SHAPES"];
if (shapes !== undefined && shapes !== "" && shapes !== "0" && npmStaticPackages.length > 0)
reportShapes(load.program, shapes);
return {
...load,
services,
@@ -5358,6 +5352,16 @@ function preflight7(load: LoadResult): {
return new RegExp(`\\btypeof\\s*\\(?\\s*${name.replace(/\$/g, "\\$")}(?![\\w$])`).test(sf.text);
};
// A `return` at the top level of a CommonJS module (TS1108): Node runs
// the module as a function body, so the return ends the module's
// evaluation. ES modules and TypeScript keep the error.
const cjsTopLevelReturnSuppressed = (p: ts.Program, d: ts.Diagnostic): boolean => {
if (d.code !== 1108 || d.fileName === undefined || !isJsSourceFileName(d.fileName))
return false;
const sf = p.getSourceFile(d.fileName);
return sf !== undefined && isCjsJsFile7(sf, p);
};
const workspaceImplicitAnySuppressed = (p: ts.Program, d: ts.Diagnostic): boolean => {
if (d.code !== 7016 || d.fileName === undefined || d.pos === undefined || d.end === undefined)
return false;
@@ -5404,6 +5408,7 @@ function preflight7(load: LoadResult): {
!isNpmStaticSubclassArgument(p, d) &&
!workspaceImplicitAnySuppressed(p, d) &&
!typeofTestedNameSuppressed(p, d) &&
!cjsTopLevelReturnSuppressed(p, d) &&
!jsdocTypeSuppressed(p, d, commentDup),
);
};
+12 -1
View File
@@ -20,6 +20,12 @@ import * as syntax from "./npm-static-declaration-syntax.js";
import { normalizeCjsExportsVar } from "./npm-static-cjs-exports-var.js";
import type { SourceFile } from "./ts7/ast-types.js";
/** Whether the source can contain `word` as a keyword or identifier: it
* holds the word itself or a Unicode escape that might spell it. */
function spells(source: string, word: string): boolean {
return source.includes(word) || source.includes("\\u");
}
/** Evaluate an isolated, self-contained TypeScript callback under a finite
* budget. Hosts supply execution; shared lowering owns capture checks and
* verifies the returned value before baking it into the program. */
@@ -150,13 +156,18 @@ export class FrontendServices {
return normalizeCjsExportsVar(this.parse(path, source, "js"));
}
nullableClassFields(path: string, source: string): syntax.NpmStaticOverloadRewrite | null {
// Rewrites `null`-initialized fields of class declarations.
if (!spells(source, "class") || !spells(source, "null")) return null;
return syntax.applyNpmStaticNullableClassFields(this.parse(path, source, "js"), source);
}
jsDocNamepaths(path: string, source: string): string | null {
if (!source.includes("~") && !source.includes("#")) return null;
// A namepath segment joined by `#` or `~` (applyNpmStaticJsDocNamepaths).
if (!/[$\w][#~][$A-Z_a-z]/.test(source)) return null;
return syntax.applyNpmStaticJsDocNamepaths(this.parse(path, source, "js"), source);
}
findReturnWidening(path: string, source: string): syntax.NpmStaticOverloadRewrite | null {
// Widens `find` calls returned by methods of class declarations.
if (!spells(source, "class") || !spells(source, "find")) return null;
return syntax.applyNpmStaticFindReturnWidening(this.parse(path, source, "js"), source);
}
declarationProperties(
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,421 @@
/* Static-object specialization, stage 1 (DESIGN-constant-objects.md):
* constant object graphs.
*
* An object literal is a SINGLETON when the code that creates it runs at
* most once: at module top level, or in a function every call path to which
* starts at module evaluation through exactly one call site outside loops
* (IIFEs, revealing modules, init helpers). A singleton's field is CONSTANT
* when every store to it runs in such once-only code, no reflective or
* computed write reaches the object, and the object never escapes to code
* the analysis cannot see. A constant object has only constant fields and a
* fixed key set; constant objects whose constant fields point at each other
* form a constant graph (`ns`, `ns.util`, `ns.util.trim`).
*
* This stage computes the graphs and reports them, with the rule that
* demoted every near miss, and the reads and calls a later stage could fold.
* Nothing reaches code generation. */
import * as ts from "../ts7/adapter.js";
import type { Atom } from "./domain.js";
import type { FunctionInfo, Op, ShapeAnalysis } from "./analyze.js";
import { npmStaticPackageOfPath } from "../npm-static.js";
type Node = ts.Node;
export interface ConstantField {
readonly key: string;
/** Distinct atoms the field may hold. */
readonly values: readonly Atom[];
/** Stores (the literal's own member included). */
readonly stores: number;
}
/** An object the analysis tracks as one allocation: an object literal, a
* function object (a namespace or an ES5 constructor), or a constructor's
* default prototype object. */
export interface Candidate {
readonly atom: Atom;
/** The syntax that creates it (the literal or the function). */
readonly node: Node;
readonly kind: "literal" | "function" | "prototype";
/** Keys the creating syntax itself defines. */
readonly keys: readonly string[];
/** Whether the creating syntax is a plain data literal. */
readonly plain: boolean;
readonly inPackage: boolean;
}
export interface ConstantObject {
readonly site: Candidate;
readonly fields: readonly ConstantField[];
readonly frozen: boolean;
}
export interface NearMiss {
readonly site: Candidate;
/** Null when the whole object was demoted. */
readonly key: string | null;
readonly rule:
| "init-factory"
| "allocated-repeatedly"
| "escapes"
| "reflective-write"
| "computed-write"
| "literal-form"
| "written-after-init"
| "lazy-cache";
readonly detail: string;
}
export interface ConstantGraphs {
readonly objects: readonly ConstantObject[];
readonly nearMisses: readonly NearMiss[];
/** Reads `e.k` whose receiver is exactly one constant object and whose
* field is constant, and the subset that are callees. */
readonly foldableReads: number;
readonly foldableCalls: number;
/** Reads of a constant field through a receiver that may also be other
* values. */
readonly mixedReads: number;
readonly once: (fn: Node) => boolean;
}
function isLoop(n: Node): boolean {
return (
ts.isForStatement(n) ||
ts.isForInStatement(n) ||
ts.isForOfStatement(n) ||
ts.isWhileStatement(n) ||
ts.isDoStatement(n)
);
}
/** The function (or source file) whose body contains `n`. */
function ownerFunction(n: Node): Node {
for (let p = n.parent; p !== undefined; p = p.parent) {
if (ts.isSourceFile(p)) return p;
if (ts.isFunctionLike(p) && (p as { body?: Node }).body !== undefined) return p;
}
return n.getSourceFile();
}
/** Whether `n` sits in a loop of its own function's body. */
function inLoop(n: Node, owner: Node): boolean {
for (let p = n.parent; p !== undefined && p !== owner; p = p.parent) if (isLoop(p)) return true;
return false;
}
function loc(n: Node): string {
const sf = n.getSourceFile();
const file = sf.fileName.split("/node_modules/").pop() ?? sf.fileName;
return `${file.split("/").slice(-2).join("/")}:${sf.getLineAndCharacterOfPosition(ts.tokenStartOfNode(n)).line + 1}`;
}
/** `x.k || (x.k = v)`, `x.k ??= v`, `x.k ||= v`, `if (!x.k) x.k = v`. */
function isCacheStore(write: Node): boolean {
let assign: Node | undefined = write.parent;
while (assign !== undefined && ts.isParenthesizedExpression(assign)) assign = assign.parent;
if (assign === undefined || !ts.isBinaryExpression(assign)) return false;
const op = assign.operatorToken.kind;
if (op === ts.SyntaxKind.BarBarEqualsToken || op === ts.SyntaxKind.QuestionQuestionEqualsToken)
return true;
if (op !== ts.SyntaxKind.EqualsToken) return false;
const text = write.getText();
let up: Node | undefined = assign.parent;
while (up !== undefined && ts.isParenthesizedExpression(up)) up = up.parent;
if (
up !== undefined &&
ts.isBinaryExpression(up) &&
(up.operatorToken.kind === ts.SyntaxKind.BarBarToken ||
up.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken) &&
up.left.getText() === text
)
return true;
for (let p: Node | undefined = assign.parent; p !== undefined; p = p.parent) {
if (ts.isIfStatement(p)) {
const cond = p.expression.getText().replace(/\s+/g, "");
return cond === `!${text}` || cond === `${text}===undefined` || cond === `${text}==null`;
}
if (ts.isFunctionLike(p) || ts.isSourceFile(p)) break;
}
return false;
}
const OBJECT_DEMOTING: ReadonlySet<Op["kind"]> = new Set([
"computedWrite",
"delete",
"defineProperty",
"setPrototypeOf",
"assign",
"proxy",
]);
export function computeConstantGraphs(analysis: ShapeAnalysis): ConstantGraphs {
const infosByNode = new Map<Node, FunctionInfo[]>();
for (const info of analysis.functions.values()) {
let l = infosByNode.get(info.node);
if (l === undefined) infosByNode.set(info.node, (l = []));
l.push(info);
}
const edgesTo = new Map<Node, (typeof analysis.calls)[number][]>();
for (const e of analysis.calls) {
let l = edgesTo.get(e.callee.node);
if (l === undefined) edgesTo.set(e.callee.node, (l = []));
l.push(e);
}
const escapedNodes = new Set<Node>([...analysis.escapedFunctions].map((f) => f.node));
const onceMemo = new Map<Node, boolean>();
const once = (fn: Node): boolean => {
if (ts.isSourceFile(fn)) return true;
const known = onceMemo.get(fn);
if (known !== undefined) return known;
onceMemo.set(fn, false); // a cycle is not once-only
let result = false;
const edges = edgesTo.get(fn) ?? [];
if (
infosByNode.has(fn) &&
!escapedNodes.has(fn) &&
edges.length > 0 &&
new Set(edges.map((e) => e.site)).size === 1
) {
result = edges.every(
(e) =>
!e.inLoop && e.caller !== null && !inLoop(e.site, e.caller.node) && once(e.caller.node),
);
}
onceMemo.set(fn, result);
return result;
};
// Whether every call of a function comes from once-only code outside
// loops, through any number of call sites: an init factory, whose
// allocations are one object per call site (schema builders called at
// module initialization).
const initOnlyMemo = new Map<Node, boolean>();
const initOnly = (fn: Node): boolean => {
if (ts.isSourceFile(fn)) return true;
const known = initOnlyMemo.get(fn);
if (known !== undefined) return known;
initOnlyMemo.set(fn, false);
const edges = edgesTo.get(fn) ?? [];
const result =
infosByNode.has(fn) &&
!escapedNodes.has(fn) &&
edges.length > 0 &&
edges.every(
(e) =>
!e.inLoop &&
e.caller !== null &&
!inLoop(e.site, e.caller.node) &&
(once(e.caller.node) || initOnly(e.caller.node)),
);
initOnlyMemo.set(fn, result);
return result;
};
const opsByAtom = new Map<Atom, Op[]>();
for (const op of analysis.ops) {
for (const a of analysis.nodes[op.recv]!.atoms) {
let l = opsByAtom.get(a);
if (l === undefined) opsByAtom.set(a, (l = []));
l.push(op);
}
}
const objects: ConstantObject[] = [];
const nearMisses: NearMiss[] = [];
const constantKeys = new Map<Atom, Set<string>>();
const candidates: Candidate[] = [];
for (const site of analysis.sites) {
if (site.kind !== "object") continue;
candidates.push({
atom: site.atom,
node: site.node,
kind: "literal",
keys: site.keys ?? [],
plain: !(
site.hasAccessor ||
site.hasComputedKey ||
site.spreads.length > 0 ||
site.keys === null
),
inPackage: site.inPackage,
});
}
const inPackage = (n: Node): boolean =>
npmStaticPackageOfPath(n.getSourceFile().fileName) !== null;
for (const atom of analysis.atoms.atoms) {
if (atom.kind !== "function" || atom.node === undefined) continue;
const hasFields = (analysis.fields.get(atom)?.size ?? 0) > 0;
const proto = analysis.protoOf.get(atom);
if (!hasFields && proto === undefined) continue;
candidates.push({
atom,
node: atom.node,
kind: "function",
keys: [],
plain: true,
inPackage: inPackage(atom.node),
});
if (proto !== undefined)
candidates.push({
atom: proto,
node: atom.node,
kind: "prototype",
keys: ["constructor"],
plain: true,
inPackage: inPackage(atom.node),
});
}
for (const site of candidates) {
// A function object is created where its expression or declaration
// runs; its prototype with it.
const owner = ownerFunction(site.node);
if (!once(owner) || inLoop(site.node, owner)) {
const factory = !inLoop(site.node, owner) && initOnly(owner);
if (site.inPackage || factory)
nearMisses.push({
site,
key: null,
rule: factory ? "init-factory" : "allocated-repeatedly",
detail: factory
? `one object per call of ${loc(owner)} (${new Set((edgesTo.get(owner) ?? []).map((e) => e.site)).size} call sites)`
: `created in ${owner.kind === ts.SyntaxKind.SourceFile ? "a module loop" : loc(owner)}`,
});
continue;
}
const escaped = analysis.escaped.get(site.atom);
if (escaped !== undefined) {
nearMisses.push({ site, key: null, rule: "escapes", detail: escaped });
continue;
}
if (!site.plain) {
nearMisses.push({
site,
key: null,
rule: "literal-form",
detail: "accessor, computed key or spread in the literal",
});
continue;
}
const ops = opsByAtom.get(site.atom) ?? [];
const demoting = ops.find((op) => OBJECT_DEMOTING.has(op.kind));
if (demoting !== undefined) {
nearMisses.push({
site,
key: null,
rule: demoting.kind === "computedWrite" ? "computed-write" : "reflective-write",
detail: `${demoting.kind} at ${loc(demoting.node)}`,
});
continue;
}
const frozen = ops.some((op) => op.kind === "freeze");
const stores = new Map<string, number>();
for (const k of site.keys) stores.set(k, 1);
// A function's own data fields come only from stores.
const demotedKeys = new Map<string, NearMiss>();
for (const op of ops) {
// A `length` store on an object (not an array) is an ordinary write.
if ((op.kind !== "write" && op.kind !== "lengthWrite") || op.name === null) continue;
stores.set(op.name, (stores.get(op.name) ?? 0) + 1);
const fn = ownerFunction(op.node);
if (once(fn) && !inLoop(op.node, fn)) continue;
if (demotedKeys.has(op.name)) continue;
demotedKeys.set(op.name, {
site,
key: op.name,
rule: isCacheStore(op.node) ? "lazy-cache" : "written-after-init",
detail: loc(op.node),
});
}
for (const miss of demotedKeys.values()) nearMisses.push(miss);
const fieldMap = analysis.fields.get(site.atom) ?? new Map<string, number>();
const fields: ConstantField[] = [];
const keys = new Set<string>();
for (const [key, count] of stores) {
if (demotedKeys.has(key)) continue;
const id = fieldMap.get(key);
const values = id === undefined ? [] : [...analysis.nodes[id]!.atoms];
fields.push({ key, values, stores: count });
keys.add(key);
}
constantKeys.set(site.atom, keys);
if (demotedKeys.size === 0) objects.push({ site, fields, frozen });
}
let foldableReads = 0,
foldableCalls = 0,
mixedReads = 0;
for (const op of analysis.ops) {
if (op.kind !== "read" || op.name === null) continue;
const atoms = analysis.nodes[op.recv]!.atoms;
const constant = [...atoms].filter((a) => constantKeys.get(a)?.has(op.name!) === true);
if (constant.length === 0) continue;
if (atoms.size === 1) {
foldableReads++;
const p = op.node.parent;
if (p !== undefined && ts.isCallExpression(p) && p.expression === op.node) foldableCalls++;
} else mixedReads++;
}
return { objects, nearMisses, foldableReads, foldableCalls, mixedReads, once };
}
/** Report lines (SCRIPTC_DEBUG_SHAPES=const): per package, the constant
* graphs with their roots, the near misses by rule, and the foldable reads. */
export function constantGraphReport(analysis: ShapeAnalysis, graphs: ConstantGraphs): string[] {
const lines: string[] = [];
const pkgOf = (site: Candidate): string =>
npmStaticPackageOfPath(site.node.getSourceFile().fileName) ?? "<user>";
const byPkg = new Map<string, { objects: ConstantObject[]; misses: NearMiss[] }>();
const entry = (pkg: string) => {
let e = byPkg.get(pkg);
if (e === undefined) byPkg.set(pkg, (e = { objects: [], misses: [] }));
return e;
};
for (const o of graphs.objects) entry(pkgOf(o.site)).objects.push(o);
for (const m of graphs.nearMisses) entry(pkgOf(m.site)).misses.push(m);
const constantAtoms = new Set(graphs.objects.map((o) => o.site.atom));
const referenced = new Set<Atom>();
for (const o of graphs.objects)
for (const f of o.fields)
if (f.values.length === 1 && constantAtoms.has(f.values[0]!)) referenced.add(f.values[0]!);
const describe = (o: ConstantObject, depth: number, seen: Set<Atom>): string => {
const parts = o.fields.map((f) => {
if (f.values.length !== 1)
return `${f.key}: ${f.values.length === 0 ? "?" : f.values.map((a) => a.label).join("|")}`;
const v = f.values[0]!;
const child = graphs.objects.find((x) => x.site.atom === v);
if (child !== undefined && depth < 3 && !seen.has(v)) {
seen.add(v);
return `${f.key}: ${describe(child, depth + 1, seen)}`;
}
return `${f.key}: ${v.kind === "function" && child === undefined ? "fn" : v.label}`;
});
const shown = parts.length > 8 ? [...parts.slice(0, 8), `… ${parts.length - 8} more`] : parts;
return `{${shown.join(", ")}}${o.frozen ? " (frozen)" : ""}`;
};
lines.push(
`analysis ${analysis.steps} steps${analysis.budgetExceeded ? " (BUDGET EXCEEDED: facts incomplete)" : ""}, ${analysis.files.length} files`,
);
lines.push(
`constant objects ${graphs.objects.length}, near misses ${graphs.nearMisses.length}; foldable reads ${graphs.foldableReads} (calls ${graphs.foldableCalls}), mixed-receiver reads ${graphs.mixedReads}`,
);
for (const [pkg, e] of [...byPkg].sort((a, b) => a[0].localeCompare(b[0]))) {
const fields = e.objects.reduce((n, o) => n + o.fields.length, 0);
const rules = new Map<string, number>();
for (const m of e.misses) rules.set(m.rule, (rules.get(m.rule) ?? 0) + 1);
lines.push(
`${pkg}: ${e.objects.length} constant objects (${fields} fields); near misses ${[...rules].map(([r, n]) => `${r} ${n}`).join(", ") || "none"}`,
);
for (const o of e.objects) {
if (referenced.has(o.site.atom)) continue;
lines.push(` graph ${loc(o.site.node)} ${describe(o, 0, new Set([o.site.atom]))}`);
}
for (const m of e.misses.filter((x) => x.rule !== "allocated-repeatedly").slice(0, 40)) {
lines.push(
` near miss ${loc(m.site.node)}${m.key === null ? "" : ` .${m.key}`}: ${m.rule} (${m.detail})`,
);
}
}
return lines;
}
@@ -0,0 +1,154 @@
/* Shape inference (results/design-shape-inference/DESIGN.md), stage S1:
* the abstract domain of the allocation-site points-to analysis.
*
* An abstract value is a set of atoms. An atom names a set of runtime
* values: a primitive kind, an allocation site (object or array literal, or
* a value a builtin creates at one call site), the instances of one class,
* one class value, one function (closure site), a builtin object, or ⊤ —
* every value the analysis does not track. Value nodes hold atom sets and
* flow into each other along inclusion edges; the solver (analyze.ts)
* attaches load, store and call constraints that fire as atoms arrive. */
import type * as ts from "../ts7/adapter.js";
export type AtomKind =
| "prim"
| "top"
| "object"
| "array"
| "instance"
| "class"
| "function"
| "builtin"
| "promise"
| "collection"
| "dictionary";
export interface Atom {
readonly id: number;
readonly kind: AtomKind;
/** Short display name, e.g. `string`, `ZodString`, `{status,value}@types.js:804`. */
readonly label: string;
/** The syntax that creates the atom: the literal, class or function
* declaration, or the builtin call. Undefined for prims, ⊤ and builtins. */
readonly node?: ts.Node | undefined;
/** builtin atoms: the dotted path (`Object.keys`, `array.push`). */
readonly path?: string | undefined;
/** method atoms of builtin receivers: the receiver atom. */
readonly receiver?: Atom | undefined;
/** function atoms: the analysis context of the function's clone (a
* method's receiver class), "" for the context-insensitive default. */
readonly ctx?: string | undefined;
}
export class AtomTable {
readonly atoms: Atom[] = [];
private readonly byKey = new Map<string, Atom>();
intern(key: string, make: (id: number) => Omit<Atom, "id">): Atom {
let atom = this.byKey.get(key);
if (atom === undefined) {
const id = this.atoms.length;
atom = { id, ...make(id) };
this.atoms.push(atom);
this.byKey.set(key, atom);
}
return atom;
}
}
/** Object.prototype members: a read of one of these names on a plain
* object resolves on the prototype, never to an absent own key. */
export const OBJECT_PROTOTYPE_MEMBERS: ReadonlySet<string> = new Set([
"constructor",
"hasOwnProperty",
"isPrototypeOf",
"propertyIsEnumerable",
"toLocaleString",
"toString",
"valueOf",
"__defineGetter__",
"__defineSetter__",
"__lookupGetter__",
"__lookupSetter__",
"__proto__",
]);
/** Global names the analysis models as builtin objects. Any other global is ⊤. */
export const MODELED_GLOBALS: ReadonlySet<string> = new Set([
"Object",
"Array",
"JSON",
"Math",
"Number",
"String",
"Boolean",
"Symbol",
"Promise",
"Map",
"Set",
"WeakMap",
"WeakSet",
"Error",
"TypeError",
"RangeError",
"SyntaxError",
"Date",
"RegExp",
"console",
"Reflect",
"parseInt",
"parseFloat",
"isNaN",
"isFinite",
"encodeURIComponent",
"decodeURIComponent",
"encodeURI",
"decodeURI",
"BigInt",
"Proxy",
]);
/** Results of string (and number) methods, by name. Anything else is ⊤. */
type MethodResult = "string" | "number" | "boolean" | "array";
export const STRING_METHOD_RESULTS: ReadonlyMap<string, MethodResult> = new Map<
string,
MethodResult
>([
["at", "string"],
["charAt", "string"],
["charCodeAt", "number"],
["codePointAt", "number"],
["concat", "string"],
["endsWith", "boolean"],
["includes", "boolean"],
["indexOf", "number"],
["lastIndexOf", "number"],
["localeCompare", "number"],
["match", "array"],
["matchAll", "array"],
["normalize", "string"],
["padEnd", "string"],
["padStart", "string"],
["repeat", "string"],
["replace", "string"],
["replaceAll", "string"],
["search", "number"],
["slice", "string"],
["split", "array"],
["startsWith", "boolean"],
["substring", "string"],
["substr", "string"],
["toLowerCase", "string"],
["toUpperCase", "string"],
["toLocaleLowerCase", "string"],
["toLocaleUpperCase", "string"],
["toString", "string"],
["trim", "string"],
["trimEnd", "string"],
["trimStart", "string"],
["valueOf", "string"],
["test", "boolean"],
["toFixed", "string"],
["toPrecision", "string"],
["toExponential", "string"],
]);
@@ -0,0 +1,24 @@
/* Shape inference for untyped npm JavaScript
* (results/design-shape-inference/DESIGN.md). Stage S1: report only. */
import type * as ts from "../ts7/adapter.js";
import { analyzeShapes } from "./analyze.js";
import { buildShapeReport } from "./report.js";
import { computeConstantGraphs, constantGraphReport } from "./constant-graphs.js";
export { analyzeShapes } from "./analyze.js";
export { buildShapeReport } from "./report.js";
/** SCRIPTC_DEBUG_SHAPES: `1` reports package code in hot functions,
* `verbose` every package site, `all` user files too, any other value only
* files whose path contains it. Writes to stderr; changes nothing else. */
export function reportShapes(program: ts.Program, filter: string): void {
const start = performance.now();
const analysis = analyzeShapes(program, program.getTypeChecker());
// `const`: the constant object graphs (DESIGN-constant-objects.md, stage 1).
const lines =
filter === "const"
? constantGraphReport(analysis, computeConstantGraphs(analysis))
: buildShapeReport(analysis, filter).lines;
const ms = (performance.now() - start).toFixed(0);
process.stderr.write(`[shapes] ${ms} ms\n${lines.map((l) => `[shapes] ${l}`).join("\n")}\n`);
}
@@ -0,0 +1,374 @@
/* Shape inference stage S1: the plan the analysis would hand the lowering,
* as a report (DESIGN.md §4.5, §5.5). Nothing here changes compilation.
*
* Per allocation site of package code, the verdict:
* P local — a native record would be exact: no reflection, no added
* keys, no computed writes, and its objects never escape;
* P escaping — exact as a record behind identity-keeping capsules: it
* escapes, but the census leaves every later operation
* answerable (fields untyped code may write become dyn);
* G <reason> — keeps today's dynamic object, with the rule that decided.
* Plus class field types, the census, and the hot functions' value types. */
import * as ts from "../ts7/adapter.js";
import { OBJECT_PROTOTYPE_MEMBERS, type Atom } from "./domain.js";
import { locLabel, type FunctionInfo, type LiteralSite, type ShapeAnalysis } from "./analyze.js";
export interface SiteVerdict {
readonly site: LiteralSite;
readonly label: string;
readonly verdict: "P-local" | "P-escaping" | "G";
readonly reasons: string[];
/** P-escaping: fields untyped code may write (they become dyn slots). */
readonly writableByTop: string[];
readonly freezable: boolean;
readonly brand: boolean;
}
export interface ShapeReport {
readonly sites: SiteVerdict[];
readonly hot: Set<FunctionInfo>;
readonly lines: string[];
}
const MAX_ATOMS_SHOWN = 6;
function fieldKeys(fields: ReadonlyMap<string, number> | undefined): string[] {
return fields === undefined ? [] : [...fields.keys()];
}
function fieldEntries(fields: ReadonlyMap<string, number> | undefined): [string, number][] {
return fields === undefined ? [] : [...fields.entries()];
}
export function describeValue(analysis: ShapeAnalysis, node: number): string {
const atoms = [...analysis.nodes[node]!.atoms];
if (atoms.length === 0) return "∅";
const labels = [...new Set(atoms.map((a) => a.label))];
labels.sort((a, b) => (a === "⊤" ? 1 : b === "⊤" ? -1 : a.localeCompare(b)));
const shown = labels.slice(0, MAX_ATOMS_SHOWN).join(" | ");
if (labels.length <= MAX_ATOMS_SHOWN) return shown;
return `${shown} | +${labels.length - MAX_ATOMS_SHOWN}${labels.includes("⊤") ? " | ⊤" : ""}`;
}
function atomsOf(analysis: ShapeAnalysis, node: number): Set<Atom> {
return analysis.nodes[node]!.atoms;
}
/** Functions reached from call sites in loops (or loop callbacks), and
* everything they call: the static hotness heuristic (DESIGN.md §8.1). */
export function hotFunctions(analysis: ShapeAnalysis): Set<FunctionInfo> {
const hot = new Set<FunctionInfo>();
const callees = new Map<FunctionInfo | null, FunctionInfo[]>();
const work: FunctionInfo[] = [];
for (const c of analysis.calls) {
let l = callees.get(c.caller);
if (l === undefined) callees.set(c.caller, (l = []));
l.push(c.callee);
if (c.inLoop && !hot.has(c.callee)) {
hot.add(c.callee);
work.push(c.callee);
}
}
while (work.length > 0) {
const f = work.pop()!;
for (const g of callees.get(f) ?? []) {
if (!hot.has(g)) {
hot.add(g);
work.push(g);
}
}
}
return hot;
}
export function buildShapeReport(analysis: ShapeAnalysis, filter: string): ShapeReport {
const lines: string[] = [];
const all = filter === "all" || filter === "verbose";
const verbose = filter === "verbose";
const fileFilter = filter !== "1" && !all ? filter : null;
const wanted = (n: ts.Node, inPackage: boolean): boolean => {
if (fileFilter !== null) return n.getSourceFile().fileName.includes(fileFilter);
return all || inPackage;
};
// Census facts: operations whose receiver may be ⊤ reach every escaped atom.
// Index ops by the site atoms they can reach directly.
const byAtom = new Map<Atom, typeof analysis.ops>();
const topOps: typeof analysis.ops = [];
for (const o of analysis.ops) {
const atoms = atomsOf(analysis, o.recv);
for (const a of atoms) {
if (a.kind !== "object" && a.kind !== "array") continue;
let l = byAtom.get(a);
if (l === undefined) byAtom.set(a, (l = []));
l.push(o);
}
if (atoms.has(analysis.top)) topOps.push(o);
}
// Key-order groups: sites with the same key set and different orders need brands.
const orders = new Map<string, Set<string>>();
for (const s of analysis.sites) {
if (s.kind !== "object" || s.keys === null) continue;
const set = [...s.keys].sort().join(",");
let o = orders.get(set);
if (o === undefined) orders.set(set, (o = new Set()));
o.add(s.keys.join(","));
}
const verdicts: SiteVerdict[] = [];
for (const s of analysis.sites) {
if (!s.inPackage && !all && fileFilter === null) continue;
const reasons: string[] = [];
const escapedWhy = analysis.escaped.get(s.atom);
const escapes = escapedWhy !== undefined;
if (s.hasAccessor) reasons.push("accessor in literal");
if (s.hasProto) reasons.push(s.kind === "array" ? "hole in literal" : "__proto__ in literal");
if (s.hasComputedKey) reasons.push("computed key in literal");
for (const sp of s.spreads)
if (atomsOf(analysis, sp).has(analysis.top)) reasons.push("spread of ⊤");
const direct = byAtom.get(s.atom) ?? [];
const ops = escapes ? [...direct, ...topOps] : direct;
const keys = new Set(s.keys ?? []);
const fieldsOf = analysis.fields.get(s.atom);
const knownKeys =
s.keys !== null ? keys : new Set([...fieldKeys(fieldsOf)].filter((k) => k !== "*"));
let freezable = false;
const seen = new Set<string>();
const note = (r: string): void => {
if (!seen.has(r)) {
seen.add(r);
reasons.push(r);
}
};
for (const o of ops) {
const viaTop = !atomsOf(analysis, o.recv).has(s.atom);
const where = `${locLabel(o.node)}${viaTop ? " via ⊤" : ""}`;
switch (o.kind) {
case "delete":
case "defineProperty":
case "setPrototypeOf":
case "seal":
case "proxy":
note(`${o.kind} at ${where}`);
break;
case "assign":
note(`Object.assign target at ${where}`);
break;
case "freeze":
freezable = true;
break;
case "computedWrite":
if (!viaTop)
note(s.kind === "array" ? `index write at ${where}` : `computed write at ${where}`);
break;
case "lengthWrite":
if (s.kind === "array") note(`length write at ${where}`);
break;
case "write":
if (s.kind === "object" && !viaTop && o.name !== null && !knownKeys.has(o.name))
note(`adds key '${o.name}' at ${where}`);
break;
case "read":
if (
s.kind === "object" &&
!underPromiseGuard(o.node) &&
escapes &&
o.name !== null &&
!viaTop &&
!knownKeys.has(o.name) &&
!OBJECT_PROTOTYPE_MEMBERS.has(o.name)
)
note(`reads absent key '${o.name}' at ${where} (escapes)`);
break;
default:
break;
}
}
let brand = false;
if (s.kind === "object" && s.keys !== null) {
const o = orders.get([...s.keys].sort().join(","));
if (o !== undefined && o.size > 1) brand = true;
}
const writableByTop: string[] = [];
if (escapes) {
if (analysis.topComputedWrites.length > 0) writableByTop.push("*");
else for (const k of knownKeys) if (analysis.topWriteNames.has(k)) writableByTop.push(k);
}
const verdict: SiteVerdict["verdict"] =
reasons.length > 0 ? "G" : escapes ? "P-escaping" : "P-local";
const label = s.atom.label;
if (escapes) reasons.push(`escapes: ${escapedWhy}`);
verdicts.push({ site: s, label, verdict, reasons, writableByTop, freezable, brand });
}
const hot = hotFunctions(analysis);
const hotNodes = new Set<ts.Node>([...hot].map((f) => f.node));
// ---- print ----
const pkgFns = [...analysis.functions.values()].filter((f) => f.inPackage);
lines.push(
`analyzed ${analysis.files.length} files (${analysis.packageFiles} package JS), ${analysis.functions.size} functions (${pkgFns.length} in packages), ${analysis.atoms.atoms.length} atoms, ${analysis.nodes.length} value nodes, ${analysis.steps} steps${analysis.budgetExceeded ? " — BUDGET EXCEEDED, results incomplete" : ""}`,
);
const topNames = [...analysis.topWriteNames.entries()].map(
([n, node]) => `${n} (${locLabel(node)})`,
);
lines.push(
`census: writes on ⊤ receivers: ${topNames.length === 0 ? "none" : topNames.slice(0, 20).join(", ")}${topNames.length > 20 ? ` +${topNames.length - 20}` : ""}`,
);
lines.push(
`census: computed writes on ⊤ receivers: ${analysis.topComputedWrites.length === 0 ? "none" : analysis.topComputedWrites.slice(0, 8).map(locLabel).join(", ")}`,
);
const topReflect = topOps.filter(
(o) =>
o.kind !== "read" &&
o.kind !== "computedRead" &&
o.kind !== "write" &&
o.kind !== "computedWrite",
);
lines.push(
`census: reflection on ⊤ receivers: ${topReflect.length === 0 ? "none" : topReflect.map((o) => `${o.kind} (${locLabel(o.node)})`).join(", ")}`,
);
const escapedPkg = [...analysis.escaped.keys()].filter(
(a) =>
a.node !== undefined &&
wanted(a.node, a.node.getSourceFile().fileName.includes("/node_modules/")),
);
lines.push(
`escaped atoms: ${analysis.escaped.size} (${escapedPkg.length} shown below where relevant)`,
);
const firstEscapes = [...analysis.escaped.entries()]
.filter(([, why]) => !why.startsWith("via "))
.slice(0, 12);
for (const [atom, why] of firstEscapes) lines.push(` escape root: ${atom.label}: ${why}`);
const counts = { "P-local": 0, "P-escaping": 0, G: 0 };
const hotCounts = { "P-local": 0, "P-escaping": 0, G: 0 };
const inHot = (n: ts.Node): boolean => {
let p: ts.Node | undefined = n.parent;
while (p !== undefined) {
if (hotNodes.has(p)) return true;
p = p.parent;
}
return false;
};
for (const v of verdicts) {
counts[v.verdict]++;
if (inHot(v.site.node)) hotCounts[v.verdict]++;
}
lines.push(
`sites: ${verdicts.length} (P local ${counts["P-local"]}, P escaping ${counts["P-escaping"]}, G ${counts.G}); in hot functions: P local ${hotCounts["P-local"]}, P escaping ${hotCounts["P-escaping"]}, G ${hotCounts.G}`,
);
for (const v of verdicts) {
if (!wanted(v.site.node, v.site.inPackage)) continue;
if (!verbose && !inHot(v.site.node)) continue;
const extra: string[] = [];
if (v.brand) extra.push("needs brand (key order)");
if (v.freezable) extra.push("freezable");
if (v.writableByTop.length > 0) extra.push(`⊤-writable: ${v.writableByTop.join(",")}`);
const fields = analysis.fields.get(v.site.atom);
const ftypes =
v.site.kind === "array"
? `elem: ${analysis.elements.has(v.site.atom) ? describeValue(analysis, analysis.elements.get(v.site.atom)!) : "∅"}`
: fieldEntries(fields)
.filter(([k]) => k !== "*")
.map(([k, id]) => `${k}: ${describeValue(analysis, id)}`)
.join("; ");
lines.push(
`site ${v.label}${inHot(v.site.node) ? " [hot]" : ""}: ${v.verdict}${v.reasons.length > 0 ? ` — ${v.reasons.join("; ")}` : ""}${extra.length > 0 ? ` [${extra.join("; ")}]` : ""}`,
);
if (ftypes.length > 0) lines.push(` ${ftypes}`);
}
// Class fields.
for (const atom of analysis.atoms.atoms) {
if (atom.kind !== "instance" || atom.node === undefined) continue;
const inPackage = atom.node.getSourceFile().fileName.includes("/node_modules/");
if (!wanted(atom.node, inPackage)) continue;
const fm = analysis.fields.get(atom);
if (fm === undefined || fm.size === 0) continue;
const esc = analysis.escaped.get(atom);
const parts = [...fm.entries()].map(([k, id]) => {
const w =
esc !== undefined &&
!k.startsWith("#") &&
(analysis.topComputedWrites.length > 0 || analysis.topWriteNames.has(k))
? " (⊤-writable)"
: "";
return `${k}: ${describeValue(analysis, id)}${w}`;
});
lines.push(
`class ${atom.label}${esc !== undefined ? ` (escapes: ${esc})` : ""}: ${parts.join("; ")}`,
);
}
// Hot functions.
const hotPkg = [...hot].filter((info) => wanted(info.node, info.inPackage));
lines.push(`hot functions: ${hotPkg.length} (clones per receiver class counted separately)`);
for (const info of hotPkg) {
const n = info.node;
const params = info.params.map((p, i) => `p${i}=${describeValue(analysis, p)}`).join(", ");
const flags = [
info.usesArguments ? "uses arguments" : "",
analysis.escaped.size > 0 && [...analysis.escaped.keys()].some((a) => a.node === n)
? "escapes"
: "",
].filter((x) => x !== "");
lines.push(
`fn ${info.label}${flags.length > 0 ? ` [${flags.join(", ")}]` : ""}: this=${describeValue(analysis, info.thisNode)}${params.length > 0 ? `, ${params}` : ""}, ret=${describeValue(analysis, info.ret)}`,
);
}
const why = process.env["SCRIPTC_DEBUG_SHAPES_WHY"];
if (why !== undefined) {
for (const info of analysis.functions.values()) {
if (!info.label.startsWith(why)) continue;
for (const [what, node] of [
["ret", info.ret],
...info.params.map((p, i) => [`p${i}`, p] as const),
] as const) {
if (!analysis.nodes[node]!.atoms.has(analysis.top)) continue;
lines.push(`why ⊤ in ${info.label} ${what}:`);
let cur: number | string | undefined = node;
for (let i = 0; i < 40 && typeof cur === "number"; i++) {
lines.push(` ${analysis.nodeLabel.get(cur) ?? `node ${cur}`}`);
cur = analysis.topCause.get(cur);
}
if (typeof cur === "string") lines.push(` seed: ${cur}`);
}
}
}
return { sites: verdicts, hot, lines };
}
/** A read inside the true arm of a promise test (`isAsync(x)`,
* `x instanceof Promise`): flow-sensitive narrowing removes the record
* arms there. Stage S1's locals are flow-insensitive, so the report
* recognizes the guard syntactically. */
function underPromiseGuard(node: ts.Node): boolean {
let child: ts.Node = node;
let p = node.parent;
while (p !== undefined && !ts.isFunctionLike(p)) {
let cond: ts.Expression | undefined;
if (ts.isIfStatement(p) && p.thenStatement === child) cond = p.expression;
if (ts.isConditionalExpression(p) && p.whenTrue === child) cond = p.condition;
if (cond !== undefined && isPromiseTest(cond)) return true;
child = p;
p = p.parent;
}
return false;
}
function isPromiseTest(e: ts.Expression): boolean {
if (ts.isParenthesizedExpression(e)) return isPromiseTest(e.expression);
if (ts.isBinaryExpression(e)) {
if (e.operatorToken.kind === ts.SyntaxKind.InstanceOfKeyword)
return ts.isIdentifier(e.right) && e.right.text === "Promise";
if (e.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken)
return isPromiseTest(e.left) || isPromiseTest(e.right);
return false;
}
if (ts.isCallExpression(e) && ts.isIdentifier(e.expression))
return /^is(Async|Promise|Thenable)$/.test(e.expression.text);
return false;
}
@@ -1,8 +1,10 @@
import { expect, test } from "vitest";
import { tokenEquivalentSource } from "./token-equivalence.js";
import { tokenEquivalentSource, tokenPositionEquivalentSource } from "./token-equivalence.js";
const equivalent = (previous: string, current: string, path = "/src/a.ts") =>
tokenEquivalentSource(path, previous, current);
const positionEquivalent = (previous: string, current: string, path = "/src/a.ts") =>
tokenPositionEquivalentSource(path, previous, current);
test("whitespace and plain comments are trivia", () => {
expect(equivalent("const a = 1;\n", "const a = 1; // one\n")).toBe(true);
@@ -105,3 +107,50 @@ test("text the scanner cannot classify is never equivalent", () => {
expect(equivalent("const a = <b/>; // a\n", "const a = <b/>; // b\n", "/src/a.tsx")).toBe(false);
expect(equivalent('const s = "open\n', 'const s = "open // \n')).toBe(false);
});
test("a / after a non-null assertion divides", () => {
expect(
equivalent("const q = data[i]! / 7; // ratio\n", "const q = data[i]! / 7; // the ratio\n"),
).toBe(true);
expect(
equivalent("const q = value! / 7; // ratio\n", "const q = value! / 7; // the ratio\n"),
).toBe(true);
expect(equivalent("const q = this! / 7; // ratio\n", "const q = this! / 7; // the ratio\n")).toBe(
true,
);
// A prefix `!` negates a regular expression, whose `//` is code.
expect(
equivalent("const t = !/[//]/.test(s); c = 1;\n", "const t = !/[//]/.test(s); c = 2;\n"),
).toBe(false);
expect(equivalent("return !/[//]/.test(s); // one\n", "return !/[//]/.test(s); // two\n")).toBe(
true,
);
// After a line break, `!` starts a new expression.
expect(equivalent("a\n!/[//]/.test(s); c = 1;\n", "a\n!/[//]/.test(s); c = 2;\n")).toBe(false);
// `f()!` and `if (a) !` read the same up to the `/`.
expect(equivalent("const q = f()! / 2; // one\n", "const q = f()! / 2; // two\n")).toBe(false);
});
test("position equivalence also keeps every token's line and column", () => {
const source = "const a = 1; // one\nconst b = a; /* two */ f(b);\n";
// A comment's text within its line, after the line's last token.
expect(
positionEquivalent(source, "const a = 1; // the first\nconst b = a; /* two */ f(b);\n"),
).toBe(true);
// Comments and whitespace after the last token, and trailing whitespace.
expect(positionEquivalent(source, source + "// appended\n\n")).toBe(true);
expect(
positionEquivalent(source, "const a = 1; // one\nconst b = a; /* two */ f(b); \n"),
).toBe(true);
expect(positionEquivalent(source, source.replaceAll("\n", "\r\n"))).toBe(true);
// A longer comment before a token moves its column; a new line moves the
// lines below it. Both stay token-equivalent.
const column = "const a = 1; // one\nconst b = a; /* the second */ f(b);\n";
const line = "// header\nconst a = 1; // one\nconst b = a; /* two */ f(b);\n";
expect(positionEquivalent(source, column)).toBe(false);
expect(positionEquivalent(source, line)).toBe(false);
expect(equivalent(source, column)).toBe(true);
expect(equivalent(source, line)).toBe(true);
// A token edit is never equivalent.
expect(positionEquivalent(source, source.replace("1", "2"))).toBe(false);
});
@@ -17,7 +17,10 @@
* before it. After `)` it divides unless the parenthesis closes an `if`,
* `while`, `for`, `for await`, or `with` header. After `}`, `++` or
* `--` the scanner cannot tell which, so it gives up, as it does for
* escaped identifiers, which can spell keywords.
* escaped identifiers, which can spell keywords. After `!` in
* TypeScript it divides when the `!` follows an operand on the same line
* (a non-null assertion, `x! / 2`) and gives up when that operand is a
* `)`, `}`, `++` or `--`.
* - A `/` with a line break (or a comment that spans lines) since the
* previous token gives up: whether automatic semicolon insertion ends the
* statement before it decides how it reads.
@@ -35,6 +38,8 @@ interface Token {
text: string;
comment: boolean;
lineBreakBefore: boolean;
/** The offset of the token's first code unit. */
start: number;
}
/** Keywords after which `/` starts a regular expression. */
@@ -135,14 +140,20 @@ function scan(path: string, source: string): Token[] | null {
// A line break, or a comment spanning lines, since the last token other
// than a comment.
let breakSinceCode = false;
// The previous token and the one before it, and whether a `)` closed a
// statement header.
const last: { token: string | null; before: string | null } = { token: null, before: null };
// The previous token and the one before it, whether a line break came
// before the previous token, and whether a `)` closed a statement header.
const last: { token: string | null; before: string | null; broke: boolean } = {
token: null,
before: null,
broke: false,
};
let closedHeader = false;
// Every token ends at `index` when it is pushed.
const push = (text: string, comment = false): void => {
tokens.push({ text, comment, lineBreakBefore });
tokens.push({ text, comment, lineBreakBefore, start: index - text.length });
lineBreakBefore = false;
if (!comment) {
last.broke = breakSinceCode;
breakSinceCode = false;
last.before = last.token;
last.token = text;
@@ -291,7 +302,19 @@ function scan(path: string, source: string): Token[] | null {
else if (before.startsWith("'") || before.startsWith('"') || before.endsWith("`"))
regex = false;
else if (before.startsWith("/") && before.length > 1 && before !== "/=") regex = false;
else regex = true;
else if (before === "!" && typeScript && !last.broke && last.before !== null) {
// `x! / 2` divides: a `!` right after an operand on the same line is
// a non-null assertion. `!/re/.test(s)` negates a regular expression.
const operand = last.before;
if (operand === ")" || operand === "}" || operand === "++" || operand === "--") return null;
regex = !(
operand === "]" ||
operand.startsWith("'") ||
operand.startsWith('"') ||
operand.endsWith("`") ||
(/^[A-Za-z0-9_$]/.test(operand) && !EXPRESSION_KEYWORDS.has(operand))
);
} else regex = true;
if (regex) {
const start = index;
index++;
@@ -373,9 +396,33 @@ function scan(path: string, source: string): Token[] | null {
return tokens;
}
/** Whether `current` differs from `previous` only in whitespace and in
* comments that cannot affect compilation. */
export function tokenEquivalentSource(path: string, previous: string, current: string): boolean {
/** The line and column of each token's start, with the line terminators
* source locations count (backend/source-locations.ts). */
function tokenPositions(source: string, tokens: readonly Token[]): number[] {
const positions: number[] = [];
let line = 0;
let lineStart = 0;
let offset = 0;
for (const token of tokens) {
for (; offset < token.start; offset++) {
const code = source.charCodeAt(offset);
if (code === 13 && source.charCodeAt(offset + 1) === 10) offset++;
if (code === 10 || code === 13 || code === 0x2028 || code === 0x2029) {
line++;
lineStart = offset + 1;
}
}
positions.push(line, token.start - lineStart);
}
return positions;
}
function equivalentSources(
path: string,
previous: string,
current: string,
positions: boolean,
): boolean {
if (previous === current) return true;
const left = scan(path, previous);
if (left === null) return false;
@@ -387,5 +434,27 @@ export function tokenEquivalentSource(path: string, previous: string, current: s
if (a.text !== b.text || a.comment !== b.comment) return false;
if (index > 0 && a.lineBreakBefore !== b.lineBreakBefore) return false;
}
return true;
if (!positions) return true;
const before = tokenPositions(previous, left);
const after = tokenPositions(current, right);
return before.every((value, index) => value === after[index]);
}
/** Whether `current` differs from `previous` only in whitespace and in
* comments that cannot affect compilation. */
export function tokenEquivalentSource(path: string, previous: string, current: string): boolean {
return equivalentSources(path, previous, current, false);
}
/** Whether `current` is token-equivalent to `previous` (tokenEquivalentSource)
* and every token keeps its line and column: the edit leaves the source
* positions debug information records unchanged (a comment's text within
* its line, comments and whitespace after the last token, trailing
* whitespace). */
export function tokenPositionEquivalentSource(
path: string,
previous: string,
current: string,
): boolean {
return equivalentSources(path, previous, current, true);
}
@@ -128,10 +128,20 @@ export const JS_RELAXED_TSC_CODES: ReadonlySet<number> = new Set([
// JavaScript subclasses may widen fields or add method parameters.
// Class lowering checks native storage and override ABIs at each use.
2415,
// a private or protected constructor (`new readline.Interface(...)`,
// whose declaration is protected): JavaScript has no constructor
// accessibility, and Node constructs the class. Each `new` meets the
// lowerer's per-site constructor fences.
2673,
2674,
// unresolvable modules: Node throws WHEN the require executes — the
// binding types as any and reached uses fence instead of passing unchecked
2307,
2792,
// `delete` of a property the inference typed required (2790): JS deletes
// any configurable property, and the lowering checks each delete's
// receiver itself
2790,
// useUnknownInCatchVariables' annotation demand ("'e' is of type
// 'unknown'", "Object is of type 'unknown'"): a JS catch clause CANNOT
// annotate its variable, so the demand is unsatisfiable in source —
+55 -8
View File
@@ -1747,9 +1747,17 @@ export function mapAsyncGeneratorAnnotation(type: ts.Type, ctx: TypeMapperCtx):
return { kind: "generator", async: true, ...channels };
}
/** The native fetch handles (Headers, Request, Response), optionally in
* arrays or with null/undefined: checked-dynamic handles wherever they are
* stored, like the worker handles above. */
const FETCH_HANDLE_NAMES: ReadonlySet<string> = new Set([
"Headers",
"Request",
"Response",
"AbortController",
"AbortSignal",
]);
/** The native fetch handles (Headers, Request, Response, AbortController,
* AbortSignal), optionally in arrays or with null/undefined: checked-dynamic
* handles wherever they are stored, like the worker handles above. */
export function isFetchHandleType(
type: ts.Type,
ctx: Pick<TypeMapperCtx, "checker" | "isStdlibFile">,
@@ -1765,9 +1773,7 @@ export function isFetchHandleType(
const symbol = member.getSymbol();
if (
!symbol ||
(ts.symbolName(symbol) !== "Headers" &&
ts.symbolName(symbol) !== "Request" &&
ts.symbolName(symbol) !== "Response") ||
!FETCH_HANDLE_NAMES.has(ts.symbolName(symbol)) ||
!symbol.declarations.some(
(d) =>
(ts.isInterfaceDeclaration(d) || ts.isClassDeclaration(d)) &&
@@ -7015,6 +7021,43 @@ function immutableSourceType(type: ts.Type, ctx: TypeMapperCtx): ts.Type | null
return null;
}
/** A mapped type reached through a type alias whose arguments mention no
* type parameter (`Record<never, number>`, `Catalog<typeof data, "image">`,
* a module-level alias without arguments):
* the checker computed its members from concrete inputs, so an empty
* result really is the empty object type, unlike `Partial<T>` inside a
* generic body. */
function resolvedMappedAlias(t: ts.Type, ctx: TypeMapperCtx): boolean {
if (!isMappedShape(t) || t.getAliasSymbol() === undefined) return false;
const args = t.getAliasTypeArguments();
const seen = new Set<ts.Type>();
const concrete = (part: ts.Type): boolean => {
if (part.flags & (ts.TypeFlags.TypeParameter | ts.TypeFlags.Index)) return false;
if (part.flags & (ts.TypeFlags.IndexedAccess | ts.TypeFlags.Conditional)) return false;
if (seen.has(part)) return true;
seen.add(part);
if (part.isUnionType() || part.isIntersectionType())
return ts.constituentTypes(part).every(concrete);
if (!part.getAliasTypeArguments().every(concrete)) return false;
return (
(part.flags & ts.TypeFlags.Object) === 0 ||
((part as ts.ObjectType).objectFlags & ts.ObjectFlags.Reference) === 0 ||
ctx.checker.getTypeArguments(part as ts.TypeReference).every(concrete)
);
};
// An alias without type arguments is concrete when it is declared at
// module level (a function body's alias may mention that function's type
// parameters).
if (args.length === 0)
return t
.getAliasSymbol()!
.declarations.every(
(d) =>
ts.isTypeAliasDeclaration(d) && (ts.isSourceFile(d.parent) || ts.isModuleBlock(d.parent)),
);
return args.every(concrete);
}
/** True for MAPPED-type results — `Partial<Config>`, `Record<"a", n>`,
* whatever `Pick`/`Omit` reduce to. Their shape is computed by the checker,
* not declared anywhere: the only declaration behind them is the utility
@@ -7969,8 +8012,12 @@ function mapRecordTypeInner(
// still unknown there) — interning `{}` would give a wrong type. The
// degenerate empties (`Partial<{}>`, `Omit<C, keyof C>`) go with it.
// An INDEX-SIGNATURE shape is exempt: `Record<string, T>` legitimately
// has zero declared members — the signature is the shape.
if (computed && props.length === 0 && !indexValue) return null;
// has zero declared members — the signature is the shape. So is a mapped
// alias instantiated over concrete arguments (`Record<never, T>`, a
// catalog type whose key filter keeps no key): the checker resolved it,
// and it is the empty object type below.
if (computed && props.length === 0 && !indexValue && !resolvedMappedAlias(widened, ctx))
return null;
// An empty object type — `{}` (spelled, the checker's shared
// intrinsic, or inferred from an empty object literal), `interface
// Empty {}` — is tsc's TOP type over non-nullish values: every number,
+6
View File
@@ -94,6 +94,7 @@ import { createNativeLinkPlan } from "./backend/link-plan.js";
import {
nativeProfileKey,
nativeProfileRefusal,
type NativeProfileMatch,
type NativeProfileOptions,
} from "./backend/native-profile.js";
import {
@@ -659,6 +660,7 @@ interface NativeProgramObject {
partitionPaths: string[];
artifactPath: string;
dependencies: NativeArtifactDependency[];
profileMatch?: NativeProfileMatch;
}
async function emitNativeProgramObject(
@@ -694,6 +696,7 @@ async function emitNativeProgramObject(
partitionPaths: artifact.outputPaths.slice(1),
artifactPath,
dependencies: artifact.dependencies,
...(artifact.profileMatch === undefined ? {} : { profileMatch: artifact.profileMatch }),
};
} catch (error) {
await removeNativeProgramObject(linkPath, nativePartitionPaths(linkPath, partitions));
@@ -875,6 +878,7 @@ async function prepareExecutableInput(
pointerBits: buildPlatform === "wasi" ? 32 : 64,
wasi: buildPlatform === "wasi",
runtimeAbiMarker: useRuntimePack,
unwindAbi: buildPlatform === "linux",
objectAudit: opts.sanitize === true,
lowRegion: lowRegionTarget(buildPlatform, opts.sanitize === true),
...(opts.sanitize === true ? { wasmBoundsChecks: true } : {}),
@@ -1851,6 +1855,7 @@ async function compileTracked(
}
await pruneBuildCache(cacheRoot);
timing("complete");
const profileMatch = nativeProgramObject?.profileMatch;
return {
ok: true,
artifact: {
@@ -1858,6 +1863,7 @@ async function compileTracked(
path: opts.outPath,
translationUnitPath: llvmPath,
backend,
...(profileMatch === undefined ? {} : { profileMatch }),
},
binaryPath: opts.outPath,
llvmPath,
@@ -591,6 +591,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
"util.styleText",
"util.toUSVString",
"util.inherits",
"util.inheritsPrototype",
"util.deprecate",
"util.callbackify",
"util.promisifyDyn",
@@ -976,6 +977,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
"assert.noErrorPredicate",
"assert.regexDynTest",
"assert.expectsErrDyn",
"assert.throwsValidate",
"assert.ifErrorErr",
"assert.ifErrorF64",
"assert.ifErrorStr",
@@ -1146,6 +1146,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
* arguments (scr_util_callable.c). The function results are native dyn
* wrappers; call sites with a static function type check them back. */
"util.inherits": { argTypes: [DYN, DYN], result: VOID },
"util.inheritsPrototype": { argTypes: [DYN, DYN, DYN], result: VOID },
"util.deprecate": { argTypes: [DYN, DYN, DYN, DYN], result: DYN },
"util.callbackify": { argTypes: [DYN], result: DYN },
/** util.promisify over an untyped function value: a native dyn function
@@ -2608,6 +2609,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
result: CHILD_T,
},
"child.connected": { argTypes: [CHILD_T], result: BOOL },
"child.hasChannel": { argTypes: [CHILD_T], result: BOOL },
"child.send": { argTypes: [CHILD_T, STRING], result: BOOL },
"child.sendCb": { argTypes: [CHILD_T, STRING, null], result: BOOL },
"child.disconnect": { argTypes: [CHILD_T], result: VOID },
@@ -4410,6 +4412,12 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
// The equality quartet over checked-dynamic operands (the frontend
// boxes a static side into the checked-dynamic tree first).
"assert.eqDyn": { argTypes: [DYN, DYN, BOOL, BOOL, STRING, BOOL], result: VOID },
/** A message argument that is not a string, registered for the next
* failing assertion (scr_assert_message_value). */
"assert.messageValue": { argTypes: [DYN, BOOL], result: VOID },
/** An assert method as a function value, by canonical name. */
"assert.methodValue": { argTypes: [STRING], result: DYN },
"assert.messageClear": { argTypes: [], result: VOID },
"assert.looseResult": { argTypes: [BOOL, BOOL, STRING, STRING, STRING, BOOL], result: VOID },
// The throws(fn, {shape}) accumulator: begin/slot calls never throw;
// shapeEnd throws the Comparison diff. The error slot is the
@@ -4435,6 +4443,8 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
* against the caught value's; a mismatch throws the deep-equal
* AssertionError (scr_assert.c). MAY THROW by design. */
"assert.expectsErrDyn": { argTypes: [DYN, DYN, STRING, BOOL], result: VOID },
/** A validation function against the caught value (Node's expectedException). */
"assert.throwsValidate": { argTypes: [DYN, DYN, STRING, STRING, BOOL], result: VOID },
// assert.ifError's typed entries (always throw; unit args never
// lower). The error slot is program-dependent — any %Error-hierarchy
// class, root or subclass (the insp.error precedent, null slot).
@@ -4873,6 +4883,15 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
* lib-call argument passes the time value itself, and any other use
* creates the Date. */
"date.fromTime": { argTypes: [F64], result: DATE_T },
/** Date.prototype's field setters over a time value
* (scr_date_set_fields): time value, setter code, argument count and four
* arguments; answers the new time value. */
"date.setFields": { argTypes: [F64, F64, F64, F64, F64, F64, F64], result: F64 },
/** TimeClip of a number (setTime's argument). */
"date.timeClip": { argTypes: [F64], result: F64 },
/** Stores a time value into a Date object and answers it (a setter's
* write). */
"date.store": { argTypes: [DATE_T, F64], result: F64 },
/** Date.prototype.toString / toDateString / toTimeString and String(date):
* V8's local-time formats with the host zone's long name, "Invalid Date"
* for an invalid date. Owned (+1) string; never throws. */

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