G29 — Impact-Analysis Layer: Unified Graph-Driven Impact Model¶
Origin: External impact-analysis-layer architecture review (2026-07) —
audited how far the optional L5 source/call/type graph (ADR-031, ADR-044) has
grown into a real decision-making layer (version-over-version graph diff,
public reachability, suppression gating, consumer scoping, proof paths) versus
where it still stops short of a unified model. Phase 1's P0 slice is
implemented (PR #607); Phase
2 (ADR-046), Phase 3 (ADR-052) and Phase 4's first slice (ADR-057) have since
landed, and the rest of Phases 4–6 is the remainder of the review's roadmap,
scoped below.
ADR: builds on ADR-044
(reachability-aware suppression) and ADR-031
(source implementation graph augmentation). Phase 2 onward needs its own ADR before
implementation starts — it changes graph node/edge identity
(SOURCE_GRAPH_VERSION = 2) and suppression-adjacent semantics, which is
exactly the class of change ADR-044's own "Post-merge review rounds" note
says needs a recorded decision, not a routine PR.
Type: Initiative plan (cross-cutting; not tied to a single
usecase-registry.yaml gap — spans abicheck/buildsource/,
post_processing.py, suppression.py, appcompat.py, reporter.py,
sarif.py, and the docs/examples catalog).
Effort: XL (phased) · Risk: high overall — Phase 2 changes graph
identity, Phase 3 changes reporting-contract shape, Phase 4 adds a whole new
evidence source (consumer/use-case), Phase 5 adds ~15-20 new graph edge
kinds, Phase 6 adds ~8 new detector surfaces (6 ChangeKinds and 2
report-level overlays). Mitigated by shipping each phase
independently, keeping every new signal additive/opt-in (mirrors how L3-L5
evidence already never overrides L0-L2 authority — ADR-028 D3), and requiring
the shared new-ChangeKind checklist (below) per kind.
Problem¶
The graph is already a real detector input, not a debug dump: it drives
version-over-version diff findings (PUBLIC_API_INTERNAL_DEPENDENCY_ADDED,
CALL_GRAPH_PUBLIC_ENTRY_REACHABILITY_CHANGED, INCLUDE_GRAPH_PUBLIC_HEADER_DRIFT,
etc. — source_graph_findings.py), computes transitive public reachability
with BFS proof paths (internal_leak.py), gates suppression before it can
hide a public-reachable break (post_processing.MarkReachability /
suppression.py, ADR-044, now with tri-state ReachabilityState — Phase 1),
and intersects real --used-by consumer binaries against the diff
(appcompat.py).
What is still missing, per the review, is that this stays a flat Change +
several independently-computed graph-derived annotations, not a unified
model:
source_graph_findings.py,internal_leak.py,post_processing.py,suppression.py, andappcompat.pyeach answer overlapping "is this reachable / why / how confidently" questions independently, with no shared object.- Graph node/edge identity is a
(src, dst, kind)triple with a fallback identity chain (mangled name → qualified name + signature hash → qualified name) — no canonical USR-based identity, no relation-vs-occurrence split, so semantically distinct dependencies (e.g. "used as return type" vs. "used as parameter type") can collapse onto the same edge. - Node/edge merge is largely first-writer-wins — a later graph producer can fail to add missing facts to a node an earlier producer already created.
reachability_proof_pathis one human-readable string, not a structured, machine-walkable sequence of typed steps.- There is no consumer graph (only a symbol-level
--used-byintersection) and no use-case concept at all for runtime/business scenarios (the existingusecase-registry.yamltracks abicheck's own feature coverage, a deliberately different thing — see Phase 4). - Several graph families the review calls out as open (template instantiation, virtual dispatch, macro/config dependency, callback/function-pointer, object/archive link provenance) don't exist yet.
Goal & acceptance criteria¶
- G29.1 (Phase 1, DONE) —
Change.reachability_statetri-state (PROVEN_REACHABLE/PROVEN_UNREACHABLE/UNKNOWN) replaces the boolean-only reachability signal for the purposes suppression needs; a new opt-inreachability: proven-unreachable-onlygate refuses to match onUNKNOWNunlessallow_unknown_reachability: trueis set explicitly. See PR #607 anddocs/learn/graph-coverage.md. - G29.2 (Phase 3, slices 1-10 done, ADR-052) — A single
abicheck/impact/package withImpactAssessment,GraphProofPath, andFindingDecisiondataclasses. Slices 1-7 implement the read-view direction only: the dataclasses exist andreporter.py/sarif.py/junit_report.py(Slice 6) surface them (including the suppression audit trail, slice 2, and--report-mode root-causegrouping in JSON, markdown/text, SARIF properties, and — Slice 6 — additive JUnit<failure>attributes). Slices 8-10 deliver the D2 direction flip:internal_leak.py(Slice 8) andappcompat.py(Slice 9) populateChange.impact_assessmentdirectly for their single-purpose finding builders (each verified safe by its own audit — a pipeline-ordering audit forinternal_leak.py, confirmation thatsuppression.evaluate()is a pure read forappcompat.py); Slice 10 closes the remaining two named producer sites, each for a documented, code-inspected reason (see ADR-052's "Slice 10" and "Deliberately not implemented this slice" sections for the full detail): post_processing.MarkReachability— the open measurement question is resolved, migrated: a real instrumentedcompare --format json --secondary-format sarifrun (tests/test_cli_unit.py:: TestCompareSecondaryFormat::test_json_then_sarif_secondary_calls_assess_change_twice_per_change) confirmedassess_change()is genuinely called more than once for the sameChangeobject within one process (reporter.py's JSON path andsarif.py's SARIF path each call it independently over the identical, already-computedDiffResult).MarkReachabilitynow cachesimpact_assessmentright after it finalizes each change's reachability fields — confirmed (fresh repo-wide grep, not carried over from Slice 8's claim) to be the only step that mutates those fields on an existingChange, so nothing downstream invalidates the cache.source_graph_findings.py— re-audited: none of itsChange(...)construction sites are individually cacheable at construction time, unlikeinternal_leak.py's builder (itself a laterDEFAULT_PIPELINEstep) — these builders' output is merged intochecker.compare'schangesbefore_run_post_processing/DEFAULT_PIPELINE.run(), soMarkReachabilitystill runs downstream of them and would invalidate an eagerly-cached assessment. Each site got a brief comment documenting this instead of a (would-be-wrong) construction-time cache write — but the practical gap is closed anyway:MarkReachability's own new caching (above) reaches everysource_graph_findings.pyfinding too, once it's tagged. See the Phase 3 section below ("Slice 10") for the exact per-function site count and breakdown.
A third entry D2's original decision text named, suppression.py,
contains no Change(...) construction at all (confirmed by direct
search, unchanged from Slice 8/9's finding) — the diagnostic construction
near it (SUPPRESSION_WOULD_HIDE_PUBLIC_BREAK) actually lives in
post_processing.py and carries no reachability evidence to cache. G29
Phase 3 Slice 11 resolved this, the one item from D2's original scope
still open after Slice 10: suppression.py mutates no Change field
anywhere in the module (confirmed by grepping every assignment target),
so it was never a producer to migrate — the mutation D2's text was
naming is checker._filter_suppressed_changes/
post_processing.ApplySuppression, already migrated in Slice 2, and
already covered by Slice 10's MarkReachability caching (which runs
earlier in DEFAULT_PIPELINE, ADR-044 D1's ordering, so every Change
those callers see already carries a cached impact_assessment). D2's
text conflated "the suppression subsystem" (the caller sites) with "the
suppression.py module" (a pure predicate engine with nothing to
migrate, by design — see ADR-052 Slice 11 for the full resolution).
- G29.3 (Phase 2, D1-D6 all done, ADR-046) — Graph core v2:
relation/occurrence identity split (done), an evidence-preserving
(order-independent) node/edge merge (done), a per-kind/per-role
coverage matrix (done, extending extractor_passes beyond the two
families Phase 1 already consults), and a USR-based canonical
EntityResolver with SOURCE_GRAPH_VERSION = 2 (v1 IDs kept as aliases —
no forced re-collection) — done, as a deliberately scoped subset:
ADR-048's
entity_identity.CanonicalIdentity is the resolution source
EntityResolver.resolve reuses; changing GraphNode.id generation itself
across every graph producer stays out of scope (still the "materially
larger" rewrite that would need its own design pass).
- G29.4 (Phase 2/3, mostly done) — Structured, machine-walkable proof
paths (JSON node/edge sequence, not a formatted string) surfaced in JSON —
done (impact_proof_path, impact_assessment.proof_path.steps); SARIF
gets additive properties instead of a codeFlows restructuring — that
specific codeFlows shape is not implemented and not currently planned.
A decision-audit object per finding (kept/suppressed + reason code) —
done (FindingDecision; suppression_withheld as a distinct state
beyond kept/suppressed is not implemented — suppression's own
SUPPRESSION_WOULD_HIDE_PUBLIC_BREAK/SUPPRESSION_REACHABILITY_UNKNOWN
diagnostics cover that case today). Root-cause grouping
(--report-mode root-cause) — done for every format including JUnit
(Slice 6). Stable finding_id — done, but by design still includes
description text (unlike this criterion's original wording — see G24's
shared checklist reasoning and ADR-052's "Deliberately not implemented"
section for why changing that would be a breaking change to an
already-published field). occurrence_id — done (ADR-046 D1 +
ADR-052 Slice 6). root_cause_id/root_cause_display/impact_group_id
as per-finding ImpactAssessment fields — done (ADR-052 Slice 7):
the report-level caller (reporter_markdown.root_cause_lookup_for_changes)
resolves the value from whole-DiffResult context and passes it into
assess_change, so ImpactAssessment itself stays a pure single-Change
read view — see the
Detector Impact Contract.
impact_group_id is currently always an alias of root_cause_id;
making it independently meaningful still needs Phase 6's
RootCauseCorrelator.
- G29.5 (Phase 4, slices 1-2 done, ADR-057) — A consumer graph
(CONSUMER_REQUIRES_SYMBOL, CONSUMER_REQUIRES_VERSION, …) that joins
with the source graph so a CONSUMER_REQUIRED_SYMBOL_REMOVED finding can
name the public entry point that produced the dependency — done
(abicheck/impact/consumer_graph.py; the join is one shared
binary_symbol:// node id, not a parallel node kind, and the walk reuses
ADR-046 D5's CALL_GRAPH_TRAVERSAL_POLICY rather than a fresh BFS). This
also closed ADR-046 D6's tier 1 ("consumer-proven"), which had been
unreachable since it was written. Slice 2 also done: the optional
impact-use-cases.yaml manifest (declared entrypoints/tests, explicitly
not a reuse of usecase-registry.yaml) — abicheck/impact/use_cases.py
parses the manifest and builds/joins use_case/test_case nodes and
USE_CASE_USES_ENTRY/TEST_COVERS_USE_CASE edges onto the library graph,
mirroring slice 1's build/join API and mutation-safety discipline.
Update (2026-09-02, also done): abicheck/impact/use_case_impact.py's
build_use_case_impact is a further real step past graph-building alone
— compare --use-cases MANIFEST is a real flag on the native compare
command, and it attributes a comparison's changed symbols to the declared
use cases whose entrypoints reach them as a genuine report-level
DiffResult.use_case_impact surface (emitted through the JSON/markdown/
text/review formats; see ADR-057). Still open: best-effort runtime-trace
ingestion, and a per-finding, report-schema Change.affected_use_cases
field / USE_CASE_IMPACT_CONFIRMED finding kind wired into compare's
own change objects/exit code (G29 Phase 6) — the reserved edge kinds
ADR-057 registers (CONSUMER_INSTANTIATES_DECL/
CONSUMER_COMPILED_FROM_HEADER/RUNTIME_FAILED_TO_RESOLVE_SYMBOL/
TRACE_OBSERVED_ENTRY/TRACE_OBSERVED_EDGE) mark where that work
attaches.
- G29.6 — The five open graph families (template instantiation, virtual
dispatch, macro/config, callback/function-pointer, object/archive link
provenance) implemented behind the same coverage-honesty discipline as the
existing call/type graph (narrowed/degraded flags, extractor_passes).
Object/archive link provenance is done (archive_graph.py,
extractor_passes["archive_graph"]/degraded_passes["archive_graph"] —
see Phase 5 item 6 above). Template instantiation is done
(template_graph.py, extractor_passes["template_graph"]/
degraded_passes["template_graph"] — see Phase 5 item 1 above), for the
four populated edge kinds (TEMPLATE_USES_DECL closed as a follow-up,
see item 1's own entry above); the three remaining reserved ones
(INSTANTIATION_MAPS_TO_EXPORT — intentionally never populated, see
item 1 — DECL_USES_DEFAULT_TEMPLATE_ARG, CONSTRAINT_DEPENDS_ON_DECL)
stay open, each its own follow-up. Macro/config dependency is
done (macro_graph.py, extractor_passes["macro_graph"]/
degraded_passes["macro_graph"] — see Phase 5 item 2 above), for the two
populated edge kinds (MACRO_CONTROLS_DECL/DECL_USES_MACRO); the three
reserved ones (MACRO_EXPANDS_TO_VALUE etc.) remain open, each its own
follow-up. Virtual dispatch is partially done
(virtual_dispatch_graph.py, extractor_passes["virtual_dispatch_graph"]
— see Phase 5 item 3 above): VIRTUAL_CALL_MAY_DISPATCH_TO/
TYPE_HAS_VTABLE are populated (needing no new clang invocation at all —
a pure transform over already-folded call/type/override graph state);
DECL_OVERRIDES_DECL needed no producer (already satisfied by
override_graph.py's existing confirmed-override edges) and
VTABLE_SLOT_MAPS_TO_DECL remains reserved (needs a real per-slot Itanium
layout model, deliberately not attempted). Callback/function-pointer is
done, for three of the four vocabulary members (callback_graph.py,
extractor_passes["callback_graph"] — see Phase 5 item 4 above):
DECL_REGISTERS_CALLBACK/DECL_TAKES_ADDRESS_OF are populated via a new
Clang AST pass, and CALLBACK_MAY_INVOKE is populated via a pure join
(needing no new clang invocation) over call_graph.py's already-folded
function-pointer-kind DECL_CALLS_DECL edges;
FUNCTION_POINTER_HAS_SIGNATURE is registered vocabulary with no edge
producer — a real, investigated gap (unlike DECL_OVERRIDES_DECL above),
but populated instead as a function_pointer_signature node-level fact on
the slot's own source_decl node, since a signature is a property of one
declaration, not a relation between two.
- G29.7 — The minimal new user-facing detector set from the review
(8 detector surfaces: 6 ChangeKinds and 2 report-level overlays — see
Phase 6) plus case194-case205 positive/negative example pairs and the
corresponding FP-rate-gate corpus entries.
- Acceptance gate (every phase): the shared new-ChangeKind checklist
from G24
applies verbatim here too — partition assertion, registry entry, detector,
tests, docs mention, example fixture where applicable, FP-corpus case for
any heuristic kind.
Design (phases)¶
Phase 1 — Correctness & unified reachability model (P0) — DONE¶
Implemented in PR #607:
ReachabilityStateenum (checker_policy.py) +Change.reachability_statefield (checker_types.py), set alongside the existing booleanpublic_reachableeverywhere a producer already sets it.MarkReachability(post_processing.py) computes the tri-state per change: a declared-type-domain change (layout/type-graph walk — always trustworthy, a complete closure over the snapshot's own declared types) isPROVEN_UNREACHABLEwhen examined-and-not-found; a function/variable-shaped change isPROVEN_UNREACHABLEonly when the relevant side(s) (old for*_removed, new for*_added, both for changed-in-place) have a call graph with bothextractor_passes["call_graph"]/["type_graph"]confirmed complete and the subject is internal-namespaced (a trusted call graph never proves an exported symbol's own reachability — it only walks dependencies of consumer-compiled public entries); otherwiseUNKNOWN.suppression.py: newreachability: proven-unreachable-onlyvalue +allow_unknown_reachabilityrule field;Suppression.would_withhold_unknown_reachability;SuppressionOutcome.withheld_unknown_rule.- New advisory
ChangeKind.SUPPRESSION_REACHABILITY_UNKNOWNdiagnostic, registered inchange_registry_suppression.py, wired through every suppression call site that already emitsSUPPRESSION_WOULD_HIDE_PUBLIC_BREAK(post_processing.ApplySuppression,checker._filter_suppressed_changes/_filter_pattern_synthetic; notappcompat.py/cli_compare_helpers.py, whose consumer/runtime-proven overlay findings are always constructedPROVEN_REACHABLEand can never hit theUNKNOWNbranch). docs/learn/graph-coverage.md(new) explains narrowed/degraded coverage and why an absent edge isn't proof of an absent dependency;docs/use/suppressions.mddocuments the new rule field.tests/test_reachability_state.py(new) — tri-state tagging across the declared-type/internal-callee/exported-symbol/removed-vs-added axes, suppression gate behavior, diagnostic emission, YAML load round-trip.
Explicitly out of scope for Phase 1 (this is why Phases 2-6 exist): no
unified ImpactAssessment object yet — reachability_state is still one
field alongside public_reachable/reachability_kind/reachability_proof_path,
each producer still sets it independently, and the proof path is still one
formatted string.
Phase 2 — Graph core v2 — ADR accepted; D1-D6 all implemented (D4 scoped)¶
ADR-046 records
the D1-D6 decisions below — the "needs its own ADR" gate this phase set for
itself. D1 (both the relation_key and occurrence_id halves), D2 (the
evidence-preserving node/edge merge), D3 (the per-(kind,role) coverage
matrix), D5 (TraversalPolicy including a real effect_transitions), and
D6 (a proof-path preference order split across two selectors by how
structured their walk's path representation is, plus the
primary_path/alternative_paths/discarded_path_count finding shape) are
implemented — see ADR-046's "D1 implementation"/"D2 implementation"/"D3
implementation"/"D5 implementation"/"D6 implementation" sections,
abicheck/buildsource/graph_facts.py, abicheck/buildsource/graph_impact.py,
abicheck/buildsource/inline_graph_fold.py, abicheck/internal_leak.py's
TraversalPolicy/CALL_GRAPH_TRAVERSAL_POLICY/select_preferred_path,
tests/test_source_graph_v2.py, tests/test_inline_changed_paths.py,
tests/test_internal_leak.py's TestTraversalPolicy/
TestSelectPreferredPath, tests/test_internal_leak_effect_transitions.py,
and tests/test_graph_impact.py. D4 (EntityResolver/
SOURCE_GRAPH_VERSION = 2) is now implemented too, as a deliberately
scoped subset of the originally sketched decision — see ADR-046's "D4
implementation" section: abicheck/buildsource/entity_resolver.py's
EntityResolver reuses entity_identity.CanonicalIdentity
(ADR-048,
G31 Phase B, shipped after ADR-046 was written) as its resolution source,
recording aliases[v1_id] = canonical_id rather than replacing
GraphNode.id generation itself. SOURCE_GRAPH_VERSION bumped 1 → 2 as a
signal (nothing branches on it), populated only when a caller opts in via
SourceGraphSummary.resolve_entities() — a v1 pack with no
entity_resolver key still loads and compares correctly with no forced
re-collection. What stays out of scope, and why, is exactly what the
original deferral flagged as the risky part: changing GraphNode.id
generation itself across every graph producer plus a v1/v2
identity-level compatibility matrix (not just the pack-loading
compatibility this implementation already provides) — categorically larger
and riskier than any slice landed in this phase, still deserving its own
scoped design pass if ever attempted. Two narrower items D5/D6 named as
open have since resolved differently: D6's "consumer-proven" tier is
done for select_preferred_graph_path (G29 Phase 4, ADR-057, reads the
consumer graph straight off the structured-path selector's own graph
argument) — it remains out of scope only for select_preferred_path's
plain list[str] layout-walk paths, for a structural reason (no per-hop
node identity to test), not an evidence gap. D5's TraversalPolicy
adoption on the layout walk and D6's genuinely finer
"reduced-confidence name resolution" axis (tier 5, beyond the residual
case) were both re-investigated (2026-08) and confirmed to need more than
evidence to close: the layout walk has no allowed_edges/stop_conditions
analogue at all (it walks every base/field/typedef target unconditionally,
by design), and the six-tier order is fixed by this accepted ADR with no
slot to insert a finer axis into without an amendment — see ADR-046's D5/D6
implementation sections for the full evidence.
See Source Graph Schema Reference
for the exhaustive schema this phase produced.
abicheck/buildsource/source_graph.py/graph_facts.py: split edge identity into arelation_key = (src, dst, kind, semantic_role)(used for closure/diff) and anoccurrence_idhash over(relation_key, source_location, configuration_id, instantiation_id, callsite_id)(keeps the exact evidence trail — e.g. "used as return type" vs. "used as parameter type" vs. "used under#ifdef WIN32" no longer collapse onto one edge).occurrence_idis opt-in by construction (costs nothing, andGraphEdge.occurrencesstays empty, unless a fact already carries one of the four occurrence attrs) — no current producer populates them yet.- Evidence-preserving node/edge merge: each node/edge accumulates a
facts: list[{producer, confidence, attrs}]plus a deterministicresolved: dict[str, Any]merge (order-independent — same result regardless of producer ingestion order) and aconflicts: list[...]when two producers disagree. Replaces the current first-writer-wins behavior. - Per-kind/per-role coverage matrix: extend today's family-level
extractor_passes/narrowed_passes/degraded_passes(Phase 1 already consults"call_graph"/"type_graph") to a(kind, role)grain — e.g."DECL_HAS_TYPE:variable"vs."DECL_HAS_TYPE:parameter"— so a producer that covers return/parameter types but not variable/typedef-underlying types (a real, ADR-noted clang-plugin gap) can honestly report partial coverage per role instead of one blanket family flag. EntityResolver: canonical identity keyed on the clang USR when available, withaliases: [old_v1_id, mangled_symbol, qualified_name, signature_hash, source_location]— resolves binary symbol / header declaration / source definition / debug type / consumer import / template instantiation to one entity.SOURCE_GRAPH_VERSION = 2; a v2 reader accepts v1 IDs as aliases so existing collected packs keep working. Implemented, scoped — see above:entity_resolver.EntityResolver.resolve(node) -> canonical_id,aliases/conflicts, opt-in viaresolve_entities(). The originally listed richer alias tuple (mangled symbol/qualified name/signature hash/ source location as separate alias entries, not just the one canonical id) is narrower in the shipped version —EntityResolver.aliasesmapsv1_id -> canonical_idonly; the finer-grained alias set is whatentity_identity.CanonicalIdentity.aliases(whichEntityResolver.resolvealready reads from) itself carries, one level down, for a caller that needs it.- A common
TraversalPolicy(allowed_edges,stop_conditions,effect_transitions,minimum_confidence) formalizes the five traversal shapes the review distinguishes (layout/symbol-availability/source-contract/ behavioral/deployment propagation) instead of leaving "don't walk through an ordinary out-of-line helper" as one detector's implicit knowledge (is_consumer_compiled_public_entrytoday). All four fields are implemented and wired:allowed_edges/stop_conditions/minimum_confidencereused bycompute_call_graph_leak_pathsvia the namedCALL_GRAPH_TRAVERSAL_POLICYinstance;effect_transitionsmaps a virtual/function-pointer call'scall_kindto a downgraded"overapprox"precision label, propagated sticky through_consumer_compiled_reachability'sdegradedreturn set and surfaced as an"overapprox: "prefix on the affected proof path. Adoption bycompute_leak_paths's layout walk (a different, non-graph data model) remains open. - Proof-path selection preference order (consumer-proven > exact high-confidence
path > public-header structural path > multi-producer-confirmed >
reduced-confidence name resolution > virtual/indirect over-approximation),
replacing plain shortest-BFS; keep
primary_path/alternative_paths[0..N]/discarded_path_counton the finding. Two selectors implement different slices of the six tiers, split by how much per-hop structure their walk's path representation carries:internal_leak.select_preferred_path(the layout walk's plainlist[str]paths) covers 2 tiers (exact, virtual/indirect);buildsource.graph_impact.select_preferred_graph_path(a structuredlist[GraphEdge]path — real per-edge confidence, fact- producer count, node visibility) covered 4 tiers at the time this was written (exact, public-header structural, multi-producer-confirmed, and a reduced-confidence residual); now covers 5 — G29 Phase 4 (ADR-057) wired the consumer-proven tier into the same selector once the consumer graph existed to read (see G29.5 below), reading the required-node set straight off its owngraphargument, inert for every run without--used-by. Wired intosource_graph_findings.py'sPUBLIC_API_INTERNAL_DEPENDENCY_ADDEDproducer in place of its ownmin(..., key=len). Theprimary_path/alternative_paths/discarded_path_countfinding shape is onimpact.model.GraphProofPath, populated bygraph_impact.attach_impact_metadata. Still open: only a genuinely finer reduced-confidence-name-resolution axis beyond the residual case (investigated, needs an ADR amendment to insert a new tier — see ADR-046's D6 implementation section).
ADR-046 accepted and implemented — see the Phase 2 heading above for the current per-decision status (D1-D6 all implemented, D4 as a deliberately scoped subset); this paragraph originally described the pre-implementation "needs a recorded decision" gate (ADR-044's own bar) before the ADR existed.
Phase 3 — Reporting & root causes — slices 1-10 implemented (ADR-052)¶
ADR-052 records the slice 1
decisions: abicheck/impact/model.py's ImpactAssessment/GraphProofPath/
FindingDecision dataclasses (a narrower field set than originally planned
below — changed_entities/affected_consumers/affected_use_cases/
coverage have no data source yet and are deliberately absent rather than
added as permanently-None placeholders) and
abicheck/impact/engine.py's assess_change, a pure read view built
from the Change fields source_graph_findings.py/internal_leak.py/
post_processing.py/suppression.py/appcompat.py already independently
set — none of those producers changed in slice 1 (see ADR-052 D2: the
plan's originally-stated "existing fields become derived views over
ImpactAssessment" direction is not implemented yet; this slice derives
the other way, ImpactAssessment read from Change). reporter.py/
sarif.py gained reachability_state (always present — the tri-state
signal has existed since PR #607 but was never serialized before this,
closing a real gap: PROVEN_UNREACHABLE and UNKNOWN were previously
indistinguishable in JSON/SARIF, both showing as an absent public_reachable
key) and impact_assessment (emitted only when it carries information
beyond the all-defaults case). REPORT_SCHEMA_VERSION 2.14 → 2.15. Slice 2
closed FindingDecision.suppression_rule: suppression.SuppressionOutcome
gained matched_rule, and the three call sites that move a change into
DiffResult.suppressed_changes (checker._filter_suppressed_changes/
_filter_pattern_synthetic, post_processing.ApplySuppression,
_merge_findings_respecting_suppression) now stamp Change.suppression_rule
from it. Slice 3 added --report-mode root-cause: initially JSON-only,
grouping findings by the existing Change.caused_by_type field rather than
waiting on Phase 6's RootCauseCorrelator. REPORT_SCHEMA_VERSION reached
2.15. Slice 4 added the matching markdown/text rendering
(reporter_markdown._to_markdown_root_cause) reusing the same grouping
function slice 3's JSON path now also calls (_group_changes_by_root_cause).
Slice 5 extended --report-mode root-cause to --format sarif: rather than
restructuring SARIF's flat one-result-per-finding shape (which would break
every existing SARIF/code-scanning consumer), each result gains additive
properties.rootCauseId/properties.rootCause computed via the same
_root_cause_key_and_display JSON/markdown share. Slice 6 (G29 Phase 2/3
follow-up, after ADR-046 D1/D6 landed) closed two of the four remaining
items: --format junit now gets the same additive treatment as SARIF —
rootCauseId/rootCause attributes on each <failure>, <testcase> still
grouped by symbol exactly as before (to_junit_xml/to_junit_xml_multi/
_build_testsuite gained a report_mode parameter; the actual end-to-end
gap turned out to be service_render.render_output's "junit" branch never
forwarding its own report_mode argument at all, fixed alongside the JUnit
rendering itself) — and a stable, description-independent occurrence_id
now exists on GraphProofPath, built directly on ADR-046 D1's
occurrence_id half (buildsource.graph_impact._path_occurrence_id folds a
path's edges' own GraphEdge.occurrences into one hash; None whenever no
edge on the path carries occurrence-level attrs, still every finding today
since D1's occurrence_id stays opt-in with no current producer). Slice 7
(G29 Phase 3 follow-up) closed the remaining per-finding-identifier item:
root_cause_id/root_cause_display/impact_group_id now exist on
ImpactAssessment — computed report-wide by
reporter_markdown.root_cause_lookup_for_changes (the same
_root_cause_key_and_display grouping decision --report-mode root-cause
uses) and passed into assess_change as a plain parameter, so
ImpactAssessment itself stays a pure single-Change read view rather than
gaining the ability to see whole-DiffResult context on its own.
impact_group_id is currently always identical to root_cause_id — an
alias, not yet a distinct concept. REPORT_SCHEMA_VERSION 2.16 → 2.19 (2.17
and 2.18 went to ADR-050 D2's comparability-gate work and the P0
evidence-provider audit's "unattributed" status respectively, both merged
to main first — see abicheck/schemas/__init__.py's version-history
docstring).
Slices 8-9 (G29 Phase 3 follow-up) then delivered the D2 direction flip as a
deliberately scoped subset: Change.impact_assessment (new, additive
field) is populated directly by two producers — internal_leak.py's two
leak-finding builders (Slice 8), verified safe by an explicit
pipeline-ordering audit (post_processing.MarkReachability is the only step
that mutates a Change's reachability/evidence fields, and it runs before
these findings are even constructed); and appcompat.py's one
consumer-overlay builder (Slice 9), verified safe by confirming
suppression.evaluate()/matches()/would_withhold() are pure reads of the
Change passed in — with impact.engine.assess_change reusing the cached
evidence for both while always recomputing decision/root_cause_id fresh.
Slice 10 (G29 Phase 3 follow-up) then closed both remaining named producer
sites, each resolved by a real audit rather than an assumption:
- post_processing.MarkReachability — the open measurement question is
resolved, migrated. tests/test_cli_unit.py::
TestCompareSecondaryFormat::test_json_then_sarif_secondary_calls_assess_change_twice_per_change
instruments assess_change and runs a real compare --format json
--secondary-format sarif invocation, confirming the same Change object
is assessed twice in one process (reporter.py's JSON path, sarif.py's
SARIF path — both read the identical, already-computed DiffResult).
post_processing_reachability.py's MarkReachability.run() now caches
impact_assessment right after finalizing each change's reachability
fields (all three per-change exit paths), re-confirming via a fresh
repo-wide grep (not carried over from Slice 8's own claim) that it is
still the only step that mutates those fields on an existing Change.
- source_graph_findings.py — re-audited: ten separate Change(...)
construction sites across nine finding functions
(_mapping_drift_findings, _public_reachability_findings ×2,
_generated_public_closure_findings, _call_reachability_findings,
_include_graph_drift_findings, _build_option_reach_findings,
_internal_dependency_findings, _target_dependency_findings,
_symbol_owner_findings). None are safe to cache at construction time —
unlike internal_leak.py's builder (a later DEFAULT_PIPELINE step),
these builders' findings are merged into checker.compare's changes
before _run_post_processing/DEFAULT_PIPELINE.run(), so
MarkReachability still runs downstream and would invalidate an
eagerly-cached assessment. Each site got a brief comment recording this
instead of a construction-time cache write; MarkReachability's own new
caching reaches every one of these findings anyway, once tagged.
A third entry the original decision text named, suppression.py, is
resolved (G29 Phase 3 Slice 11) rather than a producer site: direct
search found no Change(...) construction or mutation anywhere in this
module — it only reads Change fields for rule matching, returning a
SuppressionOutcome the caller acts on. The nearby diagnostic
(SUPPRESSION_WOULD_HIDE_PUBLIC_BREAK) actually lives in
post_processing.py. The mutation D2's original text was naming happens at
the caller — checker._filter_suppressed_changes/
post_processing.ApplySuppression — already migrated in Slice 2, and
already covered by Slice 10's MarkReachability caching (which runs
earlier in the pipeline, so every Change those callers see already
carries a cached impact_assessment). D2's text conflated the suppression
subsystem (the callers) with the suppression.py module (a pure
predicate engine, correctly untouched) — see ADR-052 Slice 11 for the full
resolution. This closes the last item from D2's original five-producer
scope.
Also still open: the full RootCauseCorrelator-based correlation across
consumer-overlay findings with no caused_by_type link (Phase 6) — which is
also what would ever make impact_group_id diverge from root_cause_id.
The two reference docs below now exist (Slice 6 gave them enough real
surface to be worth writing, closing what ADR-052 originally called
premature) — the rest of this list is the original Phase 3 scope this
section describes, most of it still open:
abicheck/impact/model.py:ImpactAssessment(reachability_state,contract_effect,changed_entities,public_entries,proof_paths,affected_consumers,affected_use_cases,coverage,confidence,root_cause_id,decision),GraphProofPath(root/target/effect/confidence/ steps, each step typed with edge kind, consumer-compiled flag, provenance, location),FindingDecision(state/reason_code/suppression_rule/demotion). Implemented, narrower than this original list:reachability_state,confidence,decision,proof_path(singular —root/target/is_direct/steps/prose, plus Slice 6'soccurrence_id, ADR-046 D6'salternative_paths/discarded_path_count), and — Slice 7 —root_cause_id/root_cause_display/impact_group_id. Still absent:contract_effect/changed_entities/public_entries/affected_consumers/affected_use_cases/coverage— no data source yet (Phase 4/5), left out entirely rather than added as permanently-Noneplaceholders.source_graph_findings.py,internal_leak.py,post_processing.py,appcompat.pypopulateImpactAssessmentinstead of independently setting overlappingChangefields; the existingpublic_reachable/reachability_kind/reachability_proof_path/reachability_statefields become derived, backward-compatible views over it (no JSON/SARIF breaking change). Partially done (Slices 8-10):internal_leak.pyandappcompat.pyconstructChange.impact_assessmentdirectly for their finding builders (Slices 8-9);post_processing.MarkReachabilitynow caches it for every change it tags (Slice 10), which transitively coverssource_graph_findings.py's findings too, without any of its own ten construction sites caching directly (found unsafe by Slice 10's audit — see above). The flat fields stay as real fields (not converted to derived properties) rather than the originally-described full flip, since that conversion touches every existingChange(...)construction site repo-wide and was judged out of scope for a verifiably-safe slice.suppression.py's D2 role — the one remaining item from D2's original scope — is resolved (Slice 11, above): the module has noChangeconstruction/mutation site to migrate.reporter.py/sarif.py: structuredimpactobject in JSON (done,impact_assessment),codeFlows/threadFlowsin SARIF (not done — SARIF's root-cause mode is additiveproperties.rootCauseId/rootCauseinstead, not acodeFlowsrestructuring; keepproperties.reachabilityProofPathas a derived string for old consumers — done).--report-mode root-cause: groups findings sharing a root cause — done for JSON/markdown/text/SARIF/JUnit (Slices 3-6), all keyed on the existingcaused_by_typefield; extending the grouping to cover consumer-overlay findings with nocaused_by_typelink at all still needsRootCauseCorrelatorin Phase 6.- Stable
finding_id(structured discriminator — parameter index, member ID, graph entity ID — notdescriptiontext, so a wording change or a new proof path doesn't change identity): not implemented, and not planned as originally described —reporter._finding_idalready exists (schema 2.3) and is stable across runs, but deliberately keepsdescriptionas a discriminator (changing that would break an already-published field's values).occurrence_id: done (Slice 6, above).root_cause_id/root_cause_display/impact_group_id: done (Slice 7, above) — as report-level-resolved fields passed intoassess_change, not computed byImpactAssessmentfrom a singleChangein isolation. docs/reference/source-graph-schema.md(new): the ADR-046 D1-D6 identity/ merge/traversal-policy/proof-path-preference schema — done.docs/contribute/detector-impact-contract.md(new): the required-evidence contract every new detector from Phase 5/6 must declare — done, ahead of Phase 5/6 themselves, since D5/D6/Slice 6 already provide enough real machinery (TraversalPolicy,select_preferred_graph_path,attach_impact_metadata) for the contract to point at working code rather than aspirational surface.
Phase 4 — Consumer / use-case join — slices 1-2 implemented (ADR-057)¶
ADR-057 records the slice 1 decisions.
abicheck/impact/consumer_graph.py: promotesAppRequirements(appcompat.py) to graph facts — done, with one deliberate deviation from the sketch below.consumer_binaryis populated;consumer_object/runtime_probeare registered but reserved (no normalized data source —AppRequirementsis whole-binary and static); and there is noconsumer_required_symbolnode kind at all (ADR-057 D1): a requirement is aCONSUMER_REQUIRES_SYMBOLedge onto the existingbinary_symbol://<symbol>node, because one shared node id is the whole join mechanism — a parallel node kind would have produced two structurally similar, completely disjoint graphs needing a later name-matching pass to reunite.CONSUMER_REQUIRES_SYMBOL/CONSUMER_REQUIRES_VERSIONare populated;CONSUMER_INSTANTIATES_DECL/CONSUMER_COMPILED_FROM_HEADER/RUNTIME_FAILED_TO_RESOLVE_SYMBOLare reserved. The vocabulary lives inbuildsource/graph_facts.py(unioned intosource_graph.NODE_KINDS/EDGE_KINDS) becausesource_graph.pyis at its 2000-line hard cap and the producer imports it. Joins withSOURCE_DECL_MAPS_TO_SYMBOLso aCONSUMER_REQUIRED_SYMBOL_REMOVEDfinding reports why — "training-servicerequiresdetail::train_ops_dispatcherbecause its call graph reaches it from publictrain()" — done, wired throughappcompat.scope_diff_to_apponto the overlay finding it already builds, asaffected_public_roots+impact_proof_path+ a prosereachability_proof_path(no newChangeKind, no report-schema bump —impact.engine.assess_changeand every reporter already read those fields). The walk reusesinternal_leak._consumer_compiled_reachabilityunder ADR-046 D5'sCALL_GRAPH_TRAVERSAL_POLICYrather than a fresh BFS, so a consumer proof path can never contradict an internal-leak one over the same graph.- ADR-046 D6's tier 1 ("consumer-proven") is now computable —
graph_impact.select_preferred_graph_pathreads the consumer-required node set off the graph it is already given, so the tier needs no new parameter and stays inert for every run without--used-by. Deliberately narrower than "the endpoint is consumer-required": the overapprox check still runs first and still wins, so tier 1 means "consumer-proven and exactly resolved" (ADR-057 D4). abicheck/impact/use_cases.py+ optionalimpact-use-cases.yamlmanifest (use_case/entrypoints/tests);use_case/test_casegraph nodes,USE_CASE_USES_ENTRY/TEST_COVERS_USE_CASE/TRACE_OBSERVED_ENTRY/TRACE_OBSERVED_EDGEedges. Explicitly a separate schema/file fromdocs/contribute/usecase-registry.yaml(that registry tracks abicheck's own feature coverage — reusing it for a project's business use cases would conflate "abicheck supports header-only analysis" with "the DAL training workflow usestrain()", per the review's own caution). Done (slice 2):load_use_case_manifest/parse_use_case_manifest(hard-error on a malformed document viaUseCaseManifestError, silent skip on one unresolvable entrypoint),build_use_case_graph/join_use_case_graph(deep-copy join, mirroringconsumer_graph's slice 1 API/discipline exactly; removed 2026-10 as never called —dead-code-and-single-owner.mdStage C). OnlyUSE_CASE_USES_ENTRY/TEST_COVERS_USE_CASEwere populated;TRACE_OBSERVED_ENTRY/TRACE_OBSERVED_EDGEstay reserved — runtime-trace ingestion itself is still not implemented. Done (this pass): the manifest's first CLI front door,abicheck project validate-use-cases <manifest> [--against <snapshot>](cli_project.py) — without--againstit only validates the manifest's own structure (malformed document, exit 64); with it, it resolves each declared use case's entrypoints against a real embedded L5 graph and reports resolved vs. unresolved per use case (text or--format json), reusing the newresolve_use_case_entrypoints()wrapper aroundbuild_use_case_graph's own internal join so the CLI can never disagree with what the graph actually records. An unresolved entrypoint is never a command failure (only a malformed manifest or a graph-less/unreadable--againstsnapshot is), matching the manifest format's own absence-is-not-evidence discipline. Done (this pass, closing the "manifest folded into a real diff" gapuse-case-impact.mdnamed): a newimpact.use_cases.explain_use_case_impact(definitions, library_graph, symbols)— for each of a real diff's own changed symbols, which declared use case(s)' own resolved entrypoints reach it, via the identical restricted call-graph walk (internal_leak._consumer_compiled_reachabilityunderCALL_GRAPH_TRAVERSAL_POLICY)consumer_graph. explain_required_symbolsalready uses for--used-by, just rooted at the manifest's own entrypoints instead of every public entry in the library — attributing a change to every use case whenever any public entry reaches it would make the field meaningless.project validate-use-cases's new--against-new <snapshot>option (requires--against) is its CLI front door: diffs OLD against NEW via the Tier-2service.compare_snapshots(same routing every other front end uses,cli-contractAI-readiness check), then reports which use case each resulting change reaches, per use case, text or JSON. Explains against both sides' own graphs and unions the per-symbol result (Codex review, fresh evidence: a symbol added on NEW never existed in OLD's own graph at all — e.g. a use case's own entrypoint just introduced — so resolving only against OLD's graph silently read every added change as unattributed regardless of what NEW's graph could prove; mirrorspost_processing_reachability.MarkReachability's ownold_paths + new_pathsmerge for the identical old/new asymmetry). Deliberately a read-only report view, not aChangemutation or acompare-native surface — no new node/edge, no field set on anyChangeobject, no schema bump, no effect on any exit code — the same scope boundary D9's contract-relevance work drew between "answer the question" and "gate on the answer." Two structural limitations carried over unmodified fromexplain_required_symbolsrather than introduced here: only a function/variable-shaped change (backed by aSOURCE_DECL_MAPS_TO_SYMBOLedge) can be named, never a type layout change; and only a consumer-compiled entrypoint can walk transitively past its own declaration. Update (2026-09-02, done):compare --use-cases <manifest>is now a real flag on the nativecomparecommand itself (see the update above) —project validate-use-cases --against-newremains a separate, earlier-built entry point over the same two snapshots, not the only one. Still not done: anyChange.affected_use_casesfield, anyUSE_CASE_IMPACT_CONFIRMEDfinding (both G29 Phase 6, needing their own schema bump and FP-gate examples), and runtime-trace ingestion.docs/contribute/use-case-impact.md(new, done): manifest format, entrypoint mapping, test association, declared-vs-observed use (trace ingestion itself remains unimplemented — documented honestly as not-yet-built, not described as working), full-library-vs-consumer-scoped verdict semantics (absence of a trace must never read as "not used").
Phase 5 — New semantic graph families¶
In review-stated priority order. Items 1, 2, 5 and 6 are done, items 3
and 4 partially (each with named, reserved vocabulary members and a recorded
reason per member); item 6 shipped out of order first since it needed no
compiler frontend, unlike the rest, which all depend on a Clang AST pass —
and items 3 and 5 turned out to need no new pass either, item 3 being a
pure transform over already-folded graph state and item 5 an extension of
the existing type_graph.py walk.
- Template instantiation — done.
abicheck/buildsource/template_graph.pyis a third, independentclang -ast-dump=jsonpass (alongside the call and type graph passes), driven byinline_graph_fold.fold_template_graphwhenever they run. Populatestemplate_decl/template_instantiationnodes andDECL_INSTANTIATES_TEMPLATE/TEMPLATE_USES_TYPE/INSTANTIATION_EMITS_SYMBOL— closing the "public template → concrete instantiation → internal specialization → emitted exported symbol" chain for the load-bearing half: an instantiation's own template-argument types (via clang's owndeclcross-reference on theTemplateArgumentnode — exact, not a textual heuristic) and its instantiated members' emitted symbols (joined onto an existingbinary_symbolnode only, ADR-057 D1's "one shared node id is the whole join mechanism" rule, reapplied).TEMPLATE_USES_DECLwas reserved at this point and closed as a follow-up — see this item's own dedicated entry below.INSTANTIATION_MAPS_TO_EXPORT/DECL_USES_DEFAULT_TEMPLATE_ARG/CONSTRAINT_DEPENDS_ON_DECLremain reserved, unpopulated — see the module's own docstring (a non-type/ function-pointer argument needing its own AST verification, redundancy withBINARY_EXPORTS_SYMBOLon the already-joined symbol node, explicit-vs-defaulted argument detection, and C++20 concepts needing a separate AST subsystem, respectively — none attempted this slice). Seedocs/reference/source-graph-schema.mdfor the field-level detail, including the two load-bearing empirical AST findings (the explicit- instantiation detachment quirk, and typedef-alias argument resolution).TEMPLATE_USES_DECL— implemented (2026-08, G29 Phase 5 follow-up), closing both blockers a prior investigation round found and left unimplemented. Confirmed against real Clang 18: the canonicalClassTemplateSpecializationDecl's ownTemplateArgumentchild for a declaration-valued NTTP (template <auto Fn> struct Holder;/Holder<&detail::f>) is exactly{"kind": "TemplateArgument", "decl": {"id": "...", "kind": "FunctionDecl", "name": "f", "type": {"qualType": "void ()"}}}— notype/valuekey at theTemplateArgument's own top level._template_arg_usenow recognizes this shape directly (a top-leveldecldict), and_is_opaque_template_argumentno longer treats it as opaque (it's distinct from a template-template argument's entirely bare{"kind": "TemplateArgument"}, verified empirically against both real shapes). The resolution blocker —id_to_qname(built by_index_type_declsfor_RECORD_DECL_KINDSonly) never indexed aFunctionDecl/VarDeclid — is closed by a second, independent whole-TU pass,index_value_decls(template_graph_ value_decls.py, split out to keeptemplate_graph.pyunder its own 2000-line hard cap), scoped to free functions and namespace-scope variables only (deliberately not widening_index_type_declsitself, since its nested-specialization scoping logic is written specifically for record-shaped children). It computes each target's owndecl://-node identity viasource_graph.function_decl_identity— the exact same computationcall_graph.py/type_graph.pyalready use for their own function/ variable declaration nodes — so a resolvedTEMPLATE_USES_DECLedge lands on the same node those modules' own edges would. The second, independent label-collision risk the prior round also flagged (a bare, unqualifiedf"&{name}"spelling lettingHolder<&ns1::f>andHolder<&ns2::f>collide onto onetemplate_instantiationnode) is closed the same way:arg_label_spellinguses the resolvedtarget_qname(the unique identity string) for a decl-referencing argument's contribution to the instantiation label, not the barespelling, falling back to the bare spelling only when unresolved (the same degrade-gracefully behavior an unresolved type argument already has). A member-function/static-data-member NTTP target is a known, left-open gap —index_value_declsdeliberately skips class-scope descent entirely rather than risk misattributing a member's identity to its enclosing namespace. Seetemplate_graph.py's own docstring andtests/test_template_graph_value_decls.py's dedicated collision regression test (test_two_instantiations_sharing_only_a_bare_callee_name_stay_distinct). A separate gap the same investigation round originally flagged and this pass closed too: an argument whose target is a decl-referencing NTTP but resolves to no declaration this TU's AST indexes anywhere (e.g. a C++17 address-of-local-static NTTP, whichindex_value_declsdeliberately never descends into) used to fall back to the same bare, collidablespellingarg_label_spellinguses for an unresolved type argument —_has_unresolved_decl_argumentnow detects this case (by comparing the pre-resolution argument's owntarget_decl_kindagainst the post-resolutiontarget_qname) and skips the whole instantiation, the same discipline the opaque-argument guard above already applies. - Macro/config dependency — done.
abicheck/buildsource/macro_graph.pyis a two-pass extractor (a Clang AST pass indexing every declaration's own(file, begin_line, end_line)span, plus a pure raw-text scan for conditional regions and macro definitions — clang's own AST carries no representation of preprocessor conditionals at all, confirmed empirically), driven byinline_graph_fold.fold_macro_graphalongside the other Clang-backed passes. PopulatesMACRO_CONTROLS_DECL(macro → source_decl,CONF_HIGH— a declaration compiled only under a simple#ifdef/#ifndef/#if defined/#if !definedguard) andDECL_USES_MACRO(source_decl → macro,CONF_REDUCED— a declaration's own text references a macro defined earlier in the same file, a textual heuristic).MACRO_EXPANDS_TO_VALUE/MACRO_EXPANDS_TO_TYPE/MACRO_CONTROLS_EDGEremain reserved, unpopulated — see the module's own docstring (real macro-expansion tracing for the first two; per-edge rather than per-declaration conditional attribution for the third; none attempted this slice). A compound condition (#if defined(X) && defined(Y)) or an#elifchain is deliberately unmodeled but still correctly maintains nesting depth across it, so a sibling/enclosing simple guard is never desynchronized. No new node kind — both edges join onto the existingmacro/source_declnodes only (ADR-057 D1's join-only-onto-an-existing-node rule, reapplied). Seedocs/reference/source-graph-schema.mdfor the field-level detail, including the load-bearing empirical AST-dump finding this slice depends on: a declaration'srange.begin/range.endshare one sticky, whole-document(file, line)cursor with every other AST node'sloc(not just file-only sibling tracking, the way the pre-existingtype_graph.pypass already threads) — printed only when it changes from the immediately preceding node's, in document-traversal order. Post-merge Codex review (PR #708) found and fixed three real bugs in the first cut:_LocationCursordidn't unwrap clang's nestedspellingLoc/expansionLocshape for a macro-generated declaration's own location (only the flatfile/linekeys), desyncing the sticky cursor for every later sibling too;ClangMacroGraphExtractorread a compile unit's out-of-source relativefilespelling against the process's own cwd instead of that compile unit's replayed cwd, silently finding no source text (or, worse, the wrong file); andextract_from_build's(identity, file)dedup discarded a real definition's span whenever its forward declaration was visited first (now(identity, file, begin_line, end_line)). One further finding was accepted as a documented, known gap rather than fixed:MACRO_CONTROLS_DECLrarely fires for the two negated guard forms (#ifndef X/#if !defined(X)/an#ifdef X's#elsebranch), since the guard macro is by definition typically undefined in the scanned configuration and so was never seeded as amacronode to join onto — fixing that for real would mean overriding the join-only-onto-an-existing-node invariant this module's own test suite pins down (test_augment_never_mints_new_nodes), which is its own scoped design decision, not a drive-by override; see the module docstring'sEDGE_MACRO_CONTROLS_DECLentry for the full reasoning. - Virtual dispatch — partially done.
abicheck/buildsource/ virtual_dispatch_graph.pypopulates two of the four edge kinds this item originally sketched, both as pure graph transformations — unlike every other item in this phase, it shells out to no compiler at all, since both parts read already-collectedcall_graph.py/type_graph.py/override_graph.pyfacts and write more graph. Driven byinline_graph_fold.fold_virtual_dispatch_graph, run immediately afterfold_override_graphinfold_semantic_graphs(all three of its inputs must have already run). VIRTUAL_CALL_MAY_DISPATCH_TO(CONF_REDUCED, explicitlyresolution: "overapprox", never"exact"— the design brief's own instruction) joins a virtualDECL_CALLS_DECLedge (call_kind == "virtual", pointing at the statically-resolved base method) against everyMETHOD_POSSIBLE_OVERRIDEedge naming that same base method as its target, re-pointing from the original caller to each override candidate. A leaf virtual method with no recorded override candidates emits nothing — no spurious self-edge back to the base method, since theDECL_CALLS_DECLedge already names it and there is no dispatch ambiguity to represent.TYPE_HAS_VTABLE(CONF_HIGH, the one genuinely new node kind this phase introduces — items 1/2/6 all reused pre-existing node kinds) derives, per the Itanium ABI rule "a class is polymorphic iff it declares or inherits ≥1 virtual function", whichrecord_typenodes are polymorphic: seeded from arecord_type's exact-match ownership of a method appearing in aMETHOD_POSSIBLE_OVERRIDEedge (decoded from the method's own Itanium/MSVC mangled identity viadiff_cxx_rules.itanium_scope_components/msvc_scope_components— the same structural, no-external-demangler decodersdiff_cxx_rules.owner_class_ofalready uses elsewhere), then closed transitively overTYPE_INHERITS. Mints at most onevtablenode per polymorphic class (vtable://<record-type-identity>), bounded by class count. The owner-join is deliberately exact-match only (a known, conservative false negative for a class-template specialization, whose raw Itanium encoding doesn't equaltype_graph.py's spelled record identity — never a wrong match on an unrelated same-named type).- Post-merge Codex review (PR #708) found and fixed two real correctness
gaps in the first cut, both in the same "one-hop only" shape: (1)
VIRTUAL_CALL_MAY_DISPATCH_TOonly reached the direct, one-hop override candidates of a call's static target —override_graph.pyrecords each override against its nearest declaring ancestor only (chains, never flattened), so a multi-level chainBase::f <- Mid::f <- Derived::fpreviously reachedMid::fbut not the equally real runtime targetDerived::f. Fixed with_all_reachable_overrides, a cycle-safe transitive closure overMETHOD_POSSIBLE_OVERRIDE, each candidate keeping its own edge's resolution label. (2)TYPE_HAS_VTABLEseeded polymorphism only fromMETHOD_POSSIBLE_OVERRIDEedges — blind to arguably the single most common polymorphic-class shape, a virtual method with no override anywhere in the scanned codebase (a base class's own leaf virtual method, or any method on a class with no base at all). Fixed by extendingoverride_graph.pyitself: a new pure function,parse_clang_ast_virtual_methods, returns every virtual method's identity (own or inherited) independent of whether it has an override candidate;ClangOverrideGraphExtractor.last_virtual_methodsexposes the per-TU-aggregated set the same side-effecting waylast_jobs/diagnosticsalready are, andaugment_graph_with_overrides's newvirtual_methodsparameter stamps anis_virtual: Truefact onto each identity's already-existingsource_declnode (never minting one, same join-only discipline) — whichaugment_graph_with_vtable_presence's seed loop now also reads. - A third Codex-review round found a deeper root cause than either fix
above touched:
VIRTUAL_CALL_MAY_DISPATCH_TOwas effectively inert for the single most common call shape,p->f(), because the upstreamcall_graph.py(ADR-031 D4, predates this item) never even classified it ascall_kind="virtual"in the first place. Verified against real Clang 17/18 output:MemberExpr.referencedMemberDeclis a bare node-id string, not the nested dictcall_graph._find_referenced_declonly recognized — so the DFS fell through to the receiver's ownDeclRefExpr(p, aParmVarDecl) and misclassified the whole call asCALL_KIND_FUNCTION_POINTERthroughp. Fixed incall_graph.pyitself (foundational, shared by every consumer ofDECL_CALLS_DECL, not scoped to this item): a newmember_index(clang node id -> full decl node, built the same way the existingid_indexalready is) resolves a stringreferencedMemberDeclback to the realCXXMethodDecl, carrying its ownvirtual/type.qualTypefields. An id not found inmember_index(a forward reference, or — see G29 Phase 5 item 4's own callback-graph section — aFieldDecl, never indexed at all since only_FUNCTION_DECL_KINDSpopulate it) resolves to no edge at all rather than falling through to the receiver: ADR-028 D3's "degrade to no fact, never a wrong one" rule, applied to a call classification for the first time.p->f()now correctly classifies ascall_kind="virtual", which is what makesVIRTUAL_CALL_MAY_DISPATCH_TOactually reachable in the common case this feature exists for. - A fourth and fifth Codex-review round found two more real gaps in
call_graph.py's own resolution, both empirically verified against real Clang 17 output and both fixed without touching this item's own modules. (1)member_indexwas built incrementally during_walk_calls's single combined pre-order walk (_enter_function_scope'smember_index.setdefault), so a call to a member declared later in the same class body silently dropped instead of resolving — confirmed forstruct A { virtual void f(){ g(); } virtual void g(); };: clang visitsf's body (and its call tog) beforeg's ownCXXMethodDeclsibling, somember_indexdoesn't yet havegat the moment the call needs it, and the earlier fix's own "unresolved id -> no edge" rule (correctly) dropped it rather than misattributing it. Fixed with a new, separate whole-AST pre-pass (_index_member_decls, no scope tracking needed — a plainid -> nodeindex is order-independent) run once at the top ofparse_clang_ast_calls, before_walk_callsstarts resolving any call site; the existing incremental population is left in place (harmless —setdefaultagainst an already-populated id) rather than removed. (2) Separately, when the resolved static target of a member call is itself an override, clang commonly omits"virtual": truefrom that override's ownCXXMethodDecl(only the slot's original declaring ancestor carries it) — confirmed forstruct B { virtual void f(); }; struct D : B { void f() override; void h(){ f(); } };:D::f(the resolved target ofh()'s call) carries no"virtual": true, so_classify_callmisclassified the call asdirect/exact, excluding a real further-derived-override chain fromVIRTUAL_CALL_MAY_DISPATCH_TOentirely. The first fix attempt threaded a precomputedvirtual_identitiesset fromoverride_graph.parse_clang_ast_virtual_methodsinto_classify_call— correct in isolation, but it makescall_graph.pyimport fromoverride_graph.py, which already function-locally imports several helpers fromcall_graph.py(_safe_clang_args_from_compile_unit/_call_graph_jobs/_deadline_bound_worker) — even as a function-local import, the AI-readinessimport-cycle-growthgate walks allImport/ImportFromnodes regardless of nesting, so it correctly flagged the resulting new cycle (call_graph -> override_graph -> type_graph -> call_graph) as an ERROR; confirmed reproducing the same rejection locally before reverting that approach. Fixed instead with a self-contained, dependency-free check: clang always marks a writtenoverride/finalkeyword with anOverrideAttr/FinalAttrchild on the override's own declaration (verified against the same real AST —D::fcarries anOverrideAttrchild; a second real-clang repro withfinalinstead ofoverrideconfirmed the identicalFinalAttrshape), and theref_classify_callreceives for aCXXMemberCallExpris always the full declaration node (resolved throughmember_index, never a compact stub) — so a new_ref_is_virtual()helper checksref["virtual"]first, then falls back to scanningref's owninnerchildren forOverrideAttr/FinalAttr, entirely withincall_graph.py. A derived method that redeclares a virtual signature with neither theoverride/finalkeyword nor a repeated"virtual": true(legal but unusual C++ style) remains a documented, conservative false negative — the same false-negative-over-false-positive default this module already uses throughout (ADR-028 D3), and out of scope for a fix that specifically targets the empirically-confirmed common case. - A sixth and seventh Codex-review round, on the same
bb7d139push, found the fourth/fifth rounds' own fixes were each themselves too aggressive in one real case, both verified against real Clang 18 output. (1) The inherited-virtual fix from the fourth/fifth rounds made_classify_callclassify everyCXXMemberCallExprwhose resolved target is virtual (own or viaOverrideAttr/FinalAttr) asvirtual/overapprox— but an explicitly qualified call,obj.Base::f(), suppresses virtual dispatch by C++ rule regardless ofBase::f's own virtuality; clang's static resolution still namesBase::fas the target either way, so nothing inrefalone distinguishes the two. Confirmed clang's JSON AST dump carries no qualifier information at all — its text-ast-dumpshows a siblingNestedNameSpecifier TypeSpec 'B'node forobj.B::f()that is entirely absent from the identical AST's JSON form (byte-for-byte identicalMemberExprkey sets, includinginner, betweenobj.B::f()andobj.f()). Fixed by deriving qualification from source-range arithmetic the JSON does carry instead (_member_expr_is_qualified): an unqualified access's member-name token begins immediately after the receiver's own end plus one operator character (./->); a qualifier occupies the extra bytes in between. A second real gap surfaced while verifying this against the common "call the base implementation from an override" pattern,struct D : B { void f() override { B::f(); } };— clang anchors a genuinely implicitthisreceiver's own synthesized position at the member name itself (not before a written qualifier), so the receiver-to-member gap always reads as zero for this shape regardless of whetherB::is present._is_implicit_this_receiverdistinguishes a synthesizedthis(CXXThisExprwith"implicit": true, optionally wrapped in anUncheckedDerivedToBasecast) from an explicitly writtenthis->f()(noimplicitkey at all) — verified against three distinct real ASTs (implicit unqualifiedf(), implicit qualifiedB::f(), explicitthis->f()/this->B::f()) — and for the implicit case measures theMemberExpr's own begin-to-end span against the bare member-name length instead. Only a strictly-larger-than-expected gap counts as qualified in either branch; a missing offset/tokLen field degrades to "not qualified" (the pre-existing over-approximation), never the reverse — wrongly suppressing a real virtual call would silently drop a genuine dispatch target, a worse error than the over-approximation this narrows. (2) Separately, incallback_graph.py(notcall_graph.py):_address_taken_functiononly unwrappedParenExpraround a&func/decay shape, so an explicitly cast callback argument —register_cb((handler_t)handler)(CStyleCastExpr,castKind == "NoOp", wrapping the identicalImplicitCastExpr/FunctionToPointerDecay) orregister_cb(static_cast<handler_t>(handler))(CXXStaticCastExpr/CXXReinterpretCastExprover the same shape) — silently produced noDECL_REGISTERS_CALLBACKedge, for any API that requires or commonly receives a cast callback argument. Fixed by also unwrapping the named-cast kinds (CStyleCastExpr/CXXStaticCastExpr/CXXReinterpretCastExpr/CXXConstCastExpr/CXXFunctionalCastExpr/CXXDynamicCastExpr) the same wayParenExpralready was. - An eighth finding was a genuine, pre-existing, unrelated test bug
surfaced by CI running the sixth/seventh rounds' own new code on
platforms this session's Linux sandbox can't reach. Windows CI
failed
test_callback_graph.py::test_extractor_unavailable_returns_empty_and_records_diagnostic: it passed an emptyBuildEvidence()(no compile units) toextract_from_build, which hits that method's own "nothing to do" early return before its availability check ever runs — the identical orderingClangCallGraphExtractor/ClangTypeGraphExtractor/ClangOverrideGraphExtractor.extract_from_buildall share, and whose own sibling tests always pass at least one real compile unit for exactly this reason (confirmed by readingtest_call_graph.test_extractor_missing_clang_returns_empty). This test alone used an emptyBuildEvidence(), which happened to still record a diagnostic on this session's Linux sandbox's earlier ad hoc verification (a real, presentclangon that machine took a different code path than the CI runner'sclang-does-not-exist), masking the gap until CI's genuinely-missing-binary case exercised it. Fixed the test to match the established sibling convention (one realCompileUnit), not the shared production ordering — with no work to attempt, recording a diagnostic would be noise, not signal, so the production behavior is correct as-is. A second, separately-triaged Windows CI failure in the same run,test_macro_graph.py::test_parse_real_clang_ast_decl_ranges_end_to_end, was a genuine, pre-existing, unrelated test bug: it filtered ranges by an Itanium-mangled prefix (identity.startswith("_Z7guardedv")), but a Windows runner's realclangtargets the MSVC ABI by default, mangling the same function as?guarded@@YAXXZ— both schemes embed the plain identifier as a literal substring, so the filter was changed to"guarded" in r.identity(mangling-scheme-agnostic) instead of an Itanium-only prefix match. A third Windows-only failure in the same run,test_build_source_cli.py::test_collect_source_graph_folds_override_and_macro_graph_passesshowingoverride_graph/virtual_dispatch_graphasdegraded_passesrather than fully covered, was investigated but not fixed this round: the job log shows the derivedvirtual_dispatch_graphcorrectly propagatingoverride_graph's own degraded state — this session's inline_graph_fold.py fix (documented above under Codex-review finding "Preserve narrowed coverage for derived virtual passes") working exactly as designed — but the cause ofoverride_graphitself degrading is a real Clang/MSVC-target compiler behavior on the Windows CI runner this session's Linux sandbox cannot reproduce or safely guess at (the extractor's own per-TU diagnostic text wasn't captured in the truncated CI log excerpt read during triage). Left open, flagged in the PR thread rather than blind-patched. - A ninth, tenth, and eleventh finding, on the very push that added the
sixth/seventh rounds' own qualified-call/cast-unwrap fixes, found two
more real gaps and one real bug in that same code. (9) The
qualified-call heuristic (
_member_expr_is_qualified) also misfires on legal whitespace/comments between the receiver and the member —obj . f(),ptr /* note */ -> f()— since the JSON offset arithmetic cannot distinguish two incidental whitespace bytes from a realBase::qualifier's bytes; confirmed against a real Clang 18 AST forobj . f(). Investigated and deliberately left open, not patched: closing this exactly needs the actual source text between the two offsets, whichparse_clang_ast_calls(a pure function over the AST dict alone, by design — unlikemacro_graph.py's own Pass B, which has a source-file path to read from) does not have; no fixed numeric threshold soundly separates "a couple of stray spaces" from "a real single-letter class name plus::" in the general case, and giving this module source-text access is a distinct, larger architectural change out of scope for a drive-by fix. Documented in_member_expr_is_qualified's own docstring and pinned by a dedicated regression test (test_parse_call_with_whitespace_around_dot_is_a_known_false_positive) recording the current, accepted behavior — this only misfires on non-idiomatic whitespace styling no common formatter (clang-format included) produces. (10) Separately, incallback_graph.py:_FUNCTION_POINTER_TYPE_REonly allowed whitespace between*and)in a function-pointer declarator ("(*)("), so a top-level cv-qualifier on the pointer itself —void (*const)(int), the desugared spelling ofusing H = void (*)(int); void reg(H const h);— was rejected outright, silently omitting the registration for any cv-qualified function-pointer parameter. Confirmed against a real Clang 18 AST (desugaredQualTypeis exactly"void (*const)(int)"for aconst-qualified typedef'd parameter). Fixed by extending the regex to acceptconst/volatile(in either order, or both) between*and). (11) A genuine mutual-exclusion bug infold_virtual_dispatch_graphitself: the seventh round's own narrowed/degraded-propagation fix added the propagation but left the prior unconditionalgraph.extractor_passes["virtual_dispatch_graph"] = Trueassignment in place alongside it — so a narrowed or degraded run stamped BOTH the narrowed/degraded key AND the full-coverage key for the same pass, contradicting the persisted coverage contract every other clang-backed pass's ownif fully_covered: ... elif narrowed: ... elif degraded: ...chain already enforces (confirmed against real data: the Windows CI log from the eighth finding's own investigation already showed exactly this —virtual_dispatch_graphpresent in bothextractor_passesanddegraded_passessimultaneously, which the eighth finding's triage read past without flagging as its own separate bug). Fixed by converting to the same mutually-exclusive if/elif/else shape as the sibling passes. - A twelfth finding, on the very fix that closed the eleventh, found the
mutual-exclusion fix's own
elif/elsesplit was still too permissive — "not narrowed and not degraded" is not the same claim as "fully covered." Whenclang/clang++isn't onPATHat all, each offold_call_graph/fold_type_graph/fold_override_graphreturns early after recording a"failed"ExtractorRecord— setting none ofextractor_passes/narrowed_passes/degraded_passesfor its own pass (there's no per-TU diagnostic to attach a degraded stamp to; the extractor never ran at all). The eleventh finding's own fix readnarrowed/degradedas bothFalsein exactly this state — nothing is narrowed, nothing recorded a diagnostic — so it fell through to theelsebranch and claimed full virtual-dispatch coverage from zero prerequisite facts, the identical class of bug the eleventh finding itself closed, one level down. Fixed by requiringfully_covered— every one of the three prerequisites has its ownextractor_passesentry set, not merely the absence of narrowed/degraded — before stamping this pass's own full coverage; the fallback (neither narrowed nor fully covered) now stampsdegraded_passes, covering both "some prerequisite recorded a real diagnostic" and "some prerequisite never ran at all" uniformly. This also changed what a hand-built test fixture with no prerequisite coverage stamps at all means: it is now indistinguishable, from this pass's own vantage point, from a real clang-unavailable run — so the existing unit tests were updated to explicitly mark the three prerequisitesextractor_passes-covered before asserting this pass's own full coverage, and a new dedicated test pins the previously-mishandled all-prerequisites-absent case. - A thirteenth finding, on the same push, found the third round's own
fix still ordered its checks wrong. The three prerequisites stamp
independently, so one can land in
narrowed_passes(a clean, explicitly-scoped run) while another lands indegraded_passes(a real per-TU clang failure) — e.g.call_graphnarrows cleanly whileoverride_graphdegrades. Checkingnarrowedbeforedegraded(the third round's own order) stamped only the narrowed key for that mix, silently dropping the untracked gap from the failed TUs — contradictingSourceGraphSummary.degraded_passes's own documented precedence ("a narrowed run with diagnostics lands here too ... since it is even less trustworthy than either"). Fixed by folding the "some prerequisite recorded a real diagnostic" case and the "some prerequisite never ran at all" case (the twelfth finding above) into onedegradedcheck, checked first — only when nothing is degraded or missing, and at least one prerequisite is narrowed, does this pass count as narrowed. A dedicated regression test covers the mixed narrowed+degraded case. - Three more findings in the same review round, unrelated to the
coverage-stamping bug above. (1)
macro_graph.py's unmodeled- conditional fallback (_IF_RE) matched only bare/compound#if, not#ifdef/#ifndef—\bfails right after "if" since "d"/"n" are word characters too — so a malformed-but-compiler-accepted directive with trailing tokens (#ifdef FEATURE_X extra, which real compilers accept with a warning) or a line continuation matched none of the four simple patterns and none of the fallback, pushing no frame at all; the matching#endifthen popped the enclosing guard's frame instead, truncating it early — a real violation of the module's own documented "an unmodeled block never desyncs an enclosing guard's depth" invariant. Fixed by widening_IF_REto also match#ifdef/#ifndef. (2)test_inline_graph_folds_macro_edges_when_clang_available(a fast, non-integration-marked test) fakedClangCallGraphExtractor/ClangTypeGraphExtractor/ClangMacroGraphExtractorbut notClangOverrideGraphExtractor/ClangTemplateGraphExtractor/ClangCallbackGraphExtractor— all three of whichwith_call_graph= Truealso constructs viafold_semantic_graphs— so the test could shell out to a realclang++if one happened to be on the runner's PATH. Fixed by faking all six. (3)docs/reference/source-graph-schema. md's callback-family section still said every edge "joins onto pre-existingsource_declnodes only," contradicting the callback- endpoint-minting fix from an earlier round (documented correctly in this plan doc, but not in the schema reference) — fixed to describe the implemented minting behavior instead. Two lower-priority defensive nitpicks from the same round were also applied:callback_graph.py's andvirtual_dispatch_graph.py's own edge-folding loops now iterate alist(graph.edges)snapshot rather than the live list before callinggraph.add_edge()inside the loop — currently safe only because every edge kind each loop adds is itself rejected by that loop's own kind filter, a coupling a future edge kind could silently break. DECL_OVERRIDES_DECLneeded no new producer at all — a closed gap, not a deferred one.override_graph.py(ADR-041 P2 item 1, which predates this item) already emitsMETHOD_POSSIBLE_OVERRIDEedges whoseresolutionis"override_confirmed"when clang's ownOverrideAttr(theoverridekeyword, compiler-checked) is present — that edge already carries the exact factDECL_OVERRIDES_DECLwould. Minting a second, redundant edge kind for the same fact would fork one piece of evidence into two with no consumer able to tell which is authoritative — the same "closed a gap by discovering pre-existing coverage" pattern this plan used for ADR-057 D6's tier-1 finding.DECL_OVERRIDES_DECLstays registered ingraph_facts.VIRTUAL_DISPATCH_EDGE_KINDSso a hand-built or future graph naming it directly is never rejected, but no producer emits it.VTABLE_SLOT_MAPS_TO_DECLremains reserved, deliberately not attempted. A precise per-slot Itanium vtable layout (offset-to-top and typeinfo pointer slots, primary vs. secondary vtables under multiple inheritance, virtual-inheritance vtables, covariant-return thunks shifting a slot's target) is exactly the class of ABI-layout complexitydiff_elf_layout.py's own binary-only vtable-slot-count detector documents in its own module docstring — a much harder, easy-to-get- subtly-wrong claim than "this class has a vtable" or "this call's target set may include these declarations." A naive "declaration order" per-slot model would get exactly those cases wrong, not merely approximately right, and this codebase's discipline is to degrade to no fact rather than emit a wrong one (ADR-028 D3) — so this edge waits for a real, verified Itanium layout model, matching this item's own design brief's distinction between "the vtable slot provably changed" and "the possible runtime dispatch target set changed".diff_elf_layout.pyitself is unrelated infrastructure (binary-only, no per-slot identity) and is not unified with this family.- Callback/function-pointer — done, for three of the four vocabulary
members.
abicheck/buildsource/callback_graph.pycloses the plugin/event-loop/C-API callback blind spot the review calls out, split the same way item 3 is: a pure join (Part A, no new clang pass) plus a genuinely new Clang AST pass (Part B) — but unlike item 3's two independentfold_*functions, this family's Part A depends on Part B's own edges already being in the graph, so both run inside oneinline_graph_fold.fold_callback_graphcall. DECL_REGISTERS_CALLBACK(CONF_HIGH) — a function's address is a direct argument of a plainCallExprat a position whose callee parameter is itself function-pointer-typed (signal(SIGINT, handler)).DECL_TAKES_ADDRESS_OF(CONF_REDUCED) — the broader case: an address-of/decay flows into a function-pointer-typed variable/field via assignment or its own initializer, not necessarily a direct call argument. Both are a new Clang AST pass (callback_graph.parse_clang_ast_callbacks), empirically verified against real Clang 18 output for an explicit&func(UnaryOperator/"&"), an implicit function-to-pointer decay (ImplicitCastExpr/"FunctionToPointerDecay"), and a typedef'd function-pointer type'sdesugaredQualTypespelling.CALLBACK_MAY_INVOKE(CONF_REDUCED,resolution: "overapprox", never"exact"— the same instruction this whole graph family follows) joinscall_graph.py's already-folded function-pointer-kindDECL_CALLS_DECLedge (caller → slot) against everyDECL_REGISTERS_CALLBACK/DECL_TAKES_ADDRESS_OFedge naming that same slot — no new clang pass. A slot with no function registered anywhere this pass examined contributes no edge (the fact is genuinely unknown, not empty, the same reasoning item 3 uses for a leaf virtual method with no override candidates).- Identity design — the single load-bearing correctness property.
Investigated (not assumed) by reading
call_graph._classify_call/_resolve_ref_callee_identity: a call through a variable/parameter/field resolves its callee identity via anid_indexpopulated only forFunctionDecl-kind nodes, so it always falls back to the reference stub's own bare, unqualified name (a parameter namedhin two unrelated functions both resolve to the identical bare identity). Part B deliberately computes the same slot identity the same (weak) way, rather than a stronger one that would simply never match anythingcall_graph.pyproduces — mirroring an existing, already-shipped limitation of the graph it joins onto. - A load-bearing negative empirical finding, partially closed by a
later same-PR fix, not fully. Originally found that a struct-field-
typed callback slot invoked through member-call syntax (
w->cb(x)) never joined in Part A, becausecall_graph._find_referenced_declcould not recognize aMemberExpr's ownreferencedMemberDecl(a bare node-id string in real clang output, not a nested dict) and fell through to the base object's own reference instead — a wrong edge (attributed to the receiverw), not merely a missing one. A separate Codex-review finding (fresh evidence, same PR) onvirtual_dispatch_graph.py's ownVIRTUAL_CALL_MAY_DISPATCH_TOproducer hit the identical root cause for a virtual method call (p->f()) and fixed it incall_graph.pydirectly: a newmember_index(id -> full decl node, built alongside the existingid_index) resolves a stringreferencedMemberDecl, but only for_FUNCTION_DECL_KINDSnodes — aFieldDecl(what a callback slot always is) is never one of those, so it is never indexed. Re-verified against the identical field-callback repro after that fix landed: the call now resolves to no edge at all, not the wrongdecl://wedge originally found — an improvement (ADR-028 D3: no fact beats a wrong one), but the field case still doesn't join. Extendingmember_indexto also coverFieldDeclstays its own scoped follow-up, not attempted here. FUNCTION_POINTER_HAS_SIGNATURE— investigated, found genuinely unmet, implemented as a node-level fact instead of an edge. Checked (not assumed) whethertype_graph.py's existingDECL_HAS_TYPE/TYPE_HAS_FIELD_TYPEedges already carry a callback slot's signature anywhere: they don't — a callback's own parameter/return types are folded under the enclosing declaration's role, losing the ordered signature shape, and no edge attribute anywhere records the slot's raw type string. A real, unmet gap. But once populated, a signature is a property of exactly one declaration, not a relation between two graph entities, so it doesn't fit an edge shape — stays registered ingraph_facts.CALLBACK_EDGE_KINDSfor vocabulary compatibility, with the real data populated as afunction_pointer_signaturenode-level fact on the slot's ownsource_declnode.- Deliberately scoped to a plain, free-function
CallExprfor the registration case (notCXXMemberCallExpr/CXXOperatorCallExpr), mirroringoverride_graph.py's own first-slice AST-node-kind narrowing. Deferred, each needing its own scoped follow-up: extendingcall_graph.py's ownmember_indexto also coverFieldDeclnodes (would close the remaining field-callback join gap above and strengthen every function-pointerDECL_CALLS_DECLedge, not just this module's own — shared infrastructure, not a same-slice fix);CXXMemberCallExpr/CXXOperatorCallExprregistration detection. - Post-merge Codex review found and fixed one real completeness gap, and
surfaced (without fixing — see below) one real correctness gap already
latent in the design. Fixed: an earlier, strict join-only-onto-an-
existing-node version of
augment_graph_with_callback_registrationssilently dropped the whole registration whenever either endpoint had no pre-existingsource_declnode — the common case for a private handler used only as a callback (never itself called, socall_graph.pynever creates a node for it either) or a registration API's own callback parameter (never a standalone type-graph node). Fixed by minting the missing endpoint instead, the same precedentoverride_graph.augment_graph_with_overridesalready establishes in this family (this module's own AST pass already has complete, real information about both declarations — minting isn't a guess). Surfaced, not fixed — a genuine false-positive risk, not merely a missing-edge one: because Part A's join keys strictly on the shared, unqualified slot identity (see "Identity design" above), two unrelated functions each declaring their own same-named function-pointer parameter (register(handler_t h)andinvoke(handler_t h)) collapse onto onedecl://hnode — so a function registered only viaregister'shis reported as a possible target of a call made throughinvoke's completely unrelatedh. This is the sharpest concrete case for the scope-qualified- identity follow-up already named above; a fix confined to this module alone cannot close it, since the ambiguity originates incall_graph.py's own edge identity. Pinned by a dedicated regression test documenting the current, known-bad behavior rather than silently accepted. - A fourteenth finding:
fold_callback_graph's own coverage stamp ignoredcall_graph's coverage state entirely. Part A'sCALLBACK_MAY_INVOKEjoin readscall_graph's own already-folded function-pointer-kindDECL_CALLS_DECLedges, so a degraded or never-runcall_graphpass means a real dispatch target can be silently absent even when this pass's own clang run (Part B) examined the whole compile DB cleanly — the identical class of bug thefold_virtual_dispatch_graphpropagation fix (findings eleven through thirteen above) already closed for its own three prerequisites, just not yet applied here. Fixed the same way: this pass's own extractor-run state andcall_graph's recorded state are combined worst-wins (degraded/missing >narrowed>full) before stampingextractor_passes/narrowed_passes/degraded_passesforcallback_graph, with a dedicated regression test class (TestFoldCallbackGraphPropagatesCallGraphCoverage) covering all four combinations. A companion, much narrower finding in the same round:docs/_meta/topics.yaml'simpact-analysistopic listedgraph_impact.pyas a fact source but notgraph_facts.py— the shared node/edge kind and confidence-tier vocabulary every graph module in this family (macro_graph.py/virtual_dispatch_graph.py/callback_graph.py/...) actually builds on. Added. - A fifteenth finding, investigated and confirmed real, deliberately
not implemented: a callback propagated through an intermediate
parameter-to-slot assignment is never joined at all. A registration
API that stashes its own parameter (not a function) into a stored
slot —
void reg(handler_t h) { stored = h; }, with the eventual indirect call made throughstored, noth— breaks Part A's join. Reproduced empirically end to end against real Clang 18:call_graph.pycorrectly emitsDECL_CALLS_DECL(invoke -> stored, function_pointer), and this module correctly emitsDECL_REGISTERS_CALLBACK(my_handler -> h)for the outerreg(my_handler)registration call — butstored = h;insideregis never captured as any edge at all, since the RHS-resolution helper_address_taken_functiononly recognizes a real function address/decay, not a parameter/variable/field alias. The whole chain — a real, runtime-reachableinvoke -> my_handlerrelationship — therefore produces noCALLBACK_MAY_INVOKEedge, a false negative distinct from every other gap documented above (those under-report a slot that itself IS named; here the actually-invoked slot never gets linked to the originally-registered function at all). A sound fix needs two new pieces, not one: a new intra-procedural "slot aliases slot" edge for a function-pointer-to-function-pointer assignment, and a transitive closure in Part A's join with its own cycle guard (an alias chain can be arbitrarily long and could self-reference). Both are a scoped follow-up of their own — attempting the transitive-closure half without careful cycle protection risks turning a currently-safe "degrade to no fact" gap into a non-terminating or spuriously overapproximating one, worse than the status quo. Pinned by a dedicated regression test (test_callback_propagated_through_stored_parameter_slot_is_not_joined) documenting the current, known-incomplete behavior rather than silently accepted. - A sixteenth finding, confirmed real and fixed: a class whose only
virtual member is its destructor was invisible to every existing
vtable-presence seed.
override_graph._OVERRIDE_CANDIDATE_KINDSdeliberately excludesCXXDestructorDeclfrom override-EDGE matching (the module's own documented Itanium D1/D2 dual-mangling concern), sovirtual_dispatch_graph.augment_graph_with_vtable_presence's override-edge seed can never see a destructor at all — and its leaf-virtual-method seed readsis_virtual-taggeddecl://nodes, which a bare, uncalled destructor typically has none of (unlike an ordinary member,type_graph.pydoesn't mint adecl://node for a destructor purely from its own declaration). Reproduced empirically end to end against real Clang 18:struct Base { virtual ~Base(); };with a real derived class produced noTYPE_HAS_VTABLEedge for either side at all before the fix. Closed with a new, narrower pure function,override_graph.parse_clang_ast_virtual_destructor_owners— deliberately not extending_OVERRIDE_CANDIDATE_KINDS(that would reopen the D1/D2 concern this module's docstring already explains) — which only asks "does this destructor's own AST node carryvirtual: true", the identical direct signal every other virtual method already carries, no override-pair matching required. Its output feeds a third, independent vtable-presence seed stamped directly onto the owning class's ownrecord_typenode (not routed through adecl://node the way the other two seeds are), since a bare destructor declaration often has nodecl://node to join onto at all — matching the identical join-only-onto-an-existing-node discipline the rest of this module family uses. One residual gap, shared with the two pre-existing seeds, not new to this fix: a class isolated enough that nothing else in the graph (noTYPE_INHERITSedge, no override edge) already minted its barerecord_typenode still seeds nothing — verified empirically thattype_graph.py's ownCXXRecordDeclwalk, for a fully isolated class, mints only the class's own injected-class-name identity (e.g.record::Base::Base), never the bareBaseidentity every seed's owner-recovery logic produces, unless something else (inheritance, an override) independently triggers the bare node's creation. Confirmed this is not a fix-specific regression: the exact same isolated-class shape already failed to seed a plainvirtual void run()with no override or inheritance anywhere in scope, via the pre-existing leaf-virtual-method seed, before this fix touched anything. - A seventeenth finding, confirmed real and fixed: a leading same-line
block comment before a real directive was never recognized. A real
compiler treats
/* note */ #ifdef Xas a live directive (the comment is whitespace to it), butscan_conditional_regions's directive-family regexes are all anchored^\s*#, so a leading, still-present comment defeated every one of them — not just missing the guarded declaration's macro edge, but, when nested, leaving no stack frame for the un-recognized inner directive so its own#endifpopped the enclosing guard's frame instead (reproduced empirically: an#ifdef OUTER//* note */ #ifdef INNER/#endif/#endifnest truncatedOUTER's region three lines early). Fixed with a new_strip_leading_inline_ comment(), applied identically in bothscan_conditional_regionsand_macro_definition_lines— deliberately scoped to a comment that both opens and closes on the same line (cross-line block-comment state is_lines_starting_inside_block_comment's own, separate, already-applied job) and deliberately not//(a genuine// #ifdef Xline has its directive commented out for real, unlike a same-line-closed/* */). - An eighteenth finding, confirmed real and fixed: an unnamed callback
parameter collapsed every unrelated registration API onto one node.
void reg(void (*)(int));— a common prototype-only registration shape — has nomangledName/nameforcall_graph._identityto read, so_index_walk's per-parameter identity resolved to""; every unnamed callback slot in the whole codebase then joined onto the identicaldecl://node, making the otherwise high-confidenceDECL_REGISTERS_CALLBACKedge ambiguous across unrelated APIs. Reproduced empirically (a hand-built two-callee fixture: bothreg_a/reg_b's own unnamed slots joined onto one node before the fix). Fixed by falling back tof"{callee_identity}#param{position}"when the parameter's own identity is empty — safe because an unnamed parameter can never be referenced by name from within its own function body (or anywhere else), so the fallback only needs to be stably unique per callee, never to match some other module's own identity scheme. - A nineteenth finding, confirmed real and fixed: a multi-line-opened
comment closing mid-line before a real directive was skipped
wholesale.
/* opening\n*/ #ifdef X— the second line "starts inside" the carried-over block comment per_lines_starting_inside_block_ comment, so the previous per-line gate (skip the whole line or don't) never resumed scanning after that specific comment actually closed, omitting the guard and, nested, letting its#endifpop the enclosing guard's frame instead (reproduced empirically, same desync shape as the eighteenth finding's sibling). Fixed with_line_after_carryover_ comment_closes(): finding the first"*/"on a line already known to start inside a comment is unambiguously that comment's own close (an open block comment has no internal string/quote semantics to worry about), and the live remainder is fed through the same_strip_leading_inline_comment()path the seventeenth finding's fix already established — applied identically inscan_conditional_ regionsand_macro_definition_lines. - A twentieth finding, confirmed real and fixed: C++ list-
initialization of a callback slot was never recognized.
handler_t slot{my_handler};wraps the identicalImplicitCastExpr/FunctionToPointerDecay_address_taken_functionalready recognizes inside anInitListExpr— verified against real Clang 18 output — so passing it straight through returnedNone, silently omitting theDECL_TAKES_ADDRESS_OFedge (and everything Part A's join could have built on it). Fixed by also unwrapping a single-elementInitListExpr, the same wayParenExpr/an explicit cast are already unwrapped; scoped to exactly one element since a scalar (function-pointer) type type-checks to exactly one initializer in valid C++, so a real aggregate/multi-element list can never be accidentally swallowed. - A twenty-first finding, confirmed real and fixed: a virtual
overloaded operator invoked through a base reference/pointer was never
classified as virtual.
B &b; b();— aCXXOperatorCallExpr, not aCXXMemberCallExpr— has a plainDeclRefExpras its own callee, not aMemberExpr, so_classify_call's virtuality check (which only ever looked atCXXMemberCallExpr) never fired, silently excluding a real derivedoperator()override fromVIRTUAL_CALL_MAY_DISPATCH_TO. Reproduced empirically against real Clang 18. Extending_classify_callto also checkCXXOperatorCallExpralone wasn't enough: aDeclRefExpr's own compactreferencedDeclstub never carriesvirtual/inner(OverrideAttr/FinalAttr) the way the full declaration node does, and — unlike aMemberExpr's string-shapedreferencedMemberDecl, which_find_referenced_declalready resolved to the full node viamember_index— a dict-shapedreferencedDeclwas returned as-is, never upgraded. Fixed by extending_find_referenced_declto also resolve a dict-shaped stub's ownidthroughmember_indexwhen indexed, falling back to the stub itself otherwise (preserving_POINTER_DECL_KINDSfunction-pointer-call classification forVarDecl/ParmVarDecl/FieldDeclrefs, which are never added tomember_index). An explicitly-qualified operator call (b.Base::operator()()) is a narrower, separately-verified case not attempted here. - A twenty-second finding, investigated and deliberately NOT fixed:
backslash-newline line splicing before a
//comment. A real preprocessor removes every\<newline>pair before tokenizing, so a//comment ending in\carries onto the next physical line too — including anything that looks like a directive. Reproduced empirically: a#ifdef OUTER/// comment continues \/ (spliced-away)#endif/ real trailing#endifnest truncatesOUTER's region early, the identical desync shape the four other comment-tracking fixes in this plan close for a different mechanism each (/* */nesting, a leading same-line comment, a carried-over multi-line comment closing mid-line, and now this one). Deliberately not attempted: unlike those three, correctly handling splicing needs a genuinely different per-line carry-over mechanism than the existingin_blockstate, threaded through every line-number-keyed caller without disturbing their existing physical-line-number contract (clang's own AST ranges are physical-line-based too) — a same-slice fix risks getting that fidelity subtly wrong, worse than the current, honest gap. Documented in the module's own docstring ("a third accepted, documented limitation") and pinned by a dedicated regression test rather than silently accepted. - A twenty-third finding, confirmed real and fixed:
#if defined X(no parentheses) was unrecognized._IF_DEFINED_RE/_IF_NOT_DEFINED_ RErequireddefined(X)'s parentheses, butdefined Xis equally valid, real-compiler-accepted preprocessor syntax — the unparenthesized form fell through to the unmodeled_IF_REfallback with no diagnostic, sofold_macro_graph()could still stamp the pass as fully covered despite silently missing the guard'sMACRO_CONTROLS_DECLedge. Fixed by widening both regexes to a proper alternation between the parenthesized and bare forms (not independently-optional\(?/\)?, which would have accepted a mismatched operand likedefined(Xordefined X)as if it were valid) — a malformed, unbalanced operand still falls through to the unmodeled fallback rather than being guessed at. - A twenty-fourth finding, confirmed real and fixed: C++23's
#elifdef/#elifndefdesynced the enclosing guard, wrongly, not just silently._ELIF_REshared the identical\b-after-"elif" word- boundary gap the_IF_REfix already closed for#ifdef/#ifndef— unrecognized,#elifdef Bmatched no pattern at all, leaving the ORIGINAL#if's frame open across it. Reproduced empirically:#if defined(A) ... #elifdef B ... #endifattributed the#elifdef Bbranch's own declarations toAwithnegated=False— a wrong, high-confidenceMACRO_CONTROLS_DECLedge, worse than the "silently missing" shape most of this family's other gaps produce. Fixed by widening_ELIF_REto also match#elifdef/#elifndef, correctly marking the whole chain unmodeled (this module's existing, deliberate "don't guess at#elif's branch semantics" contract). - A twenty-fifth finding, investigated and deliberately NOT fixed: a
block comment appearing mid-directive (
#/**/ifdef X,#if/**/defined(X)). A real preprocessor treats/* */as whitespace anywhere, but every directive-family regex here only tolerates plain whitespace at those positions, and the leading-comment fix only strips a comment BEFORE the#, not one embedded after it. Reproduced empirically: neither shape matches any pattern at all, not even the unmodeled fallback — the directive is invisible to this scanner entirely. Deliberately not attempted: closing it needs a systematic rewrite replacing every\s*/\s+gap across the whole directive-regex pattern set, not a narrow addition, and mid-token block comments inside a preprocessor directive are exceedingly rare, deliberately obfuscated styling in practice — unlike the leading/ multi-line comment placements already fixed in this plan, which are realistic, commonly-seen shapes. Documented in the module's own docstring ("a fourth accepted, documented limitation") and pinned by a dedicated regression test. - Full type-role coverage — done for seven of the nine roles as
originally worded; one (non-type template argument) turned out to name a
narrower role than implemented, corrected below; the ninth
(concept/constraint) is investigated and deliberately deferred with its
evidence recorded. The item's list was: variable type, typedef target,
alias-template target, enum underlying type, non-type template argument,
default template argument, concept/constraint dependency, function-pointer
signature, member-pointer type — feeding the Phase 2 per-role coverage
matrix (
inline_graph_fold.ROLE_COVERAGE_MATRIX, ADR-046 D3). Every claim below was checked against real Clang 18 AST output before being written, not inferred from the role names: the audit found the nine split three ways rather than nine-missing. Scope-boundary correction (Codex review, fresh evidence, second round): the original wording "non-type template argument" was implemented astemplate_param(below) — the non-type parameter's own declared type (template <detail::Handle H>depends ondetail::Handle). That is real and correct for what it is, but it is not the same claim as resolving a non-type template argument's own value: what aHolder<&detail::f>-shaped specialization'sTemplateArgument.declcross-reference points at (confirmed against real Clang 18:ClassTemplateSpecializationDecl's own childTemplateArgumentnode carries{"decl": {"kind": "FunctionDecl", "name": "f", ...}}, a clean, non-heuristic reference — but_walk_typesdeliberately skips edge emission forClassTemplateSpecializationDeclnodes entirely, for an unrelated, already-documented reason: attributing one specific instantiation's dependency to the shared generic template node would misattribute it). That is exactly item 1'sTEMPLATE_USES_DECL(template_graph.py) — this item'stemplate_paramrole did not close it (the original bullet's ambiguous wording should not have been read as claiming it did); it was later closed as its own follow-up under item 1, see that entry. - Five were already covered, by an existing role, and needed no
producer — the same "closed a gap by discovering pre-existing coverage"
pattern this plan used for ADR-057 D6's tier 1 and item 3's
DECL_OVERRIDES_DECL. Variable type is_emit_var_decl_edge'svarrole and typedef target is_emit_alias_edge'saliasrole, both pre-existing. Alias-template target turned out to reach that samealiasrole for a structural reason worth recording: clang nests the realTypeAliasDeclinside theTypeAliasTemplateDecl, and_walk_typesrecurses into a template wrapper's children with the unchanged scope, sotemplate <class T> using Ptr = detail::Impl *;arrives as an ordinaryTypeAliasDecland emitsapi::Ptr -> detail::Implwith no new code at all. Member-pointer type (int Owner::*,void (Owner::*)(int)) and function-pointer signature (void (*)(detail::Impl *)) are both reached by_resolve_nested_type_names's existing pointer-to-member/declarator handling, so they surface under whichever role the enclosing declaration carries rather than needing one of their own. All five are now pinned bytests/test_type_graph_roles.pyso a later refactor of the walk can't silently drop what was never explicitly claimed. - Three were genuinely missing and are now implemented, each with a
real AST source and each landing on the node the entity's own edges
already use.
enum_underlying(TYPE_HAS_FIELD_TYPE): anEnumDeclcarries notypekey at all — the underlying type lives in its ownfixedUnderlyingTypeobject — so the shared typedef path this kind already took read an empty spelling and emitted nothing, andenum class Color : detail::Handleproduced no dependency on the private alias it is laid out as.qualType(as written,"detail::Handle") is read rather than the siblingdesugaredQualType("int"), which has already lost the identity the edge exists to name; an unscoped enum carries nofixedUnderlyingTypeat all and a scoped one with no written type gets an implicit"int"the pre-existing fundamental-type filter drops, so neither shape produces a noise edge.template_paramanddefault_template_arg(both onTYPE_HAS_FIELD_TYPEorDECL_HAS_TYPE) cover a non-type template parameter's own type (template <detail::Handle H> struct Slot) and a parameter's default type argument (template <class T = detail::Impl> struct Box). The load-bearing correctness property is the src identity: a template wrapper (ClassTemplateDecl/FunctionTemplateDecl/VarTemplateDecl/TypeAliasTemplateDecl) is not a node in this graph, and its parameters are direct children of the wrapper while the templated entity is a sibling child — so_templated_entity_srcre-derives the templated child's identity exactly the way the walk does for that child's own kind (a record/alias →record_typenode, henceTYPE_HAS_FIELD_TYPE; a function/variable →source_decl, henceDECL_HAS_TYPE). That matters concretely for a function template, whose patternFunctionDeclcarries nomangledName: both sides must fall through the same qualified-name+signature-hash identity or the constraint would sit on an orphan node nothing else reaches. Verified end to end against real clang, not just at the unit level. Two default-argument shapes are deliberately not emitted because clang's JSON carries no dependency to emit: a non-type parameter's default value (template <detail::Handle H = detail::K>dumps as{"kind": "TemplateArgument", "isExpr": true}with notype— the referenced constant is a nestedDeclRefExpr, aDECL_REFERENCES_DECLquestion rather than a type role), and a template template parameter's default (template <template <class> class C = detail::Def>dumps as a bare{"kind": "TemplateArgument"}— neither a type nor a name to resolve), so both degrade to no fact rather than a guessed one (ADR-028 D3). Both non-emissions are pinned by integration tests against the compiler itself, so a future clang that does carry them fails a test instead of leaving a silent gap. - A real collector-upgrade false positive was found and fixed as a
direct consequence of adding these three roles (Codex P1 review on PR
#712, confirmed by direct reproduction).
ROLE_COVERAGE_MATRIX/role_pass_covered()(ADR-046 D3, Phase 2) was built for exactly this situation but had zero production consumers — only ever stamped byinline_graph_fold._mark_role_coverage, never read bysource_graph_findings._common_dependency_edge_kinds(), the function that actually decides which dependency-edge kinds are safe to version-diff. That function trusts a wholeTYPE_HAS_FIELD_TYPE/DECL_HAS_TYPEfamily once both sides confirm the coarseextractor_passes["type_graph"]flag — so a persisted graph collected before this item's three new roles existed (flag set, no role key, since that producer version never emitted or tracked them) compared against one collected after (flag set, role key set, first-ever edge riding the new role) reported a falsePUBLIC_API_INTERNAL_DEPENDENCY_ ADDEDpurely from re-running a newer abicheck version over unchanged source. Fixed with_role_coverage_disagrees(), a final, isolated, monotonic (subtraction-only) filter over_common_dependency_edge_kinds()'s existing result: a kind stays trusted only when both sides'extractor_passes/narrowed_passesagree, role key by role key, on whichROLE_COVERAGE_MATRIXroles were actually examined — read directly with no family-flag fallback, since that fallback is exactly what let an absent role key silently read as "covered." Verified: the version-skew repro now produces zero findings; a same-version repro (both sides confirm the same role key) still correctly produces one; the full relevant existing test suite (every module the fix or its call sites touch) shows zero regressions; three new dedicated regression tests pin both directions plus a control case (a kind outsideROLE_COVERAGE_MATRIX, e.g.DECL_CALLS_DECL, is unaffected) intests/test_l3l4l5_new_kinds.py. A companion gap in the same fix, found by a later review round: the header-only pass (header_graph.py, ADR-041 header-only-graph addendum) reuses the identicaltype_graph.parse_clang_ast_types()walker but never stamped aROLE_COVERAGE_MATRIXkey at all, so two header-only-collected graphs compared before/after this same abicheck upgrade would read as vacuous agreement (both sides absent, forever) rather than "coverage unknown" —build_header_only_graphnow stamps role coverage under its ownheader_type_graphpass alias the same way the build-integrated pass does, and_role_coverage_disagrees()checks both the build-integrated and header-only key for each side. - Concept/constraint dependency — investigated, deliberately NOT
implemented, evidence recorded so the follow-up doesn't re-derive it.
A public template constrained by an internal concept (
template <detail::Storable T> struct Keeper, or arequires detail::Storable<T>clause) is a real dependency, but clang's JSON AST does not name the concept at the use site: aConceptSpecializationExpr's key set is exactly{id, inner, kind, range, type, valueCategory}— no name, noconceptId, noreferencedDecl— and grepping a whole real dump for the concept's own spelling finds it exactly once, on theConceptDeclitself. There is a usable join, verified across three use sites and two distinct concepts in one TU: the nestedImplicitConceptSpecializationDecl'sloc.offsetis byte-for-byte the declaringConceptDecl's ownloc.offset, so(file, offset)resolves the constraint deterministically — but consuming it needs the sticky whole-document file cursor (clang omitsloc.filewhen unchanged, the same quirkmacro_graph.pydocuments), which this module tracks only file-coarsely today. The larger blocker is the node model, not the resolution: a concept is a named declaration that is not a type, and there is noconceptnode kind insource_graph.NODE_KINDS— routing it throughDECL_REFERENCES_DECL(source_decl→source_decl) would put a class template's constraint on adecl://node while every other edge that class has lives on itsrecord_typeone, fragmenting the identity this item's other two roles were careful to keep single. Registering a new node kind is exactly the step item 3 treated as notable when it introducedvtable, so this gets its own scoped decision rather than a drive-by addition here. - Wrapper-to-entity bridge — a real, pre-existing gap, found by Codex
review on PR #712 during this item, recorded as follow-up work rather
than attempted as a side effect of a role-coverage change. When the
build-integrated graph has L4 source evidence but no pre-attached L2
header graph,
source_extractors/clang.py's_emit_templaterepresents a class/function template wrapper itself (ClassTemplateDecl/FunctionTemplateDecl) as an L4template-kindSourceEntity, joined onto the L5 graph asdecl://<qualified-name>— buttype_graph.py's own field/base/role edges for the identical templated entity (including this item's three new roles) all land ontype://<qualified-name>(for a class template) or a signature-hashed child declaration (for a function template), via the ordinary record/function walk_walk_typesalready does regardless of the entity being templated. The two never join: a standalone build-source-collection or directory/packagecomparethat only has the L4-attached wrapper node public, with no L2 header graph also attached, cannot reach the L5 field/role edges from it —public_to_internal_dependency(cross_source_checks.py) misses exactly the template-parameterized private-type dependency this item's new roles exist to detect, for that one collection shape. Confirmed pre-existing, not introduced by this item: checked against real Clang 18 for the item's own headline case (template <detail::Handle H> struct Slot { detail::Impl member; };) — both the newtemplate_paramrole and the pre-existingfieldrole emit their edges from the identicaltype://api::Slotnode (_walk_typesreaches a class template's innerCXXRecordDeclthrough the ordinary record branch regardless of whether it's templated), so a public class template's plain, non-template-parameterized private field is equally unreachable from the L4 wrapper today, with or without this item's new roles. A fix is worth having —_templated_entity_src(this item's own machinery) already proves the identity derivation for "which node does a templated entity's own edges land on" is already correct and shared; what's missing is a bridge edge from the L4template-kind entity'sdecl://node to that sametype:///decl://identity — but it changes how every L4template-kindSourceEntityjoins the L5 graph, not just the three roles this item added, so it needs verification against the pre-existing class-template field/base edges and the FP-rate gate before landing, not a drive-by addition to a role-coverage change. - Object/link provenance — done.
abicheck/buildsource/archive_graph.pyis the realar-index introspection pass: a pure parser over the archive's own linker-written symbol index (GNU///SYM64/and BSD/Mach-O__.SYMDEF/__.SYMDEF_64, both plain and thin-archive flavors), driven byinline_graph_fold.fold_archive_graphover everystatic_librarynodesource_graph._fold_link_provenancealready creates fromBuildEvidence.link_units. Populatesarchive_membernodes and the previously schema-onlyARCHIVE_CONTAINS_OBJECT/OBJECT_DEFINES_SYMBOLedges, so a removed-symbol finding can localize to "cache_dispatch.oinlibinternal_dispatch.a" viadefining_members(now inmodel/source_graph_query.py), whichlocalize_symbol(graph explain) reports asdefined_in_archive_members. Needs no compiler (unlike items 1-5 above), so it runs whenever an archive link input is present, gated only on finding and reading the archive on disk — never on clang. AnOBJECT_DEFINES_SYMBOLedge only ever joins onto abinary_symbolnode the graph already carries (ADR-057 D1's "one shared node id is the whole join mechanism" rule, reapplied here), so a real static library's thousands of internal-only indexed symbols mint no new node.linker_script/export_map/comdat_group(a member archive's other three reserved node kinds) stay unpopulated — no normalized data source for those; a real static-library symbol removal can already be localized end to end today.
Phase 6 — New detectors, examples, FP gates¶
Per the review, the goal is not a new ChangeKind per graph edge (the
registry is already large) — raw contract change stays separate from
impact/composition evidence. Minimal new user-facing set:
| Detector | Classification |
|---|---|
PUBLIC_CONSUMER_COMPILED_DEPENDENCY_CHANGED |
API_BREAK/RISK; BREAKING only with artifact/consumer proof |
PUBLIC_TEMPLATE_INSTANTIATION_TARGET_CHANGED |
source risk or consumer-proven break |
PUBLIC_VIRTUAL_DISPATCH_SET_CHANGED |
RISK, correlated with existing vtable findings |
PUBLIC_MACRO_CONTRACT_CHANGED |
API_BREAK or behavioral RISK |
PUBLIC_CALLBACK_TARGET_CHANGED |
RISK; break only with proven signature/symbol mismatch |
GRAPH_COVERAGE_INSUFFICIENT_FOR_SUPPRESSION |
quality/coverage diagnostic (the Phase 1 SUPPRESSION_REACHABILITY_UNKNOWN already covers the suppression-specific case this generalizes) |
CONSUMER_IMPACT_PATH_CONFIRMED |
impact overlay on an existing raw break, not a new raw break |
USE_CASE_IMPACT_CONFIRMED |
report-level impact, not a new ABI ChangeKind |
Plus a RootCauseCorrelator composer (not a detector) that groups
FUNC_REMOVED/INTERNAL_SYMBOL_REQUIRED_BY_PUBLIC_API/
CONSUMER_REQUIRED_SYMBOL_REMOVED/RUNTIME_LOAD_FAILED into one root cause
with per-piece evidence levels (feeds Phase 3's root_cause_id). The
correlator itself is done: abicheck/impact/correlation.py's
correlate_root_causes/RootCauseGroup group the four kinds above by
shared symbol identity (caused_by_type-else-symbol, the same precedence
reporter_markdown._root_cause_key_and_display uses) and rank each
member's evidence level (artifact_proven → call_graph_overapprox →
call_graph_proven → consumer_proven → runtime_proven) — not purely
from ChangeKind: promoted when Change.reachability_kind states a
stronger tier (appcompat._attach_consumer_impact enriching a shared
FUNC_REMOVED in place), downgraded when an INTERNAL_SYMBOL_REQUIRED_BY_
PUBLIC_API finding's own proof path is internal_leak's
"overapprox: "-prefixed over-approximation of a virtual/function-pointer
dispatch target. Wired into JSON/SARIF (this pass): every finding that
is a member of one of the correlator's multi-piece groups now carries
impact_assessment.root_cause_evidence (evidence_level for this finding,
strongest_evidence_level/evidence_levels for the whole group), and JSON
--report-mode root-cause's root_causes[] groups gain the same
strongest_evidence_level/evidence_levels fields — computed via a new
reporter_markdown.root_cause_evidence_lookup_for_changes, threaded through
reporter.py/sarif.py alongside the existing root_cause/
impact_root_cause plumbing (schema 2.29). Deliberately conservative: this
annotates the existing root_cause_id/impact_group_id grouping (still
reporter_markdown's independent caused_by_type-else-symbol rule,
unchanged) with the correlator's evidence ranking rather than replacing that
grouping's identity scheme or making impact_group_id diverge from
root_cause_id — the correlator's own root_cause_id already hashes the
identical key, so no re-keying was needed, and every existing
root_cause_id/impact_group_id value for every pre-existing report is
byte-for-byte unchanged. Still open: the eight new detector/overlay
ChangeKinds below (a materially larger, differently-shaped follow-up —
new producers, new example pairs, new FP-rate corpus cases — deliberately
not attempted in the same pass as the wiring above), and impact_group_id
actually diverging from root_cause_id by re-bucketing findings through the
correlator's own groups rather than only annotating the existing ones.
The wiring above needed three follow-up review rounds to land correctly
(all Codex, same PR): (1) the per-finding lookups were built from changes
alone, so a finding correlating only via a scoped-only
(--used-by/--required-symbol) sibling got no evidence at all —
RootCauseCorrelator needs the real sibling Change object, not just its
caused_by_type string, to recognize a pair as a group; (2) the
root_causes[] group-level rollup matched a report group against a
correlator group by root_cause_id equality, which is wrong whenever
the two grouping schemes disagree on membership — a bare-symbol pair
sharing no caused_by_type (--used-by --verify-runtime's real shape) is
one correlator group but two singleton report groups, so the hashes never
matched even though each finding's own per-finding evidence already showed
membership; fixed by folding each group's own members' already-correct
evidence directly instead of re-deriving membership by id; (3) the same gap
existed a layer up, in cli_compare_fold.py's own --report-mode
root-cause fold-in (_add_entries_to_root_causes), which appends a
scoped-only entry to an existing-or-new group after the JSON serializer
already built it, but never recomputed that group's own evidence summary
afterward. Worth recording as its own lesson before attempting any of the
eight new detectors below: a naive root_cause_id-equality join between
two independently-computed groupings is not safe by default whenever either
grouping has its own fallback/singleton rule — the correlator's grouping is
strictly coarser than the report's in exactly the no-caused_by_type
case, and nothing about that asymmetry is visible from either grouping's
own code without deliberately tracing a bare-symbol, no-caused_by_type
input through both.
A related, adjacent gap was found (not fixed) while scoping
GRAPH_COVERAGE_INSUFFICIENT_FOR_SUPPRESSION's "generalizes
SUPPRESSION_REACHABILITY_UNKNOWN" description above, and is worth
recording before anyone else picks that item up. Suppression.
_passes_reachability_gate (suppression.py) has two reachability modes
whose behavior on a graph-coverage gap differs, and only one of them has a
diagnostic today. reachability: "proven-unreachable-only" treats
Change.reachability_state == UNKNOWN as a withheld match unless
allow_unknown_reachability: true — this is exactly what
SUPPRESSION_REACHABILITY_UNKNOWN reports. reachability:
"unreachable-only" (a different, valid value) instead gates on
not change.public_reachable — and Change.public_reachable defaults to
False the same way whether it was proven False or simply never
examined (UNKNOWN reachability with insufficient graph coverage), so a
reachability: "unreachable-only" rule can silently succeed at
suppressing a change graph coverage never actually cleared, with no
diagnostic at all — a real gap, structurally similar to the one
SUPPRESSION_REACHABILITY_UNKNOWN closed for the other mode, but on the
opposite failure direction (this one risks a false negative — silently
accepting a suppression that should have been withheld — rather than a
false positive). A single GRAPH_COVERAGE_INSUFFICIENT_FOR_SUPPRESSION
ChangeKind that literally "generalizes" the existing case (by simply
reusing its detection path under a new name) would not close this gap; closing
it for real needs either extending unreachable-only's own gate to
distinguish proven-False from UNKNOWN (a semantic change to a suppression
mode real policy files already rely on — needs its own compatibility
analysis, not a drive-by) or a second, independently-designed diagnostic
covering this mode specifically. Not attempted here — flagged rather than
guessed at, per this file's own "known gaps over risky reactive patches"
convention.
Two more of the eight, investigated (not implemented) in this same pass — both turn out to be a scoping question first, not an extraction gap:
CONSUMER_IMPACT_PATH_CONFIRMED.appcompat.py's consumer-overlay pipeline (_has_impact_evidence,_enrich_covered_changes,_merge_consumer_impact_paths) already computes and surfaces exactly this information — a confirmed consumer→symbol proof path — today, as an in-place mutation of the existingChange'simpact_assessmentfields (reachability_kind,reachability_state, the proof-path plumbing this plan's Phase 3/4 sections already document as DONE), not as a standalone finding. Minting a newChangeKindfor the same fact would mean either (a) a second, parallel representation of information a report already carries once (the two-representations-of-one-fact drift this repo's owndocs/AGENTS.mdgoverning rule andchange_registry.py's "oneChangeKindMetaentry" convention both exist to prevent), or (b) redefining what the overlay does — attaching a new raw finding instead of annotating an existing one — which is a real design change toappcompat.py's enrichment contract, not a new-detector addition. The table entry above ("impact overlay on an existing raw break, not a new raw break") already states this outcome as the intent; what's newly confirmed is that the current code already delivers it, so there may be nothing left to build here beyond documentation — worth a maintainer decision on whether to close this row entirely rather than implement it.USE_CASE_IMPACT_CONFIRMED.abicheck/impact/use_cases.py's own module docstring is explicit that this is deferred pending new CLI surface:explain_use_case_impact()exists and is wired only throughproject validate-use-cases --against-new(an opt-in diagnostic command), not throughcompare's own report pipeline. Surfacing this as acompare-timeChangeKindneeds a real design decision this plan has not made — a newcompare --use-cases <manifest>flag (or equivalent),REPORT_SCHEMA_VERSIONbump, and new FP-gate examples proving a use case that doesn't reach the changed branch stays compatible (this is exactlycase203below) — not a drive-by extension of the existingproject-group command. Per this file's own root-command admission bar (AGENTS.md"Adding a new top-level command"), this also needs to clear that bar or land as acompareoption instead; not attempted here.
A fourth item was scoped for implementation attempt in this pass
(PUBLIC_VIRTUAL_DISPATCH_SET_CHANGED) and deliberately not attempted,
based on evidence rather than a guess. abicheck/buildsource/
virtual_dispatch_graph.py already emits the two graph facts a comparison
detector would need (VIRTUAL_CALL_MAY_DISPATCH_TO,
TYPE_HAS_VTABLE) — the raw data exists. What stopped the attempt is this
same plan's own recorded history for that exact module and its three
siblings (callback_graph.py, macro_graph.py, template_graph.py,
directly above and below this entry): each required double-digit rounds of
Codex review to reach its current DONE/PARTIAL state, and the findings in
those rounds were not stylistic — coverage-stamping bugs that made a
degraded extraction silently read as complete, propagation bugs that lost a
graph fact between fold and report, and at least one review round finding a
correctness bug in a previous review round's own fix. A new detector
consuming VIRTUAL_CALL_MAY_DISPATCH_TO/TYPE_HAS_VTABLE inherits every
one of those failure modes by construction (comparing two runs' worth of
already-fact-checked-fragile graph output, across old/new coverage that can
differ), plus its own new one: this plan's explicit constraint that "a
possible-target-set change must never read as a confirmed break" (echoed
in resolution always being "overapprox" in the two Part A/B functions'
own docstrings) means the comparison logic itself — not just the extraction
— has to get old-vs-new overapprox-set diffing right without silently
producing a false BREAKING the first time a target set merely reorders or
a base's coverage degrades between runs. Building and shipping that
correctly, with its own FP-rate corpus cases (case197 below) and without
a live oneAPI/multi-round-Codex-review cycle available in this session, is
not achievable to the correctness bar this codebase's own AGENTS.md
"Known gaps over risky reactive patches" convention sets — recorded here as
a scoped, concrete blocker (not "too hard, unspecified") for whoever picks
this row up next: start from virtual_dispatch_graph.py's own two
functions, budget for the same class of review-round findings this plan's
Phase 5 section already itemizes for the identical module, and do not skip
straight to a detector without first re-reading that history.
New examples (each needs a negative twin, per the review):
| Case | Scenario |
|---|---|
case194 |
Real consumer compiles a public inline wrapper requiring an internal exported dispatcher — full consumer → symbol ← public entry proof |
case195 |
Public template instantiates a removed internal specialization |
case196 |
Internal type as field by-value vs. pointer — value path blocks suppression, pointer-only doesn't |
case197 |
Stable public virtual call, changed override set — over-approx proof, no false BREAKING |
case198 |
Macro/default-argument change, export table identical — source/behavioral finding, no binary-break claim |
case199 |
Public registration API holds a function pointer to an internal callback |
case200 |
Old-side graph partial/degraded — UNKNOWN, finding stays, coverage diagnostic (already exercised at the reachability_state level by Phase 1's tests; this case exercises the full compare pipeline end to end) |
case201 |
Old side header-only, new side full source graph — no false "dependency added" from a collector upgrade |
case202 |
One dispatcher feeds two use cases but not a third — root-cause grouping and exact blast radius |
case203 |
Consumer/use case don't require the changed branch — scoped verdict compatible, full-library verdict unchanged |
case204 |
Mangled/qname/USR identity forms of one entity — stable graph join, no duplicate nodes (Phase 2) |
case205 |
Removed symbol localized to its object/archive member (Phase 5 item 6) |
New CI gates (extend the existing FP-rate/tier-accuracy/mutation pattern):
false-positive-rate additions for the new detectors, collector-upgrade
stability (case201-shaped), suppression-safety regression (the Phase 1
test_reachability_state.py suite is the seed), proof-path JSON-schema
validation, consumer/use-case attribution checks.
Files & surfaces¶
New:
abicheck/buildsource/graph_facts.py # GraphFact/FactConflict/merge, relation_key/occurrence_id (Phase 2 D1/D2, DONE); CONSUMER_NODE_KINDS/CONSUMER_EDGE_KINDS/TEMPLATE_NODE_KINDS/TEMPLATE_EDGE_KINDS/LINK_PROVENANCE_NODE_KINDS/LINK_PROVENANCE_EDGE_KINDS (Phase 4 D1 + Phase 5 items 1/6, DONE — here rather than source_graph.py, which is at its line cap)
abicheck/buildsource/graph_impact.py # select_preferred_graph_path, attach_impact_metadata, _path_occurrence_id (Phase 2 D6/ADR-052 Slice 6, DONE — landed here, not under impact/)
abicheck/buildsource/entity_resolver.py # EntityResolver/EntityConflict (Phase 2 D4, DONE — scoped implementation)
abicheck/buildsource/archive_graph.py # ar-index introspection (Phase 5 item 6, DONE — archive_member/ARCHIVE_CONTAINS_OBJECT/OBJECT_DEFINES_SYMBOL)
abicheck/buildsource/template_graph.py # Clang template-instantiation pass (Phase 5 item 1, DONE — template_decl/template_instantiation, DECL_INSTANTIATES_TEMPLATE/TEMPLATE_USES_TYPE/INSTANTIATION_EMITS_SYMBOL/TEMPLATE_USES_DECL)
abicheck/buildsource/template_graph_value_decls.py # index_value_decls/arg_label_spelling, split out for TEMPLATE_USES_DECL (Phase 5 item 1 follow-up, DONE — leaf module, no import of template_graph.py even under TYPE_CHECKING)
abicheck/buildsource/template_graph_extractor.py # ClangTemplateGraphExtractor, split out of template_graph.py (same line-cap reason); re-exported via a lazy __getattr__ shim
abicheck/buildsource/macro_graph.py # Clang + raw-text macro/config-dependency pass (Phase 5 item 2, DONE — MACRO_CONTROLS_DECL/DECL_USES_MACRO)
abicheck/buildsource/virtual_dispatch_graph.py # pure graph transform, no clang (Phase 5 item 3, PARTIAL — VIRTUAL_CALL_MAY_DISPATCH_TO/TYPE_HAS_VTABLE; DECL_OVERRIDES_DECL already satisfied by override_graph.py, VTABLE_SLOT_MAPS_TO_DECL reserved)
abicheck/buildsource/callback_graph.py # Clang callback/function-pointer pass + pure join (Phase 5 item 4, PARTIAL — DECL_REGISTERS_CALLBACK/DECL_TAKES_ADDRESS_OF/CALLBACK_MAY_INVOKE; FUNCTION_POINTER_HAS_SIGNATURE populated as a node-level fact instead of an edge)
abicheck/internal_leak.py # TraversalPolicy + effect_transitions (Phase 2 D5, DONE — landed here, not a separate impact/traversal.py)
abicheck/impact/
model.py # ImpactAssessment, GraphProofPath, FindingDecision (Phase 3 slices 1/7, DONE — ADR-052)
engine.py # assess_change(...) (Phase 3 slices 1/7, DONE — ADR-052)
correlation.py # RootCauseCorrelator (Phase 6, DONE — correlate_root_causes/RootCauseGroup; wired into JSON/SARIF root_cause_evidence, schema 2.29)
root_causes.py
consumer_graph.py # Phase 4 slice 1, DONE — ADR-057 (consumer graph + the source join)
use_cases.py # Phase 4 slice 2, DONE — ADR-057 amendment (manifest + use_case/test_case graph join; trace ingestion still not started)
docs/learn/impact-analysis.md # Phase 3 slices 1/6/7 + Phase 4's consumer join (ADR-057), DONE
docs/reference/source-graph-schema.md # Phase 2 D1-D6 identity/merge/traversal-policy schema, DONE
docs/learn/graph-coverage.md # Phase 1, DONE
docs/contribute/use-case-impact.md # Phase 4 slice 2, DONE (manifest format, entrypoint mapping, test association, declared-vs-observed; trace ingestion documented as not-yet-built)
docs/contribute/detector-impact-contract.md # DONE, ahead of Phase 5/6 themselves — see Phase 3 section above
examples/case194.../case205.../ # Phase 6
Modified (recurring across phases): abicheck/buildsource/source_graph.py,
source_graph_findings.py, internal_leak.py, post_processing.py,
suppression.py, appcompat.py, reporter.py, sarif.py,
junit_report.py, service_render.py, change_registry*.py,
checker_policy.py, checker_types.py.
Tests¶
tests/test_reachability_state.py— Phase 1, done.tests/test_source_graph_v2.py— Phase 2 D1/D2 (includingTestOccurrenceId), done.tests/test_internal_leak.py'sTestTraversalPolicy/TestSelectPreferredPath— Phase 2 D5/D6, done.tests/test_internal_leak_effect_transitions.py— Phase 2 D5'seffect_transitions, done (split out to stay under the line-count cap).tests/test_graph_impact.py'sTestSelectPreferredGraphPath/TestAttachImpactMetadataAlternatives/TestPathOccurrenceId— Phase 2 D6's structured-path selector and ADR-052 Slice 6'soccurrence_id, done.tests/test_impact_model.py— Phase 3 slice 1, done.tests/test_junit_report_root_cause.py— Phase 3 Slice 6's JUnit root-cause rendering, done (split out fromtest_junit_report.py).tests/test_reporter.py::TestImpactAssessmentRootCause/tests/test_sarif.py::TestImpactAssessmentRootCause— Phase 3 Slice 7's per-findingroot_cause_id/root_cause_display/impact_group_id, done.tests/test_entity_resolver.py— Phase 2 D4 (scoped implementation), done:EntityResolver.resolve's USR/mangled/qualified-signature fallback chain, idempotence, alias sharing + conflict recording for two v1 ids resolving to one canonical identity,to_dict/from_dictround-trip,SourceGraphSummary.resolve_entities()being opt-in and safe to call again, sparseto_dict()output, and v1-pack (schema_version: 1, noentity_resolverkey) load compatibility.tests/test_consumer_graph.py— Phase 4 slice 1 (ADR-057), done: the schema registration and node-id pin that the join rests on,build_consumer_graph's requirement/version edges and library scoping,join_consumer_graph's fold-onto-one-shared-node and its deep-copy/no-mutation guarantee (asserted on object identity and fact membership, not node counts — every count is identical under the shallow bug),explain_required_symbol(s)' entry attribution/direct case/four degrade paths/batch equivalence, ADR-046 D6 tier 1 including the overapprox-still-wins rule, and an end-to-endscope_diff_to_appclass covering both the enriched overlay and the byte-for-byte-unchanged no-graph run.tests/test_use_cases.py— Phase 4 slice 2 (ADR-057 amendment), done: schema registration, manifest parsing (valid, empty, and each malformed shape),build_use_case_graph's entrypoint resolution by id and by label, the unresolvable-entrypoint silent-skip path,test_casenode/edge emission independent of entrypoint resolution,join_use_case_graph's fold-onto-one-shared-node and its deep-copy/no-mutation guarantee (asserted on object identity and fact membership, mirroringtest_consumer_graph.py's own pattern), and an end-to-end scenario joining ause_casenode onto a library graph's public entry node.tests/test_type_graph_roles.py— Phase 5 item 5, done (a sibling split oftest_type_graph.py, which sits at its own line-count cap): the three new roles' emission and node-identity properties, the five roles found already covered (pinned so a walk refactor can't drop them), the two deliberately unemitted default-argument shapes, an executableROLE_COVERAGE_MATRIX-vs-parser agreement check in both directions (a role the parser emits but the matrix omits is an unclaimed capability; one the matrix claims with no producer is a false coverage claim), andintegration-marked tests re-deriving every AST shape the fixtures encode from a real compiler.tests/test_template_graph_value_decls.py— Phase 5 item 1's TEMPLATE_USES_DECL follow-up, done (a sibling split oftest_template_graph.py, at its own line-count cap): pointer-to- function/pointer-to-variable/reference NTTP resolution to the target's real identity, the unresolved-target and empty-decl-name skip cases, the label-collision regression (two instantiations sharing only a bare callee name across namespaces stay distinct), thesource_decl-not-typenode-kind routing and shared-node join, and anintegration- marked end-to-end test against a real compiler.- New per remaining phase: one
test_diff_<family>.pyper Phase 5 graph family.tests/test_root_cause_correlator.py(Phase 6), done: empty/ ignored-kind/singleton no-op cases, two- through four-piece correlation and evidence-level ranking, first-seen group ordering, the outside-the-family non-join guard, thereachability_kind-based consumer-proven promotion (and its non-demotion of a stronger kind), the"overapprox: "-prefixed call-graph-path downgrade (and that an exact path isn't downgraded, and that it still yields to real consumer proof), andto_dict()shape. tests/test_abi_examples.pypicks upcase194-case205automatically onceground_truth.jsonis updated (existing harness, no new test file needed).
Effort & risk¶
Phased XL; each phase is independently shippable and additive (mirrors how L3-L5
evidence already never overrides L0-L2 authority). Highest risk items: Phase 2's
identity/version bump (needs its own ADR + a careful v1-alias migration test),
Phase 5's virtual-dispatch over-approximation (must never fabricate a BREAKING
from a possible-target-set change alone), and Phase 6's detector count growth
(mitigated by the "composer, not detector, for aggregation" split the review
itself insists on).
Out of scope¶
Deferred by the original review, not attempted here either:
- A maintained devcontainer image baking in castxml/libabigail/abi-compliance-checker (pixi already solves "one command, working environment" without the image-maintenance burden).
- A trend-reporting database persisting
check_tier_accuracy.py/check_fp_rate.py/ mutation-score history across runs (needs a storage/retention decision first). - A full behavioral baseline / task-suite leaderboard beyond
skills-src/evaluation/agents/'s current one-task harness (should grow from real usage, not be speculatively built).