Files
rea/src/domain/managed/managedApplicationGraphRelations.ts
T
Kaoru0822 c9b4cae9dc 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>
2026-10-22 00:26:56 -07:00

201 lines
6.4 KiB
TypeScript

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}.`,
]),
];
};