fix(trace): match typed seeds against member arrays
This commit is contained in:
parent
af48fe8791
commit
38ebd54776
|
|
@ -0,0 +1,432 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createJavaScriptApplicationGraph,
|
||||
createJavaScriptApplicationNode,
|
||||
type ApplicationNode,
|
||||
} from "./javascriptApplicationGraph.js";
|
||||
import {
|
||||
artifactEvidence,
|
||||
completeCoverage,
|
||||
} from "./javascriptApplicationGraph.fixture.js";
|
||||
import { findApplicationFeatureSeeds } from "./javascriptFeatureSeed.js";
|
||||
import { traceApplicationFeature } from "./javascriptFeatureTrace.js";
|
||||
import type { ApplicationFeatureSeed } from "./javascriptFeatureTraceSchemas.js";
|
||||
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
|
||||
import type { JsonValue } from "./jsonValue.js";
|
||||
|
||||
const memberFields = [
|
||||
{ kind: "api", nodeKind: "context-bridge-api", field: "members" },
|
||||
{ kind: "api", nodeKind: "context-bridge-api", field: "methods" },
|
||||
{ kind: "module", nodeKind: "javascript-asset", field: "exports" },
|
||||
{ kind: "module", nodeKind: "javascript-module", field: "exports" },
|
||||
{ kind: "module", nodeKind: "source-module", field: "exports" },
|
||||
{
|
||||
kind: "native-export",
|
||||
nodeKind: "native-export",
|
||||
field: "requested_members",
|
||||
},
|
||||
{ kind: "native-export", nodeKind: "native-export", field: "members" },
|
||||
] as const;
|
||||
|
||||
const matchingModes = [
|
||||
{ value: "openProject", match: null, case_sensitive: true },
|
||||
{ value: "openProject", match: "exact", case_sensitive: true },
|
||||
{ value: "openProj", match: "contains", case_sensitive: true },
|
||||
{ value: "OPENPROJECT", match: "exact", case_sensitive: false },
|
||||
{ value: "OPENPROJ", match: "contains", case_sensitive: false },
|
||||
] as const;
|
||||
|
||||
describe.each(memberFields)(
|
||||
"$kind seeds for $nodeKind / $field",
|
||||
({ kind, nodeKind, field }) => {
|
||||
it.each(matchingModes)(
|
||||
"matches scalar and array values with $match / $value / case-sensitive $case_sensitive",
|
||||
(mode) => {
|
||||
const values: readonly [JsonValue, string][] = [
|
||||
["openProject", ""],
|
||||
[["closeProject", "openProject"], "[1]"],
|
||||
];
|
||||
for (const [value, suffix] of values) {
|
||||
const node = memberNode(nodeKind, { [field]: value });
|
||||
expect(
|
||||
findApplicationFeatureSeeds([node], { kind, ...mode }),
|
||||
).toEqual([propertyMatch(node, `${field}${suffix}`)]);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps exact, case-sensitive, and non-string exclusions", () => {
|
||||
const node = memberNode(nodeKind, {
|
||||
[field]: [null, 42, true, "openProject"],
|
||||
});
|
||||
for (const value of ["Project", "OPENPROJECT", "42", "true", "null"])
|
||||
expect(findApplicationFeatureSeeds([node], seed(kind, value))).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(
|
||||
findApplicationFeatureSeeds([node], {
|
||||
...seed(kind, "Project"),
|
||||
match: null,
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("retains actual indexes through nested arrays and object properties", () => {
|
||||
const properties = {
|
||||
nested: [{ [field]: [null, 42, false, [["openProject"]]] }],
|
||||
};
|
||||
const node = memberNode(nodeKind, properties);
|
||||
const before = structuredClone(node);
|
||||
|
||||
expect(findApplicationFeatureSeeds([node], seed(kind))).toEqual([
|
||||
propertyMatch(node, `nested[0].${field}[3][0][0]`),
|
||||
]);
|
||||
expect(node).toEqual(before);
|
||||
expect(properties.nested[0]?.[field]).toEqual([
|
||||
null,
|
||||
42,
|
||||
false,
|
||||
[["openProject"]],
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves literal dotted-field suffixes for scalars and arrays", () => {
|
||||
const values: readonly [JsonValue, string][] = [
|
||||
["openProject", ""],
|
||||
[["openProject"], "[0]"],
|
||||
];
|
||||
for (const [value, suffix] of values) {
|
||||
const node = memberNode(nodeKind, { [`nested.${field}`]: value });
|
||||
expect(findApplicationFeatureSeeds([node], seed(kind))).toEqual([
|
||||
propertyMatch(node, `nested.${field}${suffix}`),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not select near-fields, literal bracket keys, or object descendants", () => {
|
||||
const cases = [
|
||||
{
|
||||
properties: { [`other_${field}`]: "openProject" },
|
||||
path: `other_${field}`,
|
||||
},
|
||||
{ properties: { [`${field}[0]`]: "openProject" }, path: `${field}[0]` },
|
||||
{
|
||||
properties: { [`nested.${field}[0]`]: ["openProject"] },
|
||||
path: `nested.${field}[0][0]`,
|
||||
},
|
||||
{
|
||||
properties: { [field]: { name: "openProject" } },
|
||||
path: `${field}.name`,
|
||||
},
|
||||
{
|
||||
properties: { [field]: [{ name: "openProject" }] },
|
||||
path: `${field}[0].name`,
|
||||
},
|
||||
];
|
||||
for (const { properties, path } of cases) {
|
||||
const node = memberNode(nodeKind, properties);
|
||||
expect(findApplicationFeatureSeeds([node], seed(kind))).toEqual([]);
|
||||
expect(findApplicationFeatureSeeds([node], seed("string"))).toEqual([
|
||||
propertyMatch(node, path),
|
||||
]);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe("feature seed selection controls", () => {
|
||||
it.each([
|
||||
{ kind: "api", nodeKind: "context-bridge-api", field: "label" },
|
||||
{ kind: "api", nodeKind: "context-bridge-api", field: "api_name" },
|
||||
{ kind: "api", nodeKind: "context-bridge-api", field: "key" },
|
||||
{ kind: "module", nodeKind: "javascript-module", field: "label" },
|
||||
{ kind: "module", nodeKind: "javascript-module", field: "module_key" },
|
||||
{ kind: "module", nodeKind: "javascript-module", field: "path" },
|
||||
{ kind: "module", nodeKind: "javascript-module", field: "source" },
|
||||
{ kind: "module", nodeKind: "javascript-module", field: "original_source" },
|
||||
{ kind: "native-export", nodeKind: "native-export", field: "label" },
|
||||
{ kind: "native-export", nodeKind: "native-export", field: "key" },
|
||||
{ kind: "route", nodeKind: "endpoint", field: "label" },
|
||||
{ kind: "route", nodeKind: "endpoint", field: "value" },
|
||||
{ kind: "route", nodeKind: "endpoint", field: "path" },
|
||||
{ kind: "channel", nodeKind: "ipc-channel", field: "label" },
|
||||
{ kind: "channel", nodeKind: "ipc-channel", field: "channel" },
|
||||
{ kind: "channel", nodeKind: "ipc-channel", field: "key" },
|
||||
] as const)(
|
||||
"keeps $kind / $field scalar-only selection unchanged",
|
||||
({ kind, nodeKind, field }) => {
|
||||
const scalar = memberNode(nodeKind, {
|
||||
endpoint_kind: "route",
|
||||
[field]: "openProject",
|
||||
});
|
||||
const array = memberNode(nodeKind, {
|
||||
endpoint_kind: "route",
|
||||
[field]: ["openProject"],
|
||||
});
|
||||
expect(findApplicationFeatureSeeds([scalar], seed(kind))).toEqual([
|
||||
propertyMatch(scalar, field),
|
||||
]);
|
||||
expect(findApplicationFeatureSeeds([array], seed(kind))).toEqual([]);
|
||||
expect(findApplicationFeatureSeeds([array], seed("string"))).toEqual([
|
||||
propertyMatch(array, `${field}[0]`),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps generic matching and scalar label and identity selection", () => {
|
||||
const node = memberNode("context-bridge-api", { members: ["openProject"] });
|
||||
expect(findApplicationFeatureSeeds([node], seed("string"))).toEqual([
|
||||
propertyMatch(node, "members[0]"),
|
||||
]);
|
||||
expect(
|
||||
findApplicationFeatureSeeds([node], seed("api", "desktopApi")),
|
||||
).toEqual([
|
||||
{
|
||||
node_id: node.node_id,
|
||||
kind: node.kind,
|
||||
basis: "label",
|
||||
field: "observations[0].label",
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
findApplicationFeatureSeeds([node], seed("node-id", node.node_id)),
|
||||
).toEqual([
|
||||
{
|
||||
node_id: node.node_id,
|
||||
kind: node.kind,
|
||||
basis: "node-id",
|
||||
field: "node_id",
|
||||
},
|
||||
]);
|
||||
const module = memberNode("javascript-module", {});
|
||||
expect(
|
||||
findApplicationFeatureSeeds([module], seed("module", "preload.js")),
|
||||
).toEqual([
|
||||
{
|
||||
node_id: module.node_id,
|
||||
kind: module.kind,
|
||||
basis: "identity",
|
||||
field: "identity.path",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
kind: "route",
|
||||
nodeKind: "endpoint",
|
||||
properties: { endpoint_kind: "route", path: "/projects" },
|
||||
value: "/projects",
|
||||
field: "path",
|
||||
},
|
||||
{
|
||||
kind: "channel",
|
||||
nodeKind: "ipc-channel",
|
||||
properties: { channel: "project:open" },
|
||||
value: "project:open",
|
||||
field: "channel",
|
||||
},
|
||||
] as const)(
|
||||
"preserves $kind scalar matching and node-kind restrictions",
|
||||
({ kind, nodeKind, properties, value, field }) => {
|
||||
const node = memberNode(nodeKind, properties);
|
||||
expect(findApplicationFeatureSeeds([node], seed(kind, value))).toEqual([
|
||||
propertyMatch(node, field),
|
||||
]);
|
||||
expect(
|
||||
findApplicationFeatureSeeds(
|
||||
[memberNode("context-bridge-api", properties)],
|
||||
seed(kind, value),
|
||||
),
|
||||
).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(memberFields)(
|
||||
"rejects $kind members on an unrelated node kind",
|
||||
({ kind, field }) => {
|
||||
const node = memberNode("unknown", { [field]: ["openProject"] });
|
||||
expect(findApplicationFeatureSeeds([node], seed(kind))).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("public feature trace from contextBridge source members", () => {
|
||||
it.each(matchingModes)(
|
||||
"traces $match / $value / case-sensitive $case_sensitive",
|
||||
(mode) => {
|
||||
const graph = contextBridgeGraph();
|
||||
const before = structuredClone(graph);
|
||||
const input = {
|
||||
sourceEvidenceId,
|
||||
graph,
|
||||
nativeEvidence: [],
|
||||
direction: "both" as const,
|
||||
};
|
||||
const generic = traceApplicationFeature({
|
||||
...input,
|
||||
seed: { kind: "string", ...mode },
|
||||
});
|
||||
const typed = traceApplicationFeature({
|
||||
...input,
|
||||
seed: { kind: "api", ...mode },
|
||||
});
|
||||
|
||||
expect(typed.coverage).toEqual({
|
||||
status: "complete-within-source",
|
||||
source_graph_status: "complete",
|
||||
total_seed_matches: 1,
|
||||
});
|
||||
expect(typed.seed_matches).toEqual(generic.seed_matches);
|
||||
expect(typed.seed_matches).toEqual([
|
||||
{
|
||||
node_id: graph.nodes[0]?.node_id,
|
||||
kind: "context-bridge-api",
|
||||
basis: "property",
|
||||
field: "observations[0].properties.members[1]",
|
||||
},
|
||||
]);
|
||||
expect(typed.graph?.nodes).toEqual(graph.nodes);
|
||||
expect(typed.graph?.edges).toEqual([]);
|
||||
expect(typed.evidence_links).toEqual([sourceEvidenceId]);
|
||||
expect(typed.summary).toMatchObject({
|
||||
matched_seeds: 1,
|
||||
traced_nodes: 1,
|
||||
traced_edges: 0,
|
||||
observed_facts: 1,
|
||||
inferred_facts: 0,
|
||||
unknown_facts: 0,
|
||||
unavailable_facts: 0,
|
||||
});
|
||||
expect(typed.native_handoffs).toEqual([]);
|
||||
expect(graph).toEqual(before);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps a missing member explicit without changing source coverage", () => {
|
||||
const graph = contextBridgeGraph();
|
||||
const result = traceApplicationFeature({
|
||||
sourceEvidenceId,
|
||||
graph,
|
||||
nativeEvidence: [],
|
||||
direction: "both",
|
||||
seed: seed("api", "missingProject"),
|
||||
});
|
||||
expect(result.graph).toBeNull();
|
||||
expect(result.seed_matches).toEqual([]);
|
||||
expect(result.coverage).toEqual({
|
||||
status: "no-match",
|
||||
source_graph_status: "complete",
|
||||
total_seed_matches: 0,
|
||||
});
|
||||
expect(result.evidence_links).toEqual([sourceEvidenceId]);
|
||||
expect(result.limitations).toContain(
|
||||
"No graph entity matched the literal seed; this is not evidence that the feature is absent.",
|
||||
);
|
||||
});
|
||||
|
||||
it("retains incomplete source coverage when an array member matches", () => {
|
||||
const graph = contextBridgeGraph();
|
||||
const { graph_id: _graphId, ...content } = graph;
|
||||
const partialGraph = createJavaScriptApplicationGraph({
|
||||
...content,
|
||||
limitations: ["Only one source file was analyzed."],
|
||||
coverage: {
|
||||
status: "partial",
|
||||
truncated: false,
|
||||
omitted_count: null,
|
||||
limits: [],
|
||||
},
|
||||
});
|
||||
const result = traceApplicationFeature({
|
||||
sourceEvidenceId,
|
||||
graph: partialGraph,
|
||||
nativeEvidence: [],
|
||||
direction: "both",
|
||||
seed: seed("api"),
|
||||
});
|
||||
expect(result.coverage).toEqual({
|
||||
status: "partial",
|
||||
source_graph_status: "partial",
|
||||
total_seed_matches: 1,
|
||||
});
|
||||
expect(result.graph?.coverage).toEqual(partialGraph.coverage);
|
||||
expect(result.graph?.nodes).toEqual(partialGraph.nodes);
|
||||
expect(result.limitations).toContain(
|
||||
"The source application graph is incomplete; unmatched seeds and frontiers remain unknown.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const sourceEvidenceId = `ev_${"b".repeat(64)}`;
|
||||
|
||||
const seed = (
|
||||
kind: ApplicationFeatureSeed["kind"],
|
||||
value = "openProject",
|
||||
): ApplicationFeatureSeed => ({
|
||||
kind,
|
||||
value,
|
||||
match: "exact",
|
||||
case_sensitive: true,
|
||||
});
|
||||
|
||||
const memberNode = (
|
||||
kind: ApplicationNode["kind"],
|
||||
properties: Readonly<Record<string, JsonValue>>,
|
||||
): ApplicationNode =>
|
||||
createJavaScriptApplicationNode({
|
||||
kind,
|
||||
identity: {
|
||||
strategy: "canonical-path",
|
||||
stability: "artifact-version",
|
||||
artifact_sha256: "a".repeat(64),
|
||||
path: "preload.js",
|
||||
},
|
||||
observations: [
|
||||
{
|
||||
label: "desktopApi",
|
||||
properties,
|
||||
evidence: {
|
||||
...artifactEvidence(
|
||||
"a".repeat(64),
|
||||
"preload.js",
|
||||
"ast-static-analysis",
|
||||
),
|
||||
evidence_ids: [sourceEvidenceId],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const propertyMatch = (node: ApplicationNode, field: string) => ({
|
||||
node_id: node.node_id,
|
||||
kind: node.kind,
|
||||
basis: "property",
|
||||
field: `observations[0].properties.${field}`,
|
||||
});
|
||||
|
||||
const contextBridgeGraph = () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(
|
||||
"contextBridge.exposeInMainWorld('desktopApi', { openProject() {}, closeProject() {} });",
|
||||
);
|
||||
expect(analysis.parse_status).toBe("complete");
|
||||
const finding = analysis.electron.context_bridge_apis[0];
|
||||
if (finding === undefined) throw new Error("Expected contextBridge finding");
|
||||
expect(finding.api_key).toBe("desktopApi");
|
||||
expect(finding.members).toEqual(["closeProject", "openProject"]);
|
||||
expect(finding.unknown_members).toBe(0);
|
||||
const node = memberNode("context-bridge-api", {
|
||||
api_name: finding.api_key,
|
||||
members: [...finding.members],
|
||||
unknown_members: finding.unknown_members,
|
||||
});
|
||||
return createJavaScriptApplicationGraph({
|
||||
schema: "JavaScriptApplicationGraph",
|
||||
root_node_ids: [node.node_id],
|
||||
nodes: [node],
|
||||
edges: [],
|
||||
coverage: completeCoverage,
|
||||
limitations: [],
|
||||
});
|
||||
};
|
||||
|
|
@ -14,6 +14,8 @@ export interface ApplicationFeatureSeedMatch {
|
|||
interface CandidateField {
|
||||
readonly basis: ApplicationFeatureSeedMatch["basis"];
|
||||
readonly field: string;
|
||||
/** Structural field path, retaining literal keys but omitting array indexes. */
|
||||
readonly selectionPath?: string;
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
|
|
@ -110,11 +112,21 @@ const candidateFields = (
|
|||
];
|
||||
if (seedKind === "string") return fields;
|
||||
const names = relevantFields[seedKind];
|
||||
return fields.filter(({ field }) =>
|
||||
names.some((name) => field === "identity" || field.endsWith(name)),
|
||||
return fields.filter(({ field, selectionPath = field }) =>
|
||||
names.some((name) => {
|
||||
const path = memberFieldSuffixes.has(name) ? selectionPath : field;
|
||||
return path === "identity" || path.endsWith(name);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const memberFieldSuffixes = new Set([
|
||||
".members",
|
||||
".methods",
|
||||
".exports",
|
||||
".requested_members",
|
||||
]);
|
||||
|
||||
const relevantFields = {
|
||||
route: [".label", ".value", ".path"],
|
||||
api: [".label", ".api_name", ".key", ".methods", ".members"],
|
||||
|
|
@ -164,28 +176,43 @@ const propertyFields = (
|
|||
prefix: string,
|
||||
): CandidateField[] => {
|
||||
const output: CandidateField[] = [];
|
||||
const pending: { readonly current: JsonValue; readonly path: string }[] = [
|
||||
{ current: value, path: prefix },
|
||||
];
|
||||
const pending: {
|
||||
readonly current: JsonValue;
|
||||
readonly path: string;
|
||||
readonly selectionPath: string;
|
||||
}[] = [{ current: value, path: prefix, selectionPath: prefix }];
|
||||
while (pending.length > 0) {
|
||||
const next = pending.pop();
|
||||
if (next === undefined) continue;
|
||||
const { current, path } = next;
|
||||
const { current, path, selectionPath } = next;
|
||||
if (typeof current === "string") {
|
||||
output.push({ basis: "property", field: path, value: current });
|
||||
output.push({
|
||||
basis: "property",
|
||||
field: path,
|
||||
selectionPath,
|
||||
value: current,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(current)) {
|
||||
for (let index = current.length - 1; index >= 0; index -= 1) {
|
||||
const item = current[index];
|
||||
if (item !== undefined)
|
||||
pending.push({ current: item, path: `${path}[${String(index)}]` });
|
||||
pending.push({
|
||||
current: item,
|
||||
path: `${path}[${String(index)}]`,
|
||||
selectionPath,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (current !== null && typeof current === "object") {
|
||||
for (const key of Object.keys(current).sort(compareCodePoints).reverse())
|
||||
pending.push({ current: current[key] ?? null, path: `${path}.${key}` });
|
||||
pending.push({
|
||||
current: current[key] ?? null,
|
||||
path: `${path}.${key}`,
|
||||
selectionPath: `${selectionPath}.${key}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return output;
|
||||
|
|
|
|||
Loading…
Reference in New Issue