fix(javascript)!: preserve property paths and uncertain slot presence

Use one explicit object/array slot model and JSON-pointer property identity across mutation analysis, return projection, and semantic graphs. Require observed presence for discriminants and resolve writes only through retained object or array receivers.

BREAKING CHANGE: projected return fields require explicit presence and semantic object operations carry full property paths.
This commit is contained in:
morluto committed 2026-10-09 20:18:52 +08:00
1 parent 63bf91405c
commit 475a0f84da
23 files changed
+831 -560

No files matched your search

@@ -106,6 +106,8 @@ it("keeps exact values for empty literals without synthetic application labels",
it.each([
{ version: 3, sources: [""], names: [], mappings: "AAAA" },
{ version: 3, sourceRoot: null, sources: [""], names: [], mappings: "AAAA" },
{ version: 3, sourceRoot: "", sources: [""], names: [], mappings: "AAAA" },
{
version: 3,
sections: [
@@ -126,7 +128,9 @@ it.each([
identity.strategy === "source-map-original"
? identity.original_source
: null,
sources: observations.map(({ properties }) => properties.source),
sources: observations.map(
({ source_map_reference }) => source_map_reference?.source_name,
),
}));
expect(originals).toEqual([{ originalSource: "", sources: [""] }]);
});
@@ -267,3 +271,75 @@ it("selects an empty context-bridge API with an exact empty seed", async () => {
});
expect(matches.map(({ node_id: nodeId }) => apis.get(nodeId))).toEqual([""]);
});
it("pairs indexed source-map leaves by raw source name and root", async () => {
const map = (value: number) =>
JSON.stringify({
version: 3,
sections: ["one", "two"].map((name, line) => ({
offset: { line, column: 0 },
map: {
version: 3,
sourceRoot: `../${name}`,
sources: ["same.js"],
sourcesContent: [`export const value = ${String(value)};`],
names: [],
mappings: "AAAA",
},
})),
});
const left = await analyzeSource("//# sourceMappingURL=app.js.map", {
"app.js.map": map(1),
});
const right = await analyzeSource("//# sourceMappingURL=app.js.map", {
"app.js.map": map(2),
});
const matching = matchJavaScriptApplicationVersions(
left.graph.nodes,
right.graph.nodes,
);
const originals = matching.pairs.filter(
({ left: node }) => node.kind === "source-module",
);
expect(originals).toHaveLength(2);
for (const pair of originals) {
expect(pair).toMatchObject({
basis: "source-map-identity",
confidence: "high",
});
expect(pair.left.identity).toMatchObject({
original_source: "same.js",
source_root: expect.any(String),
});
if (pair.left.identity.strategy !== "source-map-original")
throw new Error("Expected source identity");
expect(pair.right.identity).toMatchObject({
source_root: pair.left.identity.source_root,
});
}
});
it("reports malformed source roots without manufacturing an original path", async () => {
const output = await analyzeSource("//# sourceMappingURL=app.js.map", {
"app.js.map": JSON.stringify({
version: 3,
sourceRoot: 42,
sources: ["a.js"],
names: [],
mappings: "AAAA",
}),
});
expect(
output.graph.nodes.filter(({ kind }) => kind === "source-module"),
).toEqual([]);
expect(
output.graph.nodes.flatMap(({ observations }) => observations),
).toContainEqual(
expect.objectContaining({
properties: expect.objectContaining({
limitation: "Source map sourceRoot must be a string or null.",
}),
evidence: expect.objectContaining({ state: "unavailable" }),
}),
);
});
@@ -147,7 +147,9 @@ it("retains resource-limit reasons at nested object property slots", () => {
expect.objectContaining({
family: "object-flow",
reason: "resource-limit",
detail: expect.stringMatching(/Unknown value at property:value/u),
detail: expect.stringMatching(
/Unknown value at property:\/nested\/value/u,
),
}),
);
});
@@ -625,3 +627,242 @@ it("only records node loss when the budget rejects a new identity", () => {
expect(blocked).toBeNull();
expect(state.fileNodesDropped).toBe(true);
});
it("keeps full static property identities distinct through backward provenance", () => {
const graph = graphFor(`
const root = { a: { id: 'left' }, b: { id: 'right' }, 'a.id': 'dotted', '': { 'a/b~c': 'escaped' } };
const left = root.a.id;
const right = root['b']['id'];
const missing = root.id;
const dotted = root['a.id'];
const escaped = root['']['a/b~c'];
`);
const slots = graph.nodes.filter(({ kind }) => kind === "property-slot");
for (const [pointer, literal] of [
["/a/id", "left"],
["/b/id", "right"],
["/a.id", "dotted"],
["//a~1b~0c", "escaped"],
["/id", null],
]) {
const slot = slots.find(
({ properties }) => properties.property_pointer === pointer,
);
if (slot === undefined) throw new Error(`Missing slot ${pointer}`);
expect(slot.properties.presence).toBe(
literal === null ? "absent" : "present",
);
const trace = queryJavaScriptSemanticGraph(graph, {
seed: { kind: "semantic-node", node_id: slot.node_id },
direction: "backward-provenance",
allowed_relations: ["defines"],
});
expect(
trace.nodes
.filter(({ kind }) => kind === "literal")
.map(({ properties }) => properties.value),
).toEqual(literal === null ? [] : [literal]);
const reads = graph.relations.filter(
({ source_node_id, relation }) =>
source_node_id === slot.node_id && relation === "reads-property",
);
expect(reads).toHaveLength(1);
expect(reads[0]?.resolution).toBe(
literal === null ? "candidate" : "resolved",
);
}
});
it("targets nested writes and destructuring without inventing leaf-level root slots", () => {
const graph = graphFor(`
const root = { left: { id: 'before' }, right: { id: 'untouched' } };
root.left.id = produce();
const { right: { id: selected } } = root;
const [first, missing] = [1, , 3];
`);
const rootSlots = graph.nodes.filter(
({ kind, properties }) =>
kind === "property-slot" && properties.name === "id",
);
expect(
rootSlots.map(({ properties }) => properties.property_pointer).sort(),
).toEqual(["/left/id", "/right/id"]);
const left = rootSlots.find(
({ properties }) => properties.property_pointer === "/left/id",
);
const right = rootSlots.find(
({ properties }) => properties.property_pointer === "/right/id",
);
expect(left?.properties.presence).toBe("unknown-coverage");
const write = graph.nodes.find(
({ kind, properties }) =>
kind === "expression" && properties.operation_kind === "write",
);
expect(graph.relations).toContainEqual(
expect.objectContaining({
source_node_id: write?.node_id,
target_node_id: left?.node_id,
relation: "writes-property",
resolution: "resolved",
}),
);
const selected = graph.nodes.find(
({ kind, label }) => kind === "binding" && label === "selected",
);
expect(graph.relations).toContainEqual(
expect.objectContaining({
source_node_id: right?.node_id,
target_node_id: selected?.node_id,
relation: "destructures",
resolution: "resolved",
}),
);
});
it("keeps dynamic intermediate receivers unresolved instead of selecting a static leaf", () => {
const graph = graphFor(`
const root = { id: 'root', a: { id: 'nested' } };
const selected = root[key].id;
`);
expect(
graph.relations.filter(({ relation }) => relation === "reads-property"),
).toEqual([]);
expect(graph.unknowns).toContainEqual(
expect.objectContaining({
family: "object-flow",
relation_kinds: ["reads-property"],
}),
);
const selected = graph.nodes.find(
({ kind, label }) => kind === "binding" && label === "selected",
);
if (selected === undefined) throw new Error("Missing selected binding");
const trace = queryJavaScriptSemanticGraph(graph, {
seed: { kind: "semantic-node", node_id: selected.node_id },
direction: "backward-provenance",
allowed_relations: ["defines"],
});
expect(trace.nodes.filter(({ kind }) => kind === "literal")).toEqual([]);
});
it.each([
{
initializer: "{}",
path: "missing.id",
pointer: "/missing/id",
resolved: false,
},
{
initializer: "{ a: null }",
path: "a.id",
pointer: "/a/id",
resolved: false,
},
{ initializer: "{ a: 1 }", path: "a.id", pointer: "/a/id", resolved: false },
{ initializer: "{ a: {} }", path: "a.id", pointer: "/a/id", resolved: true },
{ initializer: "{ a: [] }", path: "a[0]", pointer: "/a/0", resolved: true },
])(
"admits a write only through a retained container receiver: $initializer.$path",
({ initializer, path, pointer, resolved }) => {
const graph = graphFor(
`const root = ${initializer}; root.${path} = produce();`,
);
const write = graph.nodes.find(
({ kind, properties }) =>
kind === "expression" && properties.operation_kind === "write",
);
const slot = graph.nodes.find(
({ kind, properties }) =>
kind === "property-slot" && properties.property_pointer === pointer,
);
if (write === undefined || slot === undefined)
throw new Error("Expected write and target slot");
expect(graph.relations).toContainEqual(
expect.objectContaining({
source_node_id: write.node_id,
target_node_id: slot.node_id,
relation: "writes-property",
resolution: resolved ? "resolved" : "candidate",
}),
);
const trace = queryJavaScriptSemanticGraph(graph, {
seed: { kind: "semantic-node", node_id: write.node_id },
direction: "forward-influence",
allowed_relations: ["writes-property"],
});
expect(trace.nodes.some(({ node_id }) => node_id === slot.node_id)).toBe(
resolved,
);
if (!resolved)
expect(graph.unknowns).toContainEqual(
expect.objectContaining({
node_id: slot.node_id,
relation_kinds: ["writes-property"],
detail: expect.stringContaining(
"receiver is unresolved or not a retained container",
),
}),
);
},
);
it("retains array length presence without inventing an exact length", () => {
const graph = graphFor("const root = [, 1]; const length = root.length;");
const slot = graph.nodes.find(
({ kind, properties }) =>
kind === "property-slot" && properties.property_pointer === "/length",
);
expect(slot?.properties).toMatchObject({
name: "length",
presence: "present",
value_status: "unknown",
});
const trace = queryJavaScriptSemanticGraph(graph, {
seed: { kind: "property", name: "length" },
direction: "forward-influence",
allowed_relations: ["reads-property"],
});
expect(trace.seed_node_ids).toEqual([slot?.node_id]);
expect(trace.relations).toContainEqual(
expect.objectContaining({
source_node_id: slot?.node_id,
relation: "reads-property",
resolution: "resolved",
}),
);
expect(
graph.relations.some(
({ target_node_id, relation }) =>
target_node_id === slot?.node_id && relation === "defines",
),
).toBe(false);
});
it.each(["{}", "unknownRoot", "{ a: null }"])(
"preserves the requested deep leaf name after a blocked prefix: %s",
(initializer) => {
const graph = graphFor(
`const root = ${initializer}; const selected = root.a.b.c;`,
);
const slot = graph.nodes.find(
({ kind, properties }) =>
kind === "property-slot" && properties.property_pointer === "/a/b/c",
);
expect(slot).toMatchObject({
label: "c",
properties: {
name: "c",
property_path: ["a", "b", "c"],
presence: "unknown-coverage",
value_status: "unknown",
},
});
const trace = queryJavaScriptSemanticGraph(graph, {
seed: { kind: "property", name: "c" },
direction: "backward-provenance",
allowed_relations: ["defines"],
});
expect(trace.seed_node_ids).toEqual([slot?.node_id]);
expect(trace.nodes.filter(({ kind }) => kind === "literal")).toEqual([]);
},
);
@@ -1,3 +1,12 @@
import {
semanticSlotAtPath,
semanticContainer,
semanticPropertyPointer,
} from "../../domain/javascript/javascriptSemanticSlots.js";
import type {
JavaScriptSemanticSlotPresence,
JavaScriptSemanticValue,
} from "../../domain/javascript/javascriptSemanticValueTypes.js";
import { createJavaScriptSemanticGraphUnknown } from "../../domain/javascript/javascriptSemanticGraph.js";
import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraph.js";
import type { JavaScriptSemanticObjectOperation } from "../../domain/javascript/javascriptSemanticIr.js";
@@ -14,56 +23,115 @@ import { semanticPropertySlot } from "./JavaScriptSemanticGraphValueProjection.j
export const projectSemanticObjects = (
context: SemanticFlowProjectionContext,
): void => {
const values = new Map(
context.ir.bindings.map((binding) => [binding.bindingId, binding.value]),
);
for (const operation of context.ir.objectOperations) {
const occurrence = addObjectOccurrence(context, operation);
const slot =
operation.objectBindingId === null || operation.propertyName === null
? null
: semanticPropertySlot(
context,
operation.objectBindingId,
operation.propertyName,
);
if (operation.kind === "read")
addSemanticGraphRelation(context.state, {
source: slot,
target: occurrence,
relation: "reads-property",
resolution:
operation.resolution === "complete" ? "resolved" : "candidate",
});
else if (operation.kind === "write")
addSemanticGraphRelation(context.state, {
source: occurrence,
target: slot,
relation: "writes-property",
resolution:
operation.resolution === "complete" ? "resolved" : "candidate",
});
else if (operation.kind === "destructure")
addSemanticGraphRelation(context.state, {
source: slot,
const target = objectTarget(context, values, operation);
const ends = objectRelationEnds(context, operation, occurrence, target);
const resolved =
operation.resolution === "complete" &&
(operation.kind === "write"
? target.receiverResolved
: target.presence === "present");
addSemanticGraphRelation(context.state, {
...ends,
relation: OBJECT_RELATIONS[operation.kind],
resolution: resolved ? "resolved" : "candidate",
});
if (!resolved)
addObjectUnknown(context, operation, target.slot ?? occurrence, target);
}
};
const OBJECT_RELATIONS = {
read: "reads-property",
write: "writes-property",
destructure: "destructures",
spread: "spreads",
} as const;
interface ObjectTarget {
readonly objectNode: JavaScriptSemanticGraphNode | null | undefined;
readonly slot: JavaScriptSemanticGraphNode | null;
readonly presence: JavaScriptSemanticSlotPresence;
readonly receiverResolved: boolean;
}
const objectTarget = (
context: SemanticFlowProjectionContext,
values: ReadonlyMap<string, JavaScriptSemanticValue>,
operation: JavaScriptSemanticObjectOperation,
): ObjectTarget => {
const binding =
operation.objectBindingId === null
? undefined
: context.bindingNodes.get(operation.objectBindingId);
const value =
operation.objectBindingId === null
? undefined
: values.get(operation.objectBindingId);
if (
operation.objectBindingId === null ||
operation.propertyPath === null ||
value === undefined
)
return {
objectNode: binding,
slot: null,
presence: "unknown-coverage",
receiverResolved: false,
};
const fact = semanticSlotAtPath(value, operation.propertyPath);
if (operation.propertyPath.length === 0)
return {
objectNode: binding,
slot: null,
presence: fact.presence,
receiverResolved: semanticContainer(value) !== null,
};
const slot = semanticPropertySlot(
context,
operation.objectBindingId,
operation.propertyPath,
fact,
);
const receiver = semanticSlotAtPath(
value,
operation.propertyPath.slice(0, -1),
);
return {
objectNode: slot,
slot,
presence: fact.presence,
receiverResolved:
receiver.presence === "present" &&
semanticContainer(receiver.value) !== null,
};
};
const objectRelationEnds = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticObjectOperation,
occurrence: JavaScriptSemanticGraphNode | null,
target: ObjectTarget,
) => {
switch (operation.kind) {
case "read":
return { source: target.slot, target: occurrence };
case "write":
return { source: occurrence, target: target.slot };
case "destructure":
return {
source: target.slot,
target:
operation.targetBindingId === null
? undefined
: context.bindingNodes.get(operation.targetBindingId),
relation: "destructures",
resolution:
operation.resolution === "complete" ? "resolved" : "candidate",
});
else
addSemanticGraphRelation(context.state, {
source:
operation.objectBindingId === null
? undefined
: context.bindingNodes.get(operation.objectBindingId),
target: occurrence,
relation: "spreads",
resolution:
operation.resolution === "complete" ? "resolved" : "candidate",
});
if (operation.resolution !== "complete")
addObjectUnknown(context, operation, slot ?? occurrence);
};
case "spread":
return { source: target.objectNode, target: occurrence };
}
};
@@ -76,9 +144,9 @@ const addObjectOccurrence = (
roleKey: operation.objectOperationId,
location: operation.location,
label:
operation.propertyName === null
operation.propertyPath === null
? operation.kind
: `${operation.kind}:${operation.propertyName}`,
: `${operation.kind}:${semanticPropertyPointer(operation.propertyPath)}`,
functionNodeId:
operation.ownerCallableId === null
? null
@@ -86,7 +154,8 @@ const addObjectOccurrence = (
null),
properties: {
operation_kind: operation.kind,
property_name: operation.propertyName,
property_path:
operation.propertyPath === null ? null : [...operation.propertyPath],
},
});
@@ -94,15 +163,9 @@ const addObjectUnknown = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticObjectOperation,
node: JavaScriptSemanticGraphNode | null,
target: Pick<ObjectTarget, "presence" | "receiverResolved">,
): void => {
const relation =
operation.kind === "read"
? "reads-property"
: operation.kind === "write"
? "writes-property"
: operation.kind === "spread"
? "spreads"
: "destructures";
const relation = OBJECT_RELATIONS[operation.kind];
addSemanticGraphUnknown(
context.state,
createJavaScriptSemanticGraphUnknown({
@@ -110,7 +173,7 @@ const addObjectUnknown = (
family: "object-flow",
relation_kinds: [relation],
reason: "ambiguous-target",
detail: `Static ${operation.kind} object identity is partial.`,
detail: `Static ${operation.kind} property ${operation.propertyPath === null ? "path is unresolved" : semanticPropertyPointer(operation.propertyPath)} has ${target.presence} presence; its receiver is ${target.receiverResolved ? "a retained container" : "unresolved or not a retained container"}; object identity is ${operation.resolution}.`,
candidate_node_ids: [],
evidence: unknownSemanticEvidence(context.file, operation.location),
}),
@@ -1,259 +0,0 @@
import { expect, it } from "vitest";
import { compareCodePoints } from "../../domain/canonicalOrdering.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import { analyzeJavaScriptSemantics } from "../../domain/javascript/javascriptSemanticAnalysis.js";
import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraph.js";
import type { JavaScriptSemanticIr } from "../../domain/javascript/javascriptSemanticIr.js";
import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
import {
constructSemanticGraphNode,
createSemanticGraphProjectionState,
} from "./JavaScriptSemanticGraphConstruction.js";
import {
createSemanticCallableOwnerLookup,
createSemanticNodeRangeLookup,
createSemanticCallSiteLookup,
} from "./JavaScriptSemanticGraphProjection.js";
import { sourceRangesEqual } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js";
const fixtureFile: JavaScriptArtifactFile = {
path: "app.js",
container_sha256: "a".repeat(64),
sha256: "b".repeat(64),
bytes: 0,
inventory_artifact_id: "fixture-app",
kind: "javascript",
unpacked: false,
text: { included: true, value: "" },
};
const nodesFor = (ir: JavaScriptSemanticIr) => {
const state = createSemanticGraphProjectionState({ nodes: [] });
return new Map(
ir.callables.map((callable) => [
callable.callableId,
constructSemanticGraphNode(
fixtureFile,
{
kind: "function",
roleKey: callable.callableId,
label: callable.name,
location: callable.location,
functionNodeId: null,
},
state,
),
]),
);
};
// Reference behavior before the optimization, including stable ties and
// fallback when the most deeply nested callable has no retained graph node.
const previousOwner = (
location: JavaScriptSourceRange | null,
ir: JavaScriptSemanticIr,
nodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
) => {
if (location === null) return undefined;
const compare = (
left: JavaScriptSourceRange["start"],
right: JavaScriptSourceRange["start"],
) => left.line - right.line || left.column - right.column;
const key = (range: JavaScriptSourceRange) =>
`${String(range.start.line).padStart(12, "0")}:${String(range.start.column).padStart(12, "0")}`;
return ir.callables
.filter(
(callable) =>
compare(callable.location.start, location.start) <= 0 &&
compare(callable.location.end, location.end) >= 0,
)
.sort((left, right) =>
compareCodePoints(key(right.location), key(left.location)),
)
.map((callable) => nodes.get(callable.callableId))
.find((node) => node !== undefined);
};
const rangesFor = (ir: JavaScriptSemanticIr) => [
null,
...ir.callables.map((callable) => callable.location),
...ir.bindings.flatMap((binding) =>
binding.definitions.map((definition) => definition.location),
),
...ir.references.map((reference) => reference.location),
{ start: { line: 999, column: 0 }, end: { line: 999, column: 1 } },
];
it("matches the prior owner selection across nested functions, arrows and methods", () => {
const ir = analyzeJavaScriptSemantics(`
const outside = 1;
function outer(parameter) {
function inner(value) { return value + parameter; }
const callback = argument => inner(argument);
return callback(outside);
}
class Example { method(value) { return () => outer(value); } }
`);
const nodes = nodesFor(ir);
const lookup = createSemanticCallableOwnerLookup(ir, nodes);
for (const range of rangesFor(ir))
expect(lookup(range)).toBe(previousOwner(range, ir, nodes));
});
it("reads retained node membership at query time and keeps file indexes independent", () => {
const ir = analyzeJavaScriptSemantics(
"function outer() { function inner() { return 1; } return inner(); }",
);
const inner = ir.callables.find((callable) => callable.name === "inner");
if (inner === undefined) throw new Error("Missing inner fixture callable");
const nodes = nodesFor(ir);
const otherNodes = nodesFor(ir);
const lookup = createSemanticCallableOwnerLookup(ir, nodes);
const otherLookup = createSemanticCallableOwnerLookup(ir, otherNodes);
const before = lookup(inner.location);
expect(before?.label).toBe("inner");
nodes.delete(inner.callableId);
expect(lookup(inner.location)?.label).toBe("outer");
expect(otherLookup(inner.location)?.label).toBe("inner");
if (before === undefined) throw new Error("Missing retained fixture node");
nodes.set(inner.callableId, before);
expect(lookup(inner.location)).toBe(before);
});
it("preserves stable ties and non-nested overlapping ranges", () => {
const original = analyzeJavaScriptSemantics(
"function first() { return 1; } function second() { return 2; } function third() { return 3; }",
);
const range = (start: number, end: number): JavaScriptSourceRange => ({
start: { line: 1, column: start },
end: { line: 1, column: end },
});
const ir = {
...original,
callables: original.callables.map((callable, index) => ({
...callable,
location: index === 0 ? range(0, 90) : range(20, index === 1 ? 60 : 80),
})),
};
const nodes = nodesFor(ir);
const lookup = createSemanticCallableOwnerLookup(ir, nodes);
for (const query of [
range(25, 30),
range(25, 70),
range(0, 80),
range(80, 90),
range(91, 92),
])
expect(lookup(query)).toBe(previousOwner(query, ir, nodes));
});
it("matches the prior result across a compiled-style collection of sibling callables", () => {
const ir = analyzeJavaScriptSemantics(
Array.from(
{ length: 300 },
(_, index) =>
`function f${String(index)}(value) { return value + ${String(index)}; }`,
).join(""),
);
const nodes = nodesFor(ir);
const lookup = createSemanticCallableOwnerLookup(ir, nodes);
for (const range of rangesFor(ir))
expect(lookup(range)).toBe(previousOwner(range, ir, nodes));
});
it("returns contained nodes in original order, including repeated and overlapping entries", () => {
const ir = analyzeJavaScriptSemantics(`
function outer(value) { function inner() { return value; } return inner(); }
const sibling = () => outer(1);
`);
const nodes = [...nodesFor(ir).values()].reverse();
const repeated = nodes[0];
if (repeated === undefined) throw new Error("Missing range fixture node");
nodes.push(
repeated,
constructSemanticGraphNode(
fixtureFile,
{
kind: "module",
roleKey: "module",
location: null,
label: "module",
functionNodeId: null,
},
createSemanticGraphProjectionState({ nodes: [] }),
),
);
const lookup = createSemanticNodeRangeLookup(nodes);
const compare = (
left: JavaScriptSourceRange["start"],
right: JavaScriptSourceRange["start"],
) => left.line - right.line || left.column - right.column;
for (const range of rangesFor(ir)) {
if (range === null) continue;
const expected = nodes.filter(
({ identity }) =>
identity.source_range !== null &&
compare(range.start, identity.source_range.start) <= 0 &&
compare(range.end, identity.source_range.end) >= 0,
);
expect(lookup(range)).toEqual(expected);
}
expect(
createSemanticNodeRangeLookup([])(
repeated.identity.source_range ?? {
start: { line: 1, column: 0 },
end: { line: 1, column: 1 },
},
),
).toEqual([]);
});
it("keeps first-call matching and current retained membership for exact locations", () => {
const original = analyzeJavaScriptSemantics(
"function target(value) { return value; } target(1); target(2);",
);
const first = original.callSites[0];
const node = [...nodesFor(original).values()][0];
if (first === undefined || node === undefined)
throw new Error("Missing call fixture");
const duplicate = { ...first, callSiteId: `${first.callSiteId}:duplicate` };
const ir = {
...original,
callSites: [first, duplicate, ...original.callSites.slice(1)],
};
const nodes = new Map(
ir.callSites.map((call) => [
call.callSiteId,
constructSemanticGraphNode(
fixtureFile,
{
kind: "call-site",
roleKey: call.callSiteId,
location: call.location,
label: call.callSiteId,
functionNodeId: null,
},
createSemanticGraphProjectionState({ nodes: [] }),
),
]),
);
const lookup = createSemanticCallSiteLookup(ir, nodes);
const previous = (range: JavaScriptSourceRange) => {
const call = ir.callSites.find((candidate) =>
sourceRangesEqual(candidate.location, range),
);
return call === undefined ? undefined : nodes.get(call.callSiteId);
};
for (const call of ir.callSites)
expect(lookup(call.location)).toBe(previous(call.location));
nodes.delete(first.callSiteId);
expect(lookup(first.location)).toBeUndefined();
expect(nodes.has(duplicate.callSiteId)).toBe(true);
nodes.set(first.callSiteId, node);
expect(lookup(first.location)).toBe(node);
expect(
lookup({ start: { line: 99, column: 0 }, end: { line: 99, column: 1 } }),
).toBeUndefined();
});
@@ -1,4 +1,8 @@
import type { JsonValue } from "../../domain/jsonValue.js";
import {
semanticContainer,
semanticPropertyPointer,
type JavaScriptSemanticSlot,
} from "../../domain/javascript/javascriptSemanticSlots.js";
import type {
JavaScriptSemanticBinding,
JavaScriptSemanticValue,
@@ -24,7 +28,7 @@ export const projectSemanticValues = (
binding,
value: binding.value,
target: bindingNode,
role: "binding",
path: [],
});
}
};
@@ -34,11 +38,13 @@ interface ValueProjectionInput {
readonly binding: JavaScriptSemanticBinding;
readonly value: JavaScriptSemanticValue;
readonly target: ReturnType<typeof semanticPropertySlot>;
readonly role: string;
readonly path: readonly string[];
}
const projectValue = (input: ValueProjectionInput): void => {
const { context, binding, value, target, role } = input;
const { context, binding, value, target, path } = input;
const role =
path.length === 0 ? "binding" : `property:${semanticPropertyPointer(path)}`;
if (target === null) return;
if (value.status === "literal") {
const literal = addLiteralNode(context, binding, value.value, role);
@@ -58,31 +64,33 @@ const projectValue = (input: ValueProjectionInput): void => {
resolution: "candidate",
});
}
else if (value.status === "object")
for (const property of value.properties) {
else if (value.status === "object" || value.status === "array") {
const container = semanticContainer(value);
for (const property of container.slots) {
const propertyPath = [...path, property.name];
const slot = semanticPropertySlot(
context,
binding.bindingId,
property.name,
propertyPath,
property,
);
if (property.presence === "absent") continue;
addSemanticGraphRelation(context.state, {
source: target,
target: slot,
relation: "writes-property",
resolution:
value.unknownProperties || property.presence === "unknown-coverage"
? "candidate"
: "resolved",
});
projectValue({
context,
binding,
value: property.value,
target: slot,
role: `property:${property.name}`,
resolution: property.presence === "present" ? "resolved" : "candidate",
});
if (property.presence === "present")
projectValue({
context,
binding,
value: property.value,
target: slot,
path: propertyPath,
});
}
else if (value.status === "unknown" && value.resourceLimit !== undefined) {
} else if (value.status === "unknown" && value.resourceLimit !== undefined) {
const location = binding.definitions[0]?.location ?? null;
const evidence = observedSemanticEvidence(context.file, location);
const isPropertyValue = role.startsWith("property:");
@@ -123,21 +131,27 @@ const addLiteralNode = (
properties: { value },
});
/** Create one canonical property slot for a binding/property identity. */
/** Create one canonical slot for a root binding and exact static property path. */
export const semanticPropertySlot = (
context: SemanticFlowProjectionContext,
objectBindingId: string,
name: string,
path: readonly string[],
fact: JavaScriptSemanticSlot,
) =>
retainSemanticGraphNode(context.state, context.file, {
kind: "property-slot",
roleKey: `property:${objectBindingId}:${name}`,
roleKey: `property:${JSON.stringify([objectBindingId, path])}`,
location: null,
label: name,
label: fact.name,
functionNodeId:
context.bindingNodes.get(objectBindingId)?.function_node_id ?? null,
properties: {
name,
name: fact.name,
property_path: [...path],
property_pointer: semanticPropertyPointer(path),
object_binding_id: objectBindingId,
} satisfies Readonly<Record<string, JsonValue>>,
presence: fact.presence,
value_status: fact.value.status,
value_coverage: semanticContainer(fact.value)?.coverage ?? null,
},
});