mirror of
https://github.com/morluto/rea
synced 2026-10-27 14:42:18 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user