mirror of
https://github.com/morluto/rea
synced 2026-10-26 22:28:27 -04:00
6fa6f1f9b1
* fix(javascript): gate CommonJS exports on unshadowed globals and record dynamic scope * fix(javascript): correct dynamic and switch scope boundaries
148 lines
4.7 KiB
TypeScript
148 lines
4.7 KiB
TypeScript
import { expect, it } from "vitest";
|
|
|
|
import { buildJavaScriptSemanticGraph } from "./JavaScriptSemanticGraphBuilder.js";
|
|
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
|
|
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
|
|
import { analyzeJavaScriptSemantics } from "../domain/javascriptSemanticAnalysis.js";
|
|
import { topLevelBinding } from "../domain/javascriptSemanticAnalysis.fixture.js";
|
|
|
|
const SHA256 = "a".repeat(64);
|
|
const GRAPH_ID = `jag_${"b".repeat(64)}`;
|
|
|
|
const graphFor = (source: string) => {
|
|
const file: JavaScriptArtifactFile = {
|
|
path: "app.js",
|
|
container_sha256: SHA256,
|
|
sha256: SHA256,
|
|
bytes: Buffer.byteLength(source),
|
|
inventory_artifact_id: `art_${SHA256}`,
|
|
kind: "javascript",
|
|
unpacked: false,
|
|
text: { included: true, value: source },
|
|
};
|
|
const analysis: JavaScriptArtifactAnalysis = {
|
|
files: [
|
|
{
|
|
file,
|
|
javascript: null,
|
|
semantic: { ir: analyzeJavaScriptSemantics(source) },
|
|
},
|
|
],
|
|
packages: [],
|
|
json_modules: [],
|
|
html_scripts: [],
|
|
source_maps: [],
|
|
visited_ast_nodes: 0,
|
|
findings: 0,
|
|
modules: 0,
|
|
parse_failures: 0,
|
|
truncated_scopes: 0,
|
|
limitations: [],
|
|
};
|
|
return buildJavaScriptSemanticGraph({
|
|
rootArtifactSha256: SHA256,
|
|
applicationGraph: { graph_id: GRAPH_ID, nodes: [] },
|
|
analysis,
|
|
});
|
|
};
|
|
|
|
const dynamicScopeUnknowns = (source: string) =>
|
|
graphFor(source).unknowns.filter(({ reason }) => reason === "dynamic-scope");
|
|
|
|
it("exposes direct eval and with as dynamic-scope unknowns in the public graph", () => {
|
|
const unknowns = dynamicScopeUnknowns(`
|
|
function inspect(host, source) {
|
|
with (host) { consume(target); }
|
|
eval(source);
|
|
}
|
|
`);
|
|
expect(unknowns).toHaveLength(2);
|
|
expect(unknowns.map(({ detail }) => detail).join("\n")).toEqual(
|
|
expect.stringContaining("with"),
|
|
);
|
|
expect(unknowns.map(({ detail }) => detail).join("\n")).toEqual(
|
|
expect.stringContaining("eval"),
|
|
);
|
|
});
|
|
|
|
it("does not mark shadowed, optional, or indirect eval as dynamic scope", () => {
|
|
const unknowns = dynamicScopeUnknowns(`
|
|
function shadowed(eval, source) { eval(source); }
|
|
function optional(source) { eval?.(source); }
|
|
function indirect(source) { (0, eval)(source); }
|
|
`);
|
|
expect(unknowns).toEqual([]);
|
|
});
|
|
|
|
it("keeps switch discriminants outside the shared case lexical scope", () => {
|
|
const ir = analyzeJavaScriptSemantics(`
|
|
const selected = choose();
|
|
switch (selected) {
|
|
case 1: let selected = 1; let local = 1; break;
|
|
case 2: local = 2; break;
|
|
}
|
|
`);
|
|
const selectedBinding = topLevelBinding(ir, "selected");
|
|
const localBindings = ir.bindings.filter(({ name }) => name === "local");
|
|
expect(ir.references.find(({ name }) => name === "selected")?.bindingId).toBe(
|
|
selectedBinding.bindingId,
|
|
);
|
|
expect(localBindings).toHaveLength(1);
|
|
expect(localBindings[0]?.scopeId).not.toBe(selectedBinding?.scopeId);
|
|
expect(
|
|
ir.references.filter(
|
|
({ name, role }) => name === "selected" && role === "read",
|
|
),
|
|
).toHaveLength(1);
|
|
expect(
|
|
ir.references.filter(
|
|
({ name, role }) => name === "local" && role === "write",
|
|
),
|
|
).toHaveLength(1);
|
|
});
|
|
|
|
it("leaves with-object resolution intact and marks only body name resolution unknown", () => {
|
|
const ir = analyzeJavaScriptSemantics(`
|
|
const host = getHost();
|
|
const outer = 3;
|
|
function inspect() {
|
|
with (host) {
|
|
const local = 1;
|
|
consume(local, outer);
|
|
}
|
|
}
|
|
`);
|
|
const host = topLevelBinding(ir, "host");
|
|
const hostRead = ir.references.find(
|
|
({ name, role }) => name === "host" && role === "read",
|
|
);
|
|
const outerRead = ir.references.find(
|
|
({ name, role }) => name === "outer" && role === "read",
|
|
);
|
|
const localRead = ir.references.find(
|
|
({ name, role }) => name === "local" && role === "read",
|
|
);
|
|
expect(hostRead?.bindingId).toBe(host.bindingId);
|
|
expect(hostRead?.resolution).toBe("resolved");
|
|
expect(outerRead?.resolution).toBe("unknown");
|
|
expect(localRead?.resolution).toBe("resolved");
|
|
expect(outerRead?.bindingId).toBeNull();
|
|
});
|
|
|
|
it("keeps var declarations in each static block from leaking to another block", () => {
|
|
const ir = analyzeJavaScriptSemantics(`
|
|
class Example {
|
|
static { var first = 1; }
|
|
static { var second = 2; consume(first); }
|
|
}
|
|
`);
|
|
const first = ir.bindings.filter(({ name }) => name === "first");
|
|
const second = ir.bindings.filter(({ name }) => name === "second");
|
|
expect(ir.references.find(({ name }) => name === "first")?.resolution).toBe(
|
|
"unbound",
|
|
);
|
|
expect(first).toHaveLength(1);
|
|
expect(second).toHaveLength(1);
|
|
expect(first[0]?.scopeId).not.toBe(second[0]?.scopeId);
|
|
});
|