mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-11 02:03:59 +08:00
1044 lines
37 KiB
TypeScript
1044 lines
37 KiB
TypeScript
import { expect, test } from "vitest";
|
|
import {
|
|
analyzeIntegerRanges,
|
|
bitwiseIdentityOperand,
|
|
bitwiseRange,
|
|
type BitwiseOp,
|
|
type BitwiseOperand,
|
|
} from "./integer-ranges.js";
|
|
import { BOOL, F64, STRING, type IrExpr, type IrFunction, type IrStmt } from "./ir.js";
|
|
|
|
const loc = { file: "test.ts", start: 0, end: 0 };
|
|
const num = (value: number): IrExpr => ({ loc, kind: "numLit", value, type: F64 });
|
|
const ref = (localId = "x"): IrExpr => ({ loc, kind: "varRef", localId, type: F64 });
|
|
const bin = (op: Extract<IrExpr, { kind: "bin" }>["op"], left: IrExpr, right: IrExpr): IrExpr => ({
|
|
loc,
|
|
kind: "bin",
|
|
op,
|
|
left,
|
|
right,
|
|
type: F64,
|
|
});
|
|
const unaryMinus = (operand: IrExpr): IrExpr => ({
|
|
loc,
|
|
kind: "unary",
|
|
op: "-",
|
|
operand,
|
|
type: F64,
|
|
});
|
|
const assign = (value: IrExpr): IrStmt => ({ loc, kind: "assign", localId: "x", value });
|
|
const fn = (body: IrStmt[], boxed = false): IrFunction => ({
|
|
loc,
|
|
name: "f",
|
|
params: [],
|
|
returnType: F64,
|
|
locals: [
|
|
{ id: "x", name: "x", type: F64, mutable: true, ...(boxed ? { boxed: true as const } : {}) },
|
|
],
|
|
body,
|
|
});
|
|
|
|
test("tracks exact additions and bitwise conversions through assignments", () => {
|
|
const sum = bin("+", ref(), bin("<<", ref(), num(3)));
|
|
const unsigned = bin(">>>", ref(), num(0));
|
|
const ranges = analyzeIntegerRanges(
|
|
fn([
|
|
assign(bin("|", ref("unknown"), num(0))),
|
|
assign(sum),
|
|
{ loc, kind: "return", value: unsigned },
|
|
]),
|
|
);
|
|
expect(ranges.get(sum)).toEqual({ min: -4294967296, max: 4294967294 });
|
|
expect(ranges.get(unsigned)).toEqual({ min: 0, max: 4294967295 });
|
|
});
|
|
|
|
test("element reads of Int32[] arrays that found a value are int32s", () => {
|
|
const arrType = { kind: "array", elem: F64 } as const;
|
|
const read = (localId: string): IrExpr => ({
|
|
loc,
|
|
kind: "arrayGet",
|
|
arr: { loc, kind: "varRef", localId, type: arrType },
|
|
index: num(0),
|
|
type: F64,
|
|
});
|
|
const checked = read("ints");
|
|
const other = read("floats");
|
|
const ranges = analyzeIntegerRanges(
|
|
fn([
|
|
{ loc, kind: "exprStmt", expr: checked },
|
|
{ loc, kind: "exprStmt", expr: other },
|
|
]),
|
|
{ elements: (arr) => arr.kind === "varRef" && arr.localId === "ints" },
|
|
);
|
|
expect(ranges.get(checked)).toEqual({ min: -2147483648, max: 2147483647 });
|
|
expect(ranges.get(other) ?? null).toBeNull();
|
|
});
|
|
|
|
test("rejects negative zero, fractions, nonfinite values and imprecise sums", () => {
|
|
for (const value of [-0, 0.5, NaN, Infinity, -Infinity, 9007199254740992]) {
|
|
const sum = bin("+", num(value), num(0));
|
|
expect(analyzeIntegerRanges(fn([{ loc, kind: "return", value: sum }])).get(sum)).toBeNull();
|
|
}
|
|
const exact = bin("-", num(Number.MAX_SAFE_INTEGER), num(1));
|
|
const rounded = bin("+", num(Number.MAX_SAFE_INTEGER), num(2));
|
|
const ranges = analyzeIntegerRanges(fn([assign(exact), { loc, kind: "return", value: rounded }]));
|
|
expect(ranges.get(exact)).toEqual({ min: 9007199254740990, max: 9007199254740990 });
|
|
expect(ranges.get(rounded)).toBeNull();
|
|
});
|
|
|
|
test("preserves uncaptured slots across calls and invalidates writes and boxed locals", () => {
|
|
const sum = (): IrExpr => bin("+", ref(), num(1));
|
|
const call: IrExpr = { loc, kind: "call", callee: "unknown", args: [], type: F64 };
|
|
const afterCall = sum();
|
|
const afterBlock = sum();
|
|
const boxed = sum();
|
|
const ranges = analyzeIntegerRanges(
|
|
fn([
|
|
assign(num(1)),
|
|
{ loc, kind: "exprStmt", expr: call },
|
|
assign(afterCall),
|
|
assign(num(1)),
|
|
{ loc, kind: "block", body: [assign(num(Infinity))] },
|
|
{ loc, kind: "return", value: afterBlock },
|
|
]),
|
|
);
|
|
expect(ranges.get(afterCall)).toEqual({ min: 2, max: 2 });
|
|
expect(ranges.get(afterBlock)).toBeNull();
|
|
expect(
|
|
analyzeIntegerRanges(fn([assign(num(1)), { loc, kind: "return", value: boxed }], true)).get(
|
|
boxed,
|
|
),
|
|
).toBeNull();
|
|
});
|
|
|
|
test("analyzes nested bodies independently and snapshots operands before writes", () => {
|
|
const before = ref();
|
|
const write: IrExpr = { loc, kind: "assignExpr", localId: "x", value: num(Infinity), type: F64 };
|
|
const after = ref();
|
|
const nested = bin("+", ref(), num(1));
|
|
const ranges = analyzeIntegerRanges(
|
|
fn([
|
|
assign(num(1)),
|
|
{ loc, kind: "exprStmt", expr: bin("|", before, write) },
|
|
{ loc, kind: "exprStmt", expr: after },
|
|
{ loc, kind: "block", body: [assign(num(2)), { loc, kind: "return", value: nested }] },
|
|
]),
|
|
);
|
|
expect(ranges.get(before)).toEqual({ min: 1, max: 1 });
|
|
expect(ranges.get(after)).toBeNull();
|
|
expect(ranges.get(nested)).toEqual({ min: 3, max: 3 });
|
|
});
|
|
|
|
test("shared expression objects never inherit a proof from another occurrence", () => {
|
|
const shared = ref();
|
|
const ranges = analyzeIntegerRanges(
|
|
fn([
|
|
assign(num(1)),
|
|
{ loc, kind: "exprStmt", expr: shared },
|
|
assign(num(Infinity)),
|
|
{
|
|
loc,
|
|
kind: "return",
|
|
value: { loc, kind: "call", callee: "unknown", args: [shared], type: F64 },
|
|
},
|
|
]),
|
|
);
|
|
expect(ranges.get(shared)).toBeNull();
|
|
});
|
|
|
|
const condition = (
|
|
op: Extract<IrExpr, { kind: "bin" }>["op"],
|
|
left: IrExpr,
|
|
right: IrExpr,
|
|
): IrExpr => ({ loc, kind: "bin", op, left, right, type: BOOL });
|
|
const observe = (value: IrExpr): IrStmt => ({ loc, kind: "exprStmt", expr: value });
|
|
const INT32 = { min: -2147483648, max: 2147483647 };
|
|
const UINT32 = { min: 0, max: 4294967295 };
|
|
const INT32_AND_UINT32 = { min: 0, max: 2147483647 };
|
|
|
|
test("joins branch assignments and refines signed masks on guarded paths", () => {
|
|
const inside = ref(),
|
|
joined = ref();
|
|
const ranges = analyzeIntegerRanges(
|
|
fn([
|
|
assign(bin("|", ref("unknown"), num(0))),
|
|
{
|
|
loc,
|
|
kind: "if",
|
|
cond: condition(">=", ref(), num(0)),
|
|
then: [observe(inside), assign(bin("&", ref(), num(255)))],
|
|
else_: [assign(num(7))],
|
|
},
|
|
observe(joined),
|
|
]),
|
|
);
|
|
expect(ranges.get(inside)).toEqual({ min: 0, max: 2147483647 });
|
|
expect(ranges.get(joined)).toEqual({ min: 0, max: 255 });
|
|
});
|
|
|
|
test("assignment snapshots and lazy joins do not overwrite earlier operands", () => {
|
|
const before = ref(),
|
|
after = ref(),
|
|
last = ref();
|
|
const write: IrExpr = { loc, kind: "assignExpr", localId: "x", value: num(3), type: F64 };
|
|
const ternary: IrExpr = {
|
|
loc,
|
|
kind: "ternary",
|
|
cond: condition("===", ref("flag"), num(0)),
|
|
then: write,
|
|
else_: num(5),
|
|
type: F64,
|
|
};
|
|
const ranges = analyzeIntegerRanges(
|
|
fn([
|
|
assign(num(1)),
|
|
observe(bin("+", before, ternary)),
|
|
observe(after),
|
|
observe({
|
|
loc,
|
|
kind: "logical",
|
|
op: "&&",
|
|
left: ref("flag"),
|
|
right: { ...write, value: num(9) },
|
|
type: F64,
|
|
}),
|
|
observe(last),
|
|
]),
|
|
);
|
|
expect(ranges.get(before)).toEqual({ min: 1, max: 1 });
|
|
expect(ranges.get(after)).toEqual({ min: 1, max: 3 });
|
|
expect(ranges.get(last)).toEqual({ min: 1, max: 9 });
|
|
});
|
|
|
|
test("guards that mutate a compared binding cannot refine its later value", () => {
|
|
const current = ref();
|
|
const writes: IrExpr = {
|
|
loc,
|
|
kind: "seqExpr",
|
|
stmts: [assign(bin(">>>", ref("unknown"), num(0)))],
|
|
result: num(100),
|
|
type: F64,
|
|
};
|
|
const ranges = analyzeIntegerRanges(
|
|
fn([
|
|
assign(num(1)),
|
|
{
|
|
loc,
|
|
kind: "if",
|
|
cond: condition("<", ref(), writes),
|
|
then: [observe(current)],
|
|
else_: null,
|
|
},
|
|
]),
|
|
);
|
|
expect(ranges.get(current)).toEqual({ min: 0, max: 4294967295 });
|
|
});
|
|
|
|
test("labeled exits and do-while conditions cannot lend facts to skipped paths", () => {
|
|
const afterBlock = ref(),
|
|
firstBody = ref();
|
|
const ranges = analyzeIntegerRanges(
|
|
fn([
|
|
assign(num(100)),
|
|
{
|
|
loc,
|
|
kind: "block",
|
|
labels: ["done"],
|
|
body: [
|
|
{
|
|
loc,
|
|
kind: "if",
|
|
cond: condition("===", ref("flag"), num(0)),
|
|
then: [{ loc, kind: "break", label: "done" }],
|
|
else_: null,
|
|
},
|
|
assign(num(1)),
|
|
],
|
|
},
|
|
observe(afterBlock),
|
|
{
|
|
loc,
|
|
kind: "doWhile",
|
|
cond: condition(
|
|
"<",
|
|
{ loc, kind: "assignExpr", localId: "x", value: num(0), type: F64 },
|
|
num(-1),
|
|
),
|
|
body: [observe(firstBody)],
|
|
},
|
|
]),
|
|
);
|
|
// The break skips `x = 1`; both writes are literals, so the join keeps
|
|
// only the closed range of the entry value and the block's write, never
|
|
// the skipped path's fact (`x === 1`).
|
|
expect(ranges.get(afterBlock)).toEqual({ min: 1, max: 100 });
|
|
// Likewise the condition's `x = 0` does not reach the first iteration:
|
|
// the body sees the entry range joined with that write.
|
|
expect(ranges.get(firstBody)).toEqual({ min: 0, max: 100 });
|
|
});
|
|
|
|
test("unit induction is exact but loop-carried assignments remain unknown", () => {
|
|
const counter = ref(),
|
|
carried = ref("state"),
|
|
after = ref();
|
|
const f = fn([
|
|
{
|
|
loc,
|
|
kind: "for",
|
|
init: { loc, kind: "varDecl", localId: "x", init: num(0) },
|
|
cond: condition("<", ref(), num(10)),
|
|
update: assign(bin("+", ref(), num(1))),
|
|
body: [
|
|
observe(counter),
|
|
observe(carried),
|
|
{ loc, kind: "assign", localId: "state", value: num(3) },
|
|
],
|
|
},
|
|
observe(after),
|
|
]);
|
|
f.locals.push({ id: "state", name: "state", type: F64, mutable: true });
|
|
const ranges = analyzeIntegerRanges(f);
|
|
expect(ranges.get(counter)).toEqual({ min: 0, max: 9 });
|
|
expect(ranges.get(carried)).toBeNull();
|
|
expect(ranges.get(after)).toBeNull();
|
|
});
|
|
|
|
test("filtered strided loops retain decoded values and bound secondary cursors", () => {
|
|
const cursor = ref("position"),
|
|
decoded = ref("decoded"),
|
|
conditionRead = ref("x");
|
|
const loop: IrStmt & { kind: "for" } = {
|
|
loc,
|
|
kind: "for",
|
|
init: { loc, kind: "varDecl", localId: "x", init: num(0) },
|
|
cond: condition("<", conditionRead, num(100)),
|
|
update: assign(bin("+", ref(), num(24))),
|
|
body: [
|
|
{ loc, kind: "varDecl", localId: "decoded", init: bin(">>>", ref("input"), num(0)) },
|
|
{
|
|
loc,
|
|
kind: "if",
|
|
cond: condition("===", ref("flag"), num(0)),
|
|
then: [{ loc, kind: "continue" }],
|
|
else_: null,
|
|
},
|
|
observe(decoded),
|
|
observe(cursor),
|
|
{ loc, kind: "assign", localId: "position", value: bin("+", ref("position"), num(16)) },
|
|
],
|
|
};
|
|
const f = fn([{ loc, kind: "varDecl", localId: "position", init: num(0) }, loop]);
|
|
f.locals.push(
|
|
...["position", "decoded"].map((id) => ({ id, name: id, type: F64, mutable: true })),
|
|
);
|
|
const ranges = analyzeIntegerRanges(f);
|
|
expect(ranges.get(cursor)).toEqual({ min: 0, max: 80 });
|
|
expect(ranges.get(decoded)).toEqual({ min: 0, max: 4294967295 });
|
|
// The last pass enters at most at 99, so the failing test reads at most 123.
|
|
expect(ranges.get(conditionRead)).toEqual({ min: 0, max: 123 });
|
|
for (const mutation of ["fraction", "repeat", "overflow", "counter"] as const) {
|
|
const changed = structuredClone(f);
|
|
const body = (changed.body[1] as IrStmt & { kind: "for" }).body;
|
|
const update = body[4] as IrStmt & { kind: "assign" };
|
|
if (mutation === "fraction") update.value = bin("+", ref("position"), num(0.5));
|
|
if (mutation === "repeat")
|
|
body[4] = {
|
|
loc,
|
|
kind: "while",
|
|
cond: condition("<", ref("unknown"), num(1)),
|
|
body: [update],
|
|
};
|
|
if (mutation === "overflow")
|
|
(changed.body[0] as IrStmt & { kind: "varDecl" }).init = num(Number.MAX_SAFE_INTEGER);
|
|
if (mutation === "counter") body.push(assign(num(0)));
|
|
const observation = body[3] as IrStmt & { kind: "exprStmt" };
|
|
expect(analyzeIntegerRanges(changed).get(observation.expr), mutation).toBeNull();
|
|
}
|
|
});
|
|
|
|
test("an exit branch does not discard the surviving facts or escape its own label", () => {
|
|
const afterReturn = ref(),
|
|
afterLabel = ref();
|
|
const ranges = analyzeIntegerRanges(
|
|
fn([
|
|
assign(num(3)),
|
|
{
|
|
loc,
|
|
kind: "if",
|
|
cond: condition("===", ref("flag"), num(0)),
|
|
then: [{ loc, kind: "return", value: num(1) }],
|
|
else_: null,
|
|
},
|
|
observe(afterReturn),
|
|
{
|
|
loc,
|
|
kind: "if",
|
|
cond: condition("===", ref("flag"), num(1)),
|
|
then: [
|
|
{
|
|
loc,
|
|
kind: "block",
|
|
labels: ["done"],
|
|
body: [assign(num(0.5)), { loc, kind: "break", label: "done" }],
|
|
},
|
|
],
|
|
else_: null,
|
|
},
|
|
observe(afterLabel),
|
|
]),
|
|
);
|
|
expect(ranges.get(afterReturn)).toEqual({ min: 3, max: 3 });
|
|
expect(ranges.get(afterLabel)).toBeNull();
|
|
});
|
|
|
|
test("int32-closed locals keep the int32 range across loops and switches", () => {
|
|
const loop = (write: IrExpr): { inside: IrExpr; after: IrExpr; f: IrFunction } => {
|
|
const inside = ref(),
|
|
after = ref();
|
|
const f = fn([
|
|
assign(num(0)),
|
|
{
|
|
loc,
|
|
kind: "while",
|
|
cond: condition("<", ref("unknown"), num(1)),
|
|
body: [assign(write), observe(inside)],
|
|
},
|
|
observe(after),
|
|
]);
|
|
return { inside, after, f };
|
|
};
|
|
const closed = loop(bin("|", ref(), ref("unknown")));
|
|
const closedRanges = analyzeIntegerRanges(closed.f);
|
|
expect(closedRanges.get(closed.inside)).toEqual(INT32);
|
|
expect(closedRanges.get(closed.after)).toEqual(INT32);
|
|
const unsigned = loop(bin(">>>", ref("unknown"), num(0)));
|
|
expect(analyzeIntegerRanges(unsigned.f).get(unsigned.after)).toEqual(UINT32);
|
|
// `x >>> 0` of a local that only ever holds non-negative int32s is one.
|
|
const identity = loop(bin(">>>", ref(), num(0)));
|
|
expect(analyzeIntegerRanges(identity.f).get(identity.after)).toEqual(INT32_AND_UINT32);
|
|
for (const write of [bin("+", ref(), num(1)), bin("/", ref(), num(2))]) {
|
|
const open = loop(write);
|
|
expect(analyzeIntegerRanges(open.f).get(open.after), write.kind).toBeNull();
|
|
}
|
|
const afterSwitch = ref();
|
|
const switched = analyzeIntegerRanges(
|
|
fn([
|
|
assign(num(1)),
|
|
{
|
|
loc,
|
|
kind: "switch",
|
|
disc: ref("unknown"),
|
|
cases: [
|
|
{ test: num(0), body: [assign(bin("^", ref(), num(4)))] },
|
|
{ test: null, body: [assign(num(-1))] },
|
|
],
|
|
} as IrStmt,
|
|
observe(afterSwitch),
|
|
]),
|
|
);
|
|
expect(switched.get(afterSwitch)).toEqual(INT32);
|
|
});
|
|
|
|
test("uint32-closed locals keep the uint32 range across loop back edges", () => {
|
|
// for (let i = 0; i < n; i++) { v = unknown >>> 0; acc = (acc + v) >>> 0; ... }
|
|
const loop = (start: IrExpr | null, writes: IrExpr[]) => {
|
|
const inside = ref("acc"),
|
|
sum = bin("+", inside, ref("v")),
|
|
after = ref("acc");
|
|
const set = (localId: string, value: IrExpr): IrStmt => ({
|
|
loc,
|
|
kind: "assign",
|
|
localId,
|
|
value,
|
|
});
|
|
const f = fn([
|
|
...(start ? [set("acc", start)] : []),
|
|
{
|
|
loc,
|
|
kind: "for",
|
|
init: { loc, kind: "varDecl", localId: "i", init: num(0) },
|
|
cond: condition("<", ref("i"), ref("n")),
|
|
update: set("i", bin("+", ref("i"), num(1))),
|
|
body: [
|
|
set("v", bin(">>>", ref("unknown"), num(0))),
|
|
set("acc", bin(">>>", sum, num(0))),
|
|
...writes.map((value) => set("acc", value)),
|
|
],
|
|
},
|
|
observe(after),
|
|
]);
|
|
for (const id of ["acc", "i", "v", "n"])
|
|
f.locals.push({ id, name: id, type: F64, mutable: true });
|
|
return { inside, sum, after, ranges: analyzeIntegerRanges(f) };
|
|
};
|
|
const closed = loop(num(0), []);
|
|
expect(closed.ranges.get(closed.inside)).toEqual(UINT32);
|
|
// The add before `>>> 0` stays exact: it can exceed 2^32.
|
|
expect(closed.ranges.get(closed.sum)).toEqual({ min: 0, max: 2 * 4294967295 });
|
|
expect(closed.ranges.get(closed.after)).toEqual(UINT32);
|
|
// A uint32 literal above 2^31 and a `>>>` chain through the closed local
|
|
// keep the closure.
|
|
for (const write of [num(4294967295), bin(">>>", ref("acc"), num(1))]) {
|
|
const kept = loop(num(2147483648), [write]);
|
|
expect(kept.ranges.get(kept.inside)).toEqual(UINT32);
|
|
expect(kept.ranges.get(kept.after)).toEqual(UINT32);
|
|
}
|
|
// Mixing signed and unsigned writes, any other write, an entry outside
|
|
// uint32 or an unknown entry leaves the local unknown at the loop head.
|
|
const open: [IrExpr | null, IrExpr | null][] = [
|
|
[num(0), bin("|", ref("acc"), num(0))],
|
|
[num(0), num(-1)],
|
|
[num(0), num(4294967296)],
|
|
[num(0), num(-0)],
|
|
[num(0), num(0.5)],
|
|
[num(0), bin("+", ref("acc"), num(1))],
|
|
[num(-1), null],
|
|
[num(4294967296), null],
|
|
[null, null],
|
|
];
|
|
for (const [start, write] of open) {
|
|
const result = loop(start, write ? [write] : []);
|
|
expect(result.ranges.get(result.inside)).toBeNull();
|
|
expect(result.ranges.get(result.after)).toBeNull();
|
|
}
|
|
});
|
|
|
|
test("arithmetic-closed loop-carried locals keep exact ranges across back edges", () => {
|
|
// n = unknown & 3; v = start;
|
|
// for (let i = 0; i < 100000; i++) { v = write(v); observe(v) } observe(v)
|
|
const set = (localId: string, value: IrExpr): IrStmt => ({ loc, kind: "assign", localId, value });
|
|
const loop = (start: IrExpr, write: (v: IrExpr) => IrExpr) => {
|
|
const inside = ref("v"),
|
|
after = ref("v"),
|
|
value = write(ref("v"));
|
|
const f = fn([
|
|
set("n", bin("&", ref("unknown"), num(3))),
|
|
set("v", start),
|
|
{
|
|
loc,
|
|
kind: "for",
|
|
init: { loc, kind: "varDecl", localId: "i", init: num(0) },
|
|
cond: condition("<", ref("i"), num(100000)),
|
|
update: set("i", bin("+", ref("i"), num(1))),
|
|
body: [observe(inside), set("v", value)],
|
|
},
|
|
observe(after),
|
|
]);
|
|
for (const id of ["v", "i", "n"]) f.locals.push({ id, name: id, type: F64, mutable: true });
|
|
const ranges = analyzeIntegerRanges(f);
|
|
return { inside: ranges.get(inside), after: ranges.get(after), value: ranges.get(value) };
|
|
};
|
|
const M = 1000003;
|
|
// The 41-closures kernel: the dividend is a non-negative exact integer.
|
|
const hash = loop(num(7), (v) => bin("%", bin("+", bin("*", v, num(31)), ref("n")), num(M)));
|
|
expect(hash).toEqual({
|
|
inside: { min: 0, max: M - 1 },
|
|
after: { min: 0, max: M - 1 },
|
|
value: { min: 0, max: M - 1 },
|
|
});
|
|
// The counter's header range holds at every read in the body.
|
|
const counted = loop(num(0), (v) => bin("%", bin("+", bin("*", v, num(31)), ref("i")), num(M)));
|
|
expect(counted.inside).toEqual({ min: 0, max: M - 1 });
|
|
// A wrap counter, and a product that crosses 2^31 but stays exact.
|
|
expect(loop(num(0), (v) => bin("%", bin("+", v, num(1)), num(10))).after).toEqual({
|
|
min: 0,
|
|
max: 9,
|
|
});
|
|
const wide = loop(num(1), (v) =>
|
|
bin("%", bin("+", bin("*", v, num(31)), ref("n")), num(2 ** 31)),
|
|
);
|
|
expect(wide.after).toEqual({ min: 0, max: 2 ** 31 - 1 });
|
|
// Unary minus of a value that cannot be zero stays exact, also into
|
|
// negative ranges (closed under int32 rather than the non-negative bound).
|
|
expect(
|
|
loop(num(1), (v) => unaryMinus(unaryMinus(bin("+", bin("%", v, num(7)), num(1))))).after,
|
|
).toEqual({ min: 1, max: 7 });
|
|
expect(loop(num(-1), (v) => unaryMinus(bin("+", bin("&", v, num(7)), num(1)))).after).toEqual({
|
|
min: -8,
|
|
max: -1,
|
|
});
|
|
// Writes that could leave the bound, produce -0, NaN, a fraction or an
|
|
// inexact product, and entries outside it, leave the local unknown.
|
|
const open: [string, IrExpr, (v: IrExpr) => IrExpr][] = [
|
|
[
|
|
"negative dividend (-0)",
|
|
num(7),
|
|
(v) => bin("%", bin("-", bin("*", v, num(31)), ref("n")), num(M)),
|
|
],
|
|
["growth", num(7), (v) => bin("+", bin("*", v, num(31)), ref("n"))],
|
|
["unknown divisor", num(7), (v) => bin("%", v, ref("unknown"))],
|
|
["zero divisor (NaN)", num(7), (v) => bin("%", v, num(0))],
|
|
["fraction divisor", num(7), (v) => bin("%", v, num(2.5))],
|
|
["negated remainder (-0)", num(7), (v) => unaryMinus(bin("%", v, num(7)))],
|
|
["inexact product", num(7), (v) => bin("%", bin("*", v, num(2 ** 30)), num(M))],
|
|
["negative entry", num(-1), (v) => bin("%", bin("+", bin("*", v, num(31)), ref("n")), num(M))],
|
|
["fraction entry", num(0.5), (v) => bin("%", bin("+", v, num(1)), num(10))],
|
|
["unknown entry", ref("unknown"), (v) => bin("%", bin("+", v, num(1)), num(10))],
|
|
["infinite entry", num(Infinity), (v) => bin("%", bin("+", v, num(1)), num(10))],
|
|
["division", num(7), (v) => bin("%", bin("/", v, num(2)), num(10))],
|
|
];
|
|
for (const [label, start, write] of open) {
|
|
const result = loop(start, write);
|
|
expect(result.inside, label).toBeNull();
|
|
expect(result.after, label).toBeNull();
|
|
}
|
|
});
|
|
|
|
test("a loop-invariant positive step bounds the counter of a limited loop", () => {
|
|
// s = (unknown & 7) + 1; for (let j = s * 2; j <= 1000; j += s) observe(j)
|
|
const set = (localId: string, value: IrExpr): IrStmt => ({ loc, kind: "assign", localId, value });
|
|
const loop = (
|
|
step: IrExpr,
|
|
update: (j: IrExpr) => IrExpr = (j) => bin("+", j, ref("s")),
|
|
body: IrStmt[] = [],
|
|
) => {
|
|
const inside = ref("j"),
|
|
next = update(ref("j"));
|
|
const f = fn([
|
|
set("s", step),
|
|
{
|
|
loc,
|
|
kind: "for",
|
|
init: { loc, kind: "varDecl", localId: "j", init: bin("*", ref("s"), num(2)) },
|
|
cond: condition("<=", ref("j"), num(1000)),
|
|
update: set("j", next),
|
|
body: [observe(inside), ...body],
|
|
},
|
|
]);
|
|
for (const id of ["s", "j"]) f.locals.push({ id, name: id, type: F64, mutable: true });
|
|
const ranges = analyzeIntegerRanges(f);
|
|
return { inside: ranges.get(inside), next: ranges.get(next) };
|
|
};
|
|
const positive = bin("+", bin("&", ref("unknown"), num(7)), num(1));
|
|
expect(loop(positive)).toEqual({ inside: { min: 2, max: 1000 }, next: { min: 3, max: 1008 } });
|
|
// Early exits only shorten the walk.
|
|
const exits: IrStmt = {
|
|
loc,
|
|
kind: "if",
|
|
cond: condition("===", ref("unknown"), num(0)),
|
|
then: [{ loc, kind: "break" }],
|
|
else_: [{ loc, kind: "continue" }],
|
|
};
|
|
expect(loop(positive, undefined, [exits]).inside).toEqual({ min: 2, max: 1000 });
|
|
// A step that can be zero, negative or a fraction, a step the body
|
|
// writes, and a subtracted positive step, prove nothing.
|
|
const open: [string, IrExpr, ((j: IrExpr) => IrExpr) | undefined, IrStmt[]][] = [
|
|
["zero step", bin("&", ref("unknown"), num(7)), undefined, []],
|
|
["negative step", unaryMinus(positive), undefined, []],
|
|
["fraction step", num(0.5), undefined, []],
|
|
["unknown step", ref("unknown"), undefined, []],
|
|
["written step", positive, undefined, [set("s", num(1))]],
|
|
["subtracted step", positive, (j) => bin("-", j, ref("s")), []],
|
|
];
|
|
for (const [label, step, update, body] of open)
|
|
expect(loop(step, update, body).inside, label).toBeNull();
|
|
});
|
|
|
|
test("a while condition bounds a counter the loop only increments", () => {
|
|
// it = start; while (unknown <= 4 && it < MAX) { observe(it); it++ } observe(it)
|
|
const set = (localId: string, value: IrExpr): IrStmt => ({ loc, kind: "assign", localId, value });
|
|
const bump = (op: "+" | "-" = "+"): IrStmt => ({
|
|
loc,
|
|
kind: "exprStmt",
|
|
expr: { loc, kind: "incDec", op, prefix: false, localId: "it", type: F64 },
|
|
});
|
|
const and = (left: IrExpr, right: IrExpr): IrExpr => ({
|
|
loc,
|
|
kind: "logical",
|
|
op: "&&",
|
|
left,
|
|
right,
|
|
type: BOOL,
|
|
});
|
|
const run = (
|
|
options: {
|
|
start?: IrExpr;
|
|
cond?: IrExpr;
|
|
body?: IrStmt[];
|
|
before?: IrStmt[];
|
|
} = {},
|
|
) => {
|
|
const inside = ref("it"),
|
|
after = ref("it");
|
|
const f = fn([
|
|
set("max", num(300)),
|
|
set("it", options.start ?? num(0)),
|
|
...(options.before ?? []),
|
|
{
|
|
loc,
|
|
kind: "while",
|
|
cond:
|
|
options.cond ??
|
|
and(condition("<=", ref("unknown"), num(4)), condition("<", ref("it"), ref("max"))),
|
|
body: options.body ?? [observe(inside), bump()],
|
|
},
|
|
observe(after),
|
|
]);
|
|
for (const id of ["it", "max"]) f.locals.push({ id, name: id, type: F64, mutable: true });
|
|
const ranges = analyzeIntegerRanges(f);
|
|
return { inside: ranges.get(inside), after: ranges.get(after) };
|
|
};
|
|
expect(run()).toEqual({ inside: { min: 0, max: 299 }, after: { min: 0, max: 300 } });
|
|
// `MAX > it`, an inclusive bound, two increments per pass and an early exit.
|
|
expect(run({ cond: condition(">", ref("max"), ref("it")) }).after).toEqual({ min: 0, max: 300 });
|
|
expect(run({ cond: condition("<=", ref("it"), num(10)) }).after).toEqual({ min: 0, max: 11 });
|
|
const twice = [observe(ref("it")), bump(), set("it", bin("+", ref("it"), num(2)))];
|
|
expect(run({ body: twice }).after).toEqual({ min: 0, max: 302 });
|
|
const exit: IrStmt = {
|
|
loc,
|
|
kind: "if",
|
|
cond: condition("===", ref("unknown"), num(0)),
|
|
then: [{ loc, kind: "break" }],
|
|
else_: null,
|
|
};
|
|
expect(run({ body: [exit, bump()] }).after).toEqual({ min: 0, max: 300 });
|
|
// A start above the bound stays the entry value.
|
|
expect(run({ start: num(500) }).after).toEqual({ min: 500, max: 500 });
|
|
// Decrements, fractional or variable steps, increments in a nested loop,
|
|
// writes in the condition, a bound the loop writes, a disjunction, and an
|
|
// unknown start prove nothing.
|
|
const nested: IrStmt = {
|
|
loc,
|
|
kind: "while",
|
|
cond: condition("<", ref("unknown"), num(3)),
|
|
body: [bump()],
|
|
};
|
|
const counted: IrExpr = { loc, kind: "incDec", op: "+", prefix: false, localId: "it", type: F64 };
|
|
const open: [string, Parameters<typeof run>[0]][] = [
|
|
["decrement", { body: [bump("-")] }],
|
|
["fraction step", { body: [set("it", bin("+", ref("it"), num(0.5)))] }],
|
|
["variable step", { body: [set("it", bin("+", ref("it"), ref("unknown")))] }],
|
|
["nested increment", { body: [nested] }],
|
|
["condition write", { cond: condition("<", counted, ref("max")), body: [] }],
|
|
["written bound", { body: [bump(), set("max", num(1e12))] }],
|
|
[
|
|
"disjunction",
|
|
{
|
|
cond: {
|
|
loc,
|
|
kind: "logical",
|
|
op: "||",
|
|
left: condition("<=", ref("unknown"), num(4)),
|
|
right: condition("<", ref("it"), ref("max")),
|
|
type: BOOL,
|
|
},
|
|
},
|
|
],
|
|
["unknown start", { start: ref("unknown") }],
|
|
];
|
|
for (const [label, options] of open) expect(run(options).after, label).toBeNull();
|
|
});
|
|
|
|
test("a while condition on a string's length bounds a counter, and guarded increments stay below it", () => {
|
|
// while (pos < text.length) { observe(pos); pos++; if (pos < text.length && unknown === 13) pos++; }
|
|
const text: IrExpr = { loc, kind: "varRef", localId: "text", type: STRING };
|
|
const length: IrExpr = {
|
|
loc,
|
|
kind: "strIntrinsic",
|
|
method: "length",
|
|
receiver: text,
|
|
args: [],
|
|
type: F64,
|
|
};
|
|
const set = (localId: string, value: IrExpr): IrStmt => ({ loc, kind: "assign", localId, value });
|
|
const bump: IrStmt = {
|
|
loc,
|
|
kind: "exprStmt",
|
|
expr: { loc, kind: "incDec", op: "+", prefix: false, localId: "pos", type: F64 },
|
|
};
|
|
const and = (left: IrExpr, right: IrExpr): IrExpr => ({
|
|
loc,
|
|
kind: "logical",
|
|
op: "&&",
|
|
left,
|
|
right,
|
|
type: BOOL,
|
|
});
|
|
const run = (body: (inside: IrExpr) => IrStmt[]) => {
|
|
const inside = ref("pos"),
|
|
after = ref("pos");
|
|
const f = fn([
|
|
set("pos", num(0)),
|
|
{ loc, kind: "while", cond: condition("<", ref("pos"), length), body: body(inside) },
|
|
observe(after),
|
|
]);
|
|
f.locals.push(
|
|
{ id: "pos", name: "pos", type: F64, mutable: true },
|
|
{ id: "text", name: "text", type: STRING, mutable: true },
|
|
);
|
|
const ranges = analyzeIntegerRanges(f);
|
|
return { inside: ranges.get(inside), after: ranges.get(after) };
|
|
};
|
|
const guarded = (cond: IrExpr): IrStmt => ({ loc, kind: "if", cond, then: [bump], else_: null });
|
|
const crlf = guarded(
|
|
and(condition("<", ref("pos"), length), condition("===", ref("unknown"), num(13))),
|
|
);
|
|
expect(run((inside) => [observe(inside), bump, crlf])).toEqual({
|
|
inside: { min: 0, max: 2147483646 },
|
|
after: { min: 0, max: 2147483647 },
|
|
});
|
|
// An exit before the second increment, and an else branch, keep the bound.
|
|
const exit: IrStmt = {
|
|
loc,
|
|
kind: "if",
|
|
cond: condition("===", ref("unknown"), num(0)),
|
|
then: [{ loc, kind: "continue" }],
|
|
else_: [crlf],
|
|
};
|
|
expect(run((inside) => [observe(inside), bump, exit]).after).toEqual({ min: 0, max: 2147483647 });
|
|
// Without the guard both increments count; a string the loop rebinds bounds nothing.
|
|
const unguarded = guarded(condition("===", ref("unknown"), num(13)));
|
|
expect(run((inside) => [observe(inside), bump, unguarded]).after).toEqual({
|
|
min: 0,
|
|
max: 2147483648,
|
|
});
|
|
const rebound = set("text", { loc, kind: "strLit", value: "abc", type: STRING });
|
|
expect(run((inside) => [observe(inside), bump, rebound]).after).toBeNull();
|
|
});
|
|
|
|
test("an array length bounds while and for counters even when the body resizes the array", () => {
|
|
// let i = 0; while (i < items.length) { observe(i); items.push(i); i++; } observe(i);
|
|
const ARR = { kind: "array", elem: F64 } as const;
|
|
const items: IrExpr = { loc, kind: "varRef", localId: "items", type: ARR };
|
|
const length: IrExpr = {
|
|
loc,
|
|
kind: "arrIntrinsic",
|
|
method: "length",
|
|
receiver: items,
|
|
args: [],
|
|
type: F64,
|
|
};
|
|
const push: IrStmt = {
|
|
loc,
|
|
kind: "exprStmt",
|
|
expr: {
|
|
loc,
|
|
kind: "arrIntrinsic",
|
|
method: "push",
|
|
receiver: items,
|
|
args: [ref("i")],
|
|
type: F64,
|
|
},
|
|
};
|
|
const bump: IrStmt = {
|
|
loc,
|
|
kind: "exprStmt",
|
|
expr: { loc, kind: "incDec", op: "+", prefix: false, localId: "i", type: F64 },
|
|
};
|
|
const inside = ref("i"),
|
|
after = ref("i");
|
|
const f = fn([
|
|
{ loc, kind: "assign", localId: "i", value: num(0) },
|
|
{
|
|
loc,
|
|
kind: "while",
|
|
cond: condition("<", ref("i"), length),
|
|
body: [observe(inside), push, bump],
|
|
},
|
|
observe(after),
|
|
]);
|
|
f.locals.push(
|
|
{ id: "i", name: "i", type: F64, mutable: true },
|
|
{ id: "items", name: "items", type: ARR, mutable: false },
|
|
);
|
|
const ranges = analyzeIntegerRanges(f);
|
|
expect(ranges.get(inside)).toEqual({ min: 0, max: 4294967294 });
|
|
expect(ranges.get(after)).toEqual({ min: 0, max: 4294967295 });
|
|
// A secondary cursor of `for (let j = 0; j < items.length; j++) k++` stays a uint32.
|
|
const cursor = ref("k");
|
|
const loop: IrStmt = {
|
|
loc,
|
|
kind: "for",
|
|
init: { loc, kind: "varDecl", localId: "j", init: num(0) },
|
|
cond: condition("<", ref("j"), length),
|
|
update: { loc, kind: "assign", localId: "j", value: bin("+", ref("j"), num(1)) },
|
|
body: [
|
|
push,
|
|
observe(cursor),
|
|
{ loc, kind: "assign", localId: "k", value: bin("+", ref("k"), num(1)) },
|
|
],
|
|
};
|
|
const g = fn([{ loc, kind: "assign", localId: "k", value: num(0) }, loop]);
|
|
g.locals.push(
|
|
{ id: "j", name: "j", type: F64, mutable: true },
|
|
{ id: "k", name: "k", type: F64, mutable: true },
|
|
{ id: "items", name: "items", type: ARR, mutable: false },
|
|
);
|
|
expect(analyzeIntegerRanges(g).get(cursor)).toEqual({ min: 0, max: 4294967295 });
|
|
});
|
|
|
|
test("whole-program slot facts seed parameters, field reads and call results", () => {
|
|
const field: IrExpr = {
|
|
loc,
|
|
kind: "fieldGet",
|
|
obj: { loc, kind: "varRef", localId: "o", type: { kind: "object", className: "C" } },
|
|
className: "C",
|
|
field: "flags",
|
|
type: F64,
|
|
};
|
|
const result: IrExpr = { loc, kind: "call", callee: "bits", args: [], type: F64 };
|
|
const param = ref("p");
|
|
const f: IrFunction = {
|
|
...fn([observe(field), observe(result), observe(param)]),
|
|
params: [{ localId: "p", name: "p", type: F64 }],
|
|
locals: [{ id: "p", name: "p", type: F64, mutable: true }],
|
|
};
|
|
const plain = analyzeIntegerRanges(f);
|
|
expect([plain.get(field), plain.get(result), plain.get(param)]).toEqual([null, null, null]);
|
|
const seeded = analyzeIntegerRanges(f, {
|
|
params: new Map([["p", INT32]]),
|
|
field: (className, name) => (className === "C" && name === "flags" ? INT32 : null),
|
|
call: (callee) => (callee === "bits" ? INT32 : null),
|
|
});
|
|
expect([seeded.get(field), seeded.get(result), seeded.get(param)]).toEqual([INT32, INT32, INT32]);
|
|
});
|
|
|
|
test("string lengths and found indices are int32, other string reads stay unknown", () => {
|
|
const text: IrExpr = { loc, kind: "strLit", value: "abc", type: STRING };
|
|
const read = (method: "length" | "indexOf" | "charCodeAt", args: IrExpr[] = []): IrExpr => ({
|
|
loc,
|
|
kind: "strIntrinsic",
|
|
method,
|
|
receiver: text,
|
|
args,
|
|
type: F64,
|
|
});
|
|
const length = read("length");
|
|
const found = read("indexOf", [{ loc, kind: "strLit", value: "b", type: STRING }]);
|
|
const code = read("charCodeAt", [num(0)]);
|
|
const next = bin("+", length, num(1));
|
|
const ranges = analyzeIntegerRanges(
|
|
fn([observe(length), observe(found), observe(code), observe(next)]),
|
|
);
|
|
expect(ranges.get(length)).toEqual({ min: 0, max: 2147483647 });
|
|
expect(ranges.get(found)).toEqual({ min: -1, max: 2147483647 });
|
|
// charCodeAt can be NaN.
|
|
expect(ranges.get(code)).toBeNull();
|
|
expect(ranges.get(next)).toEqual({ min: 1, max: 2147483648 });
|
|
});
|
|
|
|
const BITWISE: Record<BitwiseOp, (left: number, right: number) => number> = {
|
|
"&": (left, right) => left & right,
|
|
"|": (left, right) => left | right,
|
|
"^": (left, right) => left ^ right,
|
|
"<<": (left, right) => left << right,
|
|
">>": (left, right) => left >> right,
|
|
">>>": (left, right) => left >>> right,
|
|
};
|
|
|
|
test("bitwise result ranges contain every JS result over their operands", () => {
|
|
const ranges: { min: number; max: number }[] = [
|
|
{ min: 0, max: 0 },
|
|
{ min: 0, max: 1 },
|
|
{ min: 0, max: 100 },
|
|
{ min: 3, max: 5 },
|
|
{ min: -1, max: -1 },
|
|
{ min: -7, max: -2 },
|
|
{ min: -5, max: 9 },
|
|
{ min: 0, max: 31 },
|
|
{ min: 30, max: 33 },
|
|
{ min: 2 ** 31 - 2, max: 2 ** 31 + 1 },
|
|
{ min: 2 ** 31, max: 2 ** 32 - 1 },
|
|
{ min: 2 ** 32 - 1, max: 2 ** 32 + 1 },
|
|
{ min: -(2 ** 31) - 1, max: -(2 ** 31) + 1 },
|
|
{ min: -(2 ** 31), max: 2 ** 31 - 1 },
|
|
{ min: 0, max: 2 ** 32 - 1 },
|
|
{ min: -(2 ** 53), max: 2 ** 53 },
|
|
];
|
|
const literals = [
|
|
-0,
|
|
0.5,
|
|
-1.5,
|
|
NaN,
|
|
Infinity,
|
|
-Infinity,
|
|
2 ** 31,
|
|
2 ** 32,
|
|
2 ** 32 + 3,
|
|
2 ** 53 + 2,
|
|
];
|
|
const samples = (operand: BitwiseOperand): number[] => {
|
|
if (typeof operand === "number") return [operand];
|
|
if (operand === null) return [...literals, 7, -7, 2 ** 31 - 1, -(2 ** 31)];
|
|
const { min, max } = operand;
|
|
const values = new Set([min, max, Math.floor((min + max) / 2), min + 1, max - 1]);
|
|
if (max - min <= 64) for (let value = min; value <= max; value++) values.add(value);
|
|
return [...values].filter((value) => value >= min && value <= max);
|
|
};
|
|
const operands: BitwiseOperand[] = [null, ...ranges, ...literals, 0, -1, 3, 31, 32, 255];
|
|
for (const op of Object.keys(BITWISE) as BitwiseOp[])
|
|
for (const left of operands)
|
|
for (const right of operands) {
|
|
const range = bitwiseRange(op, left, right);
|
|
for (const a of samples(left))
|
|
for (const b of samples(right)) {
|
|
const result = BITWISE[op](a, b);
|
|
if (result < range.min || result > range.max || Object.is(result, -0))
|
|
throw new Error(
|
|
`${a} ${op} ${b} = ${result} outside [${range.min}, ${range.max}] (operands ${JSON.stringify(left)}, ${JSON.stringify(right)})`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("bitwise ranges keep known operand ranges", () => {
|
|
const small = { min: 0, max: 100 };
|
|
expect(bitwiseRange("|", small, 0)).toEqual(small);
|
|
expect(bitwiseRange("|", 0, small)).toEqual(small);
|
|
expect(bitwiseRange("^", small, 2 ** 32)).toEqual(small);
|
|
expect(bitwiseRange("&", small, -1)).toEqual(small);
|
|
expect(bitwiseRange("&", null, 255)).toEqual({ min: 0, max: 255 });
|
|
expect(bitwiseRange("|", small, { min: 1, max: 2 })).toEqual({ min: 1, max: 127 });
|
|
expect(bitwiseRange(">>", small, 1)).toEqual({ min: 0, max: 50 });
|
|
expect(bitwiseRange(">>", { min: -9, max: 9 }, null)).toEqual({ min: -9, max: 9 });
|
|
expect(bitwiseRange("<<", small, 2)).toEqual({ min: 0, max: 400 });
|
|
expect(bitwiseRange(">>>", null, 1)).toEqual({ min: 0, max: 2 ** 31 - 1 });
|
|
expect(bitwiseRange(">>>", { min: -1, max: -1 }, 0)).toEqual({
|
|
min: 2 ** 32 - 1,
|
|
max: 2 ** 32 - 1,
|
|
});
|
|
expect(bitwiseRange("|", { min: 2 ** 31, max: 2 ** 31 }, 0)).toEqual({
|
|
min: -(2 ** 31),
|
|
max: -(2 ** 31),
|
|
});
|
|
expect(bitwiseRange("|", null, 0)).toEqual(INT32);
|
|
expect(bitwiseRange("|", NaN, 0)).toEqual({ min: 0, max: 0 });
|
|
// The analysis carries operand ranges through `| 0` and shifts.
|
|
const length: IrExpr = {
|
|
loc,
|
|
kind: "strIntrinsic",
|
|
method: "length",
|
|
receiver: { loc, kind: "varRef", localId: "s", type: STRING },
|
|
args: [],
|
|
type: F64,
|
|
};
|
|
const truncated = bin("|", length, num(0));
|
|
const half = bin(">>", length, num(1));
|
|
const analyzed = analyzeIntegerRanges(
|
|
fn([observe(truncated), observe(half), observe(bin("|", ref("unknown"), num(0)))]),
|
|
);
|
|
expect(analyzed.get(truncated)).toEqual({ min: 0, max: 2 ** 31 - 1 });
|
|
expect(analyzed.get(half)).toEqual({ min: 0, max: 2 ** 30 - 1 });
|
|
});
|
|
|
|
test("bitwise identity operands follow ToInt32 and the shift-count mask", () => {
|
|
const x = ref();
|
|
const identity = (op: BitwiseOp, left: IrExpr, right: IrExpr) =>
|
|
bitwiseIdentityOperand(bin(op, left, right) as IrExpr & { kind: "bin" });
|
|
expect(identity("|", x, num(0))).toBe("left");
|
|
expect(identity("|", num(0), x)).toBe("right");
|
|
expect(identity("|", x, num(-0))).toBe("left");
|
|
expect(identity("|", x, num(2 ** 32))).toBe("left");
|
|
expect(identity("^", x, num(NaN))).toBe("left");
|
|
expect(identity("&", x, num(-1))).toBe("left");
|
|
expect(identity("&", num(2 ** 32 - 1), x)).toBe("right");
|
|
expect(identity("<<", x, num(0))).toBe("left");
|
|
expect(identity(">>", x, num(32))).toBe("left");
|
|
expect(identity(">>>", x, num(0))).toBe("left");
|
|
expect(identity("|", x, num(1))).toBeNull();
|
|
expect(identity("&", x, num(0))).toBeNull();
|
|
expect(identity(">>", x, num(1))).toBeNull();
|
|
expect(identity("<<", num(0), x)).toBeNull();
|
|
expect(identity(">>>", num(0), x)).toBeNull();
|
|
});
|