mirror of
https://github.com/morluto/rea
synced 2026-10-26 22:28:27 -04:00
7436ac8e20
The semantic collectors admitted Node timers, fetch, WebSocket, JSON.parse and global coercions from the identifier spelling alone, so a source that replaced those globals (`setTimeout = () => null`) still produced resolved built-in effects. Require the existing isUnmodifiedGlobalReference guard, which the static analyzer already uses for fetch and WebSocket, before admitting these effects. The guard treats any visible possible write as a replacement without evaluating order or reachability, so computed global writes and guarded polyfills also remove the resolved effect. The workflow docs now say so. Fixes #3367
279 lines
8.0 KiB
TypeScript
279 lines
8.0 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
|
|
|
|
describe("JavaScript semantic analysis: runtime boundaries 1", () => {
|
|
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("keeps an overflowing timer delay unknown in a complete semantic result", () => {
|
|
const ir = analyzeJavaScriptSemantics(`
|
|
function greet() { return "hello"; }
|
|
setTimeout(() => greet(), 1e999);
|
|
`);
|
|
|
|
expect(ir.timerOperations).toEqual([
|
|
expect.objectContaining({
|
|
kind: "schedule",
|
|
method: "setTimeout",
|
|
delayMilliseconds: null,
|
|
resolution: "complete",
|
|
}),
|
|
]);
|
|
expect(ir.callables.some(({ name }) => name === "greet")).toBe(true);
|
|
});
|
|
|
|
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("does not invent request fields or option keys from dynamic properties", () => {
|
|
const ir = analyzeJavaScriptSemantics(`
|
|
const key = resolveKey();
|
|
fetch("https://example.test", { [key]: 1, body: "x" });
|
|
spawn("/bin/tool", { [key]: vars });
|
|
`);
|
|
expect(
|
|
ir.requestOperations.flatMap(({ fields }) =>
|
|
fields.map(({ name }) => name),
|
|
),
|
|
).toEqual(["body"]);
|
|
expect(
|
|
ir.childProcessSpawns.some(
|
|
({ environmentSupplied }) => environmentSupplied,
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("retains every added semantic effect family", () => {
|
|
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);
|
|
`,
|
|
);
|
|
|
|
expect(ir.coverage.status).toBe("complete");
|
|
expect(ir.promiseOperations).toHaveLength(1);
|
|
expect(ir.eventOperations).toHaveLength(1);
|
|
expect(ir.timerOperations).toHaveLength(1);
|
|
expect(ir.childProcessSpawns).toHaveLength(1);
|
|
expect(ir.configurationOperations).toHaveLength(1);
|
|
expect(ir.requestOperations).toHaveLength(1);
|
|
expect(ir.boundaryOperations).toHaveLength(1);
|
|
expect(ir.resourceOperations).toHaveLength(1);
|
|
expect(ir.objectOperations.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe("JavaScript semantic analysis: event dispatch data", () => {
|
|
it("keeps emitted data out of dispatch listener fields", () => {
|
|
const ir = analyzeJavaScriptSemantics(`
|
|
import { EventEmitter } from "node:events";
|
|
const bus = new EventEmitter();
|
|
const data = 1;
|
|
const handler = () => {};
|
|
bus.on("ready", handler);
|
|
bus.emit("ready", data);
|
|
bus.emit("ready");
|
|
bus.off("ready", handler);
|
|
bus.removeAllListeners("ready", data);
|
|
`);
|
|
const handler = ir.bindings.find(({ name }) => name === "handler");
|
|
expect(handler).toBeDefined();
|
|
expect(
|
|
ir.eventOperations.map(
|
|
({ kind, method, listenerBindingId, listenerLocation }) => ({
|
|
kind,
|
|
method,
|
|
listenerBindingId,
|
|
hasListenerLocation: listenerLocation !== null,
|
|
}),
|
|
),
|
|
).toEqual([
|
|
{
|
|
kind: "register",
|
|
method: "on",
|
|
listenerBindingId: handler?.bindingId,
|
|
hasListenerLocation: true,
|
|
},
|
|
{
|
|
kind: "dispatch",
|
|
method: "emit",
|
|
listenerBindingId: null,
|
|
hasListenerLocation: false,
|
|
},
|
|
{
|
|
kind: "dispatch",
|
|
method: "emit",
|
|
listenerBindingId: null,
|
|
hasListenerLocation: false,
|
|
},
|
|
{
|
|
kind: "remove",
|
|
method: "off",
|
|
listenerBindingId: handler?.bindingId,
|
|
hasListenerLocation: true,
|
|
},
|
|
{
|
|
kind: "remove",
|
|
method: "removeAllListeners",
|
|
listenerBindingId: null,
|
|
hasListenerLocation: false,
|
|
},
|
|
]);
|
|
expect(
|
|
ir.eventOperations.map(({ eventName, resolution }) => [
|
|
eventName,
|
|
resolution,
|
|
]),
|
|
).toEqual([
|
|
["ready", "complete"],
|
|
["ready", "complete"],
|
|
["ready", "complete"],
|
|
["ready", "complete"],
|
|
["ready", "complete"],
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("JavaScript semantic analysis: replaced built-in globals", () => {
|
|
it.each([
|
|
["setTimeout", "setTimeout = local; setTimeout(work, 1);", "timer"],
|
|
[
|
|
"globalThis.setTimeout",
|
|
"globalThis.setTimeout = local; setTimeout(work, 1);",
|
|
"timer",
|
|
],
|
|
["fetch", "fetch = local; fetch('https://example.test');", "request"],
|
|
[
|
|
"WebSocket",
|
|
"WebSocket = local; new WebSocket('wss://example.test');",
|
|
"request",
|
|
],
|
|
// A replaced JSON.parse keeps only the generic, partial `.parse` candidate.
|
|
["JSON.parse", "JSON.parse = local; JSON.parse('null');", "boundary"],
|
|
["JSON", "JSON = { parse: local }; JSON.parse('null');", "boundary"],
|
|
["String", "String = local; String(value);", "boundary"],
|
|
] as const)(
|
|
"does not admit built-in effects after a visible %s replacement",
|
|
(_name, source, family) => {
|
|
const ir = analyzeJavaScriptSemantics(source);
|
|
const operations = {
|
|
timer: ir.timerOperations,
|
|
request: ir.requestOperations,
|
|
boundary: ir.boundaryOperations,
|
|
};
|
|
expect(
|
|
operations[family].filter(
|
|
({ resolution }) => resolution === "complete",
|
|
),
|
|
).toEqual([]);
|
|
},
|
|
);
|
|
|
|
it("keeps built-in effects for unmodified globals beside unrelated writes", () => {
|
|
const ir = analyzeJavaScriptSemantics(`
|
|
JSON.stringify = local; other.setTimeout = local;
|
|
setTimeout(work, 1);
|
|
fetch('https://example.test');
|
|
new WebSocket('wss://example.test');
|
|
JSON.parse('null');
|
|
String(value);
|
|
`);
|
|
expect(ir.timerOperations).toMatchObject([
|
|
{ method: "setTimeout", resolution: "complete" },
|
|
]);
|
|
expect(ir.requestOperations.map(({ method }) => method)).toEqual([
|
|
"fetch",
|
|
"WebSocket",
|
|
]);
|
|
expect(ir.boundaryOperations).toMatchObject([
|
|
{ method: "JSON.parse", resolution: "complete" },
|
|
{ method: "String", resolution: "complete" },
|
|
]);
|
|
});
|
|
});
|