ADR-046: Source Graph Identity v2 — USR-Based Entity Resolution and Evidence-Preserving Merge¶
Date: 2026-07-19
Status: Accepted — D1, D2, D3, D4, D5, and D6 all implemented (each to
the extent described below): role-aware edge identity plus the opt-in
per-call-site occurrence trail (GraphEdge.relation_key()/edge_relation_key
for D1's relation_key half, GraphEdge.occurrences/edge_occurrence_id
for its occurrence_id half — free of cost until a producer populates the
four occurrence-level attrs, per the Costs section below), the
evidence-preserving node/edge merge (GraphFact/FactConflict/
merge_graph_facts, replacing SourceGraphSummary.add_node/add_edge's v1
first-writer-wins drop), the per-(kind,role) coverage matrix for
inline_graph_fold.fold_type_graph, a USR-preferring EntityResolver
aliasing v1 node ids to a canonical identity without changing GraphNode.id
generation itself (SOURCE_GRAPH_VERSION = 2, opt-in via
SourceGraphSummary.resolve_entities() — see "D4 implementation" below for
why this is a deliberately scoped subset of the originally sketched full
rewrite, not the whole thing), a named TraversalPolicy reifying the
call-graph leak walk's own edge-kind/stop/confidence/precision rules
(internal_leak.TraversalPolicy/CALL_GRAPH_TRAVERSAL_POLICY, now including
a real effect_transitions — see "D5 implementation" for what's covered and
what's still deferred to the layout walk), and a proof-path preference order
across two selectors split by how much per-hop structure their walk's path
representation carries (internal_leak.select_preferred_path — 2 of 6
tiers, the layout walk's plain list[str] paths;
buildsource.graph_impact.select_preferred_graph_path — 4 of 6 tiers, a
structured list[GraphEdge] path — plus the primary_path/
alternative_paths/discarded_path_count finding shape on
impact.model.GraphProofPath; see "D6 implementation" for the exact tier
mapping and what's still deferred). See "D1 implementation"/"D2
implementation"/"D3 implementation"/"D4 implementation"/"D5
implementation"/"D6 implementation" below.
Verified: main@2e43d53 on 2026-08-04
Decision maker: (pending — recorded per repository convention, the same
caveat ADR-048's header carries; a single-maintainer repo where merging the
implementing PR is the acceptance mechanism.)
Context¶
ADR-031 defined the L5 SourceGraphSummary schema (GraphNode/GraphEdge,
SOURCE_GRAPH_VERSION = 1) and its node-id scheme (abicheck/buildsource/
source_graph.py's _decl_node_id/_type_node_id/_symbol_node_id/…, each a
deterministic string hash of one identity signal — a mangled symbol, a
qualified name, a SourceEntity.identity() tuple). ADR-044's P0/P1 slices
(this repository's tri-state reachability work, most recently PR #607) then
built two independent walks on top of that v1 graph —
internal_leak.compute_leak_paths (layout/type-graph reachability) and
internal_leak.compute_call_graph_leak_paths (L5 call/reference-graph
reachability) — and, through eight-plus rounds of automated review on that
PR, exposed the same underlying shape of gap over and over: the v1 graph
identifies an entity by exactly one signal at a time, chosen ad hoc per
producer, with no reconciliation when two producers disagree or use a
different signal for the same real declaration.
Concretely, three problems recur across the codebase today:
- Identity fragmentation. A single C++ declaration is name-addressed
differently by different evidence:
dwarf_snapshot.py/elf_metadata.pysee its mangled linker symbol;dumper_castxml.py/dumper_clang.pysee its demangled qualified name;source_link.py's L4 replay sees aSourceEntity.identity()tuple (kind + qualified name + signature hash);call_graph.py/type_graph.py's L5 extractors see whatever clang's AST dump names the node.post_processing.py'sMarkReachability(PR #607) already had to hand-roll a "tryroot, then tryc.qualified_name" fallback at every lookup site intoreachable_types/call_reachable/known_type_names— the graph itself does not resolve these to one entity, so every consumer re-implements a partial version of that resolution, and each hand-rolled fallback is a fresh chance to miss a case (as PR #607's review history shows). - First-writer-wins merge.
SourceGraphSummary.add_node(source_graph.py:344-348) literally drops a second node registration for the sameid, keeping only the first producer'sattrs/confidence/provenance. When a build-integrated pass and a header-only pass (header_graph.py) both see the same declaration with different confidence or a differentconsumer_compiled_bodyvalue, whichever ran first silently wins — there is noconflictsrecord, and no way for a later consumer to know a disagreement even happened. - Coverage is family-grained, not (kind, role)-grained.
extractor_passes(the completeness signal Phase 1's_call_graph_fully_trustedgates on) is one boolean per pass name ("call_graph","type_graph"). A producer that reliably covers a function's return type but has a known gap on parameter types (a real clang-plugin limitation noted in ADR-041) has no way to say so — the family flag is all-or-nothing, so a consumer either over-trusts a partially-covered family or under-trusts a fully-covered one.
This ADR is the "own ADR" gate the impact-analysis-layer plan
(docs/contribute/plans/g29-impact-analysis-layer.md, Phase 2) set for
itself: an identity/version-bump and merge-semantics change carries the same
bar ADR-044 D1 set for a pipeline-order fix — a recorded decision, not a
drive-by refactor.
The one rule that does not change¶
Everything below is new L5 graph machinery. ADR-028 D3's authority rule is
unchanged: L3/L4/L5 evidence — however it is resolved, merged, or scored for
confidence — may explain, localize, or corroborate a break, but never
silently deletes one an artifact diff (L0/L1/L2) already proved. Nothing in
this ADR touches that boundary; checker_policy.BREAKING_KINDS membership
still requires artifact-level evidence.
Decision¶
D1. Split edge identity: relation_key vs. occurrence_id¶
GraphEdge.key() today is (src, dst, kind) — the same signature two
structurally different call sites collapse onto if they share endpoints and
kind (e.g. "used as return type" and "used as parameter type" both produce a
DECL_HAS_TYPE edge with the same (src, dst); two calls to the same
function under different #ifdef branches both produce the same
DECL_CALLS_DECL edge). Split the identity two ways:
relation_key = (src, dst, kind, semantic_role)— adds one more discriminator (attrs.get("role"), e.g."return"vs."parameter"vs."field") to the existing triple. This is what closure/diff computations key on — the same shapeEdgeKind-level reasoning uses today, just finer.occurrence_id = (relation_key, source_location, configuration_id, instantiation_id, callsite_id)— the full, non-deduplicated evidence trail. Arelation_keycan back manyoccurrence_ids (the sameDECL_CALLS_DECL:directrelation observed at three call sites); collapsing torelation_keyfor graph-shape reasoning must never discard theoccurrence_idlist, since agraph explain/ proof-path answer wants the concrete call site, not just "some call exists."
GraphEdge.key() keeps its current (src, dst, kind) return type and
becomes a documented alias for the coarsest projection of relation_key
(role-blind) — existing callers (diff_source_graph's edge-set comparison)
are unaffected; new code that needs role-awareness calls a new
GraphEdge.relation_key() instead.
D2. Evidence-preserving node/edge merge, replacing first-writer-wins¶
Replace add_node's silent drop-on-duplicate with an explicit merge:
@dataclass
class GraphNode:
id: str
kind: str
label: str = ""
facts: list[NodeFact] = field(default_factory=list) # NEW
resolved: dict[str, Any] = field(default_factory=dict) # NEW
conflicts: list[FactConflict] = field(default_factory=list) # NEW
# attrs/provenance/confidence become derived *views* over facts[0]
# (the highest-confidence fact) for read-compatibility with v1 code.
NodeFact = {producer: str, confidence: str, attrs: dict[str, Any]}— one entry per producer that ever registered this node id. Order of insertion is irrelevant to the finalresolveddict (see next point) but is kept for provenance/debugging.resolvedis a deterministic, order-independent fold overfacts: for each key present in more than one fact, resolution precedence is fixed (higherconfidencewins; a tie between equal-confidence facts is broken by a stable producer-name sort, never by arrival order) — the same result regardless of which producer's pass ran first. This is the property PR #607's own review repeatedly needed and had to hand-verify per call site ("both sides", "trusted graph on the relevant side only") — making the merge itself order-independent removes a whole class of that bug.- A genuine disagreement (two facts disagree on a key
resolvedcannot silently pick a winner for — e.g.is_virtual: truevs.is_virtual: falsefrom two producers of equal confidence) is recorded inconflicts, not dropped.conflictsis advisory (RISK-tier, never authoritative on its own — ADR-028 D3) but visible, unlike today's silent first-writer-wins. GraphEdgegets the analogousfacts/resolved/conflictstreatment.
Backward compatibility: GraphNode.attrs/.provenance/.confidence remain
real fields (not removed), populated from resolved at write time — any v1
reader touching .attrs directly keeps working unchanged; only code that
wants merge-awareness needs to look at .facts/.conflicts.
D3. Per-(kind, role) coverage matrix¶
Extend extractor_passes/narrowed_passes/degraded_passes (currently
dict[str, bool] / dict[str, ...] keyed by pass name only) with an
additional finer key form: "{pass_name}:{edge_kind}:{role}" (e.g.
"call_graph:DECL_HAS_TYPE:parameter"), populated in addition to the
existing family-level key — the family key remains the union/AND of its role
keys, so every existing consumer (_call_graph_fully_trusted in Phase 1,
mark_source_edges_extractor_coverage) keeps working unchanged against the
coarser key. New code (e.g. a future TraversalPolicy.minimum_confidence
check, D5 below) can consult the finer key when it needs to know "did this
specific role get examined," rather than being stuck with the family's
weakest-covered role's honesty.
D4. EntityResolver — USR-based canonical identity, SOURCE_GRAPH_VERSION = 2¶
Historical sketch — superseded by the shipped API. The
EntityResolver/aliases: list[str]shape below is the original decision proposal, written before this ADR was implemented. What actually shipped (see "D4 implementation" below, the authoritative API description) is a graph-level resolver —EntityResolver.aliases: dict[str, str](v1 node id → canonical id, not a per-entitylist[str]), nokindfield, andresolve(node: GraphNode) -> strinstead ofresolve(any_alias). Kept here unmodified as the historical record of what D4 originally proposed, not as a second description of the real API — don't implement against this sketch.
New abicheck/buildsource/entity_resolver.py:
@dataclass
class EntityResolver:
"""Canonical identity for one real declaration/definition, resolved
across every evidence source that can name it."""
canonical_id: str # clang USR when available, else a v1-style hash
aliases: list[str] # old_v1_node_id, mangled_symbol, qualified_name,
# signature_hash, source_location — every signal
# any producer used to name this entity
kind: str
- Primary key is the clang USR (
Unified Symbol Resolution— clang's own stable, mangling-independent, cross-TU identity string) when the producer is clang-based (call_graph.py/type_graph.py/the clang AST frontend); USRs are already available in clang's-ast-dump=jsonoutput and are the standard "same declaration across TUs" key clang tooling (clangd, IWYU) itself relies on. - Every other identity signal a v1 node carried becomes an alias, not a
replacement:
_decl_node_id's v1 hash, the mangled symbol, the demangled qualified name, theSourceEntity.identity()signature hash, and the declaringsource_location.EntityResolver.resolve(any_alias) -> canonical_idis the one lookup every consumer should use instead of re-implementing the "try root, then qualified_name" fallback pattern. - A castxml-only or DWARF-only pipeline (no clang AST, hence no USR) falls
back to
canonical_id = _decl_node_id(...)unchanged — v1 behavior, degraded gracefully, not a hard requirement on clang. SOURCE_GRAPH_VERSION = 2. A v2 reader (GraphNode.from_dict) accepts a v1 pack's node/edge ids as aliases of a synthesized canonical id (there is no USR in a v1 pack, socanonical_idfalls back to the v1 id itself) — an existingcollect-produced pack on disk keeps loading and comparing correctly against a v2-built pack; no forced re-collection.
D5. TraversalPolicy — formalize the five propagation shapes¶
internal_leak.py's is_consumer_compiled_public_entry today encodes one
detector's implicit knowledge of "don't walk through an ordinary
out-of-line helper" — a real, load-bearing rule (PR #607's sixth Codex
round depended on it) that exists as one function's docstring, not a named,
reusable policy. Formalize the five traversal shapes the original
impact-analysis review distinguished — layout propagation (by-value
embedding), symbol-availability propagation (does the symbol still link),
source-contract propagation (does the source still compile — default args,
concepts, etc.), behavioral propagation (does the runtime behavior change —
inline/template body edits), deployment propagation (build-flag/toolchain
drift) — as one shared type:
@dataclass
class TraversalPolicy:
allowed_edges: frozenset[str] # which EDGE_KINDS this walk may follow
stop_conditions: Callable[[GraphNode], bool] # e.g. is_consumer_compiled_node
effect_transitions: dict[str, str] # how "effect" changes crossing an edge kind
# (e.g. crossing DECL_CALLS_DECL:virtual
# downgrades exact-path to over-approximation)
minimum_confidence: str # walk ignores an edge below this confidence
compute_leak_paths/compute_call_graph_leak_paths become thin callers that
each construct one named TraversalPolicy instance instead of hand-coding
their stop/expand rules inline — the policy object is what a future detector
reuses instead of re-deriving the same "don't cross an ordinary exported
function's own body" rule PR #607 needed a targeted fix for.
D6. Proof-path selection preference order¶
Replace plain shortest-BFS path selection (min(paths, key=len) in
post_processing.py today) with an explicit preference order when multiple
proof paths exist for the same finding:
- Consumer-proven (a real
--used-byconsumer binary actually references this symbol — the strongest possible evidence) - Exact, high-confidence path (a single, unambiguous edge chain at
confidence >= CONF_HIGH) - Public-header structural path (a
ScopeOrigin.PUBLIC_HEADER-tagged direct match — today'sdirect_public_symboltag) - Multi-producer-confirmed (the same relation independently observed by ≥2
producers — visible via D2's
factslist) - Reduced-confidence name resolution (a bare-name/suffix match, no USR confirmation)
- Virtual/indirect over-approximation (a vtable/function-pointer edge — real but structurally imprecise)
The finding keeps primary_path (the highest-preference path found) plus
alternative_paths[0..N] and a discarded_path_count — visibility into
"how much evidence was there, and how strong was the best of it," not just
one arbitrary shortest path.
D1 implementation (G29 Phase 2, slices 3 and 6 — both halves)¶
abicheck/buildsource/graph_facts.py:edge_relation_key(src, dst, kind, resolved)andGraphEdge.relation_key()— the(src, dst, kind, role)tuple, readingrolefromresolved(D2's merged view) when populated, falling back to rawattrsfor a bare edge that hasn't gone throughadd_edge/ensure_facts_and_resolveyet.GraphEdge.key()keeps its exact(src, dst, kind)shape anddiff_source_graph's edge-set comparison keeps using it unchanged.SourceGraphSummary.add_edge's dedup is not unaffected — a same-PR follow-up fix (see "Follow-up fix:add_edgededup granularity" below) switched its dedup key fromkey()torelation_key(), since deduping on the coarse key alone silently folded two real, role-distinct edges into one beforerelation_key()could ever observe both roles on a real,add_edge-built graph.occurrence_id(slice 6, G29 Phase 2 follow-up):edge_occurrence_id( relation_key, attrs)andGraphEdge.occurrences— a stablesha256:<hex>hash over(relation_key, source_location, configuration_id, instantiation_id, callsite_id), computed per fact and accumulated (deduplicated) intooccurrencesbyensure_facts_and_resolvealongsideresolved/conflicts. Answers the Costs section's own gate differently than a default-on path would:edge_occurrence_idreturnsNone— contributing nothing tooccurrences— whenever a fact carries none of the four occurrence keys, which is every fact from every current producer. So this lands as genuinely opt-in surface (zero cost, zero behavior change, until a producer populates those attrs) rather than needing the scan-level cost-model measurement a default-on path would have required. No current producer opts in yet; this is forward-compatible schema, not a change to what any pack contains today.tests/test_source_graph_v2.py(TestRelationKey,TestOccurrenceId): role discriminates two edges that collapse onkey(); defaults to""when no role attr; defaults absent; reads the D2-mergedresolvedview (not whichever fact registered first) rather than a raw/stale attr; the free function and method agree.TestOccurrenceIdcovers: no occurrence attrs →None/emptyoccurrences; a stable, deterministic hash for identical inputs; distinct call sites and distinct relation keys both produce distinct ids; two facts with differentcallsite_ids on onerelation_key-deduped edge both survive inoccurrences; re-registering the same call site doesn't duplicate; round-trips throughto_dict()/from_dict(); self-heals from a v1 pack with nooccurrenceskey.
Mechanical follow-up: GraphNode/GraphEdge moved into graph_facts.py¶
Landing D1 alongside D2/D3 pushed source_graph.py back over the
AI-readiness 2000-line hard cap. Rather than trim prose further (this module
is already dense with Codex-review-driven rationale that would lose meaning
compressed), GraphNode/GraphEdge themselves — not just the D1/D2 merge
functions — moved into graph_facts.py, which already held their supporting
GraphFact/FactConflict/merge machinery. This simplified graph_facts.py
rather than complicating it: the _FactHolder structural Protocol that
previously stood in for GraphNode/GraphEdge (to avoid importing them and
risking a cycle) is gone — with the real classes now defined in the same
module, ensure_facts_and_resolve/register_fact just type-hint
GraphNode | GraphEdge directly. source_graph.py re-exports both names
(alongside the existing CONF_HIGH/GraphFact/FactConflict re-exports) so
every existing from .source_graph import GraphNode call site is unaffected.
Follow-up fix: add_edge dedup granularity (Codex review)¶
The first relation_key landing left a gap relation_key() itself could not
close: SourceGraphSummary.add_edge still deduplicated on the coarse
GraphEdge.key() triple (src, dst, kind), so two edges that differ only by
role (e.g. the same function's return-type and parameter-type dependency on
the same private type — DECL_HAS_TYPE:return vs. DECL_HAS_TYPE:param)
were folded into one GraphEdge object by register_fact before either
role's relation_key() could ever be observed on a real, add_edge-built
graph. relation_key() worked correctly on hand-constructed test objects
(which is why the original D1 tests missed this), but the actual ingestion
path silently lost the second role's identity — the exact "role-distinct
edges stay distinct" property D1 exists to guarantee.
Fix: add_edge's dedup key (and __post_init__'s edge-index construction)
now use relation_key() (the 4-tuple including role) instead of key().
Two edges that only differ by role now persist as separate GraphEdge
objects with independent facts/resolved/conflicts, instead of the
second silently merging into the first's fact list under the wrong role.
diff_source_graph's edge-set comparison deliberately stays at the coarse
key() granularity — an explicit, documented boundary matching the original
"existing callers unaffected" promise, not an oversight — since role-level
diff granularity is out of scope for this ADR's D1 slice.
tests/test_source_graph_v2.py: test_add_edge_preserves_role_distinct_edges_as_separate_objects
is the regression test (two role-distinct edges through the real add_edge
path stay separate, with correct relation_key()s and no fact
cross-contamination); test_add_edge_dedups_true_duplicates_on_relation_key
confirms a genuine same-role duplicate still merges as before.
Follow-up fix: compute_graph_id hashed the coarse key (Codex review)¶
A second gap the add_edge dedup-granularity fix above exposed: before that
fix, two role-distinct edges sharing (src, dst, kind) could never coexist
in one graph (the coarse dedup silently merged them), so compute_graph_id
hashing e.key() (role-blind) was harmless — there was no role-only graph
difference for it to miss. Once add_edge started keying on relation_key(),
that stopped being true: two graphs differing only in which role an edge
carries (e.g. the same DECL_HAS_TYPE edge as role="return" in one graph,
role="param" in the other) are genuinely different content, but
compute_graph_id's edge list still canonicalized on the coarse key, so both
graphs produced the identical graph_id — a real hash collision anything
keyed on graph_id (a pack reference, a future content-addressed cache, a
comparison shortcut) could silently miss.
Fix: compute_graph_id now hashes e.relation_key() (role-aware) instead of
e.key(). This changes the graph_id value computed for every graph, not
just role-distinct ones (the tuple shape gains a role element throughout) —
graph_id carries no documented cross-version stability contract today (only
SOURCE_GRAPH_VERSION does), so this is a correctness fix, not a compat
break.
Follow-up fix: resolve before indexing, not after (Codex review)¶
A third gap in the same family, caught in code review of PR #620:
SourceGraphSummary.add_edge computed rkey = edge.relation_key() before
calling ensure_facts_and_resolve(edge) — so an edge whose role lives only
in facts (not yet mirrored into attrs at construction time) had its
dedup key computed against an empty attrs/resolved view (relation_key()
falls back to self.resolved or self.attrs, and both are empty
pre-resolution), producing the wrong blank-role key instead of the edge's
true, post-resolution one. Two genuinely same-role edges built this way
would fail to merge (each computing a distinct-looking blank-role key that
happened to coincide, or not, depending on registration order); two
genuinely role-distinct edges could collapse onto the same blank-role key
and incorrectly merge. SourceGraphSummary.__post_init__ had the identical
ordering bug for constructor-seeded edges (_edge_keys/_edge_by_key built
before the backfill/resolve loop that follows it).
No real producer hits this today — type_graph.py (the actual role-emitting
call site) constructs edges with attrs={"role": ...} directly, so
relation_key()'s self.attrs fallback already sees the role before
resolution ever runs. This is a latent correctness gap for any future
producer or constructor-seeded builder that populates facts directly
without going through attrs first (a valid, undocumented-as-forbidden
construction shape the dataclass itself allows). (See the next follow-up fix
below, though, for a different gap in this same producer — the role never
even reached add_edge for one of two role-distinct relations.)
Fix: both add_edge and __post_init__ now resolve first, index second —
ensure_facts_and_resolve runs before relation_key() is ever computed for
indexing, in both places.
tests/test_source_graph_v2.py: test_add_edge_resolves_role_only_in_facts_before_indexing/
test_add_edge_resolves_role_only_in_facts_stays_role_distinct (the
add_edge path) and test_constructor_seeded_edge_index_uses_resolved_relation_key
(the constructor-seeded path) — all three fail without the fix (verified by
temporarily reverting it) and pass with it.
Follow-up fix: type_graph.py's own upstream dedup dropped a role before add_edge ever saw it (Codex review)¶
A fourth gap, one level upstream of everything else in this family: even
after add_edge/__post_init__ correctly dedup on the role-aware
relation_key(), the actual production ingestion path
(ClangTypeGraphExtractor.extract_from_build → parse_clang_ast_types →
augment_graph_with_types → graph.add_edge) never gave add_edge the
chance: type_graph.py's own _dedupe_edges (the per-TU pass inside
parse_clang_ast_types) and the add_edges closure inside
extract_from_build (the cross-TU merge) both deduplicated raw TypeEdges
on the coarse (src, dst, kind) triple — without role — before a
GraphEdge was ever constructed. A function that both returns and takes
the same private type (detail::Impl foo(detail::Impl)) emits two real,
role-distinct TypeEdges from _walk_types (role="return" and
role="param", both DECL_HAS_TYPE, same src/dst); _dedupe_edges
silently kept only whichever was emitted first ("return", since return
types are walked before parameters), so the "param" TypeEdge never
reached augment_graph_with_types/add_edge at all — no amount of
role-awareness downstream could recover evidence that was already dropped.
This also means the D3 coverage matrix could mark
type_graph:DECL_HAS_TYPE:param "covered" for a confirmed pass even when
this exact scenario silently ate the param edge for one specific
declaration — the pass genuinely examined every role, but this one
relation's evidence didn't survive to be stored.
Fix: both _dedupe_edges and extract_from_build's cross-TU add_edges
now key on (src, dst, kind, role), matching add_edge's own dedup
granularity. _merge_type_edges (the cross-TU confidence/dst_file merge)
needed no change — two edges that now share a dedup key by construction
already share a role too.
tests/test_type_graph.py:
test_same_private_type_as_return_and_param_stays_role_distinct builds a
synthetic AST for exactly the return+param-same-type shape and asserts
parse_clang_ast_types emits both roles (fails without the fix, verified by
temporarily reverting it — only "return" survived).
Follow-up fix: add_node/add_edge's duplicate branch flattened an already multi-fact incoming entity (Codex review)¶
A fifth gap, back in graph_facts.py/source_graph.py themselves rather
than an upstream producer: SourceGraphSummary.add_node/add_edge's
duplicate-registration branch called register_fact(existing,
incoming.provenance, incoming.confidence, incoming.attrs) — correct for the
common case where incoming is a bare, single-producer GraphNode(...)/
GraphEdge(...) construction (after resolution, its own facts list holds
exactly one entry matching that triple), but wrong whenever incoming
already carries multiple facts of its own — e.g. a node/edge re-added
from an already evidence-merged graph (a graph-combining scenario like
cli_buildsource.py's pack merge, not yet exercised by any current call
site, but exactly the shape D2 exists to handle correctly). register_fact
only ever appends one new, freshly-flattened fact; it had no way to unpack
incoming's own multi-producer history, so re-merging an already-merged
node/edge silently collapsed its accumulated facts (and any conflicts it
had already recorded) into a single derived one.
Fix: new graph_facts.merge_entity_facts(existing, incoming) resolves
incoming first (so evidence living only in its facts, not yet mirrored
into attrs, isn't missed either — same principle as the resolve-before-
index fix above), then merges every fact in incoming's own facts list
into existing one at a time (a duplicate fact is a no-op, matching
register_fact's own idempotence). add_node/add_edge's duplicate
branches now call this instead of register_fact. register_fact itself
is unchanged and still used by fold_source_edges's ad hoc single-fact
backfill, which isn't merging a full entity.
tests/test_source_graph_v2.py:
test_re_adding_an_already_multi_fact_node_preserves_all_its_facts/
test_re_adding_an_already_multi_fact_edge_preserves_all_its_facts build an
incoming node/edge with two facts already attached and assert both
survive the merge (fail without the fix, verified by temporarily reverting
it — only one of the two incoming producers survived).
D2 implementation (G29 Phase 2, slice 1)¶
Implemented as the first slice of Phase 2, chosen because D2 is the change
this ADR's own Context section cites the most concrete recurring evidence
for (PR #607's repeated "which producer wins" bugs), and because it is
self-contained: no other decision in this ADR needs to land first, and
downstream consumers (crosscheck.py, internal_leak.py,
source_graph_findings.py) never read GraphNode.attrs/GraphEdge.attrs
differently — they still see one merged dict, now assembled honestly instead
of by first-writer-wins.
- New
abicheck/buildsource/graph_facts.py:GraphFact(producer/confidence/attrs),FactConflict,merge_graph_facts()(the order-independent fold described in D2 above — highest confidence wins per key, ties broken by a stable producer-name sort, a genuine value disagreement recorded as aFactConflictinstead of dropped),ensure_facts_and_resolve()andregister_fact()(the two operationsSourceGraphSummary.add_node/add_edgenow delegate to). Deliberately a dependency-free leaf module —GraphNodeandGraphEdgeare defined here alongside the fact/merge machinery, so the helpers type directly against those classes; this module never importssource_graph.py(not even underTYPE_CHECKING) and cannot form an import cycle with it (CLAUDE.md "M1-3");source_graph.pyimports and re-exports its public names (CONF_HIGH/CONF_REDUCED/CONF_UNKNOWN/GraphFact/FactConflict) so existingfrom .source_graph import CONF_HIGHcall sites are unaffected. Also the mechanical reason for the split:source_graph.pywas already the largest module inbuildsource/and D2's dataclass/merge code would have pushed it past the AI-readiness 2000-line hard cap.
(At the time this D2 slice landed, GraphNode/GraphEdge still lived in
source_graph.py and a structural _FactHolder Protocol stood in for
them here to avoid an import cycle; the "Mechanical follow-up:
GraphNode/GraphEdge moved into graph_facts.py" section above
describes the later D1 slice that moved the classes themselves into this
module and removed _FactHolder as no longer needed — this section is
written to describe the module's current, post-D1 shape throughout.)
- GraphNode/GraphEdge (source_graph.py) gain facts: list[GraphFact],
resolved: dict[str, Any], conflicts: list[FactConflict]. attrs/
provenance/confidence remain real fields (v1 read-compatibility, exactly
as D2 specified) but are now populated from the merged view at write time
instead of frozen at first registration.
- SourceGraphSummary.add_node/add_edge replace the v1 silent
drop-on-duplicate with register_fact(): a second (or third, …) producer's
attrs are folded into resolved/conflicts instead of vanishing.
kind/label (structural identity, not evidence) still keep the first
registration's value — only the accumulated attrs evidence merges.
- GraphNode.from_dict/GraphEdge.from_dict accept a v1 pack with no
facts/resolved/conflicts keys unchanged — synthesizing the single
fact its attrs/provenance/confidence already imply (D4's compat rule,
applied to D2's schema) — and deliberately never trust a stored
resolved/conflicts value from any pack: both are always recomputed from
facts, so a hand-edited or stale pack self-heals to what the facts
actually support rather than silently persisting a bad merge.
SourceGraphSummary.__post_init__ runs the same backfill/re-derive step
for every node/edge reachable from a constructor-seeded summary (the
pattern most of the existing test suite and a few builders use, bypassing
add_node/add_edge entirely) — the invariant "facts is never empty,
resolved is derived from facts" holds for every GraphNode/GraphEdge
regardless of how it entered the graph.
- tests/test_source_graph_v2.py (new): order-independence (same facts,
opposite registration order → identical resolved/confidence/
provenance), the confidence-then-producer-name tie-break, conflict
recording vs. silent agreement, edge merge parity with node merge,
constructor-seeded backfill, and v1-pack/v2-pack round-trip compatibility.
Not yet implemented at this point (D1, D4-D6): relation_key/
occurrence_id edge identity splitting, the USR-based EntityResolver
(SOURCE_GRAPH_VERSION therefore still 1, not 2 — D2 alone does not need
the version bump since it changes no on-disk key shape a v1 reader couldn't
already tolerate additively), TraversalPolicy, and the six-tier proof-path
preference order all remain open follow-up work under this same ADR — see
"D3 implementation" below for the one further slice landed since.
D3 implementation (G29 Phase 2, slice 2)¶
abicheck/buildsource/inline_graph_fold.py:ROLE_COVERAGE_MATRIX(the exact role vocabularytype_graph.py's_walk_typesemits per edge kind —TYPE_INHERITS:base,TYPE_HAS_FIELD_TYPE:{field,alias},DECL_HAS_TYPE:{var,return,param},DECL_REFERENCES_DECL:ref),role_pass_covered()(the read helper new code consults, falling back to the family-level flag for an untracked role), and_mark_role_coverage()(sets every matrix key alongside a confirmed family flag). Wired intofold_type_graph(): a confirmed full or narrowed pass earns the finerextractor_passes/narrowed_passeskeys too, not just the family key.- Deliberately scoped to
inline_graph_fold.fold_type_graph's build-integrated pass only — the primary, most-exercised collection path. Two things explicitly not covered by this slice, both documented as open follow-up rather than silently assumed: header_graph.py's header-only type-graph pass (reuses the sametype_graph.pywalker for its clang path, so the same role matrix would likely apply there too — not yet wired).- The ADR-038 C.8 clang-plugin producer (
abicheck-clang-plugin, ingested viainputs_pack.py) — the actual motivating case for a per-role gap (it structurally never emitsDECL_HAS_TYPEfor a variable's or typedef's own type, only return/parameter types). Its L4-levelfact_family_states/fact_setcoverage schema (source_abi.py,fact_set.py) only tracks one flatsource_edgesfamily today, with no role subdivision — extending that schema to plumb a per-role claim through tosource_graph.mark_source_edges_extractor_coverageis a materially larger change than this slice (touches the L4 fact-set contract, not just the L5 graph), so it stays open.call_graph.py'sDECL_CALLS_DECLis not given role granularity either: its unconditional walk visits every call expression the same way regardless ofcall_kind(direct/virtual/function_pointer/template), so there is no structural per-role gap to make the matrix honest about — a confirmed family flag already means every call kind was examined. tests/test_inline_changed_paths.py: a confirmed full pass sets every matrix key; a narrowed pass sets the narrowed-side keys only (never the family-levelextractor_passesside);role_pass_covered()'s direct-hit and family-fallback behavior.
D4 implementation (G29 Phase 2 follow-up) — deliberately scoped¶
D4 stayed open through Slices 1-8 as a genuine deferral (see "Why this stays scoped, not the full rewrite" below for the reasoning that was originally recorded here) — this section is that deferral's own follow-up, implementing the part of D4 that delivers real value without the node-id-generation rewrite the original deferral flagged as too large a single change.
abicheck/buildsource/entity_resolver.py(new):EntityResolver.resolve(node) -> strcomputes a USR-preferring canonical identity for aGraphNode— reusingentity_identity.resolve_identity_for_node(ADR-048) rather than a second, independent identity computation, exactly the "natural first aliasEntityResolver.aliaseswould fold in" relationship ADR-048's own "Relationship to ADR-046" section predicted — and records it asaliases[v1_id] = canonical_id.GraphNode.idgeneration itself is unchanged —_decl_node_id/_type_node_idet al. insource_graph.pystill produce exactly the v1 ids they always have;EntityResolvercomputes a second, richer identity alongside them, never in place of them. Two or more v1 ids that resolve to the same canonical identity (the identity-fragmentation pattern this ADR exists to name) are recorded as anEntityConflict— "conflicts gives cross-producer disagreement a visible home instead of silent first-writer-wins," the same design goal D2'sFactConflictserves for attrs/confidence, applied to identity here.SOURCE_GRAPH_VERSIONbumped 1 → 2. A signal bump, not a breaking schema change: nothing anywhere reads or branches onschema_versiontoday (confirmed before this change — it was genuinely dead data), so this only marks "this graph was built withEntityResolver-capable tooling."SourceGraphSummary.entity_resolver: EntityResolveris an additive optional field;from_dictdefaults it to an empty, unresolvedEntityResolverwhen absent. v1 IDs kept as aliases — no forced re-collection: a v1 pack (schema_version: 1, noentity_resolverkey at all) still loads and compares correctly through its existingGraphNode.idvalues with zero behavior change, exactly as the G29.3 summary's original framing promised.resolve_entities()is opt-in, not wired intoadd_node/__post_init__/finalize()— computing a USR-preferring identity for every node is extra work no current graph consumer needs by default, the same "no cost until asked for" discipline D1'soccurrence_idand D5'seffect_transitionsboth follow.to_dict()omits theentity_resolverkey entirely (never an empty{"aliases": {}, "conflicts": []}) unlessresolve_entities()was actually called — the same sparse-output conventionimpact_assessment(ADR-052) uses.- Why this stays scoped, not the full rewrite. The originally-recorded
reasons for deferring all of D4 still explain why this implementation
stops short of touching
GraphNode.idgeneration itself: - ADR-048
(G31 Phase B, accepted and implemented after this ADR was written)
already ships
entity_identity.CanonicalIdentity— the identity resolutionEntityResolver.resolvereuses — solving safe old/new reconciliation and impact/proof-path linking without touchingGraphNode.idor the schema version. This implementation wires that existing module into a graph-wide alias index rather than duplicating its resolution logic. - Changing
GraphNode.idgeneration itself (_decl_node_id/_type_node_id/_symbol_node_idet al.) would still need everyGraphNode-constructing producer (source_graph.py,call_graph.py,type_graph.py,header_graph.py,include_graph.py,graph_backends.py) updated in lockstep, plus a genuine v1-id/v2-id-scheme compatibility matrix at the identity level (not just the pack-loading levelEntityResolveralready handles) — the kind of "own scoped design pass" bar this file's own header sets for an identity/version-bump change, still not attempted here. This implementation sidesteps that lockstep-update requirement entirely: by hookingresolve_entities()intoSourceGraphSummary.nodes(populated the same way regardless of which producer added a node), everyGraphNode-constructing producer gets alias resolution automatically, without any of those six modules being touched — the risky part of the original D4 sketch never had to happen for this scoped version to deliver real value. - Open research question (not yet answered): is the full
GraphNode.id-generation migration still needed at all? Every known current consumer of stable cross-version identity (impact/proof-path linking via ADR-048'sCanonicalIdentity) is already served by the alias layer above without touchingGraphNode.id. Before scheduling the six-module lockstep rewrite in item 2, the concrete gap it would close needs to be named explicitly — e.g. a producer that needs a single id without alias indirection, or a performance case where resolving throughaliaseson every lookup is measurably too slow. Absent a named consumer with that requirement, the honest status of this item may be "not planned" rather than "deferred", the same distinction ADR-052 draws between its own still-open items and the ones it explicitly closed as out of scope. tests/test_entity_resolver.py:EntityResolver.resolveresolves a USR to its canonical id, falls back to a real mangled name then a normalized qualified-name signature when no USR is available (entity_identity's own preference order), is idempotent per node id, gives two v1 ids sharing a USR the same canonical alias, and records anEntityConflictfor the second (non-representative) v1 id — with the first-seen id stayingv1_id_for's answer.to_dict()/from_dict()round-trip (including the conflict list and the representative-id reconstruction).TestSourceGraphSummaryEntityResolutioncoversSOURCE_GRAPH_VERSION == 2;resolve_entities()not running automatically (add_node/finalize()alone leaveentity_resolver.aliasesempty); a call populating aliases for every node; being safe to call again after more nodes are added;to_dict()omitting the key when unresolved and including it once resolved; a fullto_dict()/from_dict()round trip; and the explicit v1-pack-compat case — aschema_version: 1dict with noentity_resolverkey loads with an empty, unresolvedEntityResolver, andresolve_entities()still works on it on demand afterward.
Follow-up fixes (CodeRabbit review), same PR:
SourceGraphSummary.from_dictcrashed on a hand-edited pack with an explicit"entity_resolver": null(as opposed to the key being absent entirely) —dict(None)raised beforeEntityResolver.from_dict's own defensive.get()parsing ever ran. Fixed by narrowing to a dict (or{}) before the call, matching every other optional-object field on this samefrom_dict.resolve_entities()reused the existingentity_resolveracross calls, so a node that gained stronger identity evidence via a lateradd_nodemerge (e.g. a USR contributed by a second registration after an earlier one lacked it) kept serving the canonical id resolved from its earlier, weaker attrs —EntityResolver.resolve's per-node-id idempotence, correct within one resolver instance, silently became staleness across repeatedresolve_entities()calls. Fixed by starting from a freshEntityResolveron every call, trading re-resolving every node (not just new ones) for guaranteed freshness.
D5 implementation (G29 Phase 2, slices 5 and 7)¶
abicheck/internal_leak.py:TraversalPolicy(allowed_edges,stop_conditions,minimum_confidence,effect_transitions) andCALL_GRAPH_TRAVERSAL_POLICY— the call-graph leak walk's own rules ({DECL_CALLS_DECL, DECL_REFERENCES_DECL}, the existingis_consumer_compiled_nodestop check) reified as one named, reusable object instead of the inlineedge_kindsfrozenset + hard-coded_is_consumer_compiled_nodecall_consumer_compiled_reachability/compute_call_graph_leak_pathshad before.stop_conditionsmatches the Decision text's own polarity (True = "do not expand past this node," the node itself still counts as reachable) — the inverse sense ofis_consumer_compiled_node, which the policy'sstop_conditionslambda negates.minimum_confidenceis real, wired filtering —_consumer_compiled_reachabilitynow skips any edge whose confidence rank is below the policy's floor before building its adjacency map — not just a passthrough field. The defaultCONF_UNKNOWNfloor preserves today's behavior exactly (no edge is excluded), socompute_call_graph_leak_pathsis unchanged for every existing caller; a policy built with a stricter floor (e.g.CONF_HIGH) is new capability future code can opt into without this walk's own logic changing again.effect_transitions(slice 7, G29 Phase 2 follow-up):CALL_GRAPH_TRAVERSAL_POLICY.effect_transitions = {"virtual": "overapprox", "function_pointer": "overapprox"}— a virtual/function-pointer call is never statically exact (the sameCallEdge.resolution="overapprox"label ADR-031 D4/D9 already puts on that edge), so crossing one downgrades the walk's precision from that point on._edge_effect_transition(policy, edge)looks up an edge's resolvedcall_kindagainst the policy's map;_consumer_compiled_reachabilitynow returns a third element per entry —degraded: frozenset[str]— the subset of reached nodes first reached via a transitioning edge, sticky for every node reached transitively past it (a private-function signature change, callers of_consumer_compiled_reachabilityupdated to the 3-tuple accordingly).compute_call_graph_leak_pathsprefixes a degraded path's formatted string with"overapprox: ", so a proof that only exists through a possible-dispatch-target edge is distinguishable from one proven through an unbroken chain of direct calls.compute_leak_paths's layout/type-graph walk (the other of the two walks D5's Decision text names) still does not adoptTraversalPolicy— it traversesRecordType/typedef structures, not the L5GraphNode/GraphEdgegraph this policy shape describes, so it would need its own data-model change first, which stays out of scope (matches the same-shaped scoping decision D3 made forinline_graph_foldvs.header_graph.py/the clang-plugin producer).tests/test_internal_leak.py(TestTraversalPolicy): the shared policy'sallowed_edges; a custom policy'sminimum_confidenceactually excludes a lower-confidence edge from reachability (not merely accepted and ignored); a customstop_conditionshalts expansion past a chosen node while still recording that node itself as reachable; the publiccompute_call_graph_leak_pathsentry point still routes throughCALL_GRAPH_TRAVERSAL_POLICYend to end.tests/test_internal_leak_effect_transitions.py(TestEffectTransitions, split out to stay under the line-count cap): no transition for a direct call; a real transition for a virtual call; the target of a virtual call is marked degraded; the downgrade is sticky past the transitioning edge; a policy with noeffect_transitionsbehaves exactly as before this field existed;compute_call_graph_leak_pathsflags the overapprox-only path.
D6 implementation (G29 Phase 2, slices 4 and 8)¶
abicheck/internal_leak.py:select_preferred_path(paths: list[list[str]]) -> list[str]implements the two tiers this walk's own per-path signals support out of the Decision text's six-tier order — "exact" (a value-propagating path, tier 2) and "virtual/indirect over-approximation" (tier 6). A pointer/reference-only path never wins over an available value-propagating one just because it's shorter (what plainmin(paths, key=len)did); within a tier, shortest still wins. Wired intopost_processing.py'sMarkReachabilityproof-path selection (thereachable_types-keyed branch) andinternal_leak._build_leak_change(theINTERNAL_TYPE_LEAKS_VIA_PUBLIC_APIsynthetic finding's own displayed proof path). The call-graph walk's ownmin(call_paths, key=len)selection is deliberately left unchanged for this string-path representation — a formatted string still carries no per-hop value/indirection signal to tierselect_preferred_pathon.select_preferred_graph_path(slice 8, G29 Phase 2 follow-up,abicheck/buildsource/graph_impact.py): a second selector, over structuredlist[GraphEdge]paths rather than plainlist[str]— a realGraphEdgecarries per-edge confidence, fact-producer count (D2), and (via each endpoint node'svisibilityattr) public/private surface information, so this selector implements four of the six tiers instead of two: "exact/high-confidence" (every edgeCONF_HIGH), "public-header structural" (every nodePUBLIC_VISIBILITIES), "multi-producer-confirmed" (some edge has >1 distinct fact producer), and a residual "reduced-confidence" catch-all; over-approximation (crossing aneffect_transitions-tagged edge, D5) is checked first and forces the weakest tier regardless of the other signals, since an over-approximated proof is never "exact" no matter how confident its other edges look. Tier 1 (consumer-proven) stays out of scope for both selectors — it needs the Phase 4 consumer graph. Wired intosource_graph_findings.py'sPUBLIC_API_INTERNAL_DEPENDENCY_ADDEDproducer, replacing its ownmin(target_paths, key=lambda tp: len(tp[1]))— the same plain-shortest anti-pattern this ADR's Decision text originally called out, now closed for the one detector whose path representation is already structured enough to support it.primary_path/alternative_paths[0..N]/discarded_path_count(slice 8):buildsource.graph_impact.attach_impact_metadatagained analternative_pathsparameter — the runner-up candidates not selected as primary, capped at 3 (matching_build_leak_change's own "+N more paths" cap), with any remainder recorded asChange.impact_discarded_path_count.impact.model.GraphProofPathgained matchingalternative_paths(nestedGraphProofPaths) anddiscarded_path_countfields;impact.engine._build_proof_pathreads the newChangefields into them. Both are empty/zero for the still-common single-candidate case. See Unified Impact Assessment and Source Graph Schema Reference.- Still not implemented: the two evidence-requiring tiers neither
selector covers (consumer-proven, and a genuinely finer
"reduced-confidence name resolution" axis beyond the residual case
select_preferred_graph_pathfolds it into) — both need evidence (a consumer graph; a producer-name-resolution confidence signal) that doesn't exist yet. tests/test_internal_leak.py(TestSelectPreferredPath): value-propagating preferred over a shorter indirect path; shortest wins within the same tier; a pointer/signature-only path beats a pure-indirect one; a single path returns unchanged.TestBuildLeakChangePreferredPath: theINTERNAL_TYPE_LEAKS_VIA_PUBLIC_APIfinding's ownreachability_proof_pathalso prefers the value-propagating path (verified to fail without the fix); the description's "+N more paths" count still reflects the full, unreordered candidate collection.tests/test_graph_impact.py(TestSelectPreferredGraphPath,TestAttachImpactMetadataAlternatives,TestPathOccurrenceId): each of the four computable tiers beats a weaker candidate; ties within a tier prefer the shorter path;attach_impact_metadatarecords/caps alternatives correctly and excludes the primary path from its own alternatives list.
D4: history (why it stayed deferred through Slices 1-8)¶
D4 (EntityResolver, USR-based canonical identity, SOURCE_GRAPH_VERSION =
2) was, through Slices 1-8, the one Phase 2 decision this ADR did not
implement even partially — a deliberate scope stop, not an oversight. Two
reasons drove that: ADR-048
already shipped a narrower, working identity-resolution module
(entity_identity.CanonicalIdentity) that solved D4's two motivating
problems without touching GraphNode.id/SOURCE_GRAPH_VERSION; and a
full D4 would still mean changing GraphNode.id generation itself across
every producer in lockstep plus a genuine identity-level compatibility
matrix — categorically larger than any other slice in this ADR.
Both reasons are why the follow-up implementation ("D4 implementation"
above) stops short of that full rewrite: it wires entity_identity's
existing resolution into a graph-wide alias index (EntityResolver) instead
of duplicating it, and never touches GraphNode.id generation, so none of
the lockstep-producer-update risk this section originally flagged had to be
taken on. Should a future need arise that this implementation genuinely
cannot serve (e.g. an on-disk v2 pack format, or cross-TU USR-based identity
that GraphNode.id itself must carry), that still needs the same "own
ADR" bar this file's Context section sets — a bar this history respects
rather than sidesteps.
Non-goals¶
- Not a change to any
ChangeKind's default verdict or toBREAKING_KINDS/API_BREAK_KINDS/RISK_KINDSmembership — this ADR is graph plumbing underneath the existing tri-state reachability model (ADR-044 P0/P1, PR #607), not a policy change. - Not a new CLI flag or user-facing behavior change in this ADR alone —
D1-D6 are internal
buildsource/schema/API changes. A later G29 Phase 3 ADR is where--report-mode root-causeand structured proof-path JSON output land. - Not retiring the v1 node-id functions (
_decl_node_idet al.) — they remain the non-USR fallback path (D4) and the v1-pack-compat alias source. - Not requiring every extractor to emit USRs immediately — D4's
EntityResolverdegrades gracefully per-producer; a non-clang producer keeps working exactly as it does today.
Consequences¶
Positive:
- Gives the class of bug PR #607's review repeatedly found by hand —
"which identity signal does this lookup use, and did the producer that
built the graph use the same one" — a real, tested place to centralize
resolution instead of at every call site.
EntityResolveritself is not yet consumed by any existing lookup (post_processing.py's hand-rolled fallback chain is unchanged — see "D4 implementation" above); this slice ships the alias index a future migration would build on, not that migration itself. conflictsgives cross-producer disagreement a visible home instead of silent first-writer-wins — directly useful forcrosscheck.py's existing provider-agreement matrix (ADR-035 D4/D8), which currently has no graph- level disagreement signal to draw on.TraversalPolicyturns "detector-specific tribal knowledge" into a named, testable, reusable object — the next detector that needs a graph walk (G29 Phase 6) starts from a policy, not a hand-rolled BFS.- Backward compatible at the pack level: a v1 pack on disk still loads and compares correctly against v2-produced code (D4's alias fallback).
Costs / risks:
- Every
source_graph.py/internal_leak.py/crosscheck.py/source_graph_findings.pycall site that readsGraphNode.attrs/GraphEdge.attrsdirectly needs an audit to confirm the D2 derived-view compatibility path actually covers its access pattern — a real, non-trivial migration even though the field names don't change.buildsource/is already at nine modules over the 1500-line AI-readiness soft limit; this is schema surgery on the most heavily depended-on module in that tree (source_graph.py, re-exported bysource_graph_findings.py,internal_leak.py,crosscheck.py,header_graph.py,call_graph.py,type_graph.py,poi.py). EntityResolver's USR dependency means the improvement is clang-only in its strongest form; a castxml-only pipeline sees no identity-fragmentation improvement from D4 (still falls back to v1 hashing), only the D2/D3/D5/D6 benefits.occurrence_id's extra granularity (D1) would grow pack size for a template-heavy codebase (more distinct occurrences per relation) if it landed on a default, always-on path — the implemented slice sidesteps the scan-level cost-model measurement (docs/contribute/performance.md) this would otherwise need by making it strictly opt-in instead:edge_occurrence_idcosts one dict-membership check and returnsNone(contributing nothing tooccurrences) unless a fact already carriessource_location/configuration_id/instantiation_id/callsite_id— which no current producer sets. The cost-model measurement is still owed if and when a producer opts in and this stops being a no-op in practice.- This is explicitly schema-only groundwork: none of D1-D6 alone changes
a single
ChangeKind's output. The payoff is realized only once G29 Phases 3-6 (reporting, consumer join, new edge families, new detectors) are built on top of it — shipping D1-D6 in isolation is infrastructure investment with no immediately observable user-facing effect, and should be communicated as such (e.g. in its own changelog fragment) rather than implied to close a user-visible gap by itself.
Relationship to existing ADRs¶
- ADR-031 (source/implementation graph augmentation) defined the v1
schema this ADR versions past (
SOURCE_GRAPH_VERSION 1 -> 2); D1-D6 are additive/versioned changes to that schema, not a replacement of its node/ edge-kind vocabulary (NODE_KINDS/EDGE_KINDSare unchanged). - ADR-041 (compiler-facts semantic impact graph) is where the
per-(kind,role) coverage gap (D3) and the
is_consumer_compiled_nodepredicate (D5's starting point) were first introduced; this ADR generalizes both rather than duplicating them. - ADR-044 (reachability-aware suppression) is the direct trigger: its P0/P1 slices (and PR #607's eleven-plus rounds of review) are the concrete evidence this ADR's Context section cites for identity fragmentation and first-writer-wins merge being real, recurring correctness gaps, not a speculative concern.
docs/contribute/plans/g29-impact-analysis-layer.md(Phase 2) is this ADR's origin — the plan explicitly gated Phase 2 code on "needs its own ADR," and this is that ADR. Phases 3-6 of the same plan consume D1-D6 but are out of scope here.
References¶
abicheck/buildsource/source_graph.py—SourceGraphSummary,SOURCE_GRAPH_VERSION(v1 schema container);add_node/add_edgenow delegate tograph_facts.py(D2, implemented).GraphNode/GraphEdgethemselves live ingraph_facts.py, re-exported here.abicheck/buildsource/graph_facts.py— theGraphNode/GraphEdgeschema itself, D1'sedge_relation_key/GraphEdge.relation_key(implemented) and D2'sGraphFact/FactConflict/merge_graph_facts/ensure_facts_and_resolve/register_fact/merge_entity_facts(implemented).abicheck/buildsource/inline_graph_fold.py— D3'sROLE_COVERAGE_MATRIX/role_pass_covered/_mark_role_coverage, wired intofold_type_graph.abicheck/internal_leak.py—compute_leak_paths/compute_call_graph_leak_paths,is_consumer_compiled_node/is_consumer_compiled_public_entry(D5's starting point),TraversalPolicy/CALL_GRAPH_TRAVERSAL_POLICY(D5, partial, implemented),select_preferred_path(D6, partial, implemented).abicheck/post_processing.py—MarkReachability(PR #607), the hand-rolled root/qualified_name fallback patternEntityResolver(D4) could in principle replace — not yet wired to actually consume it (D4's scoped implementation ships the alias index itself, not a rewrite of every existing lookup site that could use it); now callsselect_preferred_pathfor its layout-walk proof-path selection (D6, partial); the call-graph walk's own shortest-path selection is unchanged.abicheck/buildsource/entity_resolver.py— D4'sEntityResolver/EntityConflict(implemented, scoped — see "D4 implementation" above).tests/test_source_graph_v2.py— D1/D2 unit tests, including theadd_edgerole-distinct-edge regression test.tests/test_entity_resolver.py— D4 unit tests.tests/test_inline_changed_paths.py— D3 unit tests.tests/test_internal_leak.py—TestTraversalPolicy(D5 unit tests),TestSelectPreferredPath(D6 unit tests).- PR #607 review history — the concrete, repeated instances of identity/ coverage-granularity bugs this ADR's Context section draws on.
- ADR-031, ADR-041, ADR-044, ADR-048
docs/contribute/plans/g29-impact-analysis-layer.md