mirror of
https://github.com/morluto/rea
synced 2026-10-26 22:28:27 -04:00
508 lines
16 KiB
TypeScript
508 lines
16 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
|
|
import {
|
|
bindingById,
|
|
onlyCallable,
|
|
topLevelBinding,
|
|
} from "./javascriptSemanticAnalysis.fixture.js";
|
|
|
|
describe("JavaScript semantic analysis: calls 1", () => {
|
|
it("does not assign computed method-name reads to method closures", () => {
|
|
const ir = analyzeJavaScriptSemantics(`
|
|
function outer() {
|
|
const name = "m";
|
|
const object = { [name]() { return 1; }, ["literal"]() { return 2; } };
|
|
class Example {
|
|
[name]() { return 3; }
|
|
["literal"]() { return 4; }
|
|
}
|
|
}
|
|
`);
|
|
expect(ir.closureCaptures).toEqual([]);
|
|
|
|
const bodyRead = analyzeJavaScriptSemantics(`
|
|
function outer() {
|
|
const value = 1;
|
|
const object = { method() { return value; } };
|
|
}
|
|
`);
|
|
expect(bodyRead.closureCaptures).toHaveLength(1);
|
|
});
|
|
|
|
it("links exact local calls to parameters, returns, and closure captures", () => {
|
|
const ir = analyzeJavaScriptSemantics(`
|
|
const prefix = "rea";
|
|
function render(value, { mode }) {
|
|
return prefix + value + mode;
|
|
}
|
|
const alias = render;
|
|
const result = alias(input, { mode: "fast" });
|
|
`);
|
|
|
|
expect(ir.schema).toBe("JavaScriptSemanticIR");
|
|
const render = onlyCallable(ir, "render");
|
|
const call = ir.callSites.find(
|
|
({ calleeCallableIds }) => calleeCallableIds[0] === render.callableId,
|
|
);
|
|
expect(call).toMatchObject({
|
|
kind: "call",
|
|
callerCallableId: null,
|
|
resolution: "exact",
|
|
calleeCallableIds: [render.callableId],
|
|
arguments: [
|
|
{ index: 0, spread: false },
|
|
{ index: 1, spread: false },
|
|
],
|
|
});
|
|
if (call === undefined) throw new Error("Missing render call");
|
|
expect(
|
|
ir.argumentFlows
|
|
.filter(({ callSiteId }) => callSiteId === call.callSiteId)
|
|
.map(({ argumentIndex, parameterBindingId }) => ({
|
|
argumentIndex,
|
|
parameter: bindingById(ir, parameterBindingId)?.name,
|
|
})),
|
|
).toEqual([
|
|
{ argumentIndex: 0, parameter: "value" },
|
|
{ argumentIndex: 1, parameter: "mode" },
|
|
]);
|
|
expect(ir.callReturnFlows).toEqual([
|
|
expect.objectContaining({
|
|
callSiteId: call.callSiteId,
|
|
callableId: render.callableId,
|
|
returnSiteId: render.returnSites[0]?.returnSiteId,
|
|
}),
|
|
]);
|
|
expect(ir.closureCaptures).toEqual([
|
|
expect.objectContaining({
|
|
callableId: render.callableId,
|
|
bindingId: topLevelBinding(ir, "prefix").bindingId,
|
|
}),
|
|
]);
|
|
expect(ir.frontiers).toEqual([]);
|
|
});
|
|
|
|
it("recovers explicit Promise ownership, chains, aggregation, and awaits", () => {
|
|
const ir = analyzeJavaScriptSemantics(`
|
|
async function run(value) {
|
|
const base = Promise.resolve(value);
|
|
const chained = base.then(work).finally(cleanup);
|
|
await chained;
|
|
Promise.resolve(value).then(work);
|
|
return Promise.all([base, Promise.resolve(value)]);
|
|
}
|
|
const settled = Promise.allSettled([Promise.resolve(1)]);
|
|
`);
|
|
|
|
expect(
|
|
ir.promiseOperations.map(
|
|
({ kind, method, ownership, sourceResolution }) => ({
|
|
kind,
|
|
method,
|
|
ownership,
|
|
sourceResolution,
|
|
}),
|
|
),
|
|
).toEqual(
|
|
expect.arrayContaining([
|
|
{
|
|
kind: "static",
|
|
method: "resolve",
|
|
ownership: "assigned",
|
|
sourceResolution: "complete",
|
|
},
|
|
{
|
|
kind: "chain",
|
|
method: "then",
|
|
ownership: "chained",
|
|
sourceResolution: "complete",
|
|
},
|
|
{
|
|
kind: "chain",
|
|
method: "finally",
|
|
ownership: "assigned",
|
|
sourceResolution: "complete",
|
|
},
|
|
{
|
|
kind: "awaited-expression",
|
|
method: "await",
|
|
ownership: "awaited",
|
|
sourceResolution: "complete",
|
|
},
|
|
{
|
|
kind: "chain",
|
|
method: "then",
|
|
ownership: "detached",
|
|
sourceResolution: "complete",
|
|
},
|
|
{
|
|
kind: "aggregate",
|
|
method: "all",
|
|
ownership: "returned",
|
|
sourceResolution: "complete",
|
|
},
|
|
{
|
|
kind: "aggregate",
|
|
method: "allSettled",
|
|
ownership: "assigned",
|
|
sourceResolution: "complete",
|
|
},
|
|
]),
|
|
);
|
|
const aggregate = ir.promiseOperations.find(
|
|
({ method }) => method === "all",
|
|
);
|
|
expect(aggregate?.sourcePromiseIds).toHaveLength(2);
|
|
expect(aggregate?.returnSiteId).toBeTypeOf("string");
|
|
});
|
|
});
|
|
|
|
describe("JavaScript semantic analysis: computed method keys", () => {
|
|
it("evaluates computed method keys in the enclosing callable", () => {
|
|
const ir = analyzeJavaScriptSemantics(`
|
|
function outer() {
|
|
const name = "m";
|
|
const pick = () => "n";
|
|
const obj = { [name]() { return 1; }, [pick()]() { return name; } };
|
|
class C { [name]() { return 2; } [pick()]() { return 3; } }
|
|
}
|
|
`);
|
|
|
|
const outer = onlyCallable(ir, "outer");
|
|
const pickCall = ir.callSites.find(
|
|
({ calleeCallableIds }) => calleeCallableIds.length === 1,
|
|
);
|
|
expect(pickCall).toMatchObject({
|
|
callerCallableId: outer.callableId,
|
|
resolution: "exact",
|
|
});
|
|
expect(
|
|
ir.callSites.filter(
|
|
({ callerCallableId, calleeCallableIds, resolution }) =>
|
|
callerCallableId === outer.callableId &&
|
|
calleeCallableIds.length === 1 &&
|
|
resolution === "exact",
|
|
),
|
|
).toHaveLength(2);
|
|
// Only the method body that reads `name` captures it; the computed keys
|
|
// are evaluated outside every method environment.
|
|
expect(
|
|
ir.closureCaptures.map(({ callableId, bindingId }) => ({
|
|
owner: ir.callables.find(
|
|
(callable) => callable.callableId === callableId,
|
|
)?.kind,
|
|
binding: bindingById(ir, bindingId)?.name,
|
|
})),
|
|
).toEqual([{ owner: "method", binding: "name" }]);
|
|
});
|
|
|
|
it("keeps ordinary nested method-body reads uncertain", () => {
|
|
const ir = analyzeJavaScriptSemantics(`
|
|
function outer() {
|
|
const pick = () => "n";
|
|
return { method() { return pick(); } };
|
|
}
|
|
`);
|
|
const pick = onlyCallable(ir, "pick");
|
|
const pickCall = ir.callSites.find(({ calleeCallableIds }) =>
|
|
calleeCallableIds.includes(pick.callableId),
|
|
);
|
|
expect(pickCall).toMatchObject({
|
|
resolution: "ambiguous",
|
|
calleeCallableIds: [pick.callableId],
|
|
});
|
|
});
|
|
|
|
it("keeps callables nested in a computed method key in their own frames", () => {
|
|
const ir = analyzeJavaScriptSemantics(`
|
|
function outer() {
|
|
const name = "m";
|
|
const pick = () => "n";
|
|
return { [(() => pick())()]() { return name; } };
|
|
}
|
|
`);
|
|
|
|
const outer = onlyCallable(ir, "outer");
|
|
const pick = onlyCallable(ir, "pick");
|
|
const keyArrow = ir.callables.find(
|
|
({ kind, name }) => kind === "function" && name === null,
|
|
);
|
|
const method = ir.callables.find(({ kind }) => kind === "method");
|
|
expect(keyArrow).toBeDefined();
|
|
expect(method).toBeDefined();
|
|
const callerOf = (calleeCallableId: string | undefined) =>
|
|
ir.callSites.find(({ calleeCallableIds }) =>
|
|
calleeCallableIds.includes(calleeCallableId ?? ""),
|
|
)?.callerCallableId;
|
|
expect(callerOf(pick.callableId)).toBe(keyArrow?.callableId);
|
|
expect(callerOf(keyArrow?.callableId)).toBe(outer.callableId);
|
|
expect(
|
|
ir.closureCaptures.map(({ callableId, bindingId }) => ({
|
|
callableId,
|
|
binding: bindingById(ir, bindingId)?.name,
|
|
})),
|
|
).toEqual(
|
|
expect.arrayContaining([
|
|
{ callableId: keyArrow?.callableId, binding: "pick" },
|
|
{ callableId: method?.callableId, binding: "name" },
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("attributes effects in computed method keys to the enclosing callable", () => {
|
|
const ir = analyzeJavaScriptSemantics(`
|
|
import { spawn } from "node:child_process";
|
|
function outer(config) {
|
|
return {
|
|
[fetch("/x")]() {},
|
|
[setTimeout(tick, 1)]() {},
|
|
[Promise.resolve(1)]() {},
|
|
[spawn("ls")]() {},
|
|
[config.key]() {},
|
|
};
|
|
}
|
|
class C { [fetch("/y")]() {} }
|
|
`);
|
|
|
|
const outer = onlyCallable(ir, "outer");
|
|
const owners = (
|
|
operations: readonly { readonly ownerCallableId: string | null }[],
|
|
) => operations.map(({ ownerCallableId }) => ownerCallableId);
|
|
const methodIds = new Set(
|
|
ir.callables
|
|
.filter(({ kind }) => kind === "method")
|
|
.map(({ callableId }) => callableId),
|
|
);
|
|
expect(methodIds.size).toBe(6);
|
|
const classId = onlyCallable(ir, "C").callableId;
|
|
expect(owners(ir.requestOperations).sort()).toEqual(
|
|
[outer.callableId, classId].sort(),
|
|
);
|
|
expect(owners(ir.timerOperations)).toEqual([outer.callableId]);
|
|
expect(owners(ir.promiseOperations)).toEqual([outer.callableId]);
|
|
expect(owners(ir.childProcessSpawns)).toEqual([outer.callableId]);
|
|
expect(
|
|
ir.objectOperations.filter(({ propertyPath }) =>
|
|
propertyPath?.includes("key"),
|
|
),
|
|
).toEqual([expect.objectContaining({ ownerCallableId: outer.callableId })]);
|
|
for (const owner of [
|
|
...owners(ir.requestOperations),
|
|
...owners(ir.timerOperations),
|
|
...owners(ir.promiseOperations),
|
|
...owners(ir.childProcessSpawns),
|
|
...owners(ir.objectOperations),
|
|
])
|
|
expect(methodIds.has(owner ?? "")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("JavaScript semantic analysis: calls 2", () => {
|
|
it("does not treat a shadowed Promise binding as the intrinsic", () => {
|
|
const ir = analyzeJavaScriptSemantics(`
|
|
function run(Promise) {
|
|
return Promise.resolve(1).then(work);
|
|
}
|
|
`);
|
|
|
|
expect(ir.promiseOperations).toEqual([
|
|
expect.objectContaining({
|
|
kind: "chain",
|
|
method: "then",
|
|
ownership: "returned",
|
|
sourcePromiseIds: [],
|
|
sourceResolution: "unresolved",
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("fingerprints formatting and local-name changes identically", () => {
|
|
const left = analyzeJavaScriptSemantics(`
|
|
function calculate(value) {
|
|
const result = value + 1;
|
|
return result;
|
|
}
|
|
`);
|
|
const right = analyzeJavaScriptSemantics(
|
|
"function renamed(input){const output=input+1;return output}",
|
|
);
|
|
const changed = analyzeJavaScriptSemantics(
|
|
"function renamed(input){const output=input+2;return output}",
|
|
);
|
|
|
|
expect(left.functionFingerprints).toHaveLength(1);
|
|
expect(right.functionFingerprints).toHaveLength(1);
|
|
expect(left.functionFingerprints[0]?.components).toEqual(
|
|
right.functionFingerprints[0]?.components,
|
|
);
|
|
expect(
|
|
changed.functionFingerprints[0]?.components.literalSetSha256,
|
|
).not.toBe(left.functionFingerprints[0]?.components.literalSetSha256);
|
|
});
|
|
|
|
it("keeps nested callable syntax out of its owner's fingerprint", () => {
|
|
const analyze = (nested: number, owned: number) =>
|
|
analyzeJavaScriptSemantics(`
|
|
function outer() {
|
|
function inner() { return ${nested}; }
|
|
const object = { method() { return ${nested}; } };
|
|
class Nested { method() { return ${nested}; } }
|
|
return ${owned};
|
|
}
|
|
`);
|
|
const first = analyze(1, 2);
|
|
const changedNested = analyze(3, 2);
|
|
const changedOwner = analyze(1, 4);
|
|
const syntax = (ir: typeof first) =>
|
|
ir.functionFingerprints.map(({ components }) => ({
|
|
ast: components.normalizedAstSha256,
|
|
control: components.controlFlowSha256,
|
|
literals: components.literalSetSha256,
|
|
}));
|
|
const original = syntax(first);
|
|
expect(original).toHaveLength(4);
|
|
expect(syntax(changedNested)[0]).toEqual(original[0]);
|
|
for (const index of [1, 2, 3])
|
|
expect(syntax(changedNested)[index]).not.toEqual(original[index]);
|
|
expect(syntax(changedOwner)[0]).not.toEqual(original[0]);
|
|
expect(syntax(changedOwner).slice(1)).toEqual(original.slice(1));
|
|
});
|
|
|
|
it("keeps fingerprint relations ordered and effects scoped to their owner", () => {
|
|
const ordered = analyzeJavaScriptSemantics(`
|
|
function first() {}
|
|
function second(one, two) {}
|
|
function ordered() { first(); second(1, 2); }
|
|
function timerOwner() { setTimeout(work, 10); }
|
|
function networkOwner() { fetch('/data'); }
|
|
function noEffects() { return 1; }
|
|
`);
|
|
const reversed = analyzeJavaScriptSemantics(`
|
|
function first() {}
|
|
function second(one, two) {}
|
|
function ordered() { second(1, 2); first(); }
|
|
function timerOwner() { setTimeout(work, 10); }
|
|
function networkOwner() { fetch('/data'); }
|
|
function noEffects() { return 1; }
|
|
`);
|
|
const fingerprint = (ir: typeof ordered, name: string) =>
|
|
ir.functionFingerprints.find((value) =>
|
|
ir.callables.some(
|
|
(callable) =>
|
|
callable.callableId === value.callableId && callable.name === name,
|
|
),
|
|
);
|
|
|
|
expect(
|
|
fingerprint(ordered, "ordered")?.components.relationShapeSha256,
|
|
).not.toBe(
|
|
fingerprint(reversed, "ordered")?.components.relationShapeSha256,
|
|
);
|
|
expect(fingerprint(ordered, "timerOwner")?.components.effects).toContain(
|
|
"timer",
|
|
);
|
|
expect(fingerprint(ordered, "networkOwner")?.components.effects).toContain(
|
|
"network",
|
|
);
|
|
expect(fingerprint(ordered, "noEffects")?.components.effects).toEqual([]);
|
|
expect(
|
|
fingerprint(ordered, "timerOwner")?.components.effects,
|
|
).not.toContain("network");
|
|
expect(
|
|
fingerprint(ordered, "networkOwner")?.components.effects,
|
|
).not.toContain("timer");
|
|
});
|
|
|
|
it("retains ambiguous local callees and explicit dynamic frontiers", () => {
|
|
const ir = analyzeJavaScriptSemantics(`
|
|
function left(value) { return value; }
|
|
function right(value) { return value; }
|
|
const selected = chooseLeft ? left : right;
|
|
selected(input);
|
|
receiver[key](input);
|
|
external(input);
|
|
`);
|
|
|
|
const selected = ir.callSites.find(
|
|
({ resolution }) => resolution === "ambiguous",
|
|
);
|
|
expect(selected?.calleeCallableIds).toEqual(
|
|
[
|
|
onlyCallable(ir, "left").callableId,
|
|
onlyCallable(ir, "right").callableId,
|
|
].sort(),
|
|
);
|
|
expect(
|
|
ir.argumentFlows.filter(
|
|
({ callSiteId }) => callSiteId === selected?.callSiteId,
|
|
),
|
|
).toHaveLength(2);
|
|
expect(ir.frontiers).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
kind: "dynamic-call",
|
|
reason: expect.stringMatching(/ambiguous/iu),
|
|
}),
|
|
expect.objectContaining({
|
|
kind: "dynamic-property",
|
|
reason: expect.stringMatching(/computed member/iu),
|
|
}),
|
|
expect.objectContaining({
|
|
kind: "dynamic-call",
|
|
reason: "Unresolved call target external.",
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("JavaScript semantic rest parameter flow", () => {
|
|
it.each([
|
|
{
|
|
parameters: "first, ...rest",
|
|
arguments: "1, 2, 3",
|
|
expected: [
|
|
[0, "first"],
|
|
[1, "rest"],
|
|
[2, "rest"],
|
|
],
|
|
},
|
|
{
|
|
parameters: "...rest",
|
|
arguments: "1, 2, 3",
|
|
expected: [
|
|
[0, "rest"],
|
|
[1, "rest"],
|
|
[2, "rest"],
|
|
],
|
|
},
|
|
{
|
|
parameters: "first, second",
|
|
arguments: "1, 2, 3",
|
|
expected: [
|
|
[0, "first"],
|
|
[1, "second"],
|
|
],
|
|
},
|
|
{
|
|
parameters: "first, ...rest",
|
|
arguments: "1, ...values, 3",
|
|
expected: [[0, "first"]],
|
|
},
|
|
])(
|
|
"links exact positions for ($parameters) called with ($arguments)",
|
|
({ parameters, arguments: arguments_, expected }) => {
|
|
const ir = analyzeJavaScriptSemantics(
|
|
`function collect(${parameters}) { return 1; } collect(${arguments_});`,
|
|
);
|
|
expect(
|
|
ir.argumentFlows.map(({ argumentIndex, parameterBindingId }) => [
|
|
argumentIndex,
|
|
bindingById(ir, parameterBindingId)?.name,
|
|
]),
|
|
).toEqual(expected);
|
|
},
|
|
);
|
|
});
|