From c9b4cae9dc5700313fd671d4e85515fe12f5ca64 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Thu, 22 Oct 2026 00:26:56 -0700 Subject: [PATCH] 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 --- docs/javascript-application-graph.md | 4 +- docs/managed-code-analysis.md | 26 +++ .../managed/managedWorkflowToolContracts.ts | 2 +- .../javascriptApplicationGraphSchemas.ts | 1 + src/domain/managed/managedApplicationGraph.ts | 48 ++++- .../managedApplicationGraphCoverage.ts | 43 +++- .../managed/managedApplicationGraphNodes.ts | 2 +- .../managedApplicationGraphRelations.ts | 200 ++++++++++++++++++ .../managedApplicationGraph.test.ts | 185 +++++++++++++++- 9 files changed, 492 insertions(+), 19 deletions(-) create mode 100644 src/domain/managed/managedApplicationGraphRelations.ts diff --git a/docs/javascript-application-graph.md b/docs/javascript-application-graph.md index fa5fe7bfad..8aeecebcac 100644 --- a/docs/javascript-application-graph.md +++ b/docs/javascript-application-graph.md @@ -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 diff --git a/docs/managed-code-analysis.md b/docs/managed-code-analysis.md index 17b37d8fb4..becd9aba19 100644 --- a/docs/managed-code-analysis.md +++ b/docs/managed-code-analysis.md @@ -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 diff --git a/src/contracts/managed/managedWorkflowToolContracts.ts b/src/contracts/managed/managedWorkflowToolContracts.ts index 28406c42dd..d5c194396c 100644 --- a/src/contracts/managed/managedWorkflowToolContracts.ts +++ b/src/contracts/managed/managedWorkflowToolContracts.ts @@ -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, diff --git a/src/domain/javascript/javascriptApplicationGraphSchemas.ts b/src/domain/javascript/javascriptApplicationGraphSchemas.ts index 90af9ee439..b274d6381f 100644 --- a/src/domain/javascript/javascriptApplicationGraphSchemas.ts +++ b/src/domain/javascript/javascriptApplicationGraphSchemas.ts @@ -64,6 +64,7 @@ export const JAVASCRIPT_APPLICATION_RELATIONS = [ "invokes", "handles", "calls", + "accesses", "persists_to", "observed_as", "changed_from", diff --git a/src/domain/managed/managedApplicationGraph.ts b/src/domain/managed/managedApplicationGraph.ts index c01305e6ae..de0147d1b8 100644 --- a/src/domain/managed/managedApplicationGraph.ts +++ b/src/domain/managed/managedApplicationGraph.ts @@ -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, + 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), ]; diff --git a/src/domain/managed/managedApplicationGraphCoverage.ts b/src/domain/managed/managedApplicationGraphCoverage.ts index 0e842ba80d..e8cc9f927e 100644 --- a/src/domain/managed/managedApplicationGraphCoverage.ts +++ b/src/domain/managed/managedApplicationGraphCoverage.ts @@ -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>; + readonly fieldAccessSites: Readonly>; +} + +/** 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(); diff --git a/src/domain/managed/managedApplicationGraphNodes.ts b/src/domain/managed/managedApplicationGraphNodes.ts index 117e8f6640..d4d4da152c 100644 --- a/src/domain/managed/managedApplicationGraphNodes.ts +++ b/src/domain/managed/managedApplicationGraphNodes.ts @@ -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"], diff --git a/src/domain/managed/managedApplicationGraphRelations.ts b/src/domain/managed/managedApplicationGraphRelations.ts new file mode 100644 index 0000000000..5af9f1aa12 --- /dev/null +++ b/src/domain/managed/managedApplicationGraphRelations.ts @@ -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> = { + 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; +} + +const aggregate = ( + relations: Map, + 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) => { + 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 = { + "member-ref": 0, + "method-spec": 0, + "unknown-target": 0, + "method-reference": 0, + "absent-node": 0, + "self-call": 0, + }; + const fieldAccessSites: Record = { + "member-ref": 0, + "absent-node": 0, + }; + const calls = new Map(); + 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(); + 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 +> = { + "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>): 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}.`, + ]), + ]; +}; diff --git a/tests/composition/analysis-sessions/managedApplicationGraph.test.ts b/tests/composition/analysis-sessions/managedApplicationGraph.test.ts index c5d49eb9f9..1233100d65 100644 --- a/tests/composition/analysis-sessions/managedApplicationGraph.test.ts +++ b/tests/composition/analysis-sessions/managedApplicationGraph.test.ts @@ -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[2]["result"]) => { - const bytes = buildManagedPeFixture(); + const project = ( + result: Parameters[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"); + }); +});