fix(javascript): preserve semantic uncertainty and lexical ownership
This commit is contained in:
parent
9fe54c4073
commit
896696b2e7
|
|
@ -65,16 +65,40 @@ it("projects closure and direct interprocedural flow without execution", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("projects a destructuring assignment member target as a property write", () => {
|
||||
const graph = graphFor(`
|
||||
const source = { count: 1 };
|
||||
[source.count] = [2];
|
||||
`);
|
||||
it.each([
|
||||
["direct assignment", "source.count = 2;"],
|
||||
["destructuring assignment", "[source.count] = [2];"],
|
||||
["for-of head", "for (source.count of [4]) {}"],
|
||||
["for-in head", "for (source.count in { key: true }) {}"],
|
||||
["delete", "delete source.count;"],
|
||||
])("projects a %s member target as a property write", (_name, statement) => {
|
||||
const graph = graphFor(`const source = { count: 1 }; ${statement}`);
|
||||
const operations = graph.nodes.filter(
|
||||
({ properties }) => properties.operation_kind !== undefined,
|
||||
);
|
||||
expect(operations.map(({ label }) => label)).toEqual(["write:/count"]);
|
||||
const write = operations[0];
|
||||
if (write === undefined) throw new Error("Expected write operation node");
|
||||
expect(graph.relations).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source_node_id: write.node_id,
|
||||
relation: "writes-property",
|
||||
}),
|
||||
);
|
||||
expect(graph.relations).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
source_node_id: write.node_id,
|
||||
relation: "reads-property",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("continues to project an ordinary member access as a property read", () => {
|
||||
const graph = graphFor(`
|
||||
const source = { count: 1 };
|
||||
const value = source.count;
|
||||
`);
|
||||
expect(graph.relations).toContainEqual(
|
||||
expect.objectContaining({ relation: "reads-property" }),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,6 +25,36 @@ describe("Electron option evidence", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["for initializer", 'for (var electron = require("electron"); false;) {}'],
|
||||
["do body", 'do { var electron = require("electron"); } while (false);'],
|
||||
])(
|
||||
"recovers Electron bindings initialized unconditionally in a %s",
|
||||
(_name, declaration) => {
|
||||
const analysis = analyzeJavaScriptStaticSource(
|
||||
`${declaration} new electron.BrowserWindow({webPreferences:{preload:"./preload.js"}});`,
|
||||
);
|
||||
expect(analysis.parse_error_count).toBe(0);
|
||||
expect(analysis.electron.browser_windows).toMatchObject([
|
||||
{ preload_path: "./preload.js" },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
'for (; false;) { var electron = require("electron"); }',
|
||||
'while (false) { var electron = require("electron"); }',
|
||||
'if (flag) { for (var electron = require("electron"); false;) {} }',
|
||||
])(
|
||||
"keeps conditionally skipped loop declarations unresolved: %s",
|
||||
(declaration) => {
|
||||
const analysis = analyzeJavaScriptStaticSource(
|
||||
`${declaration} new electron.BrowserWindow({});`,
|
||||
);
|
||||
expect(analysis.electron.browser_windows).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not read an audio or video preload attribute as a preload script", () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
jsx("audio", { controls: true, preload: "metadata", src });
|
||||
|
|
|
|||
|
|
@ -180,7 +180,12 @@ const comparisonEvidence = (
|
|||
executable_sha256: null,
|
||||
},
|
||||
coverage: complete
|
||||
? { status: "complete", truncated: false, omitted_count: 0, limits: [] }
|
||||
? {
|
||||
status: "complete",
|
||||
truncated: false,
|
||||
omitted_count: 0,
|
||||
limits: sourceCoverageLimits(input),
|
||||
}
|
||||
: {
|
||||
status: "partial",
|
||||
truncated:
|
||||
|
|
@ -210,7 +215,7 @@ const changeGraphCoverage = (
|
|||
status: "complete",
|
||||
truncated: false,
|
||||
omitted_count: 0,
|
||||
limits: [],
|
||||
limits: sourceCoverageLimits(input),
|
||||
};
|
||||
return {
|
||||
status: "partial",
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ const nativeEvidence = (sha256: string | undefined, ordinal: number) =>
|
|||
},
|
||||
);
|
||||
|
||||
describe("application version comparison result", () => {
|
||||
describe("application version comparison Evidence links", () => {
|
||||
it("links every exact digest observation only to its paired or unmatched item", () => {
|
||||
const left = buildGraph(LEFT_SHA, 1);
|
||||
const right = buildGraph(RIGHT_SHA, 2);
|
||||
|
|
@ -227,7 +227,9 @@ describe("application version comparison result", () => {
|
|||
].sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("application version comparison graph ownership", () => {
|
||||
it("returns a sealed change graph that passes complete validation", () => {
|
||||
const result = compare();
|
||||
expect(
|
||||
|
|
@ -243,6 +245,58 @@ describe("application version comparison result", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("retains deduplicated complete input limits on the change graph and inferred edge", () => {
|
||||
const leftLimits = [
|
||||
{ name: "modules", value: 100, unit: "items" as const },
|
||||
];
|
||||
const rightLimits = [
|
||||
{ name: "source-bytes", value: 4096, unit: "bytes" as const },
|
||||
{ name: "modules", value: 100, unit: "items" as const },
|
||||
];
|
||||
const withLimits = (
|
||||
graph: JavaScriptApplicationGraph,
|
||||
limits: JavaScriptApplicationGraph["coverage"]["limits"],
|
||||
): JavaScriptApplicationGraph => {
|
||||
const { graph_id: _graphId, ...input } = graph;
|
||||
return createJavaScriptApplicationGraph({
|
||||
...input,
|
||||
coverage: { ...graph.coverage, limits },
|
||||
});
|
||||
};
|
||||
const result = compareJavaScriptApplicationVersions({
|
||||
left: {
|
||||
evidenceId: `ev_${"1".repeat(64)}`,
|
||||
rootArtifactSha256: LEFT_SHA,
|
||||
graph: withLimits(buildGraph(LEFT_SHA, 1), leftLimits),
|
||||
},
|
||||
right: {
|
||||
evidenceId: `ev_${"2".repeat(64)}`,
|
||||
rootArtifactSha256: RIGHT_SHA,
|
||||
graph: withLimits(buildGraph(RIGHT_SHA, 2), rightLimits),
|
||||
},
|
||||
leftNativeEvidence: [],
|
||||
rightNativeEvidence: [],
|
||||
});
|
||||
const expectedLimits = [leftLimits[0]!, rightLimits[0]!];
|
||||
const changed = result.graph.edges.find(
|
||||
({ relation }) => relation === "changed_from",
|
||||
);
|
||||
|
||||
expect(changed).toBeDefined();
|
||||
expect(result.graph.coverage).toMatchObject({
|
||||
status: "complete",
|
||||
truncated: false,
|
||||
omitted_count: 0,
|
||||
limits: expectedLimits,
|
||||
});
|
||||
expect(changed?.evidence.coverage).toMatchObject({
|
||||
status: "complete",
|
||||
truncated: false,
|
||||
omitted_count: 0,
|
||||
limits: expectedLimits,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not reuse a change graph without an owned proof", () => {
|
||||
const result = compare();
|
||||
const copy = structuredClone(result.graph);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,28 @@ import {
|
|||
} 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";
|
||||
|
|
|
|||
|
|
@ -164,6 +164,32 @@ describe("JavaScript semantic values after explicit property mutations", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("invalidates a shared value reached through an object-literal prototype getter", () => {
|
||||
const value = resultValue(`
|
||||
const shared = { x: 1 };
|
||||
const proto = { get v() { return shared; } };
|
||||
const box = { __proto__: proto };
|
||||
box.v.x = 2;
|
||||
return shared.x;
|
||||
`);
|
||||
expect(
|
||||
value?.status === "unknown" ||
|
||||
(value?.status === "literal" && value.value === 2),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not follow an object-literal prototype getter past an own property", () => {
|
||||
expect(
|
||||
resultValue(`
|
||||
const shared = { x: 1 };
|
||||
const proto = { get v() { return shared; } };
|
||||
const box = { __proto__: proto, v: { x: 3 } };
|
||||
box.v.x = 2;
|
||||
return shared.x;
|
||||
`),
|
||||
).toEqual({ status: "literal", value: 1 });
|
||||
});
|
||||
|
||||
describe("JavaScript semantic mutations through getter results (#1494)", () => {
|
||||
it.each([
|
||||
[
|
||||
|
|
|
|||
|
|
@ -860,13 +860,18 @@ const hasConditionalInitializer = (ancestors: readonly t.Node[]): boolean => {
|
|||
const node = ancestors[index];
|
||||
if (t.isFunction(node) || t.isProgram(node) || t.isStaticBlock(node))
|
||||
return false;
|
||||
if (
|
||||
t.isIfStatement(node) ||
|
||||
t.isLoop(node) ||
|
||||
t.isSwitchCase(node) ||
|
||||
t.isTryStatement(node)
|
||||
)
|
||||
if (t.isIfStatement(node) || t.isSwitchCase(node) || t.isTryStatement(node))
|
||||
return true;
|
||||
if (t.isLoop(node)) {
|
||||
const bodyOrTest = ancestors[index + 1];
|
||||
if (
|
||||
(t.isForStatement(node) && bodyOrTest === node.init) ||
|
||||
(t.isDoWhileStatement(node) &&
|
||||
(bodyOrTest === node.body || bodyOrTest === node.test))
|
||||
)
|
||||
continue;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ interface CallCollectionContext {
|
|||
readonly JavaScriptSemanticBindingState[]
|
||||
>;
|
||||
readonly ancestors: t.Node[];
|
||||
readonly callableStack: JavaScriptSemanticCallable[];
|
||||
}
|
||||
|
||||
/** Recover direct-call and lexical-capture candidates from inert syntax. */
|
||||
|
|
@ -77,6 +78,7 @@ export const collectJavaScriptSemanticCalls = (
|
|||
const callableById = new Map(
|
||||
callables.map((callable) => [callable.callableId, callable]),
|
||||
);
|
||||
const callableStack: JavaScriptSemanticCallable[] = [];
|
||||
const context: CallCollectionContext = {
|
||||
state,
|
||||
callableById,
|
||||
|
|
@ -84,8 +86,8 @@ export const collectJavaScriptSemanticCalls = (
|
|||
bindingResolutionCache: new Map(),
|
||||
parameterBindingCache: new Map(),
|
||||
ancestors: [],
|
||||
callableStack,
|
||||
};
|
||||
const callableStack: JavaScriptSemanticCallable[] = [];
|
||||
traverseJavaScriptAst(program, {
|
||||
enter: (node, parent) => {
|
||||
const callableId = semanticCallableIdForNode(node);
|
||||
|
|
@ -316,19 +318,49 @@ const collectCapture = (
|
|||
)
|
||||
return;
|
||||
if (semanticReferenceRole(node, parent) === null) return;
|
||||
const captureOwner = computedMethodKeyOwner(node, owner, context);
|
||||
if (captureOwner === undefined || captureOwner.bodyScopeId === null) return;
|
||||
const binding = resolveSemanticBindingState(state, node, node.name);
|
||||
if (
|
||||
binding === undefined ||
|
||||
bindingIsWithinCallable(binding, owner.bodyScopeId, state)
|
||||
bindingIsWithinCallable(binding, captureOwner.bodyScopeId, state)
|
||||
)
|
||||
return;
|
||||
output.closureCaptures.push({
|
||||
callableId: owner.callableId,
|
||||
callableId: captureOwner.callableId,
|
||||
bindingId: binding.bindingId,
|
||||
referenceLocation: range(node),
|
||||
});
|
||||
};
|
||||
|
||||
const computedMethodKeyOwner = (
|
||||
node: t.Node,
|
||||
owner: JavaScriptSemanticCallable,
|
||||
context: CallCollectionContext,
|
||||
): JavaScriptSemanticCallable | undefined => {
|
||||
const ownerNode = context.ancestors.findLast(
|
||||
(candidate) => semanticCallableIdForNode(candidate) === owner.callableId,
|
||||
);
|
||||
if (
|
||||
ownerNode === undefined ||
|
||||
(!t.isObjectMethod(ownerNode) && !t.isClassMethod(ownerNode)) ||
|
||||
!ownerNode.computed
|
||||
)
|
||||
return owner;
|
||||
let child = node;
|
||||
let parent = context.state.parentsByNode.get(child);
|
||||
while (parent !== undefined && parent !== ownerNode) {
|
||||
child = parent;
|
||||
parent = context.state.parentsByNode.get(parent);
|
||||
}
|
||||
if (parent !== ownerNode || ownerNode.key !== child) return owner;
|
||||
return enclosingFunction(
|
||||
context.callableStack.filter(
|
||||
({ callableId }) => callableId !== owner.callableId,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const collectDynamicProperty = (
|
||||
node: t.Node,
|
||||
owner: JavaScriptSemanticCallable | undefined,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import { expect, it } from "vitest";
|
||||
|
||||
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
|
||||
|
||||
const normalizedSyntaxFingerprint = (source: string): string => {
|
||||
const [fingerprint] = analyzeJavaScriptSemantics(source).functionFingerprints;
|
||||
expect(fingerprint?.status).toBe("complete");
|
||||
return fingerprint?.components.normalizedAstSha256 ?? "";
|
||||
};
|
||||
|
||||
it("distinguishes yielding an iterator result from delegating to the iterator", () => {
|
||||
expect(
|
||||
normalizedSyntaxFingerprint("function* sample(gen) { yield* gen(); }"),
|
||||
).not.toBe(
|
||||
normalizedSyntaxFingerprint("function* sample(gen) { yield gen(); }"),
|
||||
);
|
||||
});
|
||||
|
||||
it("distinguishes returned prefix and postfix updates", () => {
|
||||
expect(
|
||||
normalizedSyntaxFingerprint("function sample(i) { return ++i; }"),
|
||||
).not.toBe(normalizedSyntaxFingerprint("function sample(i) { return i++; }"));
|
||||
});
|
||||
|
||||
it("keeps formatting and local renames normalized", () => {
|
||||
expect(
|
||||
normalizedSyntaxFingerprint(
|
||||
"function renamed(value) {\n return value++ ;\n }",
|
||||
),
|
||||
).toBe(normalizedSyntaxFingerprint("function sample(i) { return i++; }"));
|
||||
});
|
||||
|
|
@ -198,6 +198,8 @@ const normalizedNodeToken = (node: t.Node, parent: t.Node | null): string => {
|
|||
primitiveAttribute(node, "optional"),
|
||||
primitiveAttribute(node, "async"),
|
||||
primitiveAttribute(node, "generator"),
|
||||
primitiveAttribute(node, "delegate"),
|
||||
primitiveAttribute(node, "prefix"),
|
||||
].filter((value): value is string => value !== null);
|
||||
return [node.type, ...attributes].join(":");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -306,9 +306,11 @@ function* checkCoverageSteps(
|
|||
relationsByFamily.set(family, (relationsByFamily.get(family) ?? 0) + 1);
|
||||
}
|
||||
const unknownsById = new Map<string, GraphRecord["unknowns"][number]>();
|
||||
const unknownFamilies = new Set<string>();
|
||||
for (const [index, unknown] of graph.unknowns.entries()) {
|
||||
if (index % 256 === 0) yield;
|
||||
unknownsById.set(unknown.unknown_id, unknown);
|
||||
unknownFamilies.add(unknown.family);
|
||||
}
|
||||
for (const [index, family] of graph.coverage.families.entries()) {
|
||||
if (family.retained_relations !== relationsByFamily.get(family.family))
|
||||
|
|
@ -329,7 +331,9 @@ function* checkCoverageSteps(
|
|||
});
|
||||
if (
|
||||
family.status === "complete" &&
|
||||
(family.omitted_relations !== 0 || family.unknown_ids.length > 0)
|
||||
(family.omitted_relations !== 0 ||
|
||||
family.unknown_ids.length > 0 ||
|
||||
unknownFamilies.has(family.family))
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import {
|
|||
semanticFixtureGraph,
|
||||
semanticFixtureInput,
|
||||
semanticGraphTestEvidence,
|
||||
unknownSemanticGraphTestEvidence,
|
||||
} from "../../../tests/support/javascriptSemanticGraphFixture.js";
|
||||
import { expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
|
@ -10,6 +11,7 @@ import { digestCanonicalValue } from "../canonicalDigest.js";
|
|||
import type { JsonValue } from "../jsonValue.js";
|
||||
import type { ApplicationGraphEvidence } from "./javascriptApplicationEvidenceSchemas.js";
|
||||
import {
|
||||
JAVASCRIPT_SEMANTIC_RELATION_FAMILIES,
|
||||
JAVASCRIPT_SEMANTIC_RELATION_FAMILY,
|
||||
JAVASCRIPT_SEMANTIC_NODE_KINDS,
|
||||
JAVASCRIPT_SEMANTIC_RELATIONS,
|
||||
|
|
@ -244,6 +246,86 @@ it("matches scalar literal seeds and skips non-scalar literal metadata", () => {
|
|||
expect(query("unmatched")).toEqual([]);
|
||||
});
|
||||
|
||||
it("scopes callers unknowns to call relations while preserving graph uncertainty", () => {
|
||||
const contexts = new JavaScriptSemanticEvidenceContextRegistry();
|
||||
const caller = node("function", "caller", {}, contexts);
|
||||
const seed = node("function", "seed", {}, contexts);
|
||||
const call = createJavaScriptSemanticGraphRelation(
|
||||
{
|
||||
source_node_id: caller.node_id,
|
||||
target_node_id: seed.node_id,
|
||||
relation: "calls",
|
||||
resolution: "resolved",
|
||||
properties: {},
|
||||
evidence: semanticGraphTestEvidence("inferred"),
|
||||
},
|
||||
contexts,
|
||||
);
|
||||
const readUnknown = createJavaScriptSemanticGraphUnknown(
|
||||
{
|
||||
node_id: caller.node_id,
|
||||
family: "data-flow",
|
||||
relation_kinds: ["reads"],
|
||||
reason: "dynamic-property",
|
||||
detail: "Unresolved property read at the caller.",
|
||||
candidate_node_ids: [],
|
||||
evidence: unknownSemanticGraphTestEvidence(),
|
||||
},
|
||||
contexts,
|
||||
);
|
||||
const graph = createJavaScriptSemanticGraph({
|
||||
schema: "JavaScriptSemanticRelationGraph",
|
||||
root_artifact_sha256: SHA,
|
||||
application_graph_id: `jag_${"b".repeat(64)}`,
|
||||
root_node_ids: [seed.node_id],
|
||||
evidence_contexts: contexts.contexts,
|
||||
nodes: [caller, seed],
|
||||
relations: [call],
|
||||
fingerprints: [],
|
||||
unknowns: [readUnknown],
|
||||
coverage: {
|
||||
status: "partial",
|
||||
truncated: false,
|
||||
omitted_nodes: null,
|
||||
omitted_relations: null,
|
||||
limits: [],
|
||||
families: JAVASCRIPT_SEMANTIC_RELATION_FAMILIES.map((family) => ({
|
||||
family,
|
||||
status:
|
||||
family === "data-flow" ? ("unknown" as const) : ("complete" as const),
|
||||
retained_relations:
|
||||
JAVASCRIPT_SEMANTIC_RELATION_FAMILY[call.relation] === family ? 1 : 0,
|
||||
omitted_relations: family === "data-flow" ? null : 0,
|
||||
unknown_ids: family === "data-flow" ? [readUnknown.unknown_id] : [],
|
||||
})),
|
||||
},
|
||||
limitations: ["One property read remains unresolved."],
|
||||
});
|
||||
const query = (
|
||||
direction: "callers" | "backward-provenance",
|
||||
allowed_relations?: ["calls"],
|
||||
) =>
|
||||
queryJavaScriptSemanticGraph(graph, {
|
||||
seed: { kind: "semantic-node", node_id: seed.node_id },
|
||||
direction,
|
||||
...(allowed_relations === undefined ? {} : { allowed_relations }),
|
||||
});
|
||||
const callers = query("callers");
|
||||
const filtered = query("callers", ["calls"]);
|
||||
const backward = query("backward-provenance");
|
||||
expect(callers.summary.traversed_nodes).toBe(2);
|
||||
expect(callers.unknowns).toEqual([]);
|
||||
expect(callers.summary.relevant_unknowns).toBe(0);
|
||||
expect(callers.limitations).not.toContain(
|
||||
"Relevant dynamic, unsupported, incomplete, or ambiguous semantics remain unknown.",
|
||||
);
|
||||
expect(filtered.unknowns).toEqual(callers.unknowns);
|
||||
expect(backward.unknowns.map(({ unknown_id }) => unknown_id)).toEqual([
|
||||
readUnknown.unknown_id,
|
||||
]);
|
||||
expect(callers.coverage.status).toBe("partial");
|
||||
});
|
||||
|
||||
it("counts candidate ownership cycles once and traverses them finitely", () => {
|
||||
const graph = graphWithCandidateRelations();
|
||||
const binding = graph.nodes.find(({ label }) => label === "binding");
|
||||
|
|
@ -336,6 +418,69 @@ it("rejects stale identities, dangling endpoints, and incomplete coverage claims
|
|||
);
|
||||
});
|
||||
|
||||
it("rejects complete family coverage with an unlisted retained frontier", () => {
|
||||
const input = semanticFixtureInput(true);
|
||||
input.coverage = {
|
||||
...input.coverage,
|
||||
status: "complete",
|
||||
truncated: false,
|
||||
omitted_nodes: 0,
|
||||
omitted_relations: 0,
|
||||
families: input.coverage.families.map((family) =>
|
||||
family.family === "call-flow"
|
||||
? {
|
||||
...family,
|
||||
status: "complete",
|
||||
omitted_relations: 0,
|
||||
unknown_ids: [],
|
||||
}
|
||||
: family,
|
||||
),
|
||||
};
|
||||
input.limitations = [];
|
||||
expect(() => createJavaScriptSemanticGraph(input)).toThrow(
|
||||
/Complete family coverage cannot omit or retain unknown facts/u,
|
||||
);
|
||||
|
||||
const valid = createJavaScriptSemanticGraph(semanticFixtureInput(true));
|
||||
const coverage = {
|
||||
...valid.coverage,
|
||||
status: "complete" as const,
|
||||
truncated: false,
|
||||
omitted_nodes: 0,
|
||||
omitted_relations: 0,
|
||||
families: valid.coverage.families.map((family) =>
|
||||
family.family === "call-flow"
|
||||
? {
|
||||
...family,
|
||||
status: "complete" as const,
|
||||
omitted_relations: 0,
|
||||
unknown_ids: [],
|
||||
}
|
||||
: family,
|
||||
),
|
||||
};
|
||||
const { graph_id: _previousId, ...semantic } = valid;
|
||||
const changed = { ...semantic, coverage, limitations: [] };
|
||||
const invalid = {
|
||||
...changed,
|
||||
graph_id: `jsrg_${digestCanonicalValue(
|
||||
changed,
|
||||
"JavaScript semantic graph",
|
||||
)}`,
|
||||
};
|
||||
const parsed = javaScriptSemanticGraphSchema.safeParse(invalid);
|
||||
expect(parsed.success).toBe(false);
|
||||
if (parsed.success) throw new Error("Expected inconsistent graph rejection");
|
||||
expect(parsed.error.issues.map(({ message }) => message)).toContain(
|
||||
"Complete family coverage cannot omit or retain unknown facts",
|
||||
);
|
||||
expect(
|
||||
javaScriptSemanticGraphSchema.safeParse(JSON.parse(JSON.stringify(invalid)))
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("validates evidence context ownership and references", () => {
|
||||
const graph = semanticFixtureGraph();
|
||||
const alteredContext = {
|
||||
|
|
|
|||
|
|
@ -785,6 +785,33 @@ export const semanticObjectPropertyReferences = (
|
|||
continue;
|
||||
}
|
||||
const name = semanticStaticPropertyKey(property.key, property.computed);
|
||||
if (
|
||||
t.isObjectProperty(property) &&
|
||||
!property.computed &&
|
||||
!property.shorthand &&
|
||||
name === "__proto__"
|
||||
) {
|
||||
const selected = key ?? null;
|
||||
if (
|
||||
(selected === null || typeof selected === "object") &&
|
||||
t.isExpression(property.value)
|
||||
) {
|
||||
references.push({
|
||||
node: property.value,
|
||||
path: [selected, ...remaining],
|
||||
});
|
||||
} else if (
|
||||
typeof selected === "string" &&
|
||||
!objectExpressionDefinesKey(node, selected) &&
|
||||
t.isExpression(property.value)
|
||||
) {
|
||||
references.push({
|
||||
node: property.value,
|
||||
path: [selected, ...remaining],
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const isGetter = t.isObjectMethod(property) && property.kind === "get";
|
||||
const isSetter = t.isObjectMethod(property) && property.kind === "set";
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -250,11 +250,24 @@ const relevantUnknownFrontiers = (
|
|||
return graph.unknowns.filter(
|
||||
(unknown) =>
|
||||
(unknown.node_id === null || nodeIds.has(unknown.node_id)) &&
|
||||
(allowed === null ||
|
||||
unknown.relation_kinds.some((relation) => allowed.has(relation))),
|
||||
unknown.relation_kinds.some(
|
||||
(relation) =>
|
||||
directionAllowsRelation(input.direction, relation) &&
|
||||
(allowed === null || allowed.has(relation)),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const directionAllowsRelation = (
|
||||
direction: JavaScriptSemanticQueryInput["direction"],
|
||||
relation: JavaScriptSemanticGraphRelation["relation"],
|
||||
): boolean =>
|
||||
direction === "callers"
|
||||
? relation === "calls"
|
||||
: direction === "ownership"
|
||||
? ownershipRelation(relation)
|
||||
: true;
|
||||
|
||||
const relevantCandidateRelationCount = (
|
||||
graph: JavaScriptSemanticGraph,
|
||||
nodeIds: ReadonlySet<string>,
|
||||
|
|
|
|||
|
|
@ -389,6 +389,62 @@ it("composes fallthrough guards across successive conditional returns", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["while", 'inspect((() => { while (flag) { return "loop"; } })());'],
|
||||
["for", 'inspect((() => { for (;;) { return "forever"; } })());'],
|
||||
["try", 'inspect((() => { try { return "try"; } finally {} })());'],
|
||||
[
|
||||
"switch",
|
||||
'inspect((() => { switch (x) { default: return "switch"; } })());',
|
||||
],
|
||||
])(
|
||||
"keeps unsupported %s return control flow explicitly unknown",
|
||||
(_name, source) => {
|
||||
const value = inspectOne(source);
|
||||
expect(
|
||||
symbolicVariants(value).some((variant) => variant.kind === "unknown"),
|
||||
).toBe(true);
|
||||
expect(value).not.toEqual({ kind: "literal", value: undefined });
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves later returns with unresolved conditions after unsupported return flow", () => {
|
||||
const variants = symbolicVariants(
|
||||
inspectOne(`inspect((() => {
|
||||
while (flag) { return "inside"; }
|
||||
return "after";
|
||||
})());`),
|
||||
);
|
||||
|
||||
expect(variants.some((variant) => variant.kind === "unknown")).toBe(true);
|
||||
expect(variants).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "literal",
|
||||
value: "after",
|
||||
conditionUnresolved: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not treat loops without owned returns as unknown return flow", () => {
|
||||
expect(
|
||||
inspectOne(`inspect((() => {
|
||||
while (flag) { work(); }
|
||||
return "after";
|
||||
})());`),
|
||||
).toEqual({ kind: "literal", value: "after" });
|
||||
});
|
||||
|
||||
it("does not treat nested-function returns as wrapper return flow", () => {
|
||||
expect(
|
||||
inspectOne(`inspect((() => {
|
||||
try { function nested() { return "inner"; } }
|
||||
catch {}
|
||||
return "outer";
|
||||
})());`),
|
||||
).toEqual({ kind: "literal", value: "outer" });
|
||||
});
|
||||
|
||||
it("marks return guards unresolved when a nested join cannot be represented", () => {
|
||||
const value = inspectOne(`
|
||||
function choose() {
|
||||
|
|
|
|||
|
|
@ -58,6 +58,17 @@ const isObjectPrototypeSetter = (
|
|||
!property.shorthand &&
|
||||
name === "__proto__";
|
||||
|
||||
const containsOwnedReturn = (root: t.Node): boolean => {
|
||||
let found = false;
|
||||
traverseJavaScriptAst(root, {
|
||||
skip: (node) => found || t.isFunction(node) || t.isClass(node),
|
||||
enter: (node) => {
|
||||
if (t.isReturnStatement(node)) found = true;
|
||||
},
|
||||
});
|
||||
return found;
|
||||
};
|
||||
|
||||
/** Demand-driven substitutions for local bindings, closures and literal module tables. */
|
||||
export class JavaScriptSymbolicBindings {
|
||||
readonly #writes = new Map<string, PropertyWrite[]>();
|
||||
|
|
@ -832,6 +843,24 @@ export class JavaScriptSymbolicBindings {
|
|||
unresolvedFallthrough = true;
|
||||
}
|
||||
}
|
||||
if (
|
||||
t.isWhileStatement(statement) ||
|
||||
t.isDoWhileStatement(statement) ||
|
||||
t.isForStatement(statement) ||
|
||||
t.isForInStatement(statement) ||
|
||||
t.isForOfStatement(statement) ||
|
||||
t.isSwitchStatement(statement) ||
|
||||
t.isTryStatement(statement)
|
||||
) {
|
||||
if (containsOwnedReturn(statement)) {
|
||||
possible.push(
|
||||
symbolicUnknown(
|
||||
`unsupported return control flow: ${statement.type}`,
|
||||
),
|
||||
);
|
||||
unresolvedFallthrough = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { values: possible, fallsThrough: true };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue