fix(javascript): preserve semantic uncertainty and lexical ownership

This commit is contained in:
morluto 2026-10-14 09:47:29 +08:00
parent 9fe54c4073
commit 896696b2e7
16 changed files with 525 additions and 20 deletions

View File

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

View File

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

View File

@ -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",

View File

@ -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);

View File

@ -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";

View File

@ -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([
[

View File

@ -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;
};

View File

@ -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,

View File

@ -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++; }"));
});

View File

@ -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(":");
};

View File

@ -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",

View File

@ -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 = {

View File

@ -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 (

View File

@ -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>,

View File

@ -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() {

View File

@ -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 };
}