1144 lines
34 KiB
TypeScript
1144 lines
34 KiB
TypeScript
import { fc, it } from "@fast-check/vitest";
|
|
import { describe, expect } from "vitest";
|
|
|
|
import { analyzeJavaScriptSemantics } from "../src/domain/javascriptSemanticAnalysis.js";
|
|
import {
|
|
semanticBinding,
|
|
semanticReferenceAt,
|
|
type JavaScriptSemanticBinding,
|
|
type JavaScriptSemanticIr,
|
|
} from "../src/domain/javascriptSemanticIr.js";
|
|
|
|
describe("JavaScript semantic analysis", () => {
|
|
it("resolves imports, require destructuring, aliases, assignments, and shadowing", () => {
|
|
const ir = analyzeJavaScriptSemantics(`
|
|
import { ipcRenderer as ir } from "electron";
|
|
const { ipcMain: bus } = require("electron");
|
|
const forwarded = bus;
|
|
let assigned;
|
|
assigned = ir;
|
|
ir.invoke("outside");
|
|
bus.handle("main", handler);
|
|
function local(ipcRenderer) {
|
|
const bus = require("./local-bus.js");
|
|
ipcRenderer.send("shadowed");
|
|
return bus;
|
|
}
|
|
`);
|
|
|
|
expect(ir.coverage).toEqual({
|
|
status: "complete",
|
|
omittedCount: 0,
|
|
limitsReached: [],
|
|
});
|
|
expect(origin(topLevelBinding(ir, "ir"))).toEqual({
|
|
specifier: "electron",
|
|
importedPath: ["ipcRenderer"],
|
|
});
|
|
expect(origin(topLevelBinding(ir, "bus"))).toEqual({
|
|
specifier: "electron",
|
|
importedPath: ["ipcMain"],
|
|
});
|
|
expect(origin(topLevelBinding(ir, "forwarded"))).toEqual({
|
|
specifier: "electron",
|
|
importedPath: ["ipcMain"],
|
|
});
|
|
expect(origin(topLevelBinding(ir, "assigned"))).toEqual({
|
|
specifier: "electron",
|
|
importedPath: ["ipcRenderer"],
|
|
});
|
|
expect(ir.moduleLinks).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
kind: "require",
|
|
specifier: "electron",
|
|
importedName: "ipcMain",
|
|
localName: "bus",
|
|
}),
|
|
]),
|
|
);
|
|
|
|
const innerBus = bindingsNamed(ir, "bus").find(
|
|
({ scopeId }) => scopeId !== programScope(ir).scopeId,
|
|
);
|
|
expect(innerBus).toBeDefined();
|
|
if (innerBus === undefined) return;
|
|
expect(origin(innerBus)).toEqual({
|
|
specifier: "./local-bus.js",
|
|
importedPath: [],
|
|
});
|
|
const shadow = onlyBinding(ir, "ipcRenderer");
|
|
expect(shadow.provenance).toEqual({
|
|
status: "local",
|
|
origins: [],
|
|
reason: null,
|
|
});
|
|
const shadowReference = ir.references.find(
|
|
({ name, bindingId, role }) =>
|
|
name === "ipcRenderer" &&
|
|
bindingId === shadow.bindingId &&
|
|
role === "read",
|
|
);
|
|
expect(shadowReference?.resolution).toBe("resolved");
|
|
expect(shadowReference?.bindingId).not.toBe(
|
|
topLevelBinding(ir, "ir").bindingId,
|
|
);
|
|
if (shadowReference === undefined || shadowReference.bindingId === null)
|
|
throw new Error("Missing shadow reference");
|
|
expect(semanticBinding(ir, shadowReference.bindingId)).toEqual(shadow);
|
|
expect(
|
|
semanticReferenceAt(
|
|
ir,
|
|
shadowReference.location.start.line,
|
|
shadowReference.location.start.column,
|
|
),
|
|
).toEqual(shadowReference);
|
|
});
|
|
|
|
it("propagates literal, template, object, conditional, and destructured values", () => {
|
|
const ir = analyzeJavaScriptSemantics(`
|
|
const prefix = "rea";
|
|
const suffix = "open";
|
|
const channel = \`${"${prefix}"}:${"${suffix}"}\`;
|
|
const options = {
|
|
channel,
|
|
mode: enabled ? "read" : "write",
|
|
};
|
|
const selected = options.channel;
|
|
const { mode } = options;
|
|
const [first] = ["zero", "one"];
|
|
`);
|
|
|
|
expect(topLevelBinding(ir, "channel").value).toEqual({
|
|
status: "literal",
|
|
value: "rea:open",
|
|
});
|
|
expect(topLevelBinding(ir, "selected").value).toEqual({
|
|
status: "literal",
|
|
value: "rea:open",
|
|
});
|
|
expect(topLevelBinding(ir, "mode").value).toEqual({
|
|
status: "union",
|
|
values: ["read", "write"],
|
|
});
|
|
expect(topLevelBinding(ir, "first").value).toEqual({
|
|
status: "literal",
|
|
value: "zero",
|
|
});
|
|
});
|
|
|
|
it("retains ESM, re-export, require, and CommonJS export relationships", () => {
|
|
const ir = analyzeJavaScriptSemantics(`
|
|
export { ipcRenderer as bridge } from "electron";
|
|
export * from "./wrapper.js";
|
|
export const localValue = 1;
|
|
const addon = require("./native.node");
|
|
module.exports.addon = addon;
|
|
export default function namedDefault() {}
|
|
`);
|
|
|
|
expect(ir.moduleLinks).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
kind: "re-export",
|
|
specifier: "electron",
|
|
importedName: "ipcRenderer",
|
|
exportedName: "bridge",
|
|
}),
|
|
expect.objectContaining({
|
|
kind: "re-export",
|
|
specifier: "./wrapper.js",
|
|
importedName: "*",
|
|
exportedName: "*",
|
|
}),
|
|
expect.objectContaining({
|
|
kind: "export",
|
|
localName: "localValue",
|
|
exportedName: "localValue",
|
|
}),
|
|
expect.objectContaining({
|
|
kind: "require",
|
|
specifier: "./native.node",
|
|
localName: "addon",
|
|
}),
|
|
expect.objectContaining({
|
|
kind: "commonjs-export",
|
|
localName: "addon",
|
|
exportedName: "addon",
|
|
}),
|
|
expect.objectContaining({
|
|
kind: "export",
|
|
localName: "namedDefault",
|
|
exportedName: "default",
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("keeps function, class, and method identities separate from bindings", () => {
|
|
const ir = analyzeJavaScriptSemantics(`
|
|
class Service {
|
|
run() {}
|
|
get value() { return 1; }
|
|
#privateMethod() {}
|
|
}
|
|
const arrow = () => 1;
|
|
const object = { method() {} };
|
|
`);
|
|
|
|
expect(ir.callables).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ kind: "class", name: "Service" }),
|
|
expect.objectContaining({ kind: "method", name: "run" }),
|
|
expect.objectContaining({ kind: "method", name: "value" }),
|
|
expect.objectContaining({ kind: "method", name: "#privateMethod" }),
|
|
expect.objectContaining({ kind: "function", name: "arrow" }),
|
|
expect.objectContaining({ kind: "method", name: "method" }),
|
|
]),
|
|
);
|
|
expect(bindingsNamed(ir, "run")).toEqual([]);
|
|
expect(bindingsNamed(ir, "method")).toEqual([]);
|
|
});
|
|
|
|
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.schemaVersion).toBe(4);
|
|
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: semanticBinding(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).not.toBeNull();
|
|
});
|
|
|
|
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("bounds Promise recovery with an explicit frontier", () => {
|
|
const ir = analyzeJavaScriptSemantics(
|
|
`
|
|
Promise.resolve(1);
|
|
Promise.resolve(2);
|
|
Promise.resolve(3);
|
|
`,
|
|
{ maxPromiseOperations: 1 },
|
|
);
|
|
|
|
expect(ir.promiseOperations).toHaveLength(1);
|
|
expect(ir.coverage).toMatchObject({
|
|
status: "truncated",
|
|
limitsReached: ["maxPromiseOperations"],
|
|
});
|
|
});
|
|
|
|
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("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.",
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("recovers EventEmitter candidates and imported timer handle cancellation", () => {
|
|
const ir = analyzeJavaScriptSemantics(`
|
|
import {
|
|
setTimeout as later,
|
|
clearTimeout as cancel,
|
|
} from "node:timers";
|
|
function run(bus, dynamicName) {
|
|
const handle = later(work, 25);
|
|
bus.on("ready", handler);
|
|
bus.once(dynamicName, handler);
|
|
bus.emit("ready");
|
|
bus.off("ready", handler);
|
|
cancel(handle);
|
|
}
|
|
`);
|
|
|
|
expect(
|
|
ir.eventOperations.map(({ kind, method, eventName, resolution }) => ({
|
|
kind,
|
|
method,
|
|
eventName,
|
|
resolution,
|
|
})),
|
|
).toEqual([
|
|
{
|
|
kind: "register",
|
|
method: "on",
|
|
eventName: "ready",
|
|
resolution: "complete",
|
|
},
|
|
{
|
|
kind: "register",
|
|
method: "once",
|
|
eventName: null,
|
|
resolution: "unresolved",
|
|
},
|
|
{
|
|
kind: "dispatch",
|
|
method: "emit",
|
|
eventName: "ready",
|
|
resolution: "complete",
|
|
},
|
|
{
|
|
kind: "remove",
|
|
method: "off",
|
|
eventName: "ready",
|
|
resolution: "complete",
|
|
},
|
|
]);
|
|
expect(ir.timerOperations).toEqual([
|
|
expect.objectContaining({
|
|
kind: "schedule",
|
|
method: "setTimeout",
|
|
delayMilliseconds: 25,
|
|
resolution: "complete",
|
|
}),
|
|
expect.objectContaining({
|
|
kind: "cancel",
|
|
method: "clearTimeout",
|
|
delayMilliseconds: null,
|
|
resolution: "complete",
|
|
linkedTimerId: ir.timerOperations[0]?.timerId,
|
|
}),
|
|
]);
|
|
expect(ir.functionFingerprints[0]?.components.effects).toEqual([
|
|
"event",
|
|
"timer",
|
|
]);
|
|
});
|
|
|
|
it("does not treat shadowed timer globals as Node timers", () => {
|
|
const ir = analyzeJavaScriptSemantics(`
|
|
function run(setTimeout, clearTimeout) {
|
|
const handle = setTimeout(work, 1);
|
|
clearTimeout(handle);
|
|
}
|
|
`);
|
|
|
|
expect(ir.timerOperations).toEqual([]);
|
|
});
|
|
|
|
it("recovers child-process argv, env, stdio, listeners, and signals", () => {
|
|
const ir = analyzeJavaScriptSemantics(`
|
|
import { spawn as launch } from "node:child_process";
|
|
function run() {
|
|
const child = launch(
|
|
"/bin/tool",
|
|
["--mode", "fast"],
|
|
{ env: { MODE: "test" }, stdio: ["ignore", "pipe", "pipe"] },
|
|
);
|
|
child.on("exit", onExit);
|
|
child.once("error", onError);
|
|
child.kill("SIGINT");
|
|
}
|
|
`);
|
|
|
|
expect(ir.childProcessSpawns).toEqual([
|
|
expect.objectContaining({
|
|
method: "spawn",
|
|
command: "/bin/tool",
|
|
argvCount: 2,
|
|
environmentSupplied: true,
|
|
stdioMode: "array",
|
|
resolution: "complete",
|
|
}),
|
|
]);
|
|
expect(
|
|
ir.childProcessInteractions.map(
|
|
({ kind, eventName, signalName, resolution }) => ({
|
|
kind,
|
|
eventName,
|
|
signalName,
|
|
resolution,
|
|
}),
|
|
),
|
|
).toEqual([
|
|
{
|
|
kind: "listener",
|
|
eventName: "exit",
|
|
signalName: null,
|
|
resolution: "complete",
|
|
},
|
|
{
|
|
kind: "listener",
|
|
eventName: "error",
|
|
signalName: null,
|
|
resolution: "complete",
|
|
},
|
|
{
|
|
kind: "signal",
|
|
eventName: null,
|
|
signalName: "SIGINT",
|
|
resolution: "complete",
|
|
},
|
|
]);
|
|
expect(ir.functionFingerprints[0]?.components.effects).toContain(
|
|
"child-process",
|
|
);
|
|
});
|
|
|
|
it("recovers configuration, requests, response consumers, and boundaries", () => {
|
|
const ir = analyzeJavaScriptSemantics(`
|
|
import { readFileSync } from "node:fs";
|
|
import * as http from "node:http";
|
|
async function run(schema) {
|
|
const endpoint = process.env.API_URL ?? "https://fallback.test";
|
|
const mode = process.argv[2];
|
|
const raw = readFileSync("./config.json", "utf8");
|
|
const parsed = JSON.parse(raw);
|
|
const port = Number(process.env.PORT);
|
|
const validated = schema.parse(parsed);
|
|
const response = await fetch(endpoint, {
|
|
method: "POST",
|
|
body: validated,
|
|
});
|
|
const body = await response.json();
|
|
http.request("https://audit.test", { body });
|
|
return { mode, port, body };
|
|
}
|
|
`);
|
|
|
|
expect(
|
|
ir.configurationOperations.map(({ kind, key }) => ({ kind, key })),
|
|
).toEqual(
|
|
expect.arrayContaining([
|
|
{ kind: "environment", key: "API_URL" },
|
|
{ kind: "default", key: "API_URL" },
|
|
{ kind: "argv", key: "2" },
|
|
{ kind: "file", key: "./config.json" },
|
|
{ kind: "environment", key: "PORT" },
|
|
]),
|
|
);
|
|
expect(
|
|
ir.requestOperations.map(({ kind, method, endpoint, resolution }) => ({
|
|
kind,
|
|
method,
|
|
endpoint,
|
|
resolution,
|
|
})),
|
|
).toEqual(
|
|
expect.arrayContaining([
|
|
{
|
|
kind: "request",
|
|
method: "fetch",
|
|
endpoint: null,
|
|
resolution: "partial",
|
|
},
|
|
{
|
|
kind: "response-consumer",
|
|
method: "json",
|
|
endpoint: null,
|
|
resolution: "complete",
|
|
},
|
|
{
|
|
kind: "request",
|
|
method: "request",
|
|
endpoint: "https://audit.test",
|
|
resolution: "complete",
|
|
},
|
|
]),
|
|
);
|
|
expect(
|
|
ir.boundaryOperations.map(({ kind, method, resolution }) => ({
|
|
kind,
|
|
method,
|
|
resolution,
|
|
})),
|
|
).toEqual(
|
|
expect.arrayContaining([
|
|
{ kind: "parse", method: "JSON.parse", resolution: "complete" },
|
|
{ kind: "coerce", method: "Number", resolution: "complete" },
|
|
{ kind: "parse", method: "parse", resolution: "partial" },
|
|
]),
|
|
);
|
|
expect(ir.functionFingerprints[0]?.components.effects).toContain("network");
|
|
});
|
|
|
|
it("recovers built-in resource acquisition and exact local release", () => {
|
|
const ir = analyzeJavaScriptSemantics(`
|
|
import { open } from "node:fs/promises";
|
|
import { connect as dial } from "node:net";
|
|
async function run(path) {
|
|
const file = await open(path);
|
|
const socket = dial({ port: 9000 });
|
|
await file.close();
|
|
socket.destroy();
|
|
}
|
|
`);
|
|
|
|
expect(
|
|
ir.resourceOperations.map(({ kind, method, resolution }) => ({
|
|
kind,
|
|
method,
|
|
resolution,
|
|
})),
|
|
).toEqual([
|
|
{ kind: "acquire", method: "open", resolution: "complete" },
|
|
{ kind: "acquire", method: "connect", resolution: "complete" },
|
|
{ kind: "release", method: "close", resolution: "complete" },
|
|
{ kind: "release", method: "destroy", resolution: "complete" },
|
|
]);
|
|
expect(ir.functionFingerprints[0]?.components.effects).toContain(
|
|
"resource",
|
|
);
|
|
});
|
|
|
|
it("bounds every added semantic effect family independently", () => {
|
|
const ir = analyzeJavaScriptSemantics(
|
|
`
|
|
import { spawn } from "node:child_process";
|
|
import { open } from "node:fs/promises";
|
|
Promise.resolve(1);
|
|
bus.on("ready", handler);
|
|
setTimeout(work, 1);
|
|
spawn("/bin/tool");
|
|
const endpoint = process.env.API_URL;
|
|
fetch("https://example.test");
|
|
JSON.parse(raw);
|
|
open(path);
|
|
`,
|
|
{
|
|
maxPromiseOperations: 0,
|
|
maxEventOperations: 0,
|
|
maxTimerOperations: 0,
|
|
maxChildProcessOperations: 0,
|
|
maxConfigurationOperations: 0,
|
|
maxRequestOperations: 0,
|
|
maxBoundaryOperations: 0,
|
|
maxResourceOperations: 0,
|
|
maxObjectOperations: 0,
|
|
},
|
|
);
|
|
|
|
expect(ir.coverage.limitsReached).toEqual(
|
|
expect.arrayContaining([
|
|
"maxBoundaryOperations",
|
|
"maxChildProcessOperations",
|
|
"maxConfigurationOperations",
|
|
"maxEventOperations",
|
|
"maxPromiseOperations",
|
|
"maxRequestOperations",
|
|
"maxResourceOperations",
|
|
"maxTimerOperations",
|
|
"maxObjectOperations",
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("recovers static object reads, writes, spreads, and destructuring", () => {
|
|
const ir = analyzeJavaScriptSemantics(`
|
|
const source = { token: "TOKEN", count: 1 };
|
|
const { token } = source;
|
|
const copy = { ...source };
|
|
source.count = 2;
|
|
const read = source.token;
|
|
`);
|
|
|
|
expect(
|
|
ir.objectOperations.map(({ kind, propertyName, resolution }) => ({
|
|
kind,
|
|
propertyName,
|
|
resolution,
|
|
})),
|
|
).toEqual(
|
|
expect.arrayContaining([
|
|
{
|
|
kind: "destructure",
|
|
propertyName: "token",
|
|
resolution: "complete",
|
|
},
|
|
{ kind: "spread", propertyName: null, resolution: "complete" },
|
|
{ kind: "write", propertyName: "count", resolution: "complete" },
|
|
{ kind: "read", propertyName: "token", resolution: "complete" },
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("does not project positional argument flow after a spread", () => {
|
|
const ir = analyzeJavaScriptSemantics(`
|
|
function target(first, second, third) { return third; }
|
|
target(one, ...rest, three);
|
|
`);
|
|
|
|
expect(ir.argumentFlows.map(({ argumentIndex }) => argumentIndex)).toEqual([
|
|
0,
|
|
]);
|
|
});
|
|
|
|
it("applies independent hard bounds to every new semantic fact family", () => {
|
|
const ir = analyzeJavaScriptSemantics(
|
|
`
|
|
const captured = 1;
|
|
function target(value, other) { return value + other + captured; }
|
|
target(1, 2);
|
|
target(3, 4);
|
|
object[first];
|
|
object[second];
|
|
`,
|
|
{
|
|
maxCallSites: 1,
|
|
maxCallArguments: 1,
|
|
maxArgumentFlows: 0,
|
|
maxCallReturnFlows: 0,
|
|
maxClosureCaptures: 0,
|
|
maxFrontiers: 1,
|
|
},
|
|
);
|
|
|
|
expect(ir.callSites).toHaveLength(1);
|
|
expect(ir.callSites[0]?.arguments).toEqual([
|
|
expect.objectContaining({ index: 0, spread: false }),
|
|
]);
|
|
expect(ir.argumentFlows).toEqual([]);
|
|
expect(ir.callReturnFlows).toEqual([]);
|
|
expect(ir.closureCaptures).toEqual([]);
|
|
expect(ir.frontiers).toHaveLength(1);
|
|
expect(ir.coverage).toMatchObject({
|
|
status: "truncated",
|
|
limitsReached: expect.arrayContaining([
|
|
"maxCallSites",
|
|
"maxCallArguments",
|
|
"maxArgumentFlows",
|
|
"maxCallReturnFlows",
|
|
"maxClosureCaptures",
|
|
"maxFrontiers",
|
|
]),
|
|
});
|
|
});
|
|
|
|
it("recovers only direct return sites with literal and unknown object fields", () => {
|
|
const ir = analyzeJavaScriptSemantics(`
|
|
export default function parseMarkdown(value) {
|
|
if (value.startsWith("# "))
|
|
return { type: "heading", depth: 1, text: value.slice(2) };
|
|
function nested() { return { type: "nested" }; }
|
|
const arrow = () => ({ type: "arrow" });
|
|
void nested; void arrow;
|
|
return { type: "paragraph", text: value.replaceAll("x", "y") };
|
|
}
|
|
`);
|
|
|
|
const parse = onlyCallable(ir, "parseMarkdown");
|
|
expect(parse.returnCoverage).toEqual({
|
|
status: "complete",
|
|
retainedCount: 2,
|
|
omittedCount: 0,
|
|
limitsReached: [],
|
|
});
|
|
expect(parse.returnSites).toHaveLength(2);
|
|
expect(parse.returnSites[0]?.value).toMatchObject({
|
|
status: "object",
|
|
unknownProperties: false,
|
|
omittedProperties: 0,
|
|
properties: expect.arrayContaining([
|
|
{ name: "depth", value: { status: "literal", value: 1 } },
|
|
{ name: "type", value: { status: "literal", value: "heading" } },
|
|
{
|
|
name: "text",
|
|
value: {
|
|
status: "unknown",
|
|
reason: "Unsupported CallExpression value.",
|
|
},
|
|
},
|
|
]),
|
|
});
|
|
expect(onlyCallable(ir, "nested").returnSites).toHaveLength(1);
|
|
expect(onlyCallable(ir, "arrow").returnSites).toHaveLength(1);
|
|
expect(ir.moduleLinks).toContainEqual(
|
|
expect.objectContaining({
|
|
exportedName: "default",
|
|
callableId: parse.callableId,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("retains partial property coverage, empty returns, and return limits", () => {
|
|
const partial = analyzeJavaScriptSemantics(`
|
|
const spread = () => ({ type: "spread", ...dynamic });
|
|
const computed = () => ({ type: "computed", [key]: 1 });
|
|
function noReturn() { throw new Error("stop"); }
|
|
function emptyReturn() { return; }
|
|
`);
|
|
expect(onlyCallable(partial, "spread").returnSites[0]?.value).toMatchObject(
|
|
{
|
|
status: "object",
|
|
unknownProperties: true,
|
|
omittedProperties: null,
|
|
},
|
|
);
|
|
expect(
|
|
onlyCallable(partial, "computed").returnSites[0]?.value,
|
|
).toMatchObject({
|
|
status: "object",
|
|
unknownProperties: true,
|
|
omittedProperties: 1,
|
|
});
|
|
expect(onlyCallable(partial, "noReturn").returnSites).toEqual([]);
|
|
expect(onlyCallable(partial, "emptyReturn").returnSites[0]?.value).toEqual({
|
|
status: "unknown",
|
|
reason: "Return has no value.",
|
|
});
|
|
|
|
const limited = analyzeJavaScriptSemantics(
|
|
"function many(value) { if (value) return 1; return 2; }",
|
|
{ maxReturnSites: 1 },
|
|
);
|
|
expect(onlyCallable(limited, "many")).toMatchObject({
|
|
returnSites: [{ value: { status: "literal", value: 1 } }],
|
|
returnCoverage: {
|
|
status: "truncated",
|
|
retainedCount: 1,
|
|
omittedCount: 1,
|
|
limitsReached: ["maxReturnSites"],
|
|
},
|
|
});
|
|
expect(limited.coverage.limitsReached).toContain("maxReturnSites");
|
|
});
|
|
|
|
it("fails closed on assignment ambiguity, alias cycles, and lattice limits", () => {
|
|
const ambiguous = analyzeJavaScriptSemantics(`
|
|
let current = "first";
|
|
current = "second";
|
|
const left = right;
|
|
const right = left;
|
|
`);
|
|
expect(topLevelBinding(ambiguous, "current").value).toMatchObject({
|
|
status: "ambiguous",
|
|
});
|
|
expect(topLevelBinding(ambiguous, "left").value).toMatchObject({
|
|
status: "cycle",
|
|
});
|
|
expect(topLevelBinding(ambiguous, "right").provenance).toMatchObject({
|
|
status: "cycle",
|
|
});
|
|
|
|
const limited = analyzeJavaScriptSemantics(
|
|
'const channel = enabled ? "one" : "two";',
|
|
{ maxUnionValues: 1 },
|
|
);
|
|
expect(topLevelBinding(limited, "channel").value).toEqual({
|
|
status: "limit-reached",
|
|
reason: "maxUnionValues reached.",
|
|
});
|
|
expect(limited.coverage).toMatchObject({
|
|
status: "truncated",
|
|
limitsReached: ["maxUnionValues"],
|
|
});
|
|
expect(limited.coverage.omittedCount).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("does not invent exact module paths for dynamic property access", () => {
|
|
const ir = analyzeJavaScriptSemantics(`
|
|
const key = getKey();
|
|
const dynamicMember = require("electron")[key];
|
|
const { [key]: dynamicBinding } = require("electron");
|
|
`);
|
|
|
|
expect(topLevelBinding(ir, "dynamicMember").provenance).toMatchObject({
|
|
status: "unknown",
|
|
origins: [],
|
|
});
|
|
expect(topLevelBinding(ir, "dynamicBinding").provenance).toMatchObject({
|
|
status: "unknown",
|
|
origins: [],
|
|
});
|
|
expect(
|
|
ir.moduleLinks.filter(({ localName }) =>
|
|
["dynamicMember", "dynamicBinding"].includes(localName ?? ""),
|
|
),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("bounds retained scopes and bindings without corrupting outer resolution", () => {
|
|
const ir = analyzeJavaScriptSemantics(
|
|
`
|
|
const retained = "yes";
|
|
const omitted = "no";
|
|
retained;
|
|
function nested(value) { return retained + value; }
|
|
`,
|
|
{ maxBindings: 1, maxCallables: 0, maxScopes: 1 },
|
|
);
|
|
|
|
expect(ir.bindings.map(({ name }) => name)).toEqual(["retained"]);
|
|
expect(ir.callables).toEqual([]);
|
|
expect(ir.coverage.status).toBe("truncated");
|
|
expect(ir.coverage.limitsReached).toEqual(
|
|
expect.arrayContaining(["maxBindings", "maxCallables", "maxScopes"]),
|
|
);
|
|
expect(ir.references.filter(({ name }) => name === "retained")).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
resolution: "resolved",
|
|
bindingId: topLevelBinding(ir, "retained").bindingId,
|
|
}),
|
|
expect.objectContaining({ resolution: "unknown", bindingId: null }),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("reports reference, module-link, depth, and object-property limits", () => {
|
|
const retained = analyzeJavaScriptSemantics(
|
|
`
|
|
const first = require("first");
|
|
const second = require("second");
|
|
first;
|
|
second;
|
|
`,
|
|
{ maxModuleLinks: 1, maxReferences: 1 },
|
|
);
|
|
expect(retained.moduleLinks).toHaveLength(1);
|
|
expect(retained.references).toHaveLength(1);
|
|
expect(retained.coverage.limitsReached).toEqual(
|
|
expect.arrayContaining(["maxModuleLinks", "maxReferences"]),
|
|
);
|
|
|
|
const object = analyzeJavaScriptSemantics(
|
|
"const object = { first: 1, second: 2 };",
|
|
{ maxObjectProperties: 1 },
|
|
);
|
|
expect(topLevelBinding(object, "object").value).toMatchObject({
|
|
status: "object",
|
|
unknownProperties: true,
|
|
});
|
|
expect(object.coverage.limitsReached).toContain("maxObjectProperties");
|
|
|
|
const depth = analyzeJavaScriptSemantics(
|
|
'const first = "value"; const second = first;',
|
|
{ maxValueDepth: 1 },
|
|
);
|
|
expect(topLevelBinding(depth, "second").value).toMatchObject({
|
|
status: "limit-reached",
|
|
});
|
|
expect(depth.coverage.limitsReached).toContain("maxValueDepth");
|
|
});
|
|
|
|
it("is deterministic and returns failed coverage for an unparseable source", () => {
|
|
const source = 'const { value: renamed } = require("fixture");';
|
|
expect(analyzeJavaScriptSemantics(source)).toEqual(
|
|
analyzeJavaScriptSemantics(source),
|
|
);
|
|
expect(analyzeJavaScriptSemantics("function {")).toMatchObject({
|
|
scopes: [],
|
|
bindings: [],
|
|
callables: [],
|
|
references: [],
|
|
coverage: { status: "failed", omittedCount: null },
|
|
});
|
|
});
|
|
|
|
it("does not mark parser-recovered duplicate bindings complete", () => {
|
|
const ir = analyzeJavaScriptSemantics(
|
|
"const duplicate = 'first'; const duplicate = 'second';",
|
|
);
|
|
|
|
expect(ir.coverage).toMatchObject({
|
|
status: "partial",
|
|
omittedCount: 0,
|
|
});
|
|
expect(topLevelBinding(ir, "duplicate").value).toMatchObject({
|
|
status: "ambiguous",
|
|
});
|
|
expect(ir.limitations.join(" ")).toMatch(/parser recovered/iu);
|
|
});
|
|
|
|
it("keeps parser-recovered return shapes partial", () => {
|
|
const ir = analyzeJavaScriptSemantics(
|
|
`
|
|
export default function recovered(value) {
|
|
const duplicate = 1;
|
|
const duplicate = 2;
|
|
return { type: "item", text: render(value) };
|
|
}
|
|
`,
|
|
);
|
|
const recovered = onlyCallable(ir, "recovered");
|
|
|
|
expect(ir.coverage.status).toBe("partial");
|
|
expect(recovered.returnCoverage).toMatchObject({
|
|
status: "partial",
|
|
retainedCount: 1,
|
|
omittedCount: 0,
|
|
});
|
|
expect(recovered.returnSites[0]?.value).toMatchObject({
|
|
status: "object",
|
|
properties: expect.arrayContaining([
|
|
{ name: "type", value: { status: "literal", value: "item" } },
|
|
expect.objectContaining({
|
|
name: "text",
|
|
value: expect.objectContaining({ status: "unknown" }),
|
|
}),
|
|
]),
|
|
});
|
|
});
|
|
|
|
it.prop([fc.string({ maxLength: 512 })])(
|
|
"fails closed for arbitrary bounded source text",
|
|
(source) => {
|
|
const ir = analyzeJavaScriptSemantics(source, {
|
|
maxBindings: 64,
|
|
maxCallables: 64,
|
|
maxModuleLinks: 64,
|
|
maxReferences: 256,
|
|
maxScopes: 64,
|
|
});
|
|
|
|
expect(ir.schema).toBe("JavaScriptSemanticIR");
|
|
expect(["complete", "partial", "truncated", "failed"]).toContain(
|
|
ir.coverage.status,
|
|
);
|
|
if (ir.coverage.status === "failed") {
|
|
expect(ir.scopes).toEqual([]);
|
|
expect(ir.bindings).toEqual([]);
|
|
expect(ir.callables).toEqual([]);
|
|
}
|
|
},
|
|
);
|
|
});
|
|
|
|
const programScope = (ir: JavaScriptSemanticIr) => {
|
|
const scope = ir.scopes.find(({ kind }) => kind === "program");
|
|
if (scope === undefined) throw new Error("Missing program scope");
|
|
return scope;
|
|
};
|
|
|
|
const bindingsNamed = (
|
|
ir: JavaScriptSemanticIr,
|
|
name: string,
|
|
): JavaScriptSemanticBinding[] =>
|
|
ir.bindings.filter(({ name: candidate }) => candidate === name);
|
|
|
|
const onlyBinding = (
|
|
ir: JavaScriptSemanticIr,
|
|
name: string,
|
|
): JavaScriptSemanticBinding => {
|
|
const bindings = bindingsNamed(ir, name);
|
|
expect(bindings).toHaveLength(1);
|
|
const binding = bindings[0];
|
|
if (binding === undefined) throw new Error(`Missing binding ${name}`);
|
|
return binding;
|
|
};
|
|
|
|
const onlyCallable = (ir: JavaScriptSemanticIr, name: string) => {
|
|
const callables = ir.callables.filter(
|
|
({ name: candidate }) => candidate === name,
|
|
);
|
|
if (callables.length !== 1 || callables[0] === undefined)
|
|
throw new Error(`Expected one callable named ${name}`);
|
|
return callables[0];
|
|
};
|
|
|
|
const topLevelBinding = (
|
|
ir: JavaScriptSemanticIr,
|
|
name: string,
|
|
): JavaScriptSemanticBinding => {
|
|
const scopeId = programScope(ir).scopeId;
|
|
const binding = ir.bindings.find(
|
|
({ name: candidate, scopeId: candidateScope }) =>
|
|
candidate === name && candidateScope === scopeId,
|
|
);
|
|
if (binding === undefined)
|
|
throw new Error(`Missing top-level binding ${name}`);
|
|
return binding;
|
|
};
|
|
|
|
const origin = (binding: JavaScriptSemanticBinding) => {
|
|
expect(binding.provenance.status).toBe("module");
|
|
expect(binding.provenance.origins).toHaveLength(1);
|
|
const value = binding.provenance.origins[0];
|
|
if (value === undefined)
|
|
throw new Error(`Missing origin for ${binding.name}`);
|
|
return value;
|
|
};
|