Files
rea/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts
T

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);
},
);
});