feat(managed): project call and field-access edges into the application graph (#6260)

project_managed_application_graph dropped the call_edges and
field_accesses that member Evidence already decodes, so the graph was a
containment tree that could not answer who calls a P/Invoke wrapper or
which methods touch a field, while reporting complete-within-inputs.

In-module call/callvirt/newobj/jmp sites now become one calls edge per
caller and target, and field loads, stores and address-takes become one
accesses edge per method and field, each with site and per-opcode counts
and member Evidence links. MemberRef and MethodSpec targets, unknown
targets, ldftn/ldvirtftn loads, recursive self-calls (the graph has no
self-loops), MemberRef fields and sites whose node is absent are counted
by reason in limitations, and the graph is partial with that
omitted_count. Add the provider-neutral accesses relation.

Graph Evidence projected before these counters existed still parses,
with null calls and field_accesses summaries.

Fixes #4056

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822
2026-10-22 00:26:56 -07:00
committed by GitHub
parent 86357bad54
commit c9b4cae9dc
9 changed files with 492 additions and 19 deletions
+2 -2
View File
@@ -80,8 +80,8 @@ Graph coverage describes the analyzed artifact scope and resource limits, not
runtime execution. A partial target alone does not imply omitted source.
The directed relation vocabulary is `contains`, `loads`, `imports`, `maps_to`,
`exposes`, `sends`, `invokes`, `handles`, `calls`, `persists_to`, `observed_as`,
and `changed_from`. Provider names are deliberately absent from both
`exposes`, `sends`, `invokes`, `handles`, `calls`, `accesses`, `persists_to`,
`observed_as`, and `changed_from`. Provider names are deliberately absent from both
vocabularies.
For a `maps_to` edge targeting a source-map node, the edge carries
+26
View File
@@ -428,6 +428,32 @@ Declared import inventory supports a bounded positive claim. Its absence does
not exclude dynamic resolution, generated code, protected code, native helpers,
or server-side behavior.
## Application graph relations
`project_managed_application_graph` turns member Evidence into `contains`
edges for declarations, `imports` edges for P/Invoke declarations, and two
relation kinds read from method bodies:
- `calls` links two `managed-method` nodes when a `call`, `callvirt`, `newobj`
or `jmp` site in the caller targets a MethodDef in the inspected module.
- `accesses` links a `managed-method` node to a `managed-field` node for each
field it loads, stores or takes the address of.
Each edge stands for every site between one source and one target. Its
properties carry `site_count` and per-opcode `opcodes` counts; `accesses`
edges also list `access` as `read`, `write` and/or `address`. Edge evidence
links the member Evidence.
Some sites are counted rather than projected: calls to MemberRefs (members of
other assemblies or of generic type instantiations), generic method
instantiations (`MethodSpec`), unknown targets, `ldftn`/`ldvirtftn` function
pointer loads, recursive self-calls (the graph has no self-loops), MemberRef
fields, and sites whose method or field node is absent, for example under a
type selection. Limitations name each counted class, and graph coverage is
`partial` with `omitted_count` equal to the omitted sites when nothing else is
incomplete. A missing `calls` edge is therefore not evidence that a method is
never called.
## Evidence record shape
Member comparison, application graph projection, and managed/native verification
@@ -139,7 +139,7 @@ export const MANAGED_WORKFLOW_TOOL_CONTRACTS = [
name: "project_managed_application_graph",
...toolContractMetadata("project_managed_application_graph"),
description:
"Project authenticated managed artifact, member, and native-boundary Evidence into the provider-neutral application graph without executing managed code, loading assemblies, or translating managed metadata tokens to native addresses. The graph preserves managed static observations as managed-specific nodes linked to source Evidence.",
"Project authenticated managed artifact, member, and native-boundary Evidence into the provider-neutral application graph without executing managed code, loading assemblies, or translating managed metadata tokens to native addresses. The graph preserves managed static observations as managed-specific nodes linked to source Evidence, with in-module calls and field-access edges; other call and field-access sites are counted in limitations.",
kind: "application",
inputSchema: managedApplicationGraphReferenceInputSchema,
outputSchema: managedApplicationGraphOutputSchema,
@@ -64,6 +64,7 @@ export const JAVASCRIPT_APPLICATION_RELATIONS = [
"invokes",
"handles",
"calls",
"accesses",
"persists_to",
"observed_as",
"changed_from",
+39 -9
View File
@@ -22,6 +22,7 @@ import {
assessManagedGraphOmissions,
managedGraphEvidenceCoverage,
managedGraphResultCoverage,
type ManagedGraphProjectionOmissions,
} from "./managedApplicationGraphCoverage.js";
import {
addArtifactNode,
@@ -29,6 +30,10 @@ import {
addBoundaryNodes,
addMemberNodes,
} from "./managedApplicationGraphNodes.js";
import {
addMemberRelations,
relationOmissionLimitations,
} from "./managedApplicationGraphRelations.js";
import { digestSchema } from "../digests.js";
import { prefixedDigestSchema } from "../digests.js";
import { parseManagedInspectionEvidence } from "./managedInspectionEvidence.js";
@@ -75,6 +80,22 @@ export const managedApplicationGraphResultSchema = z.strictObject({
fields: z.int().min(0),
pinvoke_imports: z.int().min(0),
native_implementations: z.int().min(0),
// Graphs projected before call and field-access edges existed omit these
// counters; null means those relations were not projected, not zero.
calls: z
.int()
.min(0)
.nullable()
.default(null)
.describe("calls edges; null for graphs projected before calls edges"),
field_accesses: z
.int()
.min(0)
.nullable()
.default(null)
.describe(
"accesses edges; null for graphs projected before accesses edges",
),
}),
graph: javascriptApplicationGraphSchema,
coverage: z.strictObject({
@@ -144,7 +165,15 @@ export const projectManagedApplicationGraph = (
addArtifactIdentityNodes(state, artifactNode, parsed);
addMemberNodes(state, artifactNode, parsed);
addBoundaryNodes(state, artifactNode, parsed);
const omissions = omittedCounts(parsed);
const relations = addMemberRelations(state, parsed);
const omissions = assessManagedGraphOmissions(
{
artifact: parsed.artifact?.result ?? null,
members: parsed.members?.result ?? null,
boundaries: parsed.boundaries?.result ?? null,
},
relations,
);
const limitations = projectionLimitations(parsed, omissions);
const graph = createJavaScriptApplicationGraph({
schema: "JavaScriptApplicationGraph",
@@ -176,6 +205,8 @@ export const projectManagedApplicationGraph = (
graph.nodes,
"managed-native-implementation",
),
calls: countEdges(graph.edges, "calls"),
field_accesses: countEdges(graph.edges, "accesses"),
},
graph,
coverage: managedGraphResultCoverage(omissions),
@@ -196,6 +227,11 @@ const countNodes = (
kind: ApplicationNode["kind"],
): number => nodes.filter((node) => node.kind === kind).length;
const countEdges = (
edges: readonly ApplicationEdge[],
relation: ApplicationEdge["relation"],
): number => edges.filter((edge) => edge.relation === relation).length;
const parseManagedInputs = (
input: ProjectManagedApplicationGraphInput,
): ParsedManagedGraphInput => ({
@@ -298,16 +334,9 @@ const assertConsistentModuleIdentity = (
}
};
const omittedCounts = (parsed: ParsedManagedGraphInput) =>
assessManagedGraphOmissions({
artifact: parsed.artifact?.result ?? null,
members: parsed.members?.result ?? null,
boundaries: parsed.boundaries?.result ?? null,
});
const projectionLimitations = (
parsed: ParsedManagedGraphInput,
omissions: ReturnType<typeof omittedCounts>,
omissions: ManagedGraphProjectionOmissions,
): string[] => [
"Managed graph projection preserves static metadata observations only; it does not execute managed code, load assemblies, or map managed tokens to native addresses.",
...(parsed.members === null
@@ -338,4 +367,5 @@ const projectionLimitations = (
"Managed boundary tokens were not linked to member nodes because matching module MVID scope was not established.",
]
: []),
...relationOmissionLimitations(omissions.relations),
];
@@ -8,6 +8,35 @@ import { managedTokenScopesMatch } from "./managedInspectionEvidence.js";
type ManagedCoverageState = "complete" | "partial" | "unavailable";
/** Why a call site did not become a `calls` edge. */
export type OmittedCallReason =
| "member-ref"
| "method-spec"
| "unknown-target"
| "method-reference"
| "absent-node"
| "self-call";
/** Why a field-access site did not become an `accesses` edge. */
export type OmittedFieldAccessReason = "member-ref" | "absent-node";
/** Member-Evidence sites the projection counted but did not turn into edges. */
export interface ManagedRelationOmissions {
readonly callSites: Readonly<Record<OmittedCallReason, number>>;
readonly fieldAccessSites: Readonly<Record<OmittedFieldAccessReason, number>>;
}
/** Total sites counted but not projected. */
export const omittedRelationSites = (
omissions: ManagedRelationOmissions | null,
): number =>
omissions === null
? 0
: [
...Object.values(omissions.callSites),
...Object.values(omissions.fieldAccessSites),
].reduce((total, count) => total + count, 0);
/** Source observations used to derive managed graph coverage. */
export interface ManagedGraphCoverageSources {
readonly artifact: ManagedArtifactInspection | null;
@@ -21,13 +50,19 @@ export interface ManagedGraphProjectionOmissions {
readonly unscopedTokenRelationships: boolean;
/** Declaring types the member Evidence selected; null when it inspected every type. */
readonly selectedTypes: readonly string[] | null;
/** Counted call and field-access sites without edges; null without member Evidence. */
readonly relations: ManagedRelationOmissions | null;
}
const omitsAny = (omissions: ManagedGraphProjectionOmissions): boolean =>
/** Omissions whose size is unknown, unlike counted relation sites. */
const omitsUncounted = (omissions: ManagedGraphProjectionOmissions): boolean =>
omissions.partialInput ||
omissions.unscopedTokenRelationships ||
omissions.selectedTypes !== null;
const omitsAny = (omissions: ManagedGraphProjectionOmissions): boolean =>
omitsUncounted(omissions) || omittedRelationSites(omissions.relations) > 0;
const completeCoverage = (): ApplicationGraphEvidence["coverage"] => ({
status: "complete",
truncated: false,
@@ -51,7 +86,9 @@ export const managedSourceCoverage = (
/** Determine whether any supplied managed inspection was partial. */
export const assessManagedGraphOmissions = (
sources: ManagedGraphCoverageSources,
relations: ManagedRelationOmissions | null,
): ManagedGraphProjectionOmissions => ({
relations,
unscopedTokenRelationships:
sources.members !== null &&
sources.boundaries !== null &&
@@ -79,7 +116,9 @@ export const managedGraphEvidenceCoverage = (
? {
status: "partial",
truncated: false,
omitted_count: null,
omitted_count: omitsUncounted(omissions)
? null
: omittedRelationSites(omissions.relations),
limits: [],
}
: completeCoverage();
@@ -502,7 +502,7 @@ const artifactLocalIdentity = (
});
/** Build managed static-analysis evidence for a graph observation. */
const sourceManagedEvidence = (
export const sourceManagedEvidence = (
state: GraphBuildState,
source: Evidence,
coverageState: ManagedArtifactInspection["coverage"]["state"],
@@ -0,0 +1,200 @@
import { createJavaScriptApplicationEdge } from "../javascript/javascriptApplicationGraph.js";
import type { ApplicationNode } from "../javascript/javascriptApplicationGraphSchemas.js";
import type { ManagedMemberInspection } from "./managedArtifact.js";
import type {
GraphBuildState,
ParsedManagedGraphInput,
} from "./managedApplicationGraph.js";
import { sourceManagedEvidence } from "./managedApplicationGraphNodes.js";
import type {
ManagedRelationOmissions,
OmittedCallReason,
OmittedFieldAccessReason,
} from "./managedApplicationGraphCoverage.js";
/** Opcodes that transfer control to their method operand. */
const CALL_OPCODES = new Set(["call", "callvirt", "newobj", "jmp"]);
const FIELD_ACCESS_KINDS: Readonly<Record<string, string>> = {
ldfld: "read",
ldsfld: "read",
stfld: "write",
stsfld: "write",
ldflda: "address",
ldsflda: "address",
};
/** ECMA-335 MemberRef table tokens name members outside the defining tables. */
const isMemberReference = (token: string): boolean =>
token.toLowerCase().startsWith("0x0a");
/** Sites of one source-target relationship, counted by opcode. */
interface AggregatedRelation {
readonly source: ApplicationNode;
readonly target: ApplicationNode;
readonly opcodes: Map<string, number>;
}
const aggregate = (
relations: Map<string, AggregatedRelation>,
source: ApplicationNode,
target: ApplicationNode,
opcode: string,
): void => {
const key = `${source.node_id}\u0000${target.node_id}`;
let relation = relations.get(key);
if (relation === undefined) {
relation = { source, target, opcodes: new Map() };
relations.set(key, relation);
}
relation.opcodes.set(opcode, (relation.opcodes.get(opcode) ?? 0) + 1);
};
const siteProperties = (opcodes: ReadonlyMap<string, number>) => {
const sorted = [...opcodes].sort(([left], [right]) =>
left < right ? -1 : left > right ? 1 : 0,
);
return {
site_count: sorted.reduce((total, [, count]) => total + count, 0),
opcodes: Object.fromEntries(sorted),
};
};
/**
* Project in-module call and field-access sites from member Evidence as one
* edge per source and target, counting every other site by reason.
*/
export const addMemberRelations = (
state: GraphBuildState,
parsed: ParsedManagedGraphInput,
): ManagedRelationOmissions | null => {
const memberInput = parsed.members;
if (memberInput === null) return null;
const members: ManagedMemberInspection = memberInput.result;
const callSites: Record<OmittedCallReason, number> = {
"member-ref": 0,
"method-spec": 0,
"unknown-target": 0,
"method-reference": 0,
"absent-node": 0,
"self-call": 0,
};
const fieldAccessSites: Record<OmittedFieldAccessReason, number> = {
"member-ref": 0,
"absent-node": 0,
};
const calls = new Map<string, AggregatedRelation>();
for (const edge of members.call_edges) {
if (edge.target_kind !== "method-def") {
callSites[
edge.target_kind === "unknown" ? "unknown-target" : edge.target_kind
]++;
continue;
}
if (!CALL_OPCODES.has(edge.opcode)) {
callSites["method-reference"]++;
continue;
}
const caller = state.methodNodes.get(edge.caller_token);
const target = state.methodNodes.get(edge.target_token);
if (caller === undefined || target === undefined) {
callSites["absent-node"]++;
continue;
}
// Graph edges cannot be self-loops, so recursion is counted instead.
if (caller === target) {
callSites["self-call"]++;
continue;
}
aggregate(calls, caller, target, edge.opcode);
}
const accesses = new Map<string, AggregatedRelation>();
for (const access of members.field_accesses) {
const method = state.methodNodes.get(access.method_token);
const field = state.fieldNodes.get(access.field_token);
if (method === undefined || field === undefined) {
fieldAccessSites[
isMemberReference(access.field_token) ? "member-ref" : "absent-node"
]++;
continue;
}
aggregate(accesses, method, field, access.opcode);
}
const evidence = sourceManagedEvidence(
state,
memberInput.evidence,
members.coverage.state,
);
for (const { source, target, opcodes } of calls.values())
state.edges.push(
createJavaScriptApplicationEdge({
source_node_id: source.node_id,
target_node_id: target.node_id,
relation: "calls",
properties: { kind: "managed-call", ...siteProperties(opcodes) },
evidence,
}),
);
for (const { source, target, opcodes } of accesses.values())
state.edges.push(
createJavaScriptApplicationEdge({
source_node_id: source.node_id,
target_node_id: target.node_id,
relation: "accesses",
properties: {
kind: "managed-field-access",
access: [
...new Set(
[...opcodes.keys()].map(
(opcode) => FIELD_ACCESS_KINDS[opcode] ?? "unknown",
),
),
].sort(),
...siteProperties(opcodes),
},
evidence,
}),
);
return { callSites, fieldAccessSites };
};
const REASON_LABELS: Readonly<
Record<OmittedCallReason | OmittedFieldAccessReason, string>
> = {
"member-ref": "member-reference",
"method-spec": "generic method-instantiation",
"unknown-target": "unknown-target",
"method-reference": "function-pointer (ldftn/ldvirtftn)",
"absent-node": "missing-node",
"self-call": "recursive self-call",
};
const describeOmissions = (counts: Readonly<Record<string, number>>): string =>
Object.entries(counts)
.filter(([, count]) => count > 0)
.map(
([reason, count]) =>
`${String(count)} ${REASON_LABELS[reason as keyof typeof REASON_LABELS]}`,
)
.join(", ");
/** Name each omitted site class so the graph never reads as complete. */
export const relationOmissionLimitations = (
omissions: ManagedRelationOmissions | null,
): string[] => {
if (omissions === null) return [];
const calls = describeOmissions(omissions.callSites);
const fields = describeOmissions(omissions.fieldAccessSites);
return [
...(calls === ""
? []
: [
`Calls edges link only method nodes in the inspected module; these call sites are omitted: ${calls}.`,
]),
...(fields === ""
? []
: [
`Accesses edges link only method and field nodes in the inspected module; these field-access sites are omitted: ${fields}.`,
]),
];
};
@@ -20,6 +20,34 @@ import {
managedPeFixtureTarget,
} from "../../../src/dotnet/ManagedPe.fixture.js";
/** Tiny-format CIL method body. */
const tinyIl = (il: readonly number[]): Buffer =>
Buffer.from([(il.length << 2) | 2, ...il]);
const memberGraph = (bytes: Buffer) => {
const binary = managedPeFixtureTarget(bytes, "/fixture/ManagedCalls.exe");
const members = inspectManagedMembersBytes(bytes, binary);
const memberEvidence = createEvidence(binary, MANAGED_STATIC_PROVIDER, {
operation: "inspect_managed_members",
parameters: {},
result: members,
rawResult: null,
limitations: members.limitations,
});
const projection = projectManagedApplicationGraphEvidence({
managed_members: memberEvidence,
});
if (!projection.ok) throw new Error(JSON.stringify(projection.error));
const result = managedApplicationGraphResultSchema.parse(
parseEvidence(projection.value).normalized_result,
);
const graph = parseJavaScriptApplicationGraph(result.graph);
const labels = new Map(
graph.nodes.map((node) => [node.node_id, node.observations[0]?.label]),
);
return { members, memberEvidence, result, graph, labels };
};
const createManagedInteropEvidence = () => {
const bytes = buildManagedPeFixture({
pinvoke: {
@@ -720,8 +748,10 @@ describe("managed application graph coverage", () => {
});
describe("managed application graph type selection", () => {
const project = (result: Parameters<typeof createEvidence>[2]["result"]) => {
const bytes = buildManagedPeFixture();
const project = (
result: Parameters<typeof createEvidence>[2]["result"],
bytes = buildManagedPeFixture(),
) => {
const binary = managedPeFixtureTarget(bytes, "/fixture/ManagedInterop.exe");
const projection = projectManagedApplicationGraphEvidence({
managed_members: createEvidence(binary, MANAGED_STATIC_PROVIDER, {
@@ -757,7 +787,10 @@ describe("managed application graph type selection", () => {
});
it("accepts member Evidence recorded before type selection existed", () => {
const bytes = buildManagedPeFixture();
// Only in-module relations, so nothing else makes the graph partial.
const bytes = buildManagedPeFixture({
ilBody: tinyIl([0x02, 0x7b, 0x01, 0, 0, 0x04, 0x26, 0x2a]),
});
const binary = managedPeFixtureTarget(bytes, "/fixture/ManagedInterop.exe");
const { selection: _selection, ...legacy } = inspectManagedMembersBytes(
bytes,
@@ -765,8 +798,152 @@ describe("managed application graph type selection", () => {
);
expect(managedMemberInspectionSchema.parse(legacy).selection).toBeNull();
expect(project(legacy).coverage).toMatchObject({
expect(project(legacy, bytes).coverage).toMatchObject({
status: "complete-within-inputs",
});
});
});
describe("managed call and field-access relations", () => {
// Both fixture methods share this body: 0x06000001 and 0x06000002.
const relationsFixture = () =>
buildManagedPeFixture({
methodRowCount: 2,
ilBody: tinyIl([
...[0x28, 0x02, 0, 0, 0x06], // call 0x06000002
...[0x6f, 0x02, 0, 0, 0x06], // callvirt 0x06000002
...[0x28, 0x01, 0, 0, 0x06], // call 0x06000001
...[0xfe, 0x06, 0x02, 0, 0, 0x06], // ldftn 0x06000002
...[0x28, 0x01, 0, 0, 0x0a], // call MemberRef 0x0a000001
0x02,
...[0x7b, 0x01, 0, 0, 0x04], // ldfld 0x04000001
0x02,
0x02,
...[0x7d, 0x01, 0, 0, 0x04], // stfld 0x04000001
0x02,
...[0x7c, 0x01, 0, 0, 0x04], // ldflda 0x04000001
0x26,
0x2a,
]),
});
it("links in-module calls and field accesses once per source and target", () => {
const { members, memberEvidence, result, graph, labels } =
memberGraph(relationsFixture());
expect(members.call_edges).toHaveLength(10);
expect(members.field_accesses).toHaveLength(6);
const relation = (name: string) =>
graph.edges
.filter((edge) => edge.relation === name)
.map((edge) => ({
source: labels.get(edge.source_node_id),
target: labels.get(edge.target_node_id),
properties: edge.properties,
evidence_ids: edge.evidence.evidence_ids,
}))
.sort((left, right) =>
`${left.source}${left.target}` < `${right.source}${right.target}`
? -1
: 1,
);
const [first, second] = members.methods.map(
({ declaring_type, name }) => `${declaring_type}.${name}`,
);
const field = `${members.fields[0]?.declaring_type}.${members.fields[0]?.name}`;
const evidence_ids = [memberEvidence.evidence_id];
expect(relation("calls")).toEqual(
[
{
source: first,
target: second,
properties: {
kind: "managed-call",
site_count: 2,
opcodes: { call: 1, callvirt: 1 },
},
evidence_ids,
},
{
source: second,
target: first,
properties: {
kind: "managed-call",
site_count: 1,
opcodes: { call: 1 },
},
evidence_ids,
},
].sort((left, right) =>
`${left.source}${left.target}` < `${right.source}${right.target}`
? -1
: 1,
),
);
for (const source of [first, second])
expect(relation("accesses")).toContainEqual({
source,
target: field,
properties: {
kind: "managed-field-access",
access: ["address", "read", "write"],
site_count: 3,
opcodes: { ldfld: 1, ldflda: 1, stfld: 1 },
},
evidence_ids,
});
expect(result.summary).toMatchObject({ calls: 2, field_accesses: 2 });
// Two MemberRef calls, two ldftn loads and three self-calls stay counted.
expect(result.coverage).toEqual({ status: "partial" });
expect(result.graph.coverage).toEqual({
status: "partial",
truncated: false,
omitted_count: 7,
limits: [],
});
expect(result.limitations).toContain(
"Calls edges link only method nodes in the inspected module; these call sites are omitted: 2 member-reference, 2 function-pointer (ldftn/ldvirtftn), 3 recursive self-call.",
);
});
it("reads graph Evidence projected before relation counters existed", () => {
const { result } = memberGraph(relationsFixture());
const {
calls: _calls,
field_accesses: _fieldAccesses,
...summary
} = result.summary;
expect(
managedApplicationGraphResultSchema.parse({ ...result, summary }).summary,
).toMatchObject({ calls: null, field_accesses: null });
});
it("counts relations whose endpoints a type selection omitted", () => {
const bytes = relationsFixture();
const binary = managedPeFixtureTarget(bytes, "/fixture/ManagedCalls.exe");
const members = inspectManagedMembersBytes(bytes, binary);
const projection = projectManagedApplicationGraphEvidence({
managed_members: createEvidence(binary, MANAGED_STATIC_PROVIDER, {
operation: "inspect_managed_members",
parameters: {},
// Drop the second method, as a narrower selection would.
result: { ...members, methods: members.methods.slice(0, 1) },
rawResult: null,
limitations: [],
}),
});
if (!projection.ok) throw new Error(JSON.stringify(projection.error));
const result = managedApplicationGraphResultSchema.parse(
parseEvidence(projection.value).normalized_result,
);
expect(result.summary).toMatchObject({ calls: 0, field_accesses: 1 });
expect(
result.limitations.find((text) => text.startsWith("Calls edges")),
).toContain("missing-node");
expect(
result.limitations.find((text) => text.startsWith("Accesses edges")),
).toContain("3 missing-node");
});
});