vercel-labs--zero-native
1516 行
63 KiB
TypeScript
1516 行
63 KiB
TypeScript
// R2 integer inference: JS semantics are f64 everywhere, but the emitter may
|
|
// use i64 wherever that is unobservable. A `number` slot (const, local, param,
|
|
// field, return) is emitted as i64 when:
|
|
//
|
|
// - it is PROVEN integer: every value flowing in is integer-valued
|
|
// (integer literals, lengths, byte reads, integer arithmetic), and the
|
|
// slot is not externally constructible; or
|
|
// - it is DEMANDED integer: some use requires an integer (memory index,
|
|
// bitwise op, equality with a proven-integer value), which is sound
|
|
// because f64 holds every integer the demand sites can produce exactly.
|
|
//
|
|
// Everything else stays f64, matching JS bit-for-bit. External slots are the
|
|
// app boundary: fields reachable from `Msg` and parameters of exported
|
|
// functions — the host may pass any f64 there, so proof never starts from
|
|
// them, only demand can claim them.
|
|
//
|
|
// A final demotion pass resolves mixed sites (e.g. `Math.max(K, x)` with x
|
|
// f64) by pushing undemanded integer slots to f64 so emitted arithmetic
|
|
// type-checks without casts. The same pass keeps every assignment edge
|
|
// same-typed by construction: a float-tainted slot demotes the undecided
|
|
// slots that flow into it, and a slot fed by a resolved-integer slot is
|
|
// demanded to match. When neither side can move — a fractional value flows
|
|
// into a slot some use forces to be an integer — that is a conflict reported
|
|
// as a teaching error (NS1016), never a downstream Zig type error.
|
|
|
|
import { ts, TypedAst } from "./typed_ast.ts";
|
|
import { constFunctionValue } from "./ownership.ts";
|
|
import { thrownShapeOf } from "./checker.ts";
|
|
import type { TypeTable } from "./types.ts";
|
|
|
|
/// The `return` expressions of a callback body, nested functions excluded —
|
|
/// the values a block-body callback can produce.
|
|
export function returnExpressionsOf(body: ts.Node): ts.Expression[] {
|
|
const out: ts.Expression[] = [];
|
|
const visit = (n: ts.Node): void => {
|
|
if (ts.isFunctionDeclaration(n) || ts.isArrowFunction(n) || ts.isFunctionExpression(n)) return;
|
|
if (ts.isReturnStatement(n) && n.expression) out.push(n.expression);
|
|
ts.forEachChild(n, visit);
|
|
};
|
|
visit(body);
|
|
return out;
|
|
}
|
|
|
|
/// Array methods whose callback carries the element index as its second
|
|
/// parameter in v1 (reduce's index is its third and stays unmapped).
|
|
export const indexCarryingMethods = new Set(["map", "filter", "find", "findIndex", "some", "every"]);
|
|
|
|
/// Array methods that accept a function-valued callback argument at all —
|
|
/// the positions where a bare reference to a module-level function inlines
|
|
/// like an arrow (`xs.map(encodeTurn)`).
|
|
export const callbackTakingMethods = new Set([
|
|
...indexCarryingMethods,
|
|
"reduce",
|
|
"sort",
|
|
"toSorted",
|
|
"forEach",
|
|
"flatMap",
|
|
]);
|
|
|
|
/// The receiver of `for (const [i, x] of xs.entries())` when the binding
|
|
/// element belongs to that loop form, else null.
|
|
function entriesLoopReceiver(el: ts.BindingElement): ts.Expression | null {
|
|
const pattern = el.parent;
|
|
if (!ts.isArrayBindingPattern(pattern)) return null;
|
|
const decl = pattern.parent;
|
|
if (!ts.isVariableDeclaration(decl) || !ts.isVariableDeclarationList(decl.parent)) return null;
|
|
const loop = decl.parent.parent;
|
|
if (!ts.isForOfStatement(loop)) return null;
|
|
let iter = loop.expression;
|
|
while (ts.isParenthesizedExpression(iter)) iter = iter.expression;
|
|
if (
|
|
!ts.isCallExpression(iter) ||
|
|
!ts.isPropertyAccessExpression(iter.expression) ||
|
|
iter.expression.name.text !== "entries"
|
|
) {
|
|
return null;
|
|
}
|
|
return iter.expression.expression;
|
|
}
|
|
|
|
export interface Slot {
|
|
readonly decl: ts.Node;
|
|
readonly label: string;
|
|
external: boolean;
|
|
/// A number parameter of an exported function — the ABI boundary the host
|
|
/// calls across. Genuine integer demand (index/bitwise chains) may still
|
|
/// claim it to i64 (the emitted signature IS the contract type), but a
|
|
/// mere comparison must not: the host may pass any f64, and an i64-claimed
|
|
/// signature would truncate what node would compare exactly.
|
|
hostBoundary: boolean;
|
|
float: boolean;
|
|
proven: boolean;
|
|
demanded: boolean;
|
|
/// The demand is TENTATIVE — claimed only so a comparison's two sides
|
|
/// land same-typed, never by a genuine integer requirement (index,
|
|
/// bitwise, size). A tentative claim yields when float later reaches the
|
|
/// slot: it demotes to f64 and the comparison widens, instead of
|
|
/// reporting a manufactured NS1016 conflict. Hard demand (`demandSlot`
|
|
/// with viaComparison=false) upgrades and clears this.
|
|
comparisonDemanded: boolean;
|
|
readonly inflows: Contribution[];
|
|
}
|
|
|
|
export interface Contribution {
|
|
readonly slots: Slot[];
|
|
/// A fractional literal, division, or other definitely-float source.
|
|
float: boolean;
|
|
/// A source the analysis cannot classify (kills proof, not demand).
|
|
unknown: boolean;
|
|
/// At least one definitely-integer source (literal, length, byte read).
|
|
intSource: boolean;
|
|
/// The expression the contribution was read from (conflict reporting).
|
|
readonly site: ts.Expression;
|
|
}
|
|
|
|
interface CompareSite {
|
|
readonly left: Contribution;
|
|
readonly right: Contribution;
|
|
}
|
|
|
|
/// An assignment or mix the fixed point could not make same-typed: a
|
|
/// fractional value meets a slot that some use forces to stay an integer.
|
|
export interface IntFloatConflict {
|
|
readonly node: ts.Node;
|
|
/// The integer-forced slot's source name, for the teaching message.
|
|
readonly slotLabel: string;
|
|
}
|
|
|
|
export class IntInference {
|
|
private readonly slots = new Map<ts.Node, Slot>();
|
|
private readonly compareSites: CompareSite[] = [];
|
|
private readonly mixSites: CompareSite[] = [];
|
|
private readonly demandSeeds: Contribution[] = [];
|
|
readonly conflicts: IntFloatConflict[] = [];
|
|
|
|
private readonly tast: TypedAst;
|
|
private readonly table: TypeTable;
|
|
/// The core's modules in canonical order; files[0] is the entry. Slots
|
|
/// and flows span all of them — declarationOf resolves across files, so
|
|
/// a call from core.ts into parsers.ts wires argument flows into the
|
|
/// callee's parameter slots exactly like a same-file call.
|
|
private readonly files: readonly ts.SourceFile[];
|
|
private readonly fileSet: Set<ts.SourceFile>;
|
|
|
|
constructor(tast: TypedAst, table: TypeTable, files: readonly ts.SourceFile[] | ts.SourceFile) {
|
|
this.tast = tast;
|
|
this.table = table;
|
|
this.files = Array.isArray(files) ? files : [files as ts.SourceFile];
|
|
this.fileSet = new Set(this.files);
|
|
this.collectCallbackReferencedFns();
|
|
this.collectSlots();
|
|
this.markExternal();
|
|
this.collectFlows();
|
|
this.resolve();
|
|
}
|
|
|
|
// ------------------------------------------------------------- slot setup
|
|
|
|
/// Module-level function declarations referenced BARE as an array-method
|
|
/// callback (`xs.map(encodeTurn)`). Their bodies inline at the use site
|
|
/// like arrows, so their number parameters read number-array elements —
|
|
/// always f64 (R2 covers slots, not elements) — and must classify like
|
|
/// arrow captures, not like ordinary called parameters.
|
|
private readonly callbackReferencedFns = new Set<ts.FunctionDeclaration>();
|
|
|
|
private collectCallbackReferencedFns(): void {
|
|
const visit = (node: ts.Node): void => {
|
|
if (
|
|
ts.isCallExpression(node) &&
|
|
ts.isPropertyAccessExpression(node.expression) &&
|
|
callbackTakingMethods.has(node.expression.name.text)
|
|
) {
|
|
for (const arg of node.arguments) {
|
|
if (!ts.isIdentifier(arg)) continue;
|
|
const decl = this.tast.declarationOf(arg);
|
|
if (decl && ts.isFunctionDeclaration(decl)) this.callbackReferencedFns.add(decl);
|
|
}
|
|
}
|
|
ts.forEachChild(node, visit);
|
|
};
|
|
for (const f of this.files) visit(f);
|
|
}
|
|
|
|
private numberish(node: ts.TypeNode | undefined): boolean {
|
|
if (!node) return false;
|
|
const t = this.table.resolveTypeNode(node);
|
|
return t.k === "number" || (t.k === "optional" && t.inner.k === "number");
|
|
}
|
|
|
|
private addSlot(decl: ts.Node, label: string): void {
|
|
if (!this.slots.has(decl)) {
|
|
this.slots.set(decl, {
|
|
decl,
|
|
label,
|
|
external: false,
|
|
hostBoundary: false,
|
|
float: false,
|
|
proven: true,
|
|
demanded: false,
|
|
comparisonDemanded: false,
|
|
inflows: [],
|
|
});
|
|
}
|
|
}
|
|
|
|
/// A slot that starts float: array-callback captures read number-array
|
|
/// elements, which are always emitted f64 (R2 covers slots, not elements).
|
|
/// A body that demands an integer from one is an NS1016 conflict.
|
|
private addFloatSlot(decl: ts.Node, label: string): void {
|
|
if (!this.slots.has(decl)) {
|
|
this.slots.set(decl, {
|
|
decl,
|
|
label,
|
|
external: false,
|
|
hostBoundary: false,
|
|
float: true,
|
|
proven: false,
|
|
demanded: false,
|
|
comparisonDemanded: false,
|
|
inflows: [],
|
|
});
|
|
}
|
|
}
|
|
|
|
private collectSlots(): void {
|
|
// Untyped `const x = <numeric expr>` locals detect numeric-ness by
|
|
// READING other slots (numericInit), so they resolve on a second,
|
|
// fixpoint pass: with multiple files a referenced field or function
|
|
// slot may be declared in a file visited later.
|
|
const untypedLocals: ts.VariableDeclaration[] = [];
|
|
const visit = (node: ts.Node): void => {
|
|
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
|
|
const list = node.parent;
|
|
if (ts.isVariableDeclarationList(list) && ts.isForOfStatement(list.parent)) {
|
|
// for...of bindings: byte elements are integer reads, number-array
|
|
// elements are f64 (R2 covers slots, not elements) — the same split
|
|
// as array-callback captures below.
|
|
const numberTyped = (this.tast.typeOf(node.name).flags & ts.TypeFlags.Number) !== 0;
|
|
if (numberTyped) {
|
|
if (this.tast.isBytesTyped(list.parent.expression)) this.addSlot(node, node.name.text);
|
|
else this.addFloatSlot(node, node.name.text);
|
|
}
|
|
} else if (this.numberish(node.type)) {
|
|
this.addSlot(node, node.name.text);
|
|
} else if (!node.type && node.initializer) {
|
|
untypedLocals.push(node);
|
|
}
|
|
} else if (ts.isParameter(node) && ts.isIdentifier(node.name)) {
|
|
if (ts.isArrowFunction(node.parent)) {
|
|
const numberTyped = node.type
|
|
? this.numberish(node.type)
|
|
: (this.tast.typeOf(node.name).flags & ts.TypeFlags.Number) !== 0;
|
|
if (numberTyped) {
|
|
// A reduce accumulator is a real slot — it receives the initial
|
|
// value and the body result (flows wired in collectFlows), so R2
|
|
// resolves its class like any local. An array-callback index
|
|
// parameter is the loop index, a proven integer. Element
|
|
// captures stay f64.
|
|
if (this.isReduceAccumulator(node.parent, node) || this.isCallbackIndexParam(node.parent, node)) {
|
|
this.addSlot(node, node.name.text);
|
|
} else {
|
|
this.addFloatSlot(node, node.name.text);
|
|
}
|
|
}
|
|
} else if (ts.isFunctionExpression(node.parent)) {
|
|
// A hoisted function-expression helper's number params are
|
|
// f64 like arrow captures — call flows are not wired, so the
|
|
// conservative JS-faithful class is the sound one.
|
|
if (this.numberish(node.type)) this.addFloatSlot(node, node.name.text);
|
|
} else if (ts.isFunctionDeclaration(node.parent) && this.callbackReferencedFns.has(node.parent)) {
|
|
// A module fn referenced bare as a callback inlines at that use
|
|
// site: its number params read array elements, which are f64.
|
|
if (this.numberish(node.type)) this.addFloatSlot(node, node.name.text);
|
|
} else if (this.numberish(node.type)) {
|
|
this.addSlot(node, node.name.text);
|
|
}
|
|
} else if (ts.isPropertySignature(node) && node.name && ts.isIdentifier(node.name)) {
|
|
if (this.numberish(node.type)) this.addSlot(node, node.name.text);
|
|
} else if (ts.isPropertyDeclaration(node) && node.name && ts.isIdentifier(node.name)) {
|
|
// R19: a class field is a slot like a record field, fed by its
|
|
// initializer and by `this.x = ...` writes (wired in collectFlows).
|
|
// An UNTYPED `static readonly K = 9` is a module const in class
|
|
// clothing: numeric-ness comes from its foldable initializer, like
|
|
// untyped module-const locals.
|
|
if (this.numberish(node.type)) this.addSlot(node, node.name.text);
|
|
else if (!node.type && node.initializer && this.tast.constEvalNumber(node.initializer) !== null) {
|
|
this.addSlot(node, node.name.text);
|
|
}
|
|
} else if (ts.isMethodDeclaration(node) && node.name && ts.isIdentifier(node.name)) {
|
|
if (this.numberish(node.type)) this.addSlot(node, `${node.name.text}()`);
|
|
} else if (ts.isFunctionDeclaration(node) && node.name) {
|
|
if (this.numberish(node.type)) this.addSlot(node, `${node.name.text}()`);
|
|
} else if (ts.isBindingElement(node) && ts.isIdentifier(node.name)) {
|
|
// `for (const [i, x] of xs.entries())`: the index is a proven
|
|
// integer (the loop index); the element splits like the plain
|
|
// for...of — byte reads are integers, number-array elements stay
|
|
// f64 (R2 covers slots, not elements).
|
|
const entriesRecv = entriesLoopReceiver(node);
|
|
if (entriesRecv !== null) {
|
|
const pattern = node.parent as ts.ArrayBindingPattern;
|
|
if (pattern.elements[0] === node) {
|
|
this.addSlot(node, node.name.text);
|
|
} else if ((this.tast.typeOf(node.name).flags & ts.TypeFlags.Number) !== 0) {
|
|
if (this.tast.isBytesTyped(entriesRecv)) this.addSlot(node, node.name.text);
|
|
else this.addFloatSlot(node, node.name.text);
|
|
}
|
|
ts.forEachChild(node, visit);
|
|
return;
|
|
}
|
|
// `const { total } = stats;` — a number-fielded binding is a real
|
|
// slot, classed by the field that feeds it (wired in collectFlows).
|
|
const f = this.destructuredField(node);
|
|
const ft = f?.type;
|
|
if (ft && (ft.k === "number" || (ft.k === "optional" && ft.inner.k === "number"))) {
|
|
this.addSlot(node, node.name.text);
|
|
}
|
|
}
|
|
ts.forEachChild(node, visit);
|
|
};
|
|
for (const file of this.files) visit(file);
|
|
// Fixpoint over the untyped locals: `const a = LIMIT` may reference a
|
|
// slot another untyped local (or a later file) introduces.
|
|
let added = true;
|
|
while (added) {
|
|
added = false;
|
|
for (const node of untypedLocals) {
|
|
if (this.slots.has(node)) continue;
|
|
if (this.numericInit(node.initializer!)) {
|
|
this.addSlot(node, (node.name as ts.Identifier).text);
|
|
added = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Untyped `const x = <numeric expr>` locals also get slots.
|
|
private numericInit(expr: ts.Expression): boolean {
|
|
if (ts.isParenthesizedExpression(expr)) return this.numericInit(expr.expression);
|
|
if (ts.isNumericLiteral(expr)) return true;
|
|
// `const z = (y = 5)`: the assignment's value is the number target's.
|
|
if (
|
|
ts.isBinaryExpression(expr) &&
|
|
ts.isIdentifier(expr.left) &&
|
|
(expr.operatorToken.kind === ts.SyntaxKind.EqualsToken ||
|
|
expr.operatorToken.kind === ts.SyntaxKind.PlusEqualsToken ||
|
|
expr.operatorToken.kind === ts.SyntaxKind.MinusEqualsToken ||
|
|
expr.operatorToken.kind === ts.SyntaxKind.AsteriskEqualsToken ||
|
|
expr.operatorToken.kind === ts.SyntaxKind.SlashEqualsToken ||
|
|
expr.operatorToken.kind === ts.SyntaxKind.PercentEqualsToken ||
|
|
expr.operatorToken.kind === ts.SyntaxKind.AsteriskAsteriskEqualsToken)
|
|
) {
|
|
const decl = this.tast.declarationOf(expr.left);
|
|
return decl !== undefined && this.slots.has(decl);
|
|
}
|
|
// `const n = ++count` / `count++` value positions read a number slot.
|
|
if (
|
|
ts.isPostfixUnaryExpression(expr) ||
|
|
(ts.isPrefixUnaryExpression(expr) &&
|
|
(expr.operator === ts.SyntaxKind.PlusPlusToken || expr.operator === ts.SyntaxKind.MinusMinusToken))
|
|
) {
|
|
return true;
|
|
}
|
|
if (
|
|
ts.isPrefixUnaryExpression(expr) &&
|
|
(expr.operator === ts.SyntaxKind.MinusToken ||
|
|
expr.operator === ts.SyntaxKind.PlusToken ||
|
|
expr.operator === ts.SyntaxKind.TildeToken)
|
|
) {
|
|
return expr.operator === ts.SyntaxKind.TildeToken || this.numericInit(expr.operand);
|
|
}
|
|
if (ts.isBinaryExpression(expr)) {
|
|
switch (expr.operatorToken.kind) {
|
|
case ts.SyntaxKind.PlusToken:
|
|
case ts.SyntaxKind.MinusToken:
|
|
case ts.SyntaxKind.AsteriskToken:
|
|
case ts.SyntaxKind.SlashToken:
|
|
case ts.SyntaxKind.PercentToken:
|
|
case ts.SyntaxKind.AsteriskAsteriskToken:
|
|
case ts.SyntaxKind.AmpersandToken:
|
|
case ts.SyntaxKind.BarToken:
|
|
case ts.SyntaxKind.CaretToken:
|
|
case ts.SyntaxKind.LessThanLessThanToken:
|
|
case ts.SyntaxKind.GreaterThanGreaterThanToken:
|
|
case ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken:
|
|
return true;
|
|
case ts.SyntaxKind.QuestionQuestionToken:
|
|
// `model.cursor ?? 0` is a number when a branch is numeric.
|
|
return this.numericInit(expr.left) || this.numericInit(expr.right);
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
if (ts.isCallExpression(expr)) {
|
|
// A reduce value is numeric when its initial value is (slot collection
|
|
// has not reached the accumulator parameter yet at this point).
|
|
if (
|
|
ts.isPropertyAccessExpression(expr.expression) &&
|
|
expr.expression.name.text === "reduce" &&
|
|
expr.arguments[1]
|
|
) {
|
|
return this.numericInit(expr.arguments[1]);
|
|
}
|
|
const c = this.contribution(expr);
|
|
return c.slots.length > 0 || c.intSource || c.float;
|
|
}
|
|
if (ts.isPropertyAccessExpression(expr)) {
|
|
if (expr.name.text === "length") return true;
|
|
return this.slotOfName(expr.name) !== undefined;
|
|
}
|
|
if (ts.isIdentifier(expr)) {
|
|
const decl = this.tast.declarationOf(expr);
|
|
return decl !== undefined && this.slots.has(decl);
|
|
}
|
|
if (ts.isElementAccessExpression(expr)) return true;
|
|
if (ts.isConditionalExpression(expr)) {
|
|
return this.numericInit(expr.whenTrue) || this.numericInit(expr.whenFalse);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// The record field a `const { a } = expr;` binding element reads,
|
|
/// resolved through the initializer's declared type — the table is keyed
|
|
/// by name, and NS1038 keeps type names unique across the core's files.
|
|
private destructuredField(el: ts.BindingElement): import("./types.ts").ZField | null {
|
|
const pattern = el.parent;
|
|
if (!ts.isObjectBindingPattern(pattern)) return null;
|
|
const varDecl = pattern.parent;
|
|
if (!ts.isVariableDeclaration(varDecl) || !varDecl.initializer) return null;
|
|
const t = this.tast.typeOf(varDecl.initializer);
|
|
const typeName = t.aliasSymbol?.name ?? t.symbol?.name;
|
|
const struct = typeName ? this.table.structs.get(typeName) : undefined;
|
|
const prop =
|
|
el.propertyName && ts.isIdentifier(el.propertyName)
|
|
? el.propertyName.text
|
|
: ts.isIdentifier(el.name)
|
|
? el.name.text
|
|
: null;
|
|
return struct?.fields.find((x) => x.tsName === prop) ?? null;
|
|
}
|
|
|
|
/// The second parameter of an arrow handed to an index-carrying array
|
|
/// method — the JS element index, a proven integer (the emitted loop
|
|
/// index widened to i64).
|
|
private isCallbackIndexParam(arrow: ts.ArrowFunction, param: ts.ParameterDeclaration): boolean {
|
|
if (arrow.parameters.length < 2 || arrow.parameters[1] !== param) return false;
|
|
const call = arrow.parent;
|
|
return (
|
|
ts.isCallExpression(call) &&
|
|
call.arguments[0] === arrow &&
|
|
ts.isPropertyAccessExpression(call.expression) &&
|
|
indexCarryingMethods.has(call.expression.name.text)
|
|
);
|
|
}
|
|
|
|
/// The first parameter of a two-parameter arrow handed to `.reduce`.
|
|
private isReduceAccumulator(arrow: ts.ArrowFunction, param: ts.ParameterDeclaration): boolean {
|
|
if (arrow.parameters.length !== 2 || arrow.parameters[0] !== param) return false;
|
|
const call = arrow.parent;
|
|
return (
|
|
ts.isCallExpression(call) &&
|
|
call.arguments[0] === arrow &&
|
|
ts.isPropertyAccessExpression(call.expression) &&
|
|
call.expression.name.text === "reduce"
|
|
);
|
|
}
|
|
|
|
/// External slots: the host constructs these — Msg-reachable number fields
|
|
/// and number parameters of exported functions.
|
|
private markExternal(): void {
|
|
const seen = new Set<string>();
|
|
const markType = (name: string): void => {
|
|
if (seen.has(name)) return;
|
|
seen.add(name);
|
|
const union = this.table.unions.get(name);
|
|
if (union) {
|
|
for (const arm of union.arms) {
|
|
for (const f of arm.fields) {
|
|
this.markField(f.decl, f.type.k);
|
|
this.markNested(f.type, markType);
|
|
}
|
|
}
|
|
}
|
|
const struct = this.table.structs.get(name);
|
|
if (struct) {
|
|
for (const f of struct.fields) {
|
|
this.markField(f.decl, f.type.k);
|
|
this.markNested(f.type, markType);
|
|
}
|
|
}
|
|
};
|
|
markType("Msg");
|
|
// Only the ENTRY module's exports face the host ABI (the wiring and
|
|
// markup bind through the entry). An exported function in an imported
|
|
// module is an ordinary cross-module call, so proof still applies.
|
|
for (const stmt of this.files[0].statements) {
|
|
if (ts.isFunctionDeclaration(stmt) && stmt.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) {
|
|
for (const p of stmt.parameters) {
|
|
const slot = this.slots.get(p);
|
|
if (slot) {
|
|
slot.external = true;
|
|
slot.hostBoundary = true;
|
|
slot.proven = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private markField(decl: ts.Node, _kind: string): void {
|
|
const slot = this.slots.get(decl);
|
|
if (slot) {
|
|
slot.external = true;
|
|
slot.proven = false;
|
|
}
|
|
}
|
|
|
|
private markNested(t: import("./types.ts").ZType, markType: (n: string) => void): void {
|
|
switch (t.k) {
|
|
case "struct":
|
|
case "union":
|
|
markType(t.name);
|
|
break;
|
|
case "slice":
|
|
this.markNested(t.elem, markType);
|
|
break;
|
|
case "optional":
|
|
this.markNested(t.inner, markType);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------- flow analysis
|
|
|
|
private slotOfNode(node: ts.Node): Slot | undefined {
|
|
return this.slots.get(node);
|
|
}
|
|
|
|
/// Whether an expression is an `import * as ns` alias (by its own symbol,
|
|
/// so shadowing locals do not count).
|
|
private isNamespaceAliasBase(e: ts.Expression): boolean {
|
|
if (!ts.isIdentifier(e)) return false;
|
|
const sym = this.tast.symbolOf(e);
|
|
const d = sym?.declarations?.[0];
|
|
return d !== undefined && ts.isNamespaceImport(d);
|
|
}
|
|
|
|
/// The class MethodDeclaration a property-access callee resolves to,
|
|
/// else null (R19 dispatch, shared by flows and contributions).
|
|
private methodDeclOf(callee: ts.PropertyAccessExpression): ts.MethodDeclaration | null {
|
|
const decl = this.tast.declarationOf(callee.name);
|
|
return decl && ts.isMethodDeclaration(decl) && ts.isClassDeclaration(decl.parent) ? decl : null;
|
|
}
|
|
|
|
private slotOfName(name: ts.Node): Slot | undefined {
|
|
const decl = this.tast.declarationOf(name);
|
|
if (decl) return this.slots.get(decl);
|
|
// A catch binding's field read (`e.at`): tsc types the binding as `any`
|
|
// here (no symbol resolves), but the subset types it as the core's
|
|
// thrown shape — resolve the field's declaration through it so payload
|
|
// reads share the field's own slot.
|
|
const p = name.parent;
|
|
if (
|
|
ts.isPropertyAccessExpression(p) &&
|
|
p.name === name &&
|
|
ts.isIdentifier(name) &&
|
|
this.isCatchBinding(p.expression)
|
|
) {
|
|
const fieldDecl = this.thrownFieldDecl(name.text);
|
|
if (fieldDecl) return this.slots.get(fieldDecl);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/// Whether an expression is a `catch (e)` binding (by declaration).
|
|
private isCatchBinding(e: ts.Expression): boolean {
|
|
let cur = e;
|
|
while (ts.isParenthesizedExpression(cur)) cur = cur.expression;
|
|
if (!ts.isIdentifier(cur)) return false;
|
|
const d = this.tast.declarationOf(cur);
|
|
return d !== undefined && ts.isVariableDeclaration(d) && ts.isCatchClause(d.parent);
|
|
}
|
|
|
|
/// The declaration of the thrown shape's field with this name (first
|
|
/// match across the thrown union's arms), memoized per inference run.
|
|
private thrownShapeMemo: import("./types.ts").ZType | null | undefined;
|
|
private thrownFieldDecl(name: string): ts.Declaration | null {
|
|
if (this.thrownShapeMemo === undefined) {
|
|
this.thrownShapeMemo = thrownShapeOf(this.tast, this.table, this.files).shape;
|
|
}
|
|
const shape = this.thrownShapeMemo;
|
|
if (!shape) return null;
|
|
if (shape.k === "union") {
|
|
for (const arm of this.table.unions.get(shape.name)?.arms ?? []) {
|
|
const f = arm.fields.find((x) => x.tsName === name);
|
|
if (f) return f.decl;
|
|
}
|
|
return null;
|
|
}
|
|
if (shape.k === "struct") {
|
|
return this.table.structs.get(shape.name)?.fields.find((x) => x.tsName === name)?.decl ?? null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// The number slots and source classes contributing to an expression.
|
|
contribution(expr: ts.Expression): Contribution {
|
|
const c: Contribution = { slots: [], float: false, unknown: false, intSource: false, site: expr };
|
|
this.contribute(expr, c);
|
|
return c;
|
|
}
|
|
|
|
private contribute(expr: ts.Expression, c: Contribution): void {
|
|
if (
|
|
ts.isParenthesizedExpression(expr) ||
|
|
ts.isAsExpression(expr) ||
|
|
ts.isNonNullExpression(expr) ||
|
|
ts.isSatisfiesExpression(expr)
|
|
) {
|
|
return this.contribute(expr.expression, c);
|
|
}
|
|
if (ts.isNumericLiteral(expr)) {
|
|
if (Number.isInteger(Number(expr.text)) && !expr.text.includes(".") && !/[eE]/.test(expr.text)) {
|
|
c.intSource = true;
|
|
} else {
|
|
c.float = true;
|
|
}
|
|
return;
|
|
}
|
|
if (ts.isPostfixUnaryExpression(expr)) {
|
|
// `i++` in value position: the value is the slot's own (pre-step).
|
|
return this.contribute(expr.operand, c);
|
|
}
|
|
if (ts.isPrefixUnaryExpression(expr)) {
|
|
if (expr.operator === ts.SyntaxKind.PlusPlusToken || expr.operator === ts.SyntaxKind.MinusMinusToken) {
|
|
return this.contribute(expr.operand, c);
|
|
}
|
|
if (expr.operator === ts.SyntaxKind.MinusToken || expr.operator === ts.SyntaxKind.PlusToken) {
|
|
// `-0` (and any negation folding to -0) is a float source: i64 has no
|
|
// signed zero, so an int-claimed slot would return +0 where node
|
|
// keeps -0 — the same family as the % fold rule below.
|
|
const folded = this.tast.constEvalNumber(expr);
|
|
if (folded !== null && Object.is(folded, -0)) {
|
|
c.float = true;
|
|
return;
|
|
}
|
|
return this.contribute(expr.operand, c);
|
|
}
|
|
if (expr.operator === ts.SyntaxKind.TildeToken) {
|
|
// `~x` is ToInt32 then bitwise-not: always a signed 32-bit integer.
|
|
c.intSource = true;
|
|
return;
|
|
}
|
|
return;
|
|
}
|
|
if (ts.isIdentifier(expr)) {
|
|
const slot = this.slotOfName(expr);
|
|
if (slot) {
|
|
c.slots.push(slot);
|
|
return;
|
|
}
|
|
// The NaN/Infinity globals are definitely-float values (a shadowing
|
|
// declaration in this module does not count).
|
|
if (expr.text === "NaN" || expr.text === "Infinity") {
|
|
const decl = this.tast.declarationOf(expr);
|
|
if (!decl || !this.fileSet.has(decl.getSourceFile())) c.float = true;
|
|
return;
|
|
}
|
|
// R15e: a slot typed by a TYPE PARAMETER has no inference slot; under
|
|
// an active instantiation scope it resolves — `T = number` slots are
|
|
// f64 (the JS-exact class), so they read as definitely-float here.
|
|
{
|
|
const decl = this.tast.declarationOf(expr);
|
|
const typeNode =
|
|
decl && (ts.isParameter(decl) || ts.isVariableDeclaration(decl)) ? decl.type : undefined;
|
|
if (typeNode) {
|
|
const t = this.table.resolveTypeNode(typeNode);
|
|
if (t.k === "f64" || (t.k === "optional" && t.inner.k === "f64")) c.float = true;
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (ts.isPropertyAccessExpression(expr)) {
|
|
if (expr.name.text === "length") {
|
|
c.intSource = true;
|
|
return;
|
|
}
|
|
const slot = this.slotOfName(expr.name);
|
|
if (slot) c.slots.push(slot);
|
|
return;
|
|
}
|
|
if (ts.isElementAccessExpression(expr)) {
|
|
// Byte reads are integer-valued, and numeric-literal-union elements
|
|
// (e.g. RunClass arrays) carry integer members. A plain number-ARRAY
|
|
// element is f64 — R2 covers slots, not elements — so it reads as a
|
|
// definitely-float source, the same rule as callback captures.
|
|
if (this.tast.isBytesTyped(expr.expression)) {
|
|
c.intSource = true;
|
|
return;
|
|
}
|
|
const t = this.tast.typeOf(expr);
|
|
const members = t.isUnion() ? t.types : [t];
|
|
if (members.every((m) => m.isNumberLiteral())) {
|
|
c.intSource = true;
|
|
return;
|
|
}
|
|
c.float = true;
|
|
return;
|
|
}
|
|
if (ts.isBinaryExpression(expr)) {
|
|
switch (expr.operatorToken.kind) {
|
|
case ts.SyntaxKind.SlashToken:
|
|
case ts.SyntaxKind.AsteriskAsteriskToken:
|
|
// Division is float always; `**` follows it (2 ** -2 is 0.25, and
|
|
// the NaN/Infinity corners are float-only values).
|
|
c.float = true;
|
|
this.contribute(expr.left, c);
|
|
this.contribute(expr.right, c);
|
|
return;
|
|
case ts.SyntaxKind.PercentToken: {
|
|
// A remainder that folds to a non-integer is a float source even
|
|
// over integer operands: a zero (or -0) divisor makes NaN, and a
|
|
// negative dividend can make -0 (both spelled f64Literal at emit).
|
|
const folded = this.tast.constEvalNumber(expr);
|
|
if (folded !== null && (!Number.isInteger(folded) || Object.is(folded, -0))) {
|
|
c.float = true;
|
|
return;
|
|
}
|
|
this.contribute(expr.left, c);
|
|
this.contribute(expr.right, c);
|
|
return;
|
|
}
|
|
case ts.SyntaxKind.PlusToken:
|
|
case ts.SyntaxKind.MinusToken:
|
|
case ts.SyntaxKind.AsteriskToken: {
|
|
// Constant arithmetic that folds to -0 (`0 * -1`) is a float
|
|
// source even over integer operands, same as the % rule below.
|
|
const folded = this.tast.constEvalNumber(expr);
|
|
if (folded !== null && Object.is(folded, -0)) {
|
|
c.float = true;
|
|
return;
|
|
}
|
|
this.contribute(expr.left, c);
|
|
this.contribute(expr.right, c);
|
|
return;
|
|
}
|
|
case ts.SyntaxKind.AmpersandToken:
|
|
case ts.SyntaxKind.BarToken:
|
|
case ts.SyntaxKind.CaretToken:
|
|
case ts.SyntaxKind.LessThanLessThanToken:
|
|
case ts.SyntaxKind.GreaterThanGreaterThanToken:
|
|
case ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken:
|
|
case ts.SyntaxKind.QuestionQuestionToken:
|
|
this.contribute(expr.left, c);
|
|
this.contribute(expr.right, c);
|
|
return;
|
|
case ts.SyntaxKind.EqualsToken:
|
|
case ts.SyntaxKind.PlusEqualsToken:
|
|
case ts.SyntaxKind.MinusEqualsToken:
|
|
case ts.SyntaxKind.AsteriskEqualsToken:
|
|
case ts.SyntaxKind.SlashEqualsToken:
|
|
case ts.SyntaxKind.PercentEqualsToken:
|
|
case ts.SyntaxKind.AsteriskAsteriskEqualsToken:
|
|
// An assignment in value position reads the target after the
|
|
// statement — the target slot's own value.
|
|
this.contribute(expr.left, c);
|
|
return;
|
|
default:
|
|
return; // comparisons/logic produce booleans
|
|
}
|
|
}
|
|
if (ts.isConditionalExpression(expr)) {
|
|
this.contribute(expr.whenTrue, c);
|
|
this.contribute(expr.whenFalse, c);
|
|
return;
|
|
}
|
|
if (ts.isCallExpression(expr)) {
|
|
const callee = expr.expression;
|
|
if (ts.isPropertyAccessExpression(callee) && this.isNamespaceAliasBase(callee.expression)) {
|
|
// `ns.helper()` contributes the target function's return slot.
|
|
const decl = this.tast.declarationOf(callee.name);
|
|
const ret = decl && ts.isFunctionDeclaration(decl) ? this.slots.get(decl) : undefined;
|
|
if (ret) c.slots.push(ret);
|
|
else c.unknown = true;
|
|
return;
|
|
}
|
|
if (ts.isPropertyAccessExpression(callee) && this.methodDeclOf(callee) !== null) {
|
|
// R19: a data-class method call contributes its return slot.
|
|
const ret = this.slots.get(this.methodDeclOf(callee)!);
|
|
if (ret) c.slots.push(ret);
|
|
else c.unknown = true;
|
|
return;
|
|
}
|
|
if (ts.isPropertyAccessExpression(callee)) {
|
|
const method = callee.name.text;
|
|
if (ts.isIdentifier(callee.expression) && callee.expression.text === "Math") {
|
|
// A Math call that folds at compile time contributes its folded JS
|
|
// value's own class — `Math.floor(5 / 2)` is the integer 2 even
|
|
// though the division inside is a float source.
|
|
const folded = this.tast.constEvalNumber(expr);
|
|
if (folded !== null) {
|
|
if (Number.isInteger(folded) && !Object.is(folded, -0)) c.intSource = true;
|
|
else c.float = true;
|
|
return;
|
|
}
|
|
if (method === "min" || method === "max") {
|
|
if (expr.arguments.length === 0) {
|
|
// Math.min() is Infinity, Math.max() is -Infinity.
|
|
c.float = true;
|
|
return;
|
|
}
|
|
for (const arg of expr.arguments) this.contribute(arg, c);
|
|
return;
|
|
}
|
|
if (method === "floor" || method === "ceil" || method === "trunc" || method === "abs" || method === "sign") {
|
|
// Integer-classed argument in, integer-classed value out (the
|
|
// identity/abs/sign of an integer). A float argument keeps the
|
|
// result float: floor(NaN) is NaN, floor(Infinity) is Infinity,
|
|
// so no integer type can hold the result soundly.
|
|
for (const arg of expr.arguments) this.contribute(arg, c);
|
|
return;
|
|
}
|
|
if (method === "round" || method === "sqrt") {
|
|
// Definitely-float sources: round/sqrt of NaN/Infinity is not an
|
|
// integer (and sqrt is irrational off perfect squares), so these
|
|
// live in f64 slots and an index use is a taught conflict. The
|
|
// argument stays unconstrained — an integer-classed read widens
|
|
// at the site.
|
|
c.float = true;
|
|
return;
|
|
}
|
|
c.unknown = true;
|
|
return;
|
|
}
|
|
if (method === "charCodeAt" || method === "indexOf" || method === "lastIndexOf" || method === "findIndex" || method === "length") {
|
|
c.intSource = true;
|
|
return;
|
|
}
|
|
if (method === "at") {
|
|
// Bytes `.at(i)` — byte values are integers (the same rule as a
|
|
// bytes element read; strings have no `.at` past the checker).
|
|
c.intSource = true;
|
|
return;
|
|
}
|
|
if (method === "reduce") {
|
|
// The value of a reduce IS its accumulator slot (fed by the initial
|
|
// value and the body result; flows wired in collectFlows).
|
|
const arrow = expr.arguments[0];
|
|
const accP = arrow !== undefined && ts.isArrowFunction(arrow) ? arrow.parameters[0] : undefined;
|
|
const slot = accP ? this.slots.get(accP) : undefined;
|
|
if (slot) {
|
|
c.slots.push(slot);
|
|
} else {
|
|
c.unknown = true;
|
|
}
|
|
return;
|
|
}
|
|
if (method === "find" || method === "pop" || method === "shift") {
|
|
// Array elements are f64-classed (R2 covers slots, not elements),
|
|
// so a find/pop/shift value reads as a definitely-float source in
|
|
// numeric positions — an index use downstream is a taught NS1016.
|
|
c.float = true;
|
|
return;
|
|
}
|
|
c.unknown = true;
|
|
return;
|
|
}
|
|
if (ts.isIdentifier(callee)) {
|
|
const decl = this.tast.declarationOf(callee);
|
|
if (decl && ts.isFunctionDeclaration(decl)) {
|
|
const ret = this.slots.get(decl);
|
|
if (ret) {
|
|
c.slots.push(ret);
|
|
return;
|
|
}
|
|
}
|
|
// A hoisted local helper's numbers emit f64 (its parameter slots
|
|
// are element-style float slots), so its call value reads float.
|
|
if (decl && ts.isVariableDeclaration(decl)) {
|
|
const fn = constFunctionValue(decl);
|
|
if (fn?.type && this.numberish(fn.type)) {
|
|
c.float = true;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
c.unknown = true;
|
|
return;
|
|
}
|
|
// Anything else (object/array literals, template strings) contributes no
|
|
// number value in a numeric position; mark unknown to stay conservative.
|
|
if (
|
|
ts.isObjectLiteralExpression(expr) ||
|
|
ts.isArrayLiteralExpression(expr) ||
|
|
ts.isTemplateExpression(expr) ||
|
|
ts.isStringLiteral(expr) ||
|
|
expr.kind === ts.SyntaxKind.NullKeyword ||
|
|
expr.kind === ts.SyntaxKind.TrueKeyword ||
|
|
expr.kind === ts.SyntaxKind.FalseKeyword ||
|
|
ts.isNewExpression(expr) ||
|
|
ts.isArrowFunction(expr)
|
|
) {
|
|
return;
|
|
}
|
|
c.unknown = true;
|
|
}
|
|
|
|
private flowInto(target: ts.Node | undefined, expr: ts.Expression): void {
|
|
if (!target) return;
|
|
const slot = this.slots.get(target);
|
|
if (!slot) return;
|
|
slot.inflows.push(this.contribution(expr));
|
|
}
|
|
|
|
private demand(expr: ts.Expression): void {
|
|
this.demandSeeds.push(this.contribution(expr));
|
|
}
|
|
|
|
private collectFlows(): void {
|
|
const enclosingFunction = (node: ts.Node): ts.Node | undefined => {
|
|
let cur: ts.Node | undefined = node.parent;
|
|
while (cur) {
|
|
if (
|
|
ts.isFunctionDeclaration(cur) ||
|
|
ts.isArrowFunction(cur) ||
|
|
ts.isFunctionExpression(cur) ||
|
|
ts.isMethodDeclaration(cur) ||
|
|
ts.isConstructorDeclaration(cur)
|
|
) {
|
|
return cur;
|
|
}
|
|
cur = cur.parent;
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
/// A class-field slot written through a property access (`this.x = e`,
|
|
/// `t.x = e`), else undefined — assignment flows wire into it exactly
|
|
/// like a local's.
|
|
const fieldAssignTarget = (left: ts.Expression): ts.Node | undefined => {
|
|
if (!ts.isPropertyAccessExpression(left)) return undefined;
|
|
const decl = this.tast.declarationOf(left.name);
|
|
return decl && ts.isPropertyDeclaration(decl) ? decl : undefined;
|
|
};
|
|
|
|
const visit = (node: ts.Node): void => {
|
|
if (ts.isVariableDeclaration(node) && node.initializer && ts.isIdentifier(node.name)) {
|
|
this.flowInto(node, node.initializer);
|
|
} else if (ts.isPropertyDeclaration(node) && node.initializer && ts.isIdentifier(node.name)) {
|
|
// R19: `count: number = 0` feeds the field slot like a local's init.
|
|
this.flowInto(node, node.initializer);
|
|
} else if (ts.isBindingElement(node) && ts.isIdentifier(node.name)) {
|
|
// `const { total } = stats;` — the local is fed by the record field's
|
|
// own slot, so its machine class follows the field's. An
|
|
// unresolvable base stays a conservative unknown (kills proof).
|
|
const slot = this.slots.get(node);
|
|
if (slot) {
|
|
const f = this.destructuredField(node);
|
|
const fieldSlot = f ? this.slots.get(f.decl) : undefined;
|
|
slot.inflows.push({
|
|
slots: fieldSlot ? [fieldSlot] : [],
|
|
float: false,
|
|
unknown: fieldSlot === undefined,
|
|
intSource: false,
|
|
site: node.name,
|
|
});
|
|
}
|
|
} else if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.TildeToken) {
|
|
// `~x` applies ToInt32: the operand is an integer-required position.
|
|
this.demand(node.operand);
|
|
} else if (ts.isBinaryExpression(node)) {
|
|
const op = node.operatorToken.kind;
|
|
if (op === ts.SyntaxKind.EqualsToken && ts.isIdentifier(node.left)) {
|
|
const decl = this.tast.declarationOf(node.left);
|
|
this.flowInto(decl, node.right);
|
|
} else if (op === ts.SyntaxKind.EqualsToken && fieldAssignTarget(node.left) !== undefined) {
|
|
this.flowInto(fieldAssignTarget(node.left), node.right);
|
|
} else if (
|
|
op === ts.SyntaxKind.PlusEqualsToken ||
|
|
op === ts.SyntaxKind.MinusEqualsToken ||
|
|
op === ts.SyntaxKind.AsteriskEqualsToken ||
|
|
op === ts.SyntaxKind.PercentEqualsToken ||
|
|
op === ts.SyntaxKind.QuestionQuestionEqualsToken
|
|
) {
|
|
if (ts.isIdentifier(node.left)) this.flowInto(this.tast.declarationOf(node.left), node.right);
|
|
else if (fieldAssignTarget(node.left) !== undefined) this.flowInto(fieldAssignTarget(node.left), node.right);
|
|
} else if (
|
|
op === ts.SyntaxKind.SlashEqualsToken ||
|
|
op === ts.SyntaxKind.AsteriskAsteriskEqualsToken
|
|
) {
|
|
// `x /= y` and `x **= y` assign a float-always result into x.
|
|
if (ts.isIdentifier(node.left)) {
|
|
const decl = this.tast.declarationOf(node.left);
|
|
const slot = decl ? this.slots.get(decl) : undefined;
|
|
if (slot) {
|
|
const c = this.contribution(node.right);
|
|
c.float = true;
|
|
slot.inflows.push(c);
|
|
}
|
|
}
|
|
} else if (
|
|
op === ts.SyntaxKind.AmpersandEqualsToken ||
|
|
op === ts.SyntaxKind.BarEqualsToken ||
|
|
op === ts.SyntaxKind.CaretEqualsToken ||
|
|
op === ts.SyntaxKind.LessThanLessThanEqualsToken ||
|
|
op === ts.SyntaxKind.GreaterThanGreaterThanEqualsToken ||
|
|
op === ts.SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken
|
|
) {
|
|
// Bitwise/shift compounds: both sides are integer-required, and
|
|
// the assigned result is a definite ToInt32/ToUint32 integer.
|
|
this.demand(node.left);
|
|
this.demand(node.right);
|
|
if (ts.isIdentifier(node.left)) {
|
|
const decl = this.tast.declarationOf(node.left);
|
|
const slot = decl ? this.slots.get(decl) : undefined;
|
|
if (slot) {
|
|
slot.inflows.push({ slots: [], float: false, unknown: false, intSource: true, site: node.right });
|
|
}
|
|
}
|
|
} else if (
|
|
op === ts.SyntaxKind.AmpersandToken ||
|
|
op === ts.SyntaxKind.BarToken ||
|
|
op === ts.SyntaxKind.CaretToken ||
|
|
op === ts.SyntaxKind.LessThanLessThanToken ||
|
|
op === ts.SyntaxKind.GreaterThanGreaterThanToken ||
|
|
op === ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken
|
|
) {
|
|
this.demand(node.left);
|
|
this.demand(node.right);
|
|
} else if (
|
|
op === ts.SyntaxKind.EqualsEqualsEqualsToken ||
|
|
op === ts.SyntaxKind.ExclamationEqualsEqualsToken ||
|
|
op === ts.SyntaxKind.LessThanToken ||
|
|
op === ts.SyntaxKind.LessThanEqualsToken ||
|
|
op === ts.SyntaxKind.GreaterThanToken ||
|
|
op === ts.SyntaxKind.GreaterThanEqualsToken
|
|
) {
|
|
this.compareSites.push({ left: this.contribution(node.left), right: this.contribution(node.right) });
|
|
} else if (
|
|
op === ts.SyntaxKind.PlusToken ||
|
|
op === ts.SyntaxKind.MinusToken ||
|
|
op === ts.SyntaxKind.AsteriskToken ||
|
|
op === ts.SyntaxKind.PercentToken
|
|
) {
|
|
this.mixSites.push({ left: this.contribution(node.left), right: this.contribution(node.right) });
|
|
}
|
|
} else if (ts.isElementAccessExpression(node)) {
|
|
this.demand(node.argumentExpression);
|
|
} else if (ts.isAsExpression(node) && ts.isObjectLiteralExpression(node.expression)) {
|
|
// `{ kind: "outer", n: err.n } as Fail` (throw payloads, mostly):
|
|
// the assertion IS the contextual type — wire each member into the
|
|
// asserted shape's field slot, arm-matched by the kind tag.
|
|
const t = this.table.resolveTypeNode(node.type);
|
|
let fields: readonly { tsName: string; decl: ts.Declaration }[] | null = null;
|
|
if (t.k === "struct") {
|
|
fields = this.table.structs.get(t.name)?.fields ?? null;
|
|
} else if (t.k === "union") {
|
|
const kindProp = node.expression.properties.find(
|
|
(pp): pp is ts.PropertyAssignment =>
|
|
ts.isPropertyAssignment(pp) && ts.isIdentifier(pp.name) && pp.name.text === "kind",
|
|
);
|
|
const tag = kindProp && ts.isStringLiteral(kindProp.initializer) ? kindProp.initializer.text : null;
|
|
fields = tag !== null ? (this.table.unions.get(t.name)?.arms.find((a) => a.tag === tag)?.fields ?? null) : null;
|
|
}
|
|
if (fields !== null) {
|
|
for (const pp of node.expression.properties) {
|
|
if (ts.isPropertyAssignment(pp) && ts.isIdentifier(pp.name)) {
|
|
const fld = fields.find((x) => x.tsName === (pp.name as ts.Identifier).text);
|
|
if (fld) this.flowInto(fld.decl, pp.initializer);
|
|
} else if (ts.isShorthandPropertyAssignment(pp)) {
|
|
const fld = fields.find((x) => x.tsName === pp.name.text);
|
|
const slot = fld ? this.slots.get(fld.decl) : undefined;
|
|
if (slot) {
|
|
const valueDecl = this.tast.shorthandValueDeclaration(pp);
|
|
const valueSlot = valueDecl ? this.slots.get(valueDecl) : undefined;
|
|
slot.inflows.push({
|
|
slots: valueSlot ? [valueSlot] : [],
|
|
float: false,
|
|
unknown: valueSlot === undefined,
|
|
intSource: false,
|
|
site: pp.name,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else if (ts.isPropertyAssignment(node)) {
|
|
const target = this.tast.contextualPropDecl(node);
|
|
this.flowInto(target, node.initializer);
|
|
} else if (ts.isShorthandPropertyAssignment(node)) {
|
|
const target = this.tast.contextualPropDecl(node as unknown as ts.PropertyAssignment);
|
|
if (target) {
|
|
const slot = this.slots.get(target);
|
|
if (slot) {
|
|
// The shorthand's name resolves to the PROPERTY symbol; the
|
|
// value flowing in is the local/param it reads, which needs its
|
|
// own symbol query. Unresolvable stays a conservative unknown
|
|
// (kills proof), never a phantom integer proof.
|
|
const valueDecl = this.tast.shorthandValueDeclaration(node);
|
|
const valueSlot = valueDecl ? this.slots.get(valueDecl) : undefined;
|
|
slot.inflows.push({
|
|
slots: valueSlot ? [valueSlot] : [],
|
|
float: false,
|
|
unknown: valueSlot === undefined,
|
|
intSource: false,
|
|
site: node.name,
|
|
});
|
|
}
|
|
}
|
|
} else if (ts.isReturnStatement(node) && node.expression) {
|
|
const fn = enclosingFunction(node);
|
|
if (fn) this.flowInto(fn, node.expression);
|
|
} else if (ts.isCallExpression(node)) {
|
|
const callee = node.expression;
|
|
if (ts.isIdentifier(callee)) {
|
|
const decl = this.tast.declarationOf(callee);
|
|
if (decl && ts.isFunctionDeclaration(decl)) {
|
|
decl.parameters.forEach((p, i) => {
|
|
const arg = node.arguments[i];
|
|
if (arg) this.flowInto(p, arg);
|
|
});
|
|
}
|
|
} else if (ts.isPropertyAccessExpression(callee) && this.isNamespaceAliasBase(callee.expression)) {
|
|
// `ns.helper(x)` — the namespace alias erases, so arguments flow
|
|
// into the target function's parameter slots exactly like a
|
|
// direct call.
|
|
const decl = this.tast.declarationOf(callee.name);
|
|
if (decl && ts.isFunctionDeclaration(decl)) {
|
|
decl.parameters.forEach((p, i) => {
|
|
const arg = node.arguments[i];
|
|
if (arg) this.flowInto(p, arg);
|
|
});
|
|
}
|
|
} else if (ts.isPropertyAccessExpression(callee) && this.methodDeclOf(callee) !== null) {
|
|
// R19: `t.method(x)` flows arguments into the method's parameter
|
|
// slots, exactly like a direct call.
|
|
this.methodDeclOf(callee)!.parameters.forEach((mp, i) => {
|
|
const arg = node.arguments[i];
|
|
if (arg) this.flowInto(mp, arg);
|
|
});
|
|
} else if (ts.isPropertyAccessExpression(callee)) {
|
|
const method = callee.name.text;
|
|
if (ts.isIdentifier(callee.expression) && callee.expression.text === "Math") {
|
|
if ((method === "min" || method === "max") && node.arguments.length >= 2) {
|
|
// Pairwise over consecutive arguments: demotion chains through,
|
|
// so one float argument floats the whole site.
|
|
for (let i = 0; i + 1 < node.arguments.length; i++) {
|
|
this.mixSites.push({
|
|
left: this.contribution(node.arguments[i]),
|
|
right: this.contribution(node.arguments[i + 1]),
|
|
});
|
|
}
|
|
}
|
|
} else if (method === "subarray" || method === "slice" || method === "set" || method === "charCodeAt" || method === "repeat" || method === "at") {
|
|
// repeat's count and at's index are integer positions, like
|
|
// every index (a fractional flow is a taught NS1016).
|
|
const start = method === "set" ? 1 : 0;
|
|
for (let i = start; i < node.arguments.length; i++) this.demand(node.arguments[i]);
|
|
} else if (method === "padStart" || method === "padEnd") {
|
|
// The target BYTE length indexes memory; the fill is bytes.
|
|
if (node.arguments[0]) this.demand(node.arguments[0]);
|
|
} else if (method === "splice") {
|
|
// start and deleteCount index memory (rt.sliceIndex); inserted
|
|
// items are elements and stay unconstrained (f64 like all array
|
|
// elements).
|
|
for (let i = 0; i < Math.min(node.arguments.length, 2); i++) this.demand(node.arguments[i]);
|
|
} else if (method === "fill") {
|
|
// start/end index memory; the fill VALUE (argument 0) is an
|
|
// element and stays unconstrained.
|
|
for (let i = 1; i < node.arguments.length; i++) this.demand(node.arguments[i]);
|
|
} else if (method === "reduce") {
|
|
// The accumulator slot is fed by the initial value and by the
|
|
// callback body's result (each iteration assigns it back) — for
|
|
// a block body, by every `return` expression.
|
|
const arrow = node.arguments[0];
|
|
if (arrow && ts.isArrowFunction(arrow) && arrow.parameters.length === 2) {
|
|
const accP = arrow.parameters[0];
|
|
if (node.arguments[1]) this.flowInto(accP, node.arguments[1]);
|
|
if (!ts.isBlock(arrow.body)) this.flowInto(accP, arrow.body);
|
|
else for (const r of returnExpressionsOf(arrow.body)) this.flowInto(accP, r);
|
|
}
|
|
}
|
|
}
|
|
} else if (ts.isNewExpression(node)) {
|
|
if (ts.isIdentifier(node.expression) && node.expression.text === "Uint8Array") {
|
|
for (const arg of node.arguments ?? []) this.demand(arg);
|
|
} else if (ts.isIdentifier(node.expression)) {
|
|
// R19: `new C(x)` flows arguments into the constructor's
|
|
// parameter slots, exactly like a direct call.
|
|
const decl = this.tast.declarationOf(node.expression);
|
|
if (decl && ts.isClassDeclaration(decl)) {
|
|
const ctor = decl.members.find(
|
|
(m): m is ts.ConstructorDeclaration => ts.isConstructorDeclaration(m) && m.body !== undefined,
|
|
);
|
|
ctor?.parameters.forEach((cp, i) => {
|
|
const arg = node.arguments?.[i];
|
|
if (arg) this.flowInto(cp, arg);
|
|
});
|
|
}
|
|
}
|
|
} else if (ts.isArrowFunction(node) && !ts.isBlock(node.body)) {
|
|
this.flowInto(node, node.body);
|
|
}
|
|
ts.forEachChild(node, visit);
|
|
};
|
|
for (const file of this.files) visit(file);
|
|
}
|
|
|
|
// ------------------------------------------------------------- resolution
|
|
|
|
private sideIsInt(c: Contribution): boolean {
|
|
if (c.float || c.unknown) return false;
|
|
if (c.slots.length === 0) return c.intSource;
|
|
return c.slots.every((s) => !s.float && (s.proven || s.demanded));
|
|
}
|
|
|
|
/// Taint-only float check (demand phase): undecided slots are not float yet.
|
|
private sideFloatTaint(c: Contribution): boolean {
|
|
if (c.float) return true;
|
|
return c.slots.some((s) => s.float);
|
|
}
|
|
|
|
/// Final float check (demotion phase): undecided slots resolve to f64.
|
|
private sideIsFloat(c: Contribution): boolean {
|
|
if (c.float) return true;
|
|
return c.slots.some((s) => s.float) || (c.slots.length > 0 && c.slots.every((s) => this.finalClassOf(s) === "f64"));
|
|
}
|
|
|
|
private finalClassOf(s: Slot): "i64" | "f64" {
|
|
if (s.float) return "f64";
|
|
return s.proven || s.demanded ? "i64" : "f64";
|
|
}
|
|
|
|
private resolve(): void {
|
|
const all = [...this.slots.values()];
|
|
|
|
// Forward float taint from literal/div sources. The fixpoint flag is
|
|
// LOCAL: this also runs inside the demotion loop below, and clobbering
|
|
// that loop's `changed` would end its fixpoint one pass early (a
|
|
// two-hop demotion chain — field -> destructured alias -> local —
|
|
// would strand the far end as a phantom NS1016 conflict).
|
|
let changed = true;
|
|
const propagateFloat = () => {
|
|
let moved = true;
|
|
while (moved) {
|
|
moved = false;
|
|
for (const s of all) {
|
|
if (s.float) continue;
|
|
if (s.inflows.some((c) => c.float || c.slots.some((x) => x.float))) {
|
|
s.float = true;
|
|
moved = true;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
propagateFloat();
|
|
|
|
// Greatest-fixpoint integer proof: demote until stable.
|
|
changed = true;
|
|
while (changed) {
|
|
changed = false;
|
|
for (const s of all) {
|
|
if (!s.proven) continue;
|
|
const bad =
|
|
s.float ||
|
|
s.external ||
|
|
s.inflows.some((c) => c.float || c.unknown || c.slots.some((x) => !x.proven || x.float));
|
|
if (bad) {
|
|
s.proven = false;
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Least-fixpoint demand: seeds, backward through inflows, plus
|
|
// comparisons against proven/demanded integer sides. Comparison-origin
|
|
// demand never claims a host-boundary slot, nor any slot a host-boundary
|
|
// slot feeds: the comparison itself does not require an integer (the
|
|
// emitter widens the integer side into f64 instead), and an i64-claimed
|
|
// exported signature would truncate host f64 values that node compares
|
|
// exactly — claiming only part of such a chain would manufacture an
|
|
// NS1016 conflict out of thin air. Genuine demand chains (index,
|
|
// bitwise, size positions) still claim host params — there the emitted
|
|
// i64 signature is the contract type the wiring builds against.
|
|
const boundaryFed = new Map<Slot, boolean>();
|
|
const touchesBoundary = (s: Slot): boolean => {
|
|
const memo = boundaryFed.get(s);
|
|
if (memo !== undefined) return memo;
|
|
boundaryFed.set(s, false); // cycle guard: a cycle adds no boundary
|
|
const touched =
|
|
s.hostBoundary || s.inflows.some((c) => !c.float && c.slots.some((x) => touchesBoundary(x)));
|
|
boundaryFed.set(s, touched);
|
|
return touched;
|
|
};
|
|
const demandSlot = (s: Slot, viaComparison: boolean): void => {
|
|
if (s.float) return;
|
|
if (s.demanded) {
|
|
// A hard requirement reaching a tentatively-claimed slot upgrades
|
|
// it (and its backward chain): the claim stops being yieldable.
|
|
if (!viaComparison && s.comparisonDemanded) {
|
|
s.comparisonDemanded = false;
|
|
for (const c of s.inflows) {
|
|
if (c.float) continue;
|
|
for (const x of c.slots) demandSlot(x, false);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (viaComparison && touchesBoundary(s)) return;
|
|
s.demanded = true;
|
|
s.comparisonDemanded = viaComparison;
|
|
for (const c of s.inflows) {
|
|
if (c.float) continue;
|
|
for (const x of c.slots) demandSlot(x, viaComparison);
|
|
}
|
|
};
|
|
for (const c of this.demandSeeds) {
|
|
if (c.float) continue;
|
|
for (const s of c.slots) demandSlot(s, false);
|
|
}
|
|
changed = true;
|
|
while (changed) {
|
|
changed = false;
|
|
for (const site of this.compareSites) {
|
|
const pairs: Array<[Contribution, Contribution]> = [
|
|
[site.left, site.right],
|
|
[site.right, site.left],
|
|
];
|
|
for (const [a, b] of pairs) {
|
|
if (this.sideIsInt(a) && !this.sideFloatTaint(b)) {
|
|
// A side partly fed by the host boundary resolves f64 as a
|
|
// whole; claiming its other slots would only split the side.
|
|
if (b.slots.some((s) => touchesBoundary(s))) continue;
|
|
for (const s of b.slots) {
|
|
if (!s.demanded && !s.float) {
|
|
demandSlot(s, true);
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Forward demand across assignment edges: a demanded source is i64, so
|
|
// the slot it is assigned into must match (and its other sources follow
|
|
// through demandSlot's backward recursion). Tentativeness rides along:
|
|
// a slot fed only by comparison-claimed sources is itself yieldable,
|
|
// while a hard-demanded source hardens what it feeds.
|
|
changed = true;
|
|
while (changed) {
|
|
changed = false;
|
|
for (const s of all) {
|
|
if (s.float) continue;
|
|
const hardFed = s.inflows.some(
|
|
(c) => !c.float && c.slots.some((x) => !x.float && x.demanded && !x.comparisonDemanded),
|
|
);
|
|
if (!s.demanded) {
|
|
const tentativeFed = s.inflows.some(
|
|
(c) => !c.float && c.slots.some((x) => !x.float && x.demanded && x.comparisonDemanded),
|
|
);
|
|
if (hardFed || tentativeFed) {
|
|
demandSlot(s, !hardFed);
|
|
changed = true;
|
|
}
|
|
} else if (s.comparisonDemanded && hardFed) {
|
|
demandSlot(s, false);
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mixed-site and assignment-edge demotion: an unclaimed integer slot
|
|
// meeting a float side becomes f64, and an f64-resolving slot demotes
|
|
// the proven-but-undemanded slots assigned into it — every edge lands
|
|
// same-typed unless a HARD-demanded slot meets float (a reported
|
|
// conflict). Comparison-claimed slots yield here: the tentative claim
|
|
// existed only to keep the comparison same-typed, so float reaching it
|
|
// demotes the slot and the comparison widens instead of manufacturing
|
|
// an NS1016 conflict.
|
|
const demote = (s: Slot): boolean => {
|
|
if (s.float) return false;
|
|
if (s.demanded && !s.comparisonDemanded) return false;
|
|
s.demanded = false;
|
|
s.comparisonDemanded = false;
|
|
s.float = true;
|
|
s.proven = false;
|
|
return true;
|
|
};
|
|
changed = true;
|
|
while (changed) {
|
|
changed = false;
|
|
const sites = [...this.mixSites, ...this.compareSites];
|
|
for (const site of sites) {
|
|
const pairs: Array<[Contribution, Contribution]> = [
|
|
[site.left, site.right],
|
|
[site.right, site.left],
|
|
];
|
|
for (const [a, b] of pairs) {
|
|
if (!this.sideIsFloat(a)) continue;
|
|
for (const s of b.slots) {
|
|
// Claimed slots (tentative included) hold their class here:
|
|
// the emitter widens the integer side of a mixed comparison
|
|
// exactly, so a compare site never needs to demote a claim.
|
|
if (!s.float && !s.demanded && this.finalClassOf(s) === "i64") {
|
|
s.float = true;
|
|
s.proven = false;
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for (const s of all) {
|
|
if (this.finalClassOf(s) !== "f64") continue;
|
|
for (const c of s.inflows) {
|
|
for (const x of c.slots) {
|
|
if (demote(x)) changed = true;
|
|
}
|
|
}
|
|
}
|
|
// A tentative claim with float flowing straight into the slot yields
|
|
// too — the same rule, seen from the inflow side.
|
|
for (const s of all) {
|
|
if (!s.demanded || !s.comparisonDemanded) continue;
|
|
const floatIn = s.inflows.some((c) => c.float || c.slots.some((x) => x.float));
|
|
if (floatIn && demote(s)) changed = true;
|
|
}
|
|
if (changed) propagateFloat();
|
|
}
|
|
|
|
this.collectConflicts();
|
|
}
|
|
|
|
/// Every edge the fixed point above could not make same-typed is a real
|
|
/// int/float conflict; report it instead of emitting mismatched Zig.
|
|
private collectConflicts(): void {
|
|
const seen = new Set<ts.Node>();
|
|
const report = (node: ts.Node, slotLabel: string): void => {
|
|
if (seen.has(node)) return;
|
|
seen.add(node);
|
|
this.conflicts.push({ node, slotLabel });
|
|
};
|
|
for (const s of this.slots.values()) {
|
|
if (s.demanded) {
|
|
// An integer-required slot with float reaching it, directly or
|
|
// through taint, cannot be emitted as either machine type.
|
|
const src = s.inflows.find((c) => c.float || c.slots.some((x) => x.float));
|
|
if (s.float || src) report(src?.site ?? s.decl, s.label);
|
|
} else if (this.finalClassOf(s) === "f64") {
|
|
for (const c of s.inflows) {
|
|
const hard = c.slots.find((x) => this.finalClassOf(x) === "i64");
|
|
if (hard) report(c.site, hard.label);
|
|
}
|
|
}
|
|
}
|
|
// Index/bitwise positions themselves: a float-valued expression can
|
|
// never become the usize the memory site needs.
|
|
for (const c of this.demandSeeds) {
|
|
if (c.float || c.slots.some((x) => this.finalClassOf(x) === "f64")) {
|
|
report(c.site, c.site.getText().replace(/\s+/g, " "));
|
|
}
|
|
}
|
|
for (const site of this.mixSites) {
|
|
const pairs: Array<[Contribution, Contribution]> = [
|
|
[site.left, site.right],
|
|
[site.right, site.left],
|
|
];
|
|
for (const [a, b] of pairs) {
|
|
if (!this.sideIsFloat(a)) continue;
|
|
const hard = b.slots.find((x) => this.finalClassOf(x) === "i64");
|
|
if (hard) report(b.site, hard.label);
|
|
}
|
|
}
|
|
// Comparisons are looser than mixes: an INTERNAL i64 slot holds only
|
|
// integer values by proof, so the emitter widens its read exactly and
|
|
// the comparison agrees with node bit for bit — no conflict. An
|
|
// EXTERNAL i64-claimed slot is different: node can receive a fraction
|
|
// there that the emitted i64 boundary cannot represent, so a float
|
|
// comparison against it is a real divergence to teach.
|
|
for (const site of this.compareSites) {
|
|
const pairs: Array<[Contribution, Contribution]> = [
|
|
[site.left, site.right],
|
|
[site.right, site.left],
|
|
];
|
|
for (const [a, b] of pairs) {
|
|
if (!this.sideIsFloat(a)) continue;
|
|
const hard = b.slots.find((x) => this.finalClassOf(x) === "i64" && x.external);
|
|
if (hard) report(b.site, hard.label);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------ query
|
|
|
|
/// Final emission class of a number slot's declaration.
|
|
classOfDecl(decl: ts.Node): "i64" | "f64" | null {
|
|
const s = this.slots.get(decl);
|
|
return s ? this.finalClassOf(s) : null;
|
|
}
|
|
|
|
/// Final emission class of a numeric expression.
|
|
classOfExpr(expr: ts.Expression): "i64" | "f64" {
|
|
const c = this.contribution(expr);
|
|
if (c.float) return "f64";
|
|
if (c.slots.length === 0) return "i64";
|
|
return c.slots.every((s) => this.finalClassOf(s) === "i64") ? "i64" : "f64";
|
|
}
|
|
|
|
debugDump(): string {
|
|
return [...this.slots.values()]
|
|
.map((s) => `${s.label}: ${this.finalClassOf(s)}${s.external ? " (ext)" : ""}${s.demanded ? " (dem)" : ""}${s.proven ? " (prov)" : ""}`)
|
|
.join("\n");
|
|
}
|
|
}
|