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:
parent
63bf91405c
commit
475a0f84da
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ it("parses projected fields into literal, union, or unknown values", () => {
|
|||
projectedExportReturnShapesSchema.safeParse(
|
||||
projectionWithField({
|
||||
path: "/kind",
|
||||
presence: "present",
|
||||
state: "unknown",
|
||||
value: "invented",
|
||||
reason: "Dynamic property",
|
||||
|
|
@ -81,6 +82,7 @@ it("parses projected fields into literal, union, or unknown values", () => {
|
|||
projectedExportReturnShapesSchema.safeParse(
|
||||
projectionWithField({
|
||||
path: "/kind",
|
||||
presence: "present",
|
||||
state: "union",
|
||||
value: ["success", "failure"],
|
||||
reason: null,
|
||||
|
|
@ -88,3 +90,16 @@ it("parses projected fields into literal, union, or unknown values", () => {
|
|||
).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects return fields that omit slot presence", () => {
|
||||
expect(
|
||||
projectedExportReturnShapesSchema.safeParse(
|
||||
projectionWithField({
|
||||
path: "/kind",
|
||||
state: "literal",
|
||||
value: "result",
|
||||
reason: null,
|
||||
}),
|
||||
).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ export const compareJavaScriptExportShapesInputSchema = z
|
|||
|
||||
const projectedReturnFieldShape = {
|
||||
path: jsonPointerSchema,
|
||||
presence: z.enum(["present", "absent", "unknown-coverage"]).optional(),
|
||||
presence: z.enum(["present", "absent", "unknown-coverage"]),
|
||||
};
|
||||
const projectedReturnFieldSchema = z.discriminatedUnion("state", [
|
||||
z.strictObject({
|
||||
|
|
|
|||
|
|
@ -10,9 +10,6 @@ import {
|
|||
import { compareCodePoints } from "../canonicalOrdering.js";
|
||||
|
||||
type Shape = ProjectedExportReturnShapes["static_return_shapes"][number];
|
||||
type Field = Shape["fields"][number];
|
||||
type NormalizedField = Field & { presence: NonNullable<Field["presence"]> };
|
||||
type NormalizedShape = Omit<Shape, "fields"> & { fields: NormalizedField[] };
|
||||
type SelectorResult = JavaScriptExportShapeComparisonResult["left"];
|
||||
type SelectorBase = Omit<SelectorResult, "status" | "selected_node_id">;
|
||||
type ReturnShapeSelection =
|
||||
|
|
@ -58,7 +55,7 @@ export type SelectedJavaScriptExport =
|
|||
|
||||
/** Complete return-shape inventory retained from an authenticated projection. */
|
||||
export interface RetainedJavaScriptExportShapes {
|
||||
readonly shapes: readonly NormalizedShape[];
|
||||
readonly shapes: readonly Shape[];
|
||||
readonly omitted: number;
|
||||
}
|
||||
|
||||
|
|
@ -129,24 +126,13 @@ export const selectJavaScriptExport = (
|
|||
};
|
||||
};
|
||||
|
||||
/** Retain every variant and normalize legacy presence on comparison-owned copies. */
|
||||
/** Retain every authenticated variant with its explicit slot presence. */
|
||||
export const retainJavaScriptExportShapes = (
|
||||
selection: SelectedJavaScriptExport,
|
||||
): RetainedJavaScriptExportShapes => {
|
||||
const shapes = (
|
||||
selection.projection?.static_return_shapes ?? []
|
||||
).map<NormalizedShape>((shape) => ({
|
||||
...shape,
|
||||
fields: shape.fields.map<NormalizedField>((field) => ({
|
||||
...field,
|
||||
// Legacy unknown fields did not distinguish values from uncertain slots.
|
||||
presence:
|
||||
field.presence ??
|
||||
(field.state === "unknown" ? "unknown-coverage" : "present"),
|
||||
})),
|
||||
}));
|
||||
return { shapes, omitted: 0 };
|
||||
};
|
||||
): RetainedJavaScriptExportShapes => ({
|
||||
shapes: selection.projection?.static_return_shapes ?? [],
|
||||
omitted: 0,
|
||||
});
|
||||
|
||||
const selectorBase = (
|
||||
side: JavaScriptExportShapeSideInput,
|
||||
|
|
|
|||
|
|
@ -17,21 +17,21 @@ describe("JavaScript semantic analysis: dataflow 1", () => {
|
|||
`);
|
||||
|
||||
expect(
|
||||
ir.objectOperations.map(({ kind, propertyName, resolution }) => ({
|
||||
ir.objectOperations.map(({ kind, propertyPath, resolution }) => ({
|
||||
kind,
|
||||
propertyName,
|
||||
propertyPath,
|
||||
resolution,
|
||||
})),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
kind: "destructure",
|
||||
propertyName: "token",
|
||||
propertyPath: ["token"],
|
||||
resolution: "complete",
|
||||
},
|
||||
{ kind: "spread", propertyName: null, resolution: "complete" },
|
||||
{ kind: "write", propertyName: "count", resolution: "complete" },
|
||||
{ kind: "read", propertyName: "token", resolution: "complete" },
|
||||
{ kind: "spread", propertyPath: [], resolution: "complete" },
|
||||
{ kind: "write", propertyPath: ["count"], resolution: "complete" },
|
||||
{ kind: "read", propertyPath: ["token"], resolution: "complete" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
|
@ -50,11 +50,11 @@ describe("JavaScript semantic analysis: dataflow 1", () => {
|
|||
({ kind }) => kind === "destructure",
|
||||
);
|
||||
|
||||
expect(destructures.map(({ propertyName }) => propertyName)).toEqual([
|
||||
"token",
|
||||
"token",
|
||||
"token",
|
||||
"1",
|
||||
expect(destructures.map(({ propertyPath }) => propertyPath)).toEqual([
|
||||
["token"],
|
||||
["token"],
|
||||
["token"],
|
||||
["1"],
|
||||
]);
|
||||
expect(
|
||||
destructures.every(({ resolution }) => resolution === "complete"),
|
||||
|
|
@ -127,10 +127,19 @@ describe("JavaScript semantic analysis: dataflow 1", () => {
|
|||
unknownProperties: false,
|
||||
omittedProperties: 0,
|
||||
properties: expect.arrayContaining([
|
||||
{ name: "depth", value: { status: "literal", value: 1 } },
|
||||
{ name: "type", value: { status: "literal", value: "heading" } },
|
||||
{
|
||||
name: "depth",
|
||||
presence: "present",
|
||||
value: { status: "literal", value: 1 },
|
||||
},
|
||||
{
|
||||
name: "type",
|
||||
presence: "present",
|
||||
value: { status: "literal", value: "heading" },
|
||||
},
|
||||
{
|
||||
name: "text",
|
||||
presence: "present",
|
||||
value: {
|
||||
status: "unknown",
|
||||
reason: "Unsupported CallExpression value.",
|
||||
|
|
|
|||
|
|
@ -62,7 +62,11 @@ it("invalidates a deep mutation while retaining unrelated literal properties", (
|
|||
expect(onlyBinding(ir, "root").value).toEqual({
|
||||
status: "object",
|
||||
properties: [
|
||||
{ name: "untouched", value: { status: "literal", value: "retained" } },
|
||||
{
|
||||
name: "untouched",
|
||||
presence: "present",
|
||||
value: { status: "literal", value: "retained" },
|
||||
},
|
||||
{
|
||||
name: "next",
|
||||
presence: "unknown-coverage",
|
||||
|
|
|
|||
|
|
@ -93,6 +93,9 @@ const unchangedProperties = [
|
|||
'const source = { nested: { token: "TOKEN", count: 1 } }; const { nested } = source; nested.count = 2; return source.nested.token;',
|
||||
'const child = { token: "TOKEN", count: 1 }; const source = { child }; source.child.count = 2; return child.token;',
|
||||
'const source = ["TOKEN", 1]; source[1] = 2; return source[0];',
|
||||
'const source = ["TOKEN"]; source.extra = 2; return source[0];',
|
||||
'const source = ["TOKEN"]; source["01"] = 2; return source[0];',
|
||||
'const source = ["TOKEN"]; source[1000000000] = 2; return source[0];',
|
||||
'const child = { token: "TOKEN", count: 1 }; const source = [child]; source[0].count = 2; return child.token;',
|
||||
'const source = { token: "TOKEN" }; source.added = 2; return source.token;',
|
||||
'const source = { token: "TOKEN", count: 1, other: 1 }; source.count = 2; source.other = 2; return source.token;',
|
||||
|
|
|
|||
|
|
@ -144,7 +144,11 @@ describe("JavaScript semantic analysis: rejection 1", () => {
|
|||
expect(recovered.returnSites[0]?.value).toMatchObject({
|
||||
status: "object",
|
||||
properties: expect.arrayContaining([
|
||||
{ name: "type", value: { status: "literal", value: "item" } },
|
||||
{
|
||||
name: "type",
|
||||
presence: "present",
|
||||
value: { status: "literal", value: "item" },
|
||||
},
|
||||
expect.objectContaining({
|
||||
name: "text",
|
||||
value: expect.objectContaining({ status: "unknown" }),
|
||||
|
|
|
|||
|
|
@ -380,7 +380,7 @@ export interface JavaScriptSemanticObjectOperation {
|
|||
readonly ownerCallableId: string | null;
|
||||
readonly objectBindingId: string | null;
|
||||
readonly targetBindingId: string | null;
|
||||
readonly propertyName: string | null;
|
||||
readonly propertyPath: readonly string[] | null;
|
||||
readonly resolution: "complete" | "partial";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,10 +6,7 @@ import {
|
|||
} from "./javascriptSemanticState.js";
|
||||
import { evaluateSemanticBinding } from "./javascriptSemanticValues.js";
|
||||
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
|
||||
import {
|
||||
semanticStaticPropertyKey,
|
||||
semanticStaticPropertyName,
|
||||
} from "./javascriptAstValues.js";
|
||||
import { semanticStaticPropertyKey } from "./javascriptAstValues.js";
|
||||
|
||||
type PropertyPath = readonly (string | number | null)[];
|
||||
|
||||
|
|
@ -105,13 +102,11 @@ const referencedValues = (
|
|||
if (t.isSpreadElement(property))
|
||||
return [{ node: property.argument, path }];
|
||||
if (!t.isObjectProperty(property)) return [];
|
||||
const name = semanticStaticPropertyName(
|
||||
const name = semanticStaticPropertyKey(
|
||||
property.key,
|
||||
property.computed,
|
||||
);
|
||||
return key === null ||
|
||||
(name === "" && !t.isStringLiteral(property.key, { value: "" })) ||
|
||||
String(key) === name
|
||||
return key === null || name === null || String(key) === name
|
||||
? [{ node: property.value, path: remaining }]
|
||||
: [];
|
||||
});
|
||||
|
|
|
|||
|
|
@ -32,25 +32,26 @@ export const invalidateSemanticMutationPath = (
|
|||
return { ...value, properties };
|
||||
}
|
||||
if (value.status === "array") {
|
||||
const index = typeof key === "number" ? key : Number(key);
|
||||
if (
|
||||
!Number.isSafeInteger(index) ||
|
||||
index < 0 ||
|
||||
String(index) !== String(key)
|
||||
)
|
||||
return unknown;
|
||||
const items = value.items.map((item, position) =>
|
||||
position === index
|
||||
? invalidateSemanticMutationPath(item, remaining)
|
||||
const name = String(key);
|
||||
// Array length writes can remove every index; ordinary named properties
|
||||
// and sparse indices affect only their own slot.
|
||||
if (name === "length") return unknown;
|
||||
const observed = value.items.some((item) => item.name === name);
|
||||
const items = value.items.map((item) =>
|
||||
item.name === name
|
||||
? {
|
||||
...item,
|
||||
presence:
|
||||
remaining.length === 0
|
||||
? ("unknown-coverage" as const)
|
||||
: item.presence,
|
||||
value: invalidateSemanticMutationPath(item.value, remaining),
|
||||
}
|
||||
: item,
|
||||
);
|
||||
const itemPresence = {
|
||||
...value.itemPresence,
|
||||
...(remaining.length === 0 || value.items[index] === undefined
|
||||
? { [index]: "unknown-coverage" as const }
|
||||
: {}),
|
||||
};
|
||||
return { ...value, items, itemPresence };
|
||||
if (!observed)
|
||||
items.push({ name, presence: "unknown-coverage", value: unknown });
|
||||
return { ...value, items };
|
||||
}
|
||||
return unknown;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -38,20 +38,24 @@ export const collectJavaScriptSemanticObjects = (
|
|||
t.isSpreadElement(node) &&
|
||||
parent !== null &&
|
||||
t.isObjectExpression(parent)
|
||||
)
|
||||
) {
|
||||
const identity = expressionObjectIdentity(node.argument, state);
|
||||
addObjectOperation(
|
||||
{
|
||||
node,
|
||||
kind: "spread",
|
||||
ownerCallableId,
|
||||
objectBindingId: expressionBindingId(node.argument, state),
|
||||
objectBindingId: identity.bindingId,
|
||||
targetBindingId: null,
|
||||
propertyName: null,
|
||||
propertyPath: identity.path,
|
||||
},
|
||||
state,
|
||||
output,
|
||||
);
|
||||
else if (t.isVariableDeclarator(node) && t.isObjectPattern(node.id))
|
||||
} else if (
|
||||
t.isVariableDeclarator(node) &&
|
||||
(t.isObjectPattern(node.id) || t.isArrayPattern(node.id))
|
||||
)
|
||||
collectDestructuring(node, ownerCallableId, state, output);
|
||||
},
|
||||
exit: (node) => {
|
||||
|
|
@ -75,8 +79,14 @@ const collectMember = (
|
|||
context: ObjectCollectionContext,
|
||||
): void => {
|
||||
const { state, output } = context;
|
||||
const name = semanticStaticPropertyKey(node.property, node.computed);
|
||||
if (name === null) return;
|
||||
// A chain is one property access; retaining every prefix duplicates work and
|
||||
// would make full paths quadratic in deeply nested producer expressions.
|
||||
if (
|
||||
(t.isMemberExpression(parent) || t.isOptionalMemberExpression(parent)) &&
|
||||
parent.object === node
|
||||
)
|
||||
return;
|
||||
const identity = expressionObjectIdentity(node, state);
|
||||
const write =
|
||||
(t.isAssignmentExpression(parent) && parent.left === node) ||
|
||||
(t.isUpdateExpression(parent) && parent.argument === node);
|
||||
|
|
@ -87,9 +97,9 @@ const collectMember = (
|
|||
node,
|
||||
kind: "read",
|
||||
ownerCallableId,
|
||||
objectBindingId: expressionBindingId(node.object, state),
|
||||
objectBindingId: identity.bindingId,
|
||||
targetBindingId: null,
|
||||
propertyName: name,
|
||||
propertyPath: identity.path,
|
||||
},
|
||||
state,
|
||||
output,
|
||||
|
|
@ -99,9 +109,9 @@ const collectMember = (
|
|||
node,
|
||||
kind: write ? "write" : "read",
|
||||
ownerCallableId,
|
||||
objectBindingId: expressionBindingId(node.object, state),
|
||||
objectBindingId: identity.bindingId,
|
||||
targetBindingId: null,
|
||||
propertyName: name,
|
||||
propertyPath: identity.path,
|
||||
},
|
||||
state,
|
||||
output,
|
||||
|
|
@ -114,30 +124,41 @@ const collectDestructuring = (
|
|||
state: JavaScriptSemanticAnalysisState,
|
||||
output: JavaScriptSemanticObjectOperation[],
|
||||
): void => {
|
||||
if (!t.isObjectPattern(node.id)) return;
|
||||
const objectBindingId = expressionBindingId(node.init, state);
|
||||
for (const property of node.id.properties) {
|
||||
if (!t.isObjectProperty(property)) continue;
|
||||
const name = semanticStaticPropertyKey(property.key, property.computed);
|
||||
if (name === null) continue;
|
||||
const target = bindingIdentifier(property.value);
|
||||
const source = expressionObjectIdentity(node.init, state);
|
||||
const visit = (pattern: t.Node, path: readonly string[]): void => {
|
||||
if (t.isObjectPattern(pattern)) {
|
||||
for (const property of pattern.properties) {
|
||||
if (!t.isObjectProperty(property)) continue;
|
||||
const name = semanticStaticPropertyKey(property.key, property.computed);
|
||||
if (name !== null) visit(property.value, [...path, name]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (t.isArrayPattern(pattern)) {
|
||||
pattern.elements.forEach((element, index) => {
|
||||
if (element !== null && !t.isRestElement(element))
|
||||
visit(element, [...path, String(index)]);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const target = bindingIdentifier(pattern);
|
||||
if (target === null) return;
|
||||
addObjectOperation(
|
||||
{
|
||||
node: property,
|
||||
node: pattern,
|
||||
kind: "destructure",
|
||||
ownerCallableId,
|
||||
objectBindingId,
|
||||
objectBindingId: source.bindingId,
|
||||
targetBindingId:
|
||||
target === null
|
||||
? null
|
||||
: (resolveSemanticBindingState(state, target, target.name)
|
||||
?.bindingId ?? null),
|
||||
propertyName: name,
|
||||
resolveSemanticBindingState(state, target, target.name)?.bindingId ??
|
||||
null,
|
||||
propertyPath: source.path === null ? null : [...source.path, ...path],
|
||||
},
|
||||
state,
|
||||
output,
|
||||
);
|
||||
}
|
||||
};
|
||||
visit(node.id, []);
|
||||
};
|
||||
|
||||
interface AddObjectOperationInput {
|
||||
|
|
@ -146,7 +167,7 @@ interface AddObjectOperationInput {
|
|||
readonly ownerCallableId: string | null;
|
||||
readonly objectBindingId: string | null;
|
||||
readonly targetBindingId: string | null;
|
||||
readonly propertyName: string | null;
|
||||
readonly propertyPath: readonly string[] | null;
|
||||
}
|
||||
|
||||
const addObjectOperation = (
|
||||
|
|
@ -161,27 +182,57 @@ const addObjectOperation = (
|
|||
ownerCallableId: input.ownerCallableId,
|
||||
objectBindingId: input.objectBindingId,
|
||||
targetBindingId: input.targetBindingId,
|
||||
propertyName: input.propertyName,
|
||||
propertyPath: input.propertyPath,
|
||||
resolution:
|
||||
input.kind === "spread"
|
||||
? input.objectBindingId === null
|
||||
? "partial"
|
||||
: "complete"
|
||||
: input.objectBindingId === null ||
|
||||
input.propertyName === null ||
|
||||
(input.kind === "destructure" && input.targetBindingId === null)
|
||||
? "partial"
|
||||
: "complete",
|
||||
input.objectBindingId === null ||
|
||||
input.propertyPath === null ||
|
||||
(input.kind === "destructure" && input.targetBindingId === null)
|
||||
? "partial"
|
||||
: "complete",
|
||||
});
|
||||
};
|
||||
|
||||
const expressionBindingId = (
|
||||
interface ObjectIdentity {
|
||||
readonly bindingId: string | null;
|
||||
readonly path: readonly string[] | null;
|
||||
}
|
||||
|
||||
const expressionObjectIdentity = (
|
||||
node: t.Node | null | undefined,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): string | null =>
|
||||
t.isIdentifier(node)
|
||||
? (resolveSemanticBindingState(state, node, node.name)?.bindingId ?? null)
|
||||
: null;
|
||||
): ObjectIdentity => {
|
||||
const path: string[] = [];
|
||||
let current = node;
|
||||
let staticPath = true;
|
||||
while (current !== null && current !== undefined) {
|
||||
if (
|
||||
t.isMemberExpression(current) ||
|
||||
t.isOptionalMemberExpression(current)
|
||||
) {
|
||||
const name = semanticStaticPropertyKey(
|
||||
current.property,
|
||||
current.computed,
|
||||
);
|
||||
if (name === null) staticPath = false;
|
||||
else path.push(name);
|
||||
current = current.object;
|
||||
} else if (
|
||||
t.isTSAsExpression(current) ||
|
||||
t.isTSTypeAssertion(current) ||
|
||||
t.isTSNonNullExpression(current) ||
|
||||
t.isTSSatisfiesExpression(current)
|
||||
)
|
||||
current = current.expression;
|
||||
else break;
|
||||
}
|
||||
return {
|
||||
bindingId: t.isIdentifier(current)
|
||||
? (resolveSemanticBindingState(state, current, current.name)?.bindingId ??
|
||||
null)
|
||||
: null,
|
||||
path: staticPath ? path.reverse() : null,
|
||||
};
|
||||
};
|
||||
|
||||
const bindingIdentifier = (node: t.Node): t.Identifier | null => {
|
||||
if (t.isIdentifier(node)) return node;
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ export const semanticResourceLimitsIn = (
|
|||
else if (value.status === "object")
|
||||
for (const property of value.properties) pending.push(property.value);
|
||||
else if (value.status === "array")
|
||||
for (const item of value.items) pending.push(item);
|
||||
for (const item of value.items) pending.push(item.value);
|
||||
}
|
||||
return [...found].sort();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import * as t from "@babel/types";
|
||||
|
||||
import {
|
||||
semanticContainer,
|
||||
semanticPropertyPointer,
|
||||
} from "./javascriptSemanticSlots.js";
|
||||
|
||||
import type {
|
||||
JavaScriptSemanticCallable,
|
||||
JavaScriptSemanticModuleLink,
|
||||
|
|
@ -244,54 +249,24 @@ const flattenValue = (
|
|||
});
|
||||
return;
|
||||
}
|
||||
if (value.status === "object") {
|
||||
coverage.push(
|
||||
value.unknownProperties
|
||||
? { path, status: "partial", omitted: value.omittedProperties }
|
||||
: { path, status: "complete", omitted: 0 },
|
||||
);
|
||||
for (const property of value.properties)
|
||||
flattenValue(
|
||||
property.value,
|
||||
`${path}/${escapePointer(property.name)}`,
|
||||
fields,
|
||||
coverage,
|
||||
property.presence ?? "present",
|
||||
);
|
||||
if ("reason" in value) {
|
||||
fields.push({
|
||||
path,
|
||||
presence,
|
||||
state: "unknown",
|
||||
value: null,
|
||||
reason: value.reason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (value.status === "array") {
|
||||
coverage.push(
|
||||
value.unknownItems
|
||||
? { path, status: "partial", omitted: value.omittedItems }
|
||||
: { path, status: "complete", omitted: 0 },
|
||||
const container = semanticContainer(value);
|
||||
coverage.push({ path, ...container.coverage });
|
||||
for (const slot of container.slots)
|
||||
flattenValue(
|
||||
slot.value,
|
||||
`${path}${semanticPropertyPointer([slot.name])}`,
|
||||
fields,
|
||||
coverage,
|
||||
slot.presence,
|
||||
);
|
||||
value.items.forEach((item, index) =>
|
||||
flattenValue(
|
||||
item,
|
||||
`${path}/${String(index)}`,
|
||||
fields,
|
||||
coverage,
|
||||
value.itemPresence?.[index] ?? "present",
|
||||
),
|
||||
);
|
||||
// Sparse mutation metadata retains uncertain slots without expanding arrays.
|
||||
for (const [index, itemPresence] of Object.entries(
|
||||
value.itemPresence ?? {},
|
||||
)) {
|
||||
if (Number(index) < value.items.length) continue;
|
||||
flattenValue(value, `${path}/${index}`, fields, coverage, itemPresence);
|
||||
}
|
||||
return;
|
||||
}
|
||||
fields.push({
|
||||
path,
|
||||
presence,
|
||||
state: "unknown",
|
||||
value: null,
|
||||
reason: value.reason,
|
||||
});
|
||||
};
|
||||
|
||||
const escapePointer = (value: string): string =>
|
||||
value.replaceAll("~", "~0").replaceAll("/", "~1");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
import type {
|
||||
JavaScriptSemanticProperty,
|
||||
JavaScriptSemanticValue,
|
||||
} from "./javascriptSemanticValueTypes.js";
|
||||
|
||||
/** A container's own slot, using the value lattice's canonical presence model. */
|
||||
export type JavaScriptSemanticSlot = JavaScriptSemanticProperty;
|
||||
|
||||
/** Static container inventory; partial siblings do not weaken retained slots. */
|
||||
export interface JavaScriptSemanticContainer {
|
||||
readonly slots: readonly JavaScriptSemanticSlot[];
|
||||
readonly coverage:
|
||||
| { readonly status: "complete"; readonly omitted: 0 }
|
||||
| { readonly status: "partial"; readonly omitted: number | null };
|
||||
}
|
||||
|
||||
/** Read the same slot inventory for object, array, and return projections. */
|
||||
export function semanticContainer(
|
||||
value: Extract<
|
||||
JavaScriptSemanticValue,
|
||||
{ readonly status: "object" | "array" }
|
||||
>,
|
||||
): JavaScriptSemanticContainer;
|
||||
export function semanticContainer(
|
||||
value: JavaScriptSemanticValue,
|
||||
): JavaScriptSemanticContainer | null;
|
||||
export function semanticContainer(
|
||||
value: JavaScriptSemanticValue,
|
||||
): JavaScriptSemanticContainer | null {
|
||||
if (value.status === "object")
|
||||
return {
|
||||
slots: value.properties,
|
||||
coverage: value.unknownProperties
|
||||
? { status: "partial", omitted: value.omittedProperties }
|
||||
: { status: "complete", omitted: 0 },
|
||||
};
|
||||
if (value.status === "array")
|
||||
return {
|
||||
slots: value.items,
|
||||
coverage: value.unknownItems
|
||||
? { status: "partial", omitted: value.omittedItems }
|
||||
: { status: "complete", omitted: 0 },
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Resolve a full static own-property path without conflating missing siblings. */
|
||||
export const semanticSlotAtPath = (
|
||||
value: JavaScriptSemanticValue,
|
||||
path: readonly string[],
|
||||
): JavaScriptSemanticSlot => {
|
||||
const leafName = path.at(-1) ?? "";
|
||||
let current: JavaScriptSemanticSlot = {
|
||||
name: "",
|
||||
presence: "present",
|
||||
value,
|
||||
};
|
||||
for (const [index, name] of path.entries()) {
|
||||
const container = semanticContainer(current.value);
|
||||
if (current.presence !== "present" || container === null)
|
||||
return {
|
||||
name: leafName,
|
||||
presence: "unknown-coverage",
|
||||
value:
|
||||
"reason" in current.value
|
||||
? {
|
||||
...current.value,
|
||||
reason: `${current.value.reason} Cannot continue ${semanticPropertyPointer(path)} through receiver ${semanticPropertyPointer(path.slice(0, index))}.`,
|
||||
}
|
||||
: {
|
||||
status: "unknown",
|
||||
reason: `Cannot resolve ${semanticPropertyPointer(path)} through ${current.value.status} receiver ${semanticPropertyPointer(path.slice(0, index))}.`,
|
||||
},
|
||||
};
|
||||
// Array position inventories omit the intrinsic non-enumerable length slot.
|
||||
// Its own presence is guaranteed even when no exact length is retained.
|
||||
if (current.value.status === "array" && name === "length") {
|
||||
current = {
|
||||
name,
|
||||
presence: "present",
|
||||
value: {
|
||||
status: "unknown",
|
||||
reason: "Array length is present but its value is not retained.",
|
||||
},
|
||||
};
|
||||
continue;
|
||||
}
|
||||
const observed = container.slots.find((slot) => slot.name === name);
|
||||
current = observed ?? {
|
||||
name,
|
||||
presence:
|
||||
container.coverage.status === "complete"
|
||||
? "absent"
|
||||
: "unknown-coverage",
|
||||
value: {
|
||||
status: "unknown",
|
||||
reason: `Own property ${name} was not observed.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
/** Encode path segments as an unambiguous JSON Pointer, preserving empty keys. */
|
||||
export const semanticPropertyPointer = (path: readonly string[]): string =>
|
||||
path
|
||||
.map((key) => `/${key.replaceAll("~", "~0").replaceAll("/", "~1")}`)
|
||||
.join("");
|
||||
|
|
@ -23,10 +23,7 @@ type JavaScriptSemanticObjectValue = {
|
|||
|
||||
type JavaScriptSemanticArrayValue = {
|
||||
readonly status: "array";
|
||||
readonly items: readonly JavaScriptSemanticValue[];
|
||||
readonly itemPresence?: Readonly<
|
||||
Record<number, "absent" | "unknown-coverage">
|
||||
>;
|
||||
readonly items: readonly JavaScriptSemanticProperty[];
|
||||
} & (
|
||||
| {
|
||||
readonly unknownItems: false;
|
||||
|
|
@ -56,9 +53,15 @@ export type JavaScriptSemanticValue =
|
|||
readonly resourceLimit?: JavaScriptSemanticResourceLimit;
|
||||
};
|
||||
|
||||
/** One statically named object-literal property. */
|
||||
/** One statically named own slot in an object or array. */
|
||||
export interface JavaScriptSemanticProperty {
|
||||
readonly name: string;
|
||||
readonly value: JavaScriptSemanticValue;
|
||||
readonly presence?: "unknown-coverage";
|
||||
readonly presence: JavaScriptSemanticSlotPresence;
|
||||
}
|
||||
|
||||
/** Own-slot presence, independent of its value and sibling coverage. */
|
||||
export type JavaScriptSemanticSlotPresence =
|
||||
| "present"
|
||||
| "absent"
|
||||
| "unknown-coverage";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import * as t from "@babel/types";
|
||||
|
||||
import { semanticSlotAtPath } from "./javascriptSemanticSlots.js";
|
||||
|
||||
import { invalidateSemanticMutationPath } from "./javascriptSemanticMutationValues.js";
|
||||
|
||||
import type {
|
||||
|
|
@ -195,9 +197,12 @@ const evaluateTemplate = (
|
|||
let candidates = [""];
|
||||
for (let index = 0; index < node.quasis.length; index += 1) {
|
||||
const quasi = node.quasis[index];
|
||||
// Semantic template evaluation retains its established raw fallback for
|
||||
// synthetic AST inputs; exact atom readers remain cooked-only.
|
||||
const text = quasi?.value.cooked ?? quasi?.value.raw ?? "";
|
||||
const text = quasi?.value.cooked;
|
||||
if (text === undefined || text === null)
|
||||
return {
|
||||
status: "unknown",
|
||||
reason: "Template text has no cooked representation.",
|
||||
};
|
||||
if (exceedsSemanticTemplateByteBudget(candidates, [text]))
|
||||
return semanticResourceLimitUnknown("primitive-bytes");
|
||||
candidates = candidates.map((prefix) => `${prefix}${text}`);
|
||||
|
|
@ -235,6 +240,7 @@ const evaluateObject = (
|
|||
for (const [name] of propertiesByName)
|
||||
propertiesByName.set(name, {
|
||||
name,
|
||||
presence: "present",
|
||||
value: {
|
||||
status: "unknown",
|
||||
reason: "A later property may overwrite this value.",
|
||||
|
|
@ -268,6 +274,7 @@ const evaluateObject = (
|
|||
}
|
||||
propertiesByName.set(name, {
|
||||
name,
|
||||
presence: "present",
|
||||
value: t.isObjectProperty(property)
|
||||
? evaluateExpression(property.value, nestedContext(context))
|
||||
: {
|
||||
|
|
@ -298,8 +305,7 @@ const evaluateArray = (
|
|||
node: t.ArrayExpression,
|
||||
context: EvaluationContext,
|
||||
): JavaScriptSemanticValue => {
|
||||
const items: JavaScriptSemanticValue[] = [];
|
||||
const itemPresence: Record<number, "absent"> = {};
|
||||
const items: JavaScriptSemanticProperty[] = [];
|
||||
let unknownItems = false;
|
||||
let omittedItems: number | null = 0;
|
||||
for (const element of node.elements) {
|
||||
|
|
@ -310,21 +316,24 @@ const evaluateArray = (
|
|||
break;
|
||||
}
|
||||
if (element === null) {
|
||||
itemPresence[items.length] = "absent";
|
||||
items.push({
|
||||
status: "unknown",
|
||||
reason: "Array hole has no primitive value.",
|
||||
name: String(items.length),
|
||||
presence: "absent",
|
||||
value: { status: "unknown", reason: "Array hole has no own value." },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
items.push(evaluateExpression(element, nestedContext(context)));
|
||||
items.push({
|
||||
name: String(items.length),
|
||||
presence: "present",
|
||||
value: evaluateExpression(element, nestedContext(context)),
|
||||
});
|
||||
}
|
||||
return unknownItems
|
||||
? { status: "array", items, itemPresence, unknownItems: true, omittedItems }
|
||||
? { status: "array", items, unknownItems: true, omittedItems }
|
||||
: {
|
||||
status: "array",
|
||||
items,
|
||||
itemPresence,
|
||||
unknownItems: false,
|
||||
omittedItems: 0,
|
||||
};
|
||||
|
|
@ -436,46 +445,16 @@ const projectValue = (
|
|||
value: JavaScriptSemanticValue,
|
||||
projection: readonly (string | number | null)[],
|
||||
): JavaScriptSemanticValue => {
|
||||
let current = value;
|
||||
for (const key of projection) {
|
||||
if (key === null)
|
||||
return {
|
||||
if (projection.includes(null))
|
||||
return { status: "unknown", reason: "Cannot project a dynamic property." };
|
||||
const path = projection.map((key) => String(key));
|
||||
const slot = semanticSlotAtPath(value, path);
|
||||
return slot.presence === "present"
|
||||
? slot.value
|
||||
: {
|
||||
status: "unknown",
|
||||
reason: "Cannot project a dynamic property.",
|
||||
reason: `Own property ${path.join("/")} is ${slot.presence}.`,
|
||||
};
|
||||
if (current.status === "object" && typeof key === "string") {
|
||||
const property = current.properties.find(({ name }) => name === key);
|
||||
if (property === undefined)
|
||||
return {
|
||||
status: current.unknownProperties ? "unknown" : "unknown",
|
||||
reason: `Object property ${key} was not observed.`,
|
||||
};
|
||||
current = property.value;
|
||||
} else if (current.status === "array") {
|
||||
const index = typeof key === "number" ? key : Number(key);
|
||||
if (
|
||||
!Number.isSafeInteger(index) ||
|
||||
index < 0 ||
|
||||
String(index) !== String(key)
|
||||
)
|
||||
return {
|
||||
status: "unknown",
|
||||
reason: `Array property ${String(key)} is not a canonical index.`,
|
||||
};
|
||||
const item = current.items[index];
|
||||
if (item === undefined)
|
||||
return {
|
||||
status: "unknown",
|
||||
reason: `Array item ${String(key)} missing.`,
|
||||
};
|
||||
current = item;
|
||||
} else
|
||||
return {
|
||||
status: "unknown",
|
||||
reason: `Cannot project ${String(key)} from ${current.status}.`,
|
||||
};
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
const provenanceForBinding = (
|
||||
|
|
|
|||
|
|
@ -463,7 +463,7 @@ describe("JavaScript export localized mutation uncertainty", () => {
|
|||
});
|
||||
|
||||
describe("JavaScript export property presence boundaries", () => {
|
||||
it("normalizes omitted legacy presence before comparing identical literals and unions", async () => {
|
||||
it("rejects return-shape observations that omit required slot presence", async () => {
|
||||
const source =
|
||||
'export default () => ({ kind: "result", count: flag ? 1 : 2, nested: { enabled: true } });';
|
||||
const [left, right] = await analyzeSources({ left: source, right: source });
|
||||
|
|
@ -513,9 +513,14 @@ describe("JavaScript export property presence boundaries", () => {
|
|||
[right, { ...left, graph }],
|
||||
] as const) {
|
||||
const result = compare(sides[0], sides[1]);
|
||||
expect(result.coverage.paired_variants).toBe(1);
|
||||
expect(result.changes).toEqual([]);
|
||||
expect(result.coverage.status).toBe("complete-within-inputs");
|
||||
expect(result.coverage.paired_variants).toBe(0);
|
||||
expect([result.left.status, result.right.status]).toContain(
|
||||
"unavailable",
|
||||
);
|
||||
expect(result.changes).toEqual([
|
||||
expect.objectContaining({ status: "unknown", path: "" }),
|
||||
]);
|
||||
expect(result.coverage.status).toBe("partial");
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ it("executes the advertised export presence example with matching analyzed Evide
|
|||
});
|
||||
expect(response.isError).not.toBe(true);
|
||||
expect(response.structuredContent).toMatchObject({
|
||||
result: {
|
||||
normalized_result: {
|
||||
left: { status: "selected" },
|
||||
right: { status: "selected" },
|
||||
summary: { added: 1, removed: 1, changed: 0, unknown: 0 },
|
||||
|
|
@ -102,7 +102,7 @@ it("traces example semantic modules with their parsed artifact digests", async (
|
|||
});
|
||||
expect(response.isError).not.toBe(true);
|
||||
expect(response.structuredContent).toMatchObject({
|
||||
result: {
|
||||
normalized_result: {
|
||||
nodes: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
node_id: module.node_id,
|
||||
|
|
@ -161,10 +161,8 @@ describe("application workflow MCP parity", () => {
|
|||
expect(relativeApplication.isError).not.toBe(true);
|
||||
expect(relativeApplication.structuredContent).toMatchObject({
|
||||
evidence_id: left.value.evidence_id,
|
||||
result: { input_path: leftAnalysis.input_path },
|
||||
evidence: {
|
||||
subject: { local_path: left.value.subject?.local_path },
|
||||
},
|
||||
normalized_result: { input_path: leftAnalysis.input_path },
|
||||
subject: { local_path: left.value.subject?.local_path },
|
||||
});
|
||||
const full = await client.callTool({
|
||||
name: "compare_javascript_export_shapes",
|
||||
|
|
@ -172,7 +170,7 @@ describe("application workflow MCP parity", () => {
|
|||
});
|
||||
expect(full.isError).not.toBe(true);
|
||||
expect(full.structuredContent).toMatchObject({
|
||||
result: {
|
||||
normalized_result: {
|
||||
summary: { added: 1, removed: 0, changed: 0, unknown: 0 },
|
||||
property_inventories: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
|
|
@ -231,7 +229,7 @@ describe("application workflow MCP parity", () => {
|
|||
});
|
||||
expect(semantic.isError).not.toBe(true);
|
||||
expect(semantic.structuredContent).toMatchObject({
|
||||
result: {
|
||||
normalized_result: {
|
||||
source_evidence_id: left.value.evidence_id,
|
||||
source_graph_id: analyzed.semantic_graph.graph_id,
|
||||
summary: { total_seed_matches: 1 },
|
||||
|
|
|
|||
Loading…
Reference in New Issue