G34 — Producer/consumer compiler-profile separation and compiler-matrix hardening¶
Origin: a status-review of the toolchain-profile/compiler-matrix surface
(abicheck/buildsource/project_targets.py's ProfileCompileSpec,
abicheck/buildsource/run_plan.py, .github/workflows/check-project.yml).
Confirms the semantic model already in place (a separate scan per compiler
profile, fail-closed aggregation, no cross-product blind sweep) is sound,
but finds the schema conflates two independent axes into one
profiles.<id> block, and finds five concrete gaps in the Actions matrix
that block a genuine "one artifact, several supported client compilers"
scenario today. Two narrower items from this review (compiler-version
enforcement; a real toolchain-identity probe) were already flagged as open
in AGENTS.md's "Known gaps" under the now-reverted GCC-argument-rendering
entry — this plan is their actionable home, generalized to the full
producer/consumer split the same investigation surfaced.
Type: Initiative plan (cross-cutting; spans
abicheck/buildsource/project_targets.py, abicheck/buildsource/run_plan.py,
abicheck/cli_project.py, abicheck/aggregate*.py/abicheck/service_scan.py
aggregate reconciliation, .github/workflows/check-project.yml,
actions/check-target/action.yml, action.yml).
Effort: XL, phased over multiple PRs — see per-phase estimate below.
Risk: low for additive schema fields (Phase 0); medium for Actions-matrix
scheduling changes (Phase C: a real OS-aware runner selection touches a
widely-used reusable workflow); medium-high for the toolchain-identity probe
(Phase A: shells out to the resolved compiler on every gated run, needs a
caching/skip story so it doesn't regress dump/compare latency).
Problem¶
profiles.<id> today names exactly one compiler and is used for two
different things at once:
- Producer/artifact identity — the compiler the library binary was actually built with (affects mangling, layout, vtables, calling convention, exception/RTTI model, the linked standard-library ABI).
- Consumer/client identity — the compiler a user of the library
compiles their own code with against the public headers (affects which
#ifdef __GNUC__/__clang__/_MSC_VERbranch, which standard-library ABI, which template instantiation, whichsizeof/packing the client actually sees).
For the common case (library and its clients share one toolchain) folding
both into one profile is fine and is exactly what today's
profiles.<id>.compile (ProfileCompileSpec: compiler_family,
compiler_version, target, standard, stdlib, binding,
abi_macros, args) already does well. It breaks down for "one binary,
several supported client compilers/dialects" (e.g. a .so built once with
GCC 14 but contractually supporting GCC 11/14 and Clang 20 clients under
different C++ standards and standard-library ABIs): expressing that today
needs one synthetic profile per producer×consumer combination
(linux-gcc14-build-client-gcc11, linux-gcc14-build-client-clang20, ...),
each re-declaring the same candidate binary/build-output.json under a
different profile id.
On top of the schema gap, five concrete mechanical gaps in
.github/workflows/check-project.yml block a real GCC/Clang/MSVC matrix
from running through the shared reusable workflow at all today (each
confirmed by reading the workflow, not asserted from a design doc):
- Every check cell runs on
runs-on: ubuntu-latest(hardcoded, independent of a profile's ownos:/arch:fields) — there is no way to route anos: windowsprofile's check cell to awindows-latestrunner through this workflow. dependency-sourceis not per-cell.actions/check-target/action.ymlandcheck-project.ymlstill forward only the old booleaninstall-deps: true|false(check-project.yml~line 839) into the root Action, even though the rootaction.ymlitself already supportsdependency-source: conda-forge|conda-forge-gcc14|conda-forge-clang20| system|none— so a GCC-profile cell and a Clang-profile cell in the same run-plan can't each provision their own matching conda environment through this workflow.ast-frontend/sources/compile-db/build-info/sysroot/policystay global workflow inputs. Onlycompile_gcc_path/compile_gcc_optionsget a per-cell override (fromprofiles.<id>.compile, per the P1 toolchain-profile audit already landed) — a project can't express "GCC profile parses via CastXML, DPC++ profile parses via the direct-clangicpxfrontend" in onecheck-project.ymlcall.compiler_family/compiler_versionare shape-validated but not enforced.run_plan.py's own docstring says so explicitly: they are "deliberately not projected into any forwarded field" and a real toolchain-identity probe "does not exist yet." A profile can declarecompiler_family: gcc,compiler_version: ">=14,<15"and havebindingresolve to a Clang 12 executable with no error — the snapshot honestly records what actually ran, but nothing fails the gated check for the mismatch itself.- ~~No per-finding cross-profile reconciliation.~~ Closed by Phase D.
aggregate(G32/ADR-050, done) already producedaffected_profiles/incomplete_profiles/unanalyzed_profilesand averdict_by_profilemap at the target level, but did not merge the same logical finding appearing in two different profiles' reports into one entry with its ownaffected_profileslist. That limit was a known one carried over from G32, not a new gap this review discovered.AggregateResult.finding_matrixnow does it; see Phase D below. (The earlier wording quoted a sentence fromindex.md's G32 row that is no longer there — CodeRabbit review; a dangling quote is worse than no citation, so the fact is stated directly instead.)
Goal & acceptance criteria¶
Phase 0 — schema: separate producer and consumer profile axes (S/M)¶
- [x]
.abicheck.ymlgainsprofiles.<id>.consumer_compile(sibling to the existingprofiles.<id>.compile), carrying the sameProfileCompileSpecshape (compiler_family/compiler_version/target/standard/stdlib/binding/abi_macros/args) —ProfileSpec.consumer_compile(project_targets.py), shape-validated identically tocompile:(unknown-key/type/whitespace-injection guards all reused via the sameProfileCompileSpec.from_dict). Not yet done: actually resolving/applying it to the header-AST (L2) extraction step only — this slice is config-schema projection only (see the next two open items). - [x] A profile with no
consumer_compile:overlay behaves exactly as today (profile.consumer_compile is None, omitted fromto_dict()) — additive, not a breaking schema change; existing single-profile projects need zero edits. Covered intests/test_project_targets_consumer_compile.py. - [ ] A profile with
consumer_compile:runs L0/L1 extraction once (producer toolchain) and L2/L4 header extraction under the consumer toolchain, then merges them into one snapshot the same way an existing hybrid/dual-backend snapshot already merges facts from two producers (seedumper_hybrid.merge_snapshots()for the existing merge pattern to extend, not duplicate). Still open — this is the actual extraction/merge integration; the schema and itsrun_plan.pyprojection (below) land first as an independently mergeable, lower-risk slice. - [x]
run_plan.pyprojectsconsumer_compile:into the generated cell the same waycompile:already does, as its own, independently resolvedRunPlanCheck.consumer_compile_gcc_path/consumer_compile_gcc_optionspair (_consumer_compile_fields_for_profile) — never falling back to the producer overlay's own resolved values. - [x]
tests/test_project_targets_consumer_compile.py(shape parsing, round-trip, absence-is-None, unknown-key/not-a-mapping validation errors) andtests/test_run_plan.py'sTestConsumerCompileOverlayProjection(target checks, bundle checks, independent binding resolution) cover the schema/projection slice landed here. Still open: an integration test exercising the actual "producer + consumer profile → two extraction passes merged" behavior, once that extraction/merge work above lands.
Phase A — toolchain-identity enforcement (L, risk: medium-high)¶
- [x] A real probe step (
abicheck/buildsource/toolchain_probe.py) resolves a profile'scompile.binding/consumer_compile.binding(via a trustedBindingsFile, both overlays checked independently) to its actual executable, runs a cheap identity check reusing the existing raw---version-capture plumbing (dumper_toolchain._tool_identity_metadata/_compiler_family_from_toolchain— no new subprocess-handling code), parses the profile's declaredcompiler_versionas a comma-separated constraint spec (==/!=/>=/<=/>/<, e.g.">=14.2,<15") and compares bothcompiler_family(reconciling the schema spelling"gcc"with the internal"gnu"label) andcompiler_versionagainst the probed executable. MSVC (compiler_family: msvc, or acl/cl.exebinding) is deliberately skipped —cl.exehas no--versionflag and the reused probe always runs exactly that flag, so this is a documented limitation, not an oversight (see the module's docstring). - [x] Wired into
project validate --toolchain-bindings(cli_project.py), alongside the existingcheck_profile_bindings_resolvecall, so a mismatch surfaces as a validation error (exit 1) the same way an unresolved binding already does. - [ ] Still open: a hard-fail before extraction on
dump/comparethemselves, as originally scoped above. Investigated and found not wireable as a drive-by extension: neitherservice.py'srun_dump/resolve_inputnor theabi_dumpMCP tool accept abinding/profile parameter at all today — there is no dump/compare call path that resolves a.abicheck.ymlprofile's toolchain binding to begin with, so there is nothing for this check to hook before. That gap belongs to a separate, already-tracked line of work (seeAGENTS.md's "Known gaps" entry on depth-contract/CLI-vs-API parity for the same class of "no such call path exists yet" finding) — closing it needs a real dump/compare-time binding-resolution feature first, not an extension of this validation-report check. - [x] The probe result is cached per resolved executable path + mtime/hash
— inherited for free from
dumper_toolchain._tool_version_output's/_executable_sha256's existing@lru_cache, since this phase reuses that plumbing directly rather than re-implementing its own subprocess call. Process-lifetime only (not persisted across separatecheck-project.ymlmatrix cells the waysnapshot_cache.pypersists to disk) — good enough for the current single-processproject validateinvocation this phase wires into; a cross-process cache would only matter once Phase C's matrix scheduling is the caller. - [x] Unit tests (
tests/test_toolchain_probe.py) stub the probe (_tool_identity_metadata) so the fast lane has no real compiler dependency; oneintegration-marked test class exercises it against a real installedgcc.
Phase B — per-profile AST frontend (M)¶
- [x]
profiles.<id>.compile.frontend/consumer_compile.frontend(both overlays, sharingProfileCompileSpec) accept the same values as the global--ast-frontend(auto/castxml/clang/hybrid, shape-validated againstapi_types.HEADER_AST_FRONTENDS— the same canonical fact source the CLI flag itself resolves against, not a hand-duplicated list), overriding the global default for that profile's cell only — same precedence pattern already established forcompile_gcc_path/compile_gcc_options(profile overlay wins over the global input, per the P1 toolchain-profile audit's own comment incheck-project.yml). - [x]
run_plan.py'sRunPlanCheckgains a resolvedcompile_ast_frontend/consumer_compile_ast_frontendpair, threaded the same waycompile_gcc_path/compile_gcc_optionsalready are (_compile_ast_frontend_for_profile/_consumer_compile_ast_frontend_for_profile), for both target and bundle checks. - [x]
check-project.yml's check job forwards the resolved field into the cell's real invocation asast-frontend: ${{ matrix.compile_ast_frontend || inputs.ast-frontend }}— the same per-cell-first precedencegcc-path/gcc-optionsalready use, and the step that makes a per-profile frontend real rather than projected: a GCC profile's cell resolvescastxmlwhile a Clang/DPC++ profile's cell in the same run resolvesclang, which one workflow-global--ast-frontendcannot express. The rest of the chain (actions/check-target→ rootaction.yml→INPUT_AST_FRONTEND→ the CLI flag) already existed for the workflow-level input, so nothing downstream needed a second pass-through. A profile with nocompile.frontend:(or a run-plan from an older abicheck, where the key is absent) falls back to the global input exactly as before. Gated onkind != 'bundle'(Codex review): a bundle cell's operand is thebundle-stagingdirectory it stages its members into, and the root Action rejects every non-autoast-frontendfor a directory/package operand outright (action/run.sh's_is_release_style_operandguard), because the per-library fan-out never threads an L2 compile context to each pair's header dump — so forwarding a profile'sfrontend:there would turn a previously working bundle check into a hard operational error. The fallback for such a cell is the workflow-global input, not the empty string, so a bundle cell behaves exactly as it did before this override existed. Note the same hazard exists, unfixed, forcompile_gcc_path/compile_gcc_options: the guard rejects those for a directory operand identically, andcheck-project.ymlhas forwarded them to every cell including bundles since the P1 toolchain-profile audit. That is a pre-existing bug, not one this phase introduced, and closing it is a behaviour change to already-shipped wiring — it needs its own decision (silently drop, gate the same way, or make the combination a hardproject validate/project planerror the way an unroutableos:already is), not a drive-by extension here. - [ ] Still open:
consumer_compile_ast_frontendis deliberately not forwarded, and that absence is pinned by a test rather than left to drift. It describes the header-AST pass of the two-pass extraction Phase 0 has not built, so there is only one dump invocation per cell for it to steer — forwarding it would apply a consumer overlay to the producer pass. It becomes wireable when Phase 0's extraction/merge lands, not before. - [ ] Still open: a fixture project with one GCC profile (castxml) and
one Clang/DPC++ profile (direct-clang
icpx) in the same.abicheck.yml, exercising two cells that actually invoke different frontends end to end. The wiring above is what such a fixture would exercise; the fixture itself needs a real DPC++ toolchain on a runner (same G17 dependency as Phase C's own remaining native-MSVC lane). - [x]
tests/test_project_targets_compile_frontend.py(shape validation, round-trip, both overlays independent) andtest_run_plan.py'sTestCompileFrontendOverlayProjection(target checks, bundle checks, independent resolution, no-override case) cover the schema/projection slice landed here.
Phase C — Actions-matrix native-OS scheduling + per-cell dependency source (L) — done¶
- [x]
check-project.yml's check-matrix job'sruns-on:is derived from the resolved profile'sos:field (ubuntu-latest/windows-latest/macos-latest) instead of the current hardcodedubuntu-latest. Derived once, at plan time:runner_label_for_os(project_targets.py) is the mapping,RunPlanCheck.runs_oncarries it, and the workflow readsmatrix.runs_on— so the widest-blast-radius half of this plan is a one-expression change in the reusable workflow rather than scheduling logic embedded in YAML. A profile with noos:(every profile written before this phase) resolves toubuntu-latestunchanged, whichtest_a_profile_without_os_keeps_todays_runnerpins. Two decisions worth not re-litigating:runs_onis serialized even at its default, unlike every other optional field, because a matrix entry missing the key resolvesruns-on:to the empty string and schedules nothing; and anos:naming no schedulable platform is a hard error at bothproject validateandproject plantime rather than a fallback to Linux, since a cell scheduled on the wrong platform reports success having gated the wrong thing. A GitHub-hosted runner label (ubuntu-24.04) passes through verbatim —os:was a free-form, never-consulted string before this phase, so narrowing it to platform names only would be a breaking config change dressed up as a feature. - [x]
check-project.yml/actions/check-target/action.ymlgain a per-celldependency-sourceinput, resolved from the profile the same waycompile_gcc_path/compile_gcc_optionsalready are (profiles.<id>.dependency_source→RunPlanCheck.dependency_source→matrix.dependency_source || inputs.dependency-source), forwarded to the rootaction.yml's existingdependency-sourceinput instead of only the legacyinstall-depsboolean. Both unset leaves that boolean deciding exactly as before — the root Action already owns that fallback, so the workflow forwards one expression rather than keeping a second copy of the rule. The accepted-value list is mirrored fromaction.yml(its own validation case is the fact owner) andTestActionYmlAgreesOnDependencySourcesasserts the two agree, plus thatcheck-targetactually forwards the input — a per-cell value accepted but never forwarded would be silently inert, which is the exact failure this phase is about. - [x]
docs/reference/check-target.md,run-plan-schema.md,project-targets-schema.md, andreusable-workflows.mddocument the new fields/inputs;tests/test_project_targets_scheduling.pycovers theos:-to-runs-on:resolution and thedependency_sourceschema in isolation (plain Python, no workflow run), andtest_run_plan.py'sTestSchedulingProjectioncovers the projection into target and bundle cells. - [x] The unset
dependency-sourcedefault is OS-aware. Making Windows cells schedulable for the first time exposed that the existing default is a Linux/macOS default: an unsetdependency-sourcewithinstall-deps: trueresolves toconda-forge, andaction.ymlthen explicitly hard-fails every conda-forge source on Windows (pixi'snative-toolchain*features don't cover win-64), so a Windows cell declaring nodependency_source:— including this plan's ownwindows-msvcexample — would have been scheduled straight into anexit 1before reaching analysis (Codex review, P1). Fixed in the one place that already owns the fallback rule and already knows the platform:action.yml's ownResolve dependency-sourcestep now resolves an unset value tosystemon a Windows runner. That is what the conda-forge-on-Windows error message already tells users to pick, andinstall-deps.sh's Windows branch warns and continues rather than failing, matching the "toolchain is pre-installed on the image" story an MSVC lane has anyway. Deliberately not fixed by injecting a default into the run-plan: that would have made a per-cell value silently outrank the workflow-leveldependency-sourceinput for Windows cells only. An explicit conda-forge still fails — requesting something unsupported should say so rather than be rewritten — and no existing consumer can regress, since the path this replaces was an unconditional error.TestWindowsDependencySourceDefaultextracts and runs* the real resolve script rather than string-matching it, so a future edit that keeps the wording but changes the branching still fails. - [x] The
checkjob's own shell steps resolve their Python interpreter. Three of them (Resolve candidate binary/binaries,Synthesize pre-check operational-error report,Sanitize check-id for artifact name) invokedpython3directly, which Git Bash on a Windows runner does not resolve — the Windows CPython layout shipspython.exeonly. Harmless while every cell ran on Linux; a guaranteed failure the moment this phase let one land onwindows-latest, and a compounding one: candidate resolution fails, then the envelope-writing fallback fails with it, so the cell produces no report at all rather than an operational-error one (Codex review). All three now resolve the interpreter the wayaction/run.shalready does (PY="$(command -v python3 || command -v python)"), andtest_check_job_shell_steps_resolve_their_python_interpreterfails on any future bare invocation in this job. Two pre-existing tests that extracted these steps' embedded Python by splitting on the literalpython3 -cbroke on the change and now split on the flag instead, so they no longer pin an interpreter name they don't care about. - [ ] Out of scope for this phase, still open: an actual native
windows-latestCI lane exercising a real MSVC profile end-to-end throughcheck-project.yml— that needs a real fixture project and belongs in G17 (real-world validation corpus) now that the scheduling mechanism itself has landed here.
Phase D — per-finding cross-profile reconciliation (M, depends on G32) — done¶
- [x]
AggregateResult.finding_matrix(abicheck/aggregate.py) extends the existing per-profile grouping (G32/ADR-050) with a per-finding one, keyed byaggregate_findings.resolve_report_change_identity— a new read-back adapter that runs the same tiered canonical/normalized/ reduced resolution (ADR-049 Phase 2, the identity modeldiff_filtering.py's cross-detector dedup key already uses) over a report's serializedchanges[]entry instead of a liveChange. OneFindingMatrixEntryper distinct finding perbase_target, with its ownaffected_profiles/unaffected_profileslists. Because the identity model collapses the rich-vs-symbols-only detector pair, one event two profiles report under different kinds (func_removedwhere DWARF was available,func_removed_elf_onlywhere it wasn't) is one entry carrying bothkinds, not two unrelated findings — the case a real GCC/Clang/MSVC matrix actually produces.kindsunions across every check a profile ran, not just its first, since one profile can run abinary-depth and aheaders-depth check that report the same event under the two equivalent kinds. - [x] Profiles on different C++ ABIs no longer falsely clear each other,
which the mangled-name identity alone could not prevent: one
declaration is spelled
_ZN3lib3addEiiby an Itanium toolchain and?add@lib@@YAHHH@Zby MSVC, so a Linux and a Windows profile reporting one logical removal produced two profile-specific entries, each asserting the other profile was clean of it.cross_abi_declarationrecovers the declaration's qualified name from either scheme (diff_cxx_rules.itanium_qualified_name/msvc_qualified_name— pure structural parsers, no demangler subprocess, so this works identically on every host), andresolve_cross_abi_identityre-resolves the identity from it, keeping the discriminator so a removal and an addition on one declaration are not treated as related. - [x] That key withholds a clean verdict; it never merges two findings.
Neither parser recovers parameter types, so
add(int, int)andadd(double)reduce to the same key — meaning two profiles matching on it may be reporting one shared removal or two unrelated overload removals, and nothing in a report distinguishes those. Withholding needs no proof and is therefore safe; merging asserts a pairing the evidence does not establish, so it is not done. A profile holding a finding on the same declaration under another mangling is reportedundetermined, and the shared declaration is exposed on the entry so a consumer can present the two together without the report claiming they are one finding. - [x] Withholding applies only where the spellings cannot be compared.
Sharing a qualified name is not itself ambiguity: two Itanium manglings
encode their parameter types, so
_ZN3lib3addEiivs_ZN3lib3addEdproves two distinct overloads, and reporting either profile as merely undetermined threw away real precision on the commonest configuration of all — a GCC and a Clang profile, both Itanium. The clean verdict is now withheld only when the other profile's spelling is in a different scheme (Itanium vs MSVC, not comparable without a type-encoding translator this module does not have) or normalizes to the same symbol. That second case is the Mach-O quirk: a macOS toolchain prefixes an extra leading underscore, so one entity appears as two raw symbols and two primary identities —comparable_mangled_symbolrecognizes them as one. (Fifth Codex review round; the Mach-O sub-case was found while implementing it, and is why the rule compares normalized symbols rather than just schemes.) (An intermediate revision did merge when each profile contributed exactly one identity; the third Codex review round pointed out that cardinality is not evidence — Linux losingadd(int, int)while Windows losesadd(double)passes that check and would have been published as a single all-profiles finding. Reverted to withholding only.) - [x] The Mach-O case is a merge, not a withholding. Withholding was
still the wrong answer for it: the two spellings normalize to
byte-identical complete Itanium encodings, parameter types included,
so they are provably one declaration — the exact evidence the
cross-ABI case lacks. Left split, a Linux and a macOS profile reporting
one removal produced two
undeterminedentries where oneall_profilesfinding is the truth._merge_equivalent_spellingsre-keys such identities onto one before the matrix is built, keyed on(cross_identity, comparable_symbol)— never on the qualified name alone, so two genuinely different overloads spelled by a Linux and a Mach-O toolchain stay two findings exactly as two Linux toolchains' would. (Sixth Codex review round; the distinction between this merge and the one the third round reverted is whether the whole mangling matches, not how many identities each profile contributed.) - [x] A finding entry is validated before it counts as enumerated: being a
JSON object is not enough, since one with no
kindstill parses into a contentless REDUCED-tier identity and would let a garbage array read as an exhaustive finding set.kindmust be a non-empty string and every other identity-essential field must be a string when present — a wrong-typed value is rejected rather than coerced, since coercion would mint an identity from a spelling no producer emitted. Valid siblings in the same array stay usable. (Third Codex review round; the same false clean claim as the non-object case, one level down.) - [x] The rule that validation follows is accept exactly what the identity
resolver handles, no less.
old_value/new_valueare annotatedstr | Nonebut the annotation is not runtime-enforced, anddiff_python.py'spython_stable_abi_violationemissions really do pass a list (new_value=sorted(group)), which_change_to_dictpreserves verbatim into JSON —finding_identity._stringify_change_valueexists precisely to fold that shape. A first cut of the validation above rejected it, which dropped a genuine finding and marked its report incomplete, demoting a demonstrably affected profile to undetermined. List/tuple values are now accepted for those two fields and forwarded intact rather than filtered toNone, so they discriminate the identity as they should; every other field stays string-only. (Fourth Codex review round — a regression the third round's fix introduced.) - [x] A third list,
undetermined_profiles, was added beyond the original scope and is the load-bearing one: a profile whose findings are not fully known is neither affected nor unaffected. Without it, this view would answer "profile X is clean of this finding" for a profile that was never checked — the per-finding form of exactly the "an expected target with no report is unknown, never compatible" invariant the rest ofaggregate.pyis built on.ReportFindingscarries the findings and a separatecompleteflag so the two facts cannot be conflated, andFindingMatrixEntry.scopeanswersundeterminedahead of every other value whenever any profile is in that state. Six things fall short of complete: a missing/unreadable/not-comparable report, a report with no finding array, a report with an unparseable or non-conformant array element, acompare-releasereport, ascan --againstreport (gating buckets only, capped), and a report whose array was narrowed for display rather than enumerated. Onlycompletemay clear a profile; an incomplete report's findings are still read, since seeing a finding proves it is there while not seeing one proves nothing. - [x] All three report shapes participate. A
comparereport carrieschanges; acompare-releasereport — what akind: bundlecheck produces, since a bundle comparison routes through the per-library release fan-out — has nochangesat all and instead carriesbundle_findings/matrix_findingsplus per-library entries that are only counts; ascan --againstreport itemizes its gating buckets underdiff.findings. All three arrays are parsed, so bundle and scan-baseline targets reconcile rather than being written off as unknown (sixth Codex review round for the scan shape, which previously vanished out of the matrix entirely); the missing per-library detail — and, for scan, the deliberately-omitted compatible findings plus the 20-entry cap — is exactly why such a report can never becomplete. A bundle finding'sconsumer_library/provider_libraryare folded into its description usingBundleFinding.to_change's own"[consumer ← provider] "flattening, so two findings differing only in which library pair they are about stay two findings. (Both this item and thecompleteflag above came from the PR's Codex review; the original slice treated every release report as unknown and let a partially-unparseablechangesarray read as an exhaustive one.) - [x] An entry must carry the fields the compare-report schema marks
required on a
changes[]entry before the array counts as enumerated — akindalone is not something a conformant producer wrote, so it is no evidence the array is exhaustive. Present-but-nullis accepted: a producer really does emit those keys withNonefor a finding carrying no before/after value, and that is emitting the field rather than omitting it. (Sixth Codex review round — the type-only validation from the third round left the "field absent entirely" hole open.) - [x] Readability and conformance are two questions, answered separately.
A first cut folded both into one predicate over
("symbol", "description"), which was wrong in both directions (seventh/eighth Codex review rounds). Too narrow:old_value/new_valuearerequiredby the schema and part of the identity discriminator, so an entry omitting them resolved to a different identity while its report stayedcomplete— each profile then listing the other as unaffected. Too broad:cli_scan_baseline's own findings carry noold_value/new_value/severityat all (verified against the producer, not assumed), so simply extending the one predicate would have dropped everyscan --againstfinding the round before had just made readable. Split into_is_usable_finding_entry(can an identity be resolved — governs whether a finding is kept, and a kept finding can only ever convict) and_is_conformant_change_entry(did a conformant producer write it — governs whether the array is exhaustive, the only thing that can clear a profile). The mirrored required-field list is checked againstcompare_report.schema.json's ownrequiredbyTestSchemaRequiredFieldsAgree, since the schema is the fact owner. Test fixtures now buildchanges[]entries through one_change_entryhelper that fills the required set — hand-written near-miss fixtures are how this validation drifted from producer reality to begin with. - [x] Conformance checks the schema's declared types, not just key
presence. A first cut of the split above tested only that each
required key existed, so
symbol: null— schema-invalid, and read as an empty spelling that resolves to a different identity than the same finding elsewhere — still counted as conformant and left the reportcomplete(ninth Codex review round). The nullability split is the schema's own:old_value/new_valueare declared["string", "null"]because a finding with no before/after value really is emitted that way, whilekind/symbol/description/severityare plain"string"._CONFORMANT_CHANGE_FIELDSbecame a field → nullable map andTestSchemaRequiredFieldsAgreenow pins both halves to the schema.severityis the one field where the two predicates genuinely disagree — required by the schema but not part of the identity, so a non-string there is readable yet non-conformant. - [x] Structured values are validated element-wise, and so is
affected_symbols. Checking only the container letold_value: [{"bad": 1}]through, which_stringify_change_valuefolds into the spelling"{'bad': 1}"— an identity no producer wrote (eleventh Codex review round).affected_symbolswas worse: the adapterstr()-coerced every element, so a[123]became the spelling"123"and could collide with an unrelated profile's genuine"123"symbol — andresolve_change_identityfolds that whole set intoheader_binary_context_mismatch's discriminator. It is no longer coerced (a non-conformant array reads as absent) and is validated in conformance despite being optional, since it is identity-bearing when present. - [x] Same scheme is proof of distinctness for Itanium only. An MSVC
decoration can encode the target ABI rather than the declaration:
ARM64EC inserts a
$$htag, so one declaration is spelled?add@lib@@YAHHH@Zon x64 and?add@lib@@$$hYAHHH@Zon ARM64EC — verified, both reduce tolib::addthroughmsvc_qualified_name. Two Windows profiles on different targets were therefore reported clean of each other's identical removal (eleventh Codex review round). MSVC pairs now withhold; Itanium keeps its precision, which matters because a GCC/Clang matrix is the commonest configuration there is. Withheld rather than normalized away, because$$his the decoration this module can name, not demonstrably the only one — and withholding needs no such proof, which is this module's whole asymmetry. - [x] A
compare-releasereport that errored still contributes its findings._format_release_jsonemitsbundle_findings/matrix_findingsfor whatever completed, independent of the top-level verdict, butaggregate._load_report_filereturned early onERRORand dropped them — losing real evidence from the profile most likely to differ (eleventh Codex review round). They are now parsed and explicitly marked incomplete at that call site, where the reason (the run errored) lives, so they convict their own profile and clear no other. The forced blocking exit anERRORreport already carries is unchanged, with a test pinning it. - [x] The structured-value carve-out covers exactly
old_value/new_value. The type check above initially acceptedstr | list | tuplefor every required field, soseverity: []still read as conformant (tenth Codex review round). The carve-out exists for one reason —diff_python.pyreally passes a list for those two fields and the identity resolver folds it — so it is now scoped to them alone; every other required field is a plain string or nothing. - [x] A well-formed array is not by itself proof that nothing else was
found:
compare --show-onlynarrowschanges(reporter.to_json) while the verdict, the gate, and thesummaryblock all keep describing the whole diff, so a profile that hid a finding read as one that did not have it. Two signals are checked, because neither covers every report mode —show_only_filter, emitted by full and root-cause mode, and asummary.total_changesexceeding the array's own length, which is the only signal--report-mode leafleaves. The count comparison is the general form of the question, so any future display filter that narrows the array while leaving the summary whole is caught by it alone. (Seventh Codex review round;TestDisplayFilteredReportsverifies it against the real producer in all three report modes rather than against hand-built dicts.) - [x] A finding present on every profile and one present on only one are
distinguished by an explicit
scopediscriminator (all_profiles/profile_specific/partial/undetermined) in both the JSON (finding_matrix, aggregate schema1.2, published inabicheck/schemas/aggregate_report.schema.json) and the text output'sCross-profile findings:section. Note on the acceptance criterion's wording:aggregatehas--format json|textand no Markdown renderer, so "JSON/Markdown" landed as JSON + text; adding a Markdown format foraggregateis separate surface, not part of this phase. - [x]
tests/test_aggregate_findings.py(a sibling oftest_aggregate.py, mirroring the source split) covers the merged/split shapes (same finding on all profiles, profile-specific, partial), the rich-vs-L0 kind collapse, every source ofundeterminedincluding the partially-malformed array, the release report, the scan report, and the display-filtered one, bundle/matrix/scan findings participating, bundle attribution keeping distinct library pairs apart, "affected outranks undetermined across one profile's own checks", ordering stability, the unknown-future-kind degradation, that the gate exit code is unmoved, and schema validation of the new block.
Not part of this phase, deliberately: finding_matrix is a reporting
view only — it never contributes to exit_code(). A cross-profile gate
("fail when a finding is profile-specific") would be a new policy axis and
needs its own design, not a drive-by addition to a reconciliation view.
Design¶
The producer/consumer split (Phase 0) is additive schema, not a rewrite:
ProfileCompileSpec already carries every field a consumer overlay needs
(project_targets.py:663); the new block is a second, optional instance of
the same dataclass, resolved through the same _compose_gcc_options/
binding-resolution path (run_plan.py) but applied only to the L2/L4
extraction step instead of the whole cell. The toolchain-identity probe
(Phase A) is new subprocess-probing logic but slots into the same place
run_plan.py's docstring already names as the missing piece ("checking the
resolved executable against compiler_family... requires a real subprocess
probe and is not implemented"). Phase C's os:-to-runs-on: mapping is
pure data transformation inside check-project.yml's existing matrix
generation step (Python, not new infrastructure) followed by a matrix
runs-on: expression keyed off it. Phase D reuses finding_identity.py
and diff_filtering.py's existing identity-resolution machinery rather
than inventing a second one.
Files & surfaces¶
abicheck/buildsource/project_targets.py—ProfileCompileSpec, new consumer-overlay dataclass/field, shape validation.abicheck/buildsource/run_plan.py— per-cell resolution of the consumer overlay, the toolchain-identity probe call site, per-cellast_frontend/dependency_sourcefields.abicheck/dumper_hybrid.py— extendmerge_snapshots()(or add a sibling merge path) for producer-toolchain L0/L1 + consumer-toolchain L2/L4 merging.abicheck/aggregate*.py/abicheck/finding_identity.py/abicheck/diff_filtering.py— Phase D's per-finding reconciliation..github/workflows/check-project.yml,actions/check-target/action.yml,action.yml— Phase C's scheduling and per-celldependency-source.docs/reference/check-target.md,docs/contribute/adr/047-*.md(or a new ADR if the producer/consumer split is judged architecturally significant enough at implementation time — this plan doesn't presume which).
Tests¶
Covered per-phase above. No golden-file changes expected (additive schema + new aggregate fields, not a change to existing output shapes).
Example fixtures¶
A new tests/fixtures/run_plan/producer_consumer_matrix/ (sibling to the
existing tests/fixtures/run_plan/toolchain_matrix/) with one producer
profile and two consumer overlays (GCC client, Clang client) — mirroring
the existing toolchain-matrix fixture's own disclaimer that it exercises
config/toolchain projection, not real compiler execution; a real
end-to-end GCC-vs-Clang-consumer example (compiled, not just projected)
belongs in G20's example catalog once this plan's schema lands.
Effort & risk¶
XL overall, phased as scoped per-phase above (S/M, L, M, L, M) so each phase is independently mergeable and independently risk-assessed rather than one large cross-cutting PR. Phase C carries the highest blast-radius risk (a widely-consumed reusable workflow); Phase A carries the highest correctness risk (a probe that's wrong in either direction — false-reject a valid toolchain, or false-accept a mismatched one — directly gates every project using it).
Out of scope¶
- A real native
windows-latestMSVC end-to-end fixture (belongs in G17 once Phase C's scheduling mechanism exists). - A full
artifact_profiles:/consumer_profiles:/compatibility_matrix:three-block schema redesign as sketched in the originating review — Phase 0 intentionally ships the smaller "one optional consumer overlay per existing profile" version first; a fuller redesign is a candidate follow-up only if Phase 0's overlay shape proves insufficient in practice. - Vendor/accelerator frontend correctness (CUDA, OpenACC, non-DPC++ SYCL toolchains) beyond what the existing direct-clang backend already recognizes as clang-family — no new frontend work is implied by this plan.