Known gaps — acknowledged remaining work¶
This page is the full, unabridged history of every investigated-but-unfixed
gap, reverted fix attempt, and multi-round review finding this codebase has
accumulated — relocated here, verbatim, from AGENTS.md's own "Known gaps"
section (2026-08-28) purely to keep that file's per-session context cost
down. Nothing was trimmed, summarized, or rephrased in the move: every
"Not fixed here," every "Reverted," every review-round citation, and every
code reference is preserved exactly as it read in AGENTS.md.
/AGENTS.md (imported into /CLAUDE.md via @AGENTS.md, and the
target .github/copilot-instructions.md points back to) remains the
canonical, primary contract for this repository — read it first. This page
is load-bearing institutional memory, not a changelog: per this
repository's own "Fix the cause, not the instance" and "known gaps over
risky reactive patches" conventions (see AGENTS.md's "Decision-making
principles"), read the relevant entry below before re-attempting a fix in
an area it already covers — several entries record a heuristic or a
narrow patch that was tried, reviewed, and reverted specifically because it
looked like the obvious fix and wasn't.
Known gaps — acknowledged remaining work¶
- A
kind: bundlecheck still cannot run atdepth: headers: only the baseline half of per-member header staging is wired (2026-09-12).abicheck/buildsource/bundle_member_snapshots.pynow resolves and stages each bundle member's own historical baseline snapshot -- the per-member header evidenceactions/baselinealready dumps from the baseline checkout -- into a clean, one-snapshot-per-member directory usable as a directory-compareold-side operand, and reports per member whether that snapshot genuinely carries header-derived evidence (AbiSnapshot.from_headers), exposed byactions/resolve-baselineas themember-snapshots-dir/member-header-evidenceoutputs (stage-member-snapshots: true).BUNDLE_CHECK_DEPTHSandactions/check-target/validate-inputs.share deliberately unchanged: the restriction is a false-clean guard, and parts of the evidence path are still missing, any of which alone would re-create the very silent-miss it prevents (updated 2026-09-13: (a)'s stated blocker is gone, its routing is not). (a)check-targetstill routes the bundle compare atbinaries-dir. The reason recorded here through 2026-09-12 -- that the cross-library bundle graph skips snapshots, so the old side would trade header evidence for bundle-graph evidence -- no longer holds, and this entry said so for longer than it was true.abicheck/bundle.pynow reads a stored member as bundle-graph evidence on both shapes: a directory-backedProjectSnapshotsub-package (_stored_elf_metadata) and a loose, file-backed snapshot, content-sniffed rather than recognized by suffix (_stored_file_snapshot_evidence, which also recovers the member's real on-disk filename fromAbiSnapshot.libraryso a sibling'sDT_NEEDEDstill resolves). A directory of loose per-member snapshots staged byactions/resolve-baseline(stage-member-snapshots: true) is therefore a usable old-side operand that keeps both kinds of evidence -- measured on a real six-member bundle: bundle layer present, no phantombundle_library_added, per-member findings matching the single-pair control leg. What remains is the routing itself (check-targetpassingmember-snapshots-dirinstead ofbinaries-dir), plus (b) below, plus the cost calibration in (c). (b) The candidate side still has no per-member header selection.check-project.ymlcarries one project-wideheader:input, and the release fan-out resolves headers run-wide (cli_compare_release_helpers._resolve_release_headers, threaded to every pair) rather than per member. A bundle whose members have disjoint public header sets would therefore parse each member's candidate side against the union. (c) Header-depth bundle cost still does not fit the runner. The per-worker memory budget half of this is closed:release_job_mem_budget_gib(depth)now defaults to 4.0 GiB atheaders(6.0 atbuild/source) instead of the binary-depth 1.0 GiB, so the fan-out clamps its worker count instead of overcommitting. What remains is the total, and the wall-clock and memory halves of it are now at different stages -- do not read this gap as closed by either alone.
Wall clock, improved but not on a verified receipt here. The ~84 min
figure this entry has carried is the original six-member observation.
A later external oneDAL experiment on the same profiled setup reports the
same shape at 2,247 s wall / 2,979 s CPU (37:27), against 4,814 s /
5,950 s CPU on the older tree. That is a reported measurement from
outside this repository, taken under a profiler, and it is recorded here
as such: it has not been reproduced in-tree, no oneDAL binary is present
in this workspace to reproduce it against, and a profiled run's overhead
does not subtract cleanly from an unprofiled one. It also measures the
scan, not the job -- setup, download, dependency resolution and output
publication are outside it, so it does not by itself establish that the
job fits a 45-minute timeout-minutes.
Memory, reduced but still short of the target. The same experiment reports 19.93 GiB peak RSS, essentially unmoved from the original 20.61 GiB -- so the speedup did not come with the memory fix this gap needs. The 16 GB runner is the binding constraint, and 16 decimal GB is ~14.90 GiB, not 16: a ~20 GiB peak is over the real byte limit by a third, not by a rounding error.
One contributor to that peak is now removed in-tree and measured:
policy/depth_projection.py answered every --depth rung with one
unconditional copy.deepcopy, so a --depth headers projection
duplicated an entire L2 surface in order to rebind two fields
(build_mode, build_source), neither of which is read-modified-written
at that rung. On a real 327-type/4,802-function header snapshot that cost
+34% resident (0.516 -> 0.691 GiB) and +12.5 s per comparison, paid once
per fan-out member, concurrently; the copy is now scoped to what each rung
actually writes to, and the same projection is 0.000 s and +0.000 GiB with
byte-identical findings. This is a contributor, not the fix: it was
measured on a local C++ fixture, not on oneDAL, and it removes a
duplicate of the surface rather than shrinking the surface itself, so
the resident baseline each worker holds is unchanged. Whether it moves the
bundle peak enough to matter is unmeasured, and a re-derived
release_job_mem_budget_gib headers constant deliberately was not
taken on the strength of it -- lowering that constant on an unvalidated
expansion factor is the same mistake the "conservative fix ... implemented
twice and reverted twice" paragraph below records.
A second contributor is now removed, and this one is an admission
change rather than a per-copy one. workflows/release_jobs.py clamped the
fan-out with int(available / budget) -- it committed 100% of what the
memory probe reported to worker working sets and reserved nothing for the
state resident beside them. That state is not hypothetical: the fan-out is
a ThreadPoolExecutor, so every member's result accumulates in the same
address space its workers allocate in, the shared header-depth context
(AST/template indexes, acquisition and metadata reuse) is live throughout,
and MemAvailable is sampled once, at pool-sizing time, before any of it
exists. Measured on this repository's own 16 GB reference host: headers
depth probed 13.17 GiB available and admitted 3 workers at the 4.0 GiB
budget -- a 12.0 GiB commitment, 91% of available, with the parent's
share still to come out of the remaining 1.17 GiB. Workers are now
admitted against committable memory (a utilization fraction, less a flat
reserve; ABICHECK_RELEASE_MEM_UTILIZATION /
ABICHECK_RELEASE_MEM_RESERVE_GIB), which on that same host admits 2
workers for an 8.0 GiB commitment (61%). It can only ever admit fewer
workers than the rule it replaces, still floors at one, still skips
entirely when RAM cannot be probed, and still scales -- a 32 GiB host
admits the full six. Binary depth is unchanged on any host that was not
already memory-clamped, so an ordinary compare OLD_DIR NEW_DIR is sized
exactly as before.
This is still not the receipt, for a specific and measurable reason.
Admission control bounds the concurrent half of the peak only. The
other half is retention, and it is bounded on the default path but not on
all of them: cli_compare_release_pairwise keeps a compact
BundleSignatureEvidence per member normally, but under
need_full_snapshots -- which JUnit output and --bundle-facts-out both
set -- it retains every member's full old and new snapshot
simultaneously, for the life of the fan-out. For a six-member bundle at
header depth that is twelve full L2 surfaces held at once, a quantity no
worker-count clamp can reduce, and the reported 19.93 GiB peak was taken
on a shape that may include it. Closing that means spooling completed
members' full results through the existing storage contracts rather than
holding them, which is a larger change than this one and is deliberately
not attempted here. Until it is done, a bundle run that requests JUnit or
bundle-facts output should not be assumed to fit the 16 GB runner on the
strength of the admission change alone.
So the routing decision this entry gates still needs a real receipt:
a full six-member run, unprofiled, under the actual runner's byte limit
and timeout-minutes, with peak RSS sampled externally. Routing a bundle
check at header depth needs that receipt (and, if the peak is still ~20
GiB, a runner/timeout decision), not another budget constant.
One further hole in the same budget is open and deliberately not closed
here: the depth a worker reaches is inferred from --depth and from
whether header roots were supplied, and a release whose members are
already-serialized snapshots answers neither -- such a run types no depth
and supplies no roots, so it is budgeted as binary depth while each worker
holds two possibly deep-evidence snapshots resident.
The conservative fix -- size such a run at the table's largest rung, since
the captured depth is not cheaply legible (reading it means decoding the
very snapshots whose size is the problem, once per member, before the
fan-out that would have loaded them lazily) -- was implemented twice and
reverted twice, and the second attempt is the one worth recording, because
the first revert was argued from a bad measurement. The claim then was that
it "moved 75 tests from passing to failing"; that number came from a run
racing concurrent local test sessions, and a clean run of the same suite
gives 8 failures against main's 13, none in the affected family. So the
measurement was noise and the reasoning that rested on it was wrong.
Re-measured properly, serially, the change still moves 21 release tests
from passing to failing, for a real reason rather than load: at a 6.0 GiB
per-worker budget an ordinary host clamps the fan-out from 4 workers to 1
for every stored-baseline comparison -- the most common workflow this
tool has -- and the clamp notice that then appears changes the command's
output. That is a defensible trade against an OOM-killed job and a bad one
against a package of small snapshots, and it is not this PR's trade to
make: it belongs in its own change, where the worker-count reduction, the
21 tests' expectations, and a measured encoded-to-resident expansion
factor (the accurate signal is a member's resident cost, for which on-disk
size is the cheap proxy, but the budget model is depth-keyed) can be
reviewed together.
(d) No first-party Action emits a bundle-facts document. The
producer itself exists and is reachable -- compare's release fan-out
takes --bundle-facts-out, forwardable today through the root Action's
extra-args -- but no Action declares it as an output, so the
stored-OLD-side bundle route has no supported CI producer to pair with
its (fully wired) consumer. Deliberately not closed by adding a root
command: "write this pipeline stage's artifact" is precisely the shape
ADR-054's admission bar rejects (AGENTS.md, "Adding a new top-level
command", items 3 and 5). The right slice is an output on the Action
that already runs the fan-out, not new CLI surface.
Until these land, a bundle check stays binary-depth, and staging is
additive: it changes no existing outcome, and every pre-existing
invocation (which does not pass stage-member-snapshots) is unaffected.
Covered by tests/test_bundle_member_baseline_staging.py, whose central
test proves executably -- over generated member sets, against an oracle
independent of every abicheck detector -- that a header-derived change
is detected per member once the old side comes from the staged
baseline, and is not when both sides come from the candidate (today's
behavior).
unversioned_exported_symbol(ADR-035 D8) fires on a real library's base/default-version-bound symbols — real, by-design, not fixed here (2026-09-07, cross-source-stage-automatic PR). Found once ADR-068 §3 row 3's first slice madechecker.compare()run this check automatically: self-comparing a real, canonically GNU-symbol-versioned C library (zlib1g, exercised by.github/workflows/realworld- validation.yml's "Ubuntu package and bundle smoke" job) reportsCOMPATIBLE_WITH_RISKwith oneunversioned_exported_symbolfinding per base-API function —readelf -V/objdump -Tconfirm every one of them is legitimately bound to the anonymous/default GNU version node (Base, i.e. the library's own SONAME), the standard, permanent convention for a versioned library's original API: symbols present before the library adopted symbol versioning stay on the base version forever, and retroactively assigning one a named version tag would itself be an ABI break._check_unversioned_exported_symbol(buildsource/cross_source_checks.py) cannot currently distinguish "bound to the base version" from "genuinely absent from.gnu.version" — both leaveElfSymbol.version == ""(elf_metadata.py's_apply_version_to_symbolintentionally never populatesversion/is_defaultforver_idx < 2, and_build_verdef_indexintentionally excludes the base/VER_FLG_BASEentry fromver_index_map), so nothing in the persisted model lets the check tell them apart.
Why not fixed here. A sound fix needs a real distinguishing fact
(e.g. a new ElfSymbol field recording "explicitly bound to the base
version"), which is a model/+storage/ schema change (AbiSnapshot.
SCHEMA_VERSION bump, elf_from_dict/elf_to_dict wiring in
snapshot_platform_blocks.py) — real, scoped work, but a materially
larger and riskier change than "make an already-migrated check
reachable," and touching the shared ElfSymbol.version field's value
for the base case (the cheaper-looking alternative) risks silently
changing every other detector that already reads version == "" as
"unversioned" (symbol-version add/remove detection, OLD/NEW symbol
matching by (name, version), and others) — an unbounded blast radius
for a targeted fix.
Why it stands regardless. buildsource/cross_source_checks.py's own module
docstring states the design tolerance this falls squarely inside: these
findings "are never BREAKING on their own... default to RISK or
API_BREAK and are advisory/suppressible until a check earns its
FP-rate-gate corpus and is promoted" (ADR-035 D4). The smoke-test
assertions were widened to accept COMPATIBLE_WITH_RISK alongside
NO_CHANGE/COMPATIBLE rather than silently suppressing or re-gating
the check — per this repository's standing rule against trading a
flag for a real capability (ADR-068 D5). Next step, if picked up:
the model change above, or narrowing the check to require some other
corroborating evidence of genuine omission (unresearched; a naive
ratio/threshold heuristic is explicitly not the fix — see this file's
own "attempted twice, reverted twice" discipline for why an unvalidated
heuristic is worse than a documented gap).
dump -H <dir>changed which channel carries the directory when the ELFdumpCLI migrated onto the shared typed executor — recorded, deliberately not reverted (ADR-063 Track 1, 2026-09-05). Found while retiringcli_dump_helpers.perform_elf_dump: the one assertion in its test suite that could not be rehomed, because the behaviour it pinned is no longer the behaviour.perform_elf_dumpsplit its-Hlist withheader_utils.split_public_header_inputsand routed the directory half intodumper.dump'sscope_header_dirs— folded into the extraction contract's scope, with ADR-015 declaration-provenance tagging deliberately left off (tests/test_dumper_contract_wiring.py:: test_scope_header_dirs_does_not_enable_provenance_taggingstill pins that distinction at thedumperlevel, which is where it lives). The typed request performs the identical split inservice_dump_pipeline. resolve_dump_requestbut passes the directory as a realpublic_header_dirsentry, so a plaindump -H include/now does tag provenance for everything under it.scope_header_dirshas no typed-request field populating it at all any more —api_types.pysays as much, where it excludes the field from thedump_manifestmutual-exclusivity set.
Why it stands. This is convergence, not drift: compare has always
treated its own -H list this way, and dump's own
--public-header/--public-header-dir pair — removed earlier precisely for
saying the same thing a second way — is what the new behaviour matches. The
original comment on perform_elf_dump's fold gave "so a dump --header
<dir> baseline and a live compare --header <dir> candidate of the
identical header set agree on scope_fingerprint" as its whole purpose;
both commands now reach that agreement through one shared split rather than
two hand-maintained ones. Reverting would mean re-introducing a dump-only
channel and a dump-vs-compare provenance asymmetry to preserve a
behaviour whose own stated goal the convergence already serves.
What is not established. Nobody has measured whether the added
provenance tagging changes any real snapshot's classification for a
directory operand that contains genuinely private siblings — the analogous
hazard the public_include_search_dirs rule exists for (an auto-derived
umbrella directory holding a private sibling header). The difference is
that -H <dir> is an explicit operand, not an auto-derived one, and
declaring a directory public is exactly what typing it means; that is the
reasoning, not a measurement. If a real case turns up where it is wrong,
the fix is at the split (split_public_header_inputs' contract, shared by
both commands), not by restoring a per-command channel.
tests/test_dump_cli_execution_behaviors.py::
test_dump_header_directory_reaches_the_extraction_scope pins the current
channel with this reasoning attached, so the next reader finds the decision
rather than re-deriving it from a diff.
--build-targetsilently does nothing when combined with a pre-captured Bazel--build-info(anaquery/cqueryjsonproto), on bothdumpandscan— investigated, not fixed (Codex review, fresh evidence, P0.2 follow-up). Fixed (ADR-063 Phase 4, "option 2" below): the combination now raises a clean usage error instead of silently collecting an unscoped graph. Status note, added once this was closed:scan(including everyscan-specific mechanism this entry narrates —scan_bazel_scoping_failure,ScanRequest,cli_scan.py) was later removed outright (ADR-068 Phase 6), anddump's own--build-targetCLI flag was removed after it (oncescan's removal resolved the routing hazard that had deferred it)..abicheck.yml'sbuild.targetsis now the only front-end-reachable source of root-target scoping fordump/compare; the historical narrative below (written while both the flag andscanstill existed) is kept for the reasoning it records about the underlyingbazel_target_scoping_failure/_discovered_config_build_targetsmechanism, which is unchanged.abicheck.workflows.plan.bazel_target_scoping_failure()is the one check bothdump/compare(viaAnalysisPlanner, wired intoservice_dump_pipeline.resolve_dump_request/service_compare_pipeline. resolve_compare_request) andscan --against's own candidate resolution (which has noCompareRequest/DumpRequestof its own to resolve throughAnalysisPlannerand so calls the same free function directly) now run before collecting anything —dump/compare/scanalike raisePlanningError(framework-neutral) from the engine layer, translated toclick.UsageErrorat each CLI boundary (cli_resolve.py/cli_buildsource.pyfordump/compare;cli_scan.py's ownscan_cmd, around itsrun_scan_corecall, forscan).scan_engine._build_new_ snapshotoriginally raisedclick.UsageErrordirectly instead — cheaper in lines, but it leaked a Click-specific exception type out of the same engine function the typedrun_scan(ScanRequest(...))API calls, with no Click context to catch it in (a third Codex review round, fresh evidence). Fixed by raisingPlanningErrorthere instead and adding the translation atcli_scan.py's boundary, matching thedump/comparepattern. A fourth Codex review round found that placement itself was still wrong:_build_new_snapshotruns afterrun_scan_core's own S3 pattern-scan and points-of-interest work, so a typedrun_scan()/run_scan_subprocess()caller — which has nocli_scan.pypre-flight ahead ofrun_scan_corethe way the CLI does — paid for that (cheap but real) work before the rejection fired. Fixed by moving the check to the very top ofrun_scan_core, guarded on the samecollection_for_ci_mode(collect_mode)emptinessworkflows.plan._check_bazel_target_scopingalready uses for itsdepth=binaryexemption, and removing it from_build_new_snapshotentirely rather than leaving a second, independently-maintained copy — which is exactly what the removed copy was: testing the move found the old_build_new_snapshot-only check had nodepth=binaryexemption at all, a real, separate bug the earlier "scan's own path was already immune" note only verified for the CLI (whose_normalize_depth_inputsprunesbuild_infotoNoneat that depth) and never checked for the typed API (service_scan.run_scandoes not prune it).tests/ test_bazel_root_targets_scan.py'stest_run_scan_typed_api_raises_ planning_error_not_click_usage_error(raisesPlanningError, notclick.UsageError),test_run_scan_rejects_before_wasted_pattern_scan_ and_poi_work(fires before the S3 pass, monkeypatch-verified), andtest_run_scan_depth_binary_exempts_the_early_bazel_scoping_check(the exemption holds at the new call site) pin all three properties together. All three CLIs still name the mismatch and the documented workaround, exit 64, not a silent, unscoped collection. The same fourth round also foundscan --artifact-set's own pre-flight check (cli_scan.py's_run_artifact_set) had never had thedepth=binaryexemption at all — unlike the single-binary path, whose_normalize_depth_inputsprunesbuild_infotoNoneat that depth before its own copy of the check runs,_run_artifact_setchecked the raw, unpruned inputs directly. Fixed by adding the same(depth or "").lower() != "binary"guard used elsewhere;tests/test_bazel_root_targets_scan.py:: test_scan_cli_artifact_set_depth_binary_exempts_the_bazel_scoping_checkpins it. A second Codex review round found this only coveredInputSpec. build_targets(the--build-targetCLI flag/typed-API field) — a root- target scope declared in.abicheck.yml's ownbuild.targets:instead reachesBuildConfig.targetsthroughembed_build_source's own CLI- overrides-config merge, a value none of the request-level pre-flight checks above can see (no config is discovered yet at that point). Fixed at the one place both sources are already unified into a single value:buildsource.inline._maybe_collect_bazel_build_info()itself, which everyembed_build_sourcecaller (dump/compare/scanalike) goes through — it now raisesValidationErrorwhen given a non-emptyconfigured_targetsand a pre-captured jsonproto. Note this closes the silent half everywhere, but the exit code still varies by front end because of a pre-existing, deliberate design choice one layer up:workflows.artifact.execute.embed_side_build_source(the shared primitivedump's typed request,compare, andscanall resolve through) already flattens anyValidationErrorfromembed_build_sourceintoSnapshotError("this surface has always flattened both onto SnapshotError" — see that function's own comment), so those three paths now fail loudly with a correctly-typed error at exit 1, not exit 64; only thedump --sources <tree>(noSO_PATH) source-only path, which callscli_buildsource.embed_build_source's own adapter directly and was never routed through that flattening primitive, gets the full exit-
Changing the shared primitive's flattening behavior to recover exit-64 uniformly is a separate, riskier change (that comment's own "has always" — other
ValidationErrors reaching it likely already rely on the flattened exit 1) and was not attempted here. Seeabicheck/workflows/plan.py's own module docstring anddocs/contribute/plans/one-semantic-pipeline.md's Phase 4 "Landed"/ "Still not landed" notes (ADR-063 itself no longer carries a per-phase status entry — its duplicated status block was removed by PR 0, 2026-09-02) for what changed and what this fix deliberately does not attempt (option 1 below, teaching the adapter to filter an already-parsed graph, remains unimplemented). A third Codex review round found a remaining dry-run/execution parity gap for this same.abicheck.ymlcase, investigated and deliberately left open rather than fixed reactively.dump --dry-run(cli_dump_helpers. render_dump_dry_run) never callscollect_inline_pack/_maybe_collect_bazel_build_infoat all — a dry-run resolves and renders purely fromresolve_dump_request'sResolvedDumpRequest, which stops well beforeexecute_dump_requestever reachesembed_build_source. So when the root-target scope comes only from.abicheck.yml'sbuild.targets:(no--build-targetflag, the case the fix above covers), the dry-run preview reports success for a request the real run then rejects — the same shape of parity gap the earlierscan --dry-run/scan --artifact-set --dry-runfix (above, in the first Codex round) closed for the CLI-flag case, just not yet closed for the config-sourced one, and not yet checked oncompare --dry-run/scan --dry-runeither (neither discovers.abicheck.yml's build config during their own dry-run preview today). Fixed in a later session, closing this gap fordump/compare/scanalike. Neither of the two designs originally floated here turned out to be necessary in the form stated:AnalysisPlanner's ownSidePlanalready carriessources(exactly what auto-discovery needs), andscan'sScanRequestalready carries bothsourcesandbuild_config(the explicit--configoverride — a seamdump/comparedon't have at the request level at all, so only their auto-discovery half closes).workflows.plan.bazel_target_scoping_failure/scan_bazel_scoping_failuregained two new, defaulted keyword parameters (sources,build_config): when the request's ownbuild_targetsis empty, the check now falls back to whatever an explicitbuild_configor an auto-discovered.abicheck.ymlatsourcesdeclares underbuild.targets:(_discovered_config_build_targets), reproducingembed_build_source's owntargets=list(build_targets) if build_targets else cfg.targetsprecedence exactly — never running the P0.3 compile-context fold itself, just a pure, deterministic config read, so it fitsAnalysisPlanner's own side-effect-free constraint. A malformed.abicheck.ymldegrades to "no config found" here (except ValueError: return ()) rather than raising a second, independently-worded error, sinceembed_build_sourcealready raises a correctly-typedValidationErrorfor it at real-execution time. Every existing caller keeps passing neither parameter, so this is additive.dump/compare's--dry-runresolve through the identicalresolve_dump_request/resolve_compare_requestchokepoint the real run does, so widening the check there closed their dry-run parity for free, with no change to either renderer; three ofscan's four pre-flight call sites (scan_engine.run_scan_coreand both ofcli_scan.py's direct call sites — the single-binary pre-flight and_run_artifact_set's own, each already running ahead of both their real-run and--dry-runbranches) were each updated to forward their own already-in-scopesources/build_configlocals. The fourth,service_scan. run_scan_set, was deliberately left unwidened for one session:service_scan.pysat exactly at the AI-readiness 2000-line hard cap, and the widened call didn't fitruff format's column budget on one line — the resulting explosion would have pushed the file over. Adding it toLARGE_FILE_ALLOWLISTwas rejected (that allowlist is reserved for pre-existingscripts:/tests/debt, not a fresh production-file exemption for an unrelated fix); trimming unrelated content in that already-densely-reviewed file to buy back the budget was rejected too. So this one call site kept its pre-fix behavior for a while: a directrun_scan_set(ScanRequest(...))typed-API call with no CLI in front of it wouldn't see the.abicheck.yml-only scope pre-flight (it still failed, just later and less cleanly, inside real embedding) —scan --artifact-set's own CLI path was unaffected throughout, sincecli_scan._run_artifact_set's pre-flight (already widened) already ran ahead ofrun_scan_setand caught the mismatch first. Closed in a later session, by splittingservice_scan.pyrather than raising its baseline._descendant_pgids/_kill_process_tree— the process-group kill machinery behindrun_scan_subprocess/run_scan_set_subprocess, with zero dependency on anything scan-specific — moved to the newabicheck/workflows/scan_subprocess.py, re-exported fromservice_scan.pyfor backward compatibility (mirroringcxx20_pair_dialect.py's own precedent: a genuine one-directional edge, since the worker/harness functions that actually call back intorun_scan/run_scan_setstayed put, avoiding the mutual-dependency shape a full move of the subprocess harness would have created). That bought back the roomrun_scan_set's ownscan_bazel_scoping_failurecall needed to forwardsources=/build_config=too, landing at a new, loweredarchitecture/debt.yamlbaseline (2000 → 1933) rather than exactly at the hard cap. Seedocs/contribute/plans/one-semantic-pipeline.md's Phase 4 "Landed" notes ("Second slice" / "Third slice" — ADR-063 itself no longer carries a per-phase status entry, its duplicated status block having been removed by PR 0, 2026-09-02) for the full accounting, andtests/test_analysis_plan.py::TestBazelBuildTargetScoping/tests/ test_bazel_root_targets.py::test_dot_abicheck_yml_build_targets_dry_run_parity/tests/test_bazel_root_targets_scan.py:: test_run_scan_depth_headers_config_sourced_target_scope_raises_planning_error/tests/test_bazel_root_targets_scan.py:: test_run_scan_set_config_sourced_target_scope_raises_planning_errorfor the regression coverage (the third-to-last of these also replaces this paragraph's earlier pinning of the pre-fixclick.ClickExceptionleak from the typedrun_scan()API with the now-cleanPlanningError, raised earlier too, fromrun_scan_core's own pre-flight check rather than leaking out of_build_new_snapshot's pre-existingexcept AbicheckErrorwart — that wart itself is unrelated and stays open, see this entry's earlier notes on it). Phase 4 is now complete: all four pre-flight call sites forwardsources=/build_config=. A later Codex/CodeRabbit review round found the.abicheck.ymlfix itself had a second, distinct gap — not deferrable the way the dry-run parity gap above was, since this one had a direct, bounded fix. At--depth headers,embed_build_source's own real check never runs at all (that depth'scollect_modeis"off", the identical condition thedepth=binaryexemption elsewhere in this entry relies on) — butbuildsource.l2_seed's three L2-seed/compile-context helpers (derive_l2_include_dirs,derive_l2_compile_context,seed_includes_and_fold_compile_context) each run their own, independentcollect_inline_packcall regardless ofcollect_mode(they exist to seed useful-Idirs and fold build context into the header parse even for a headers-only scan), each wrapped in a broad best-effortexcept Exceptionthat swallows any failure and degrades to "no seeded context" — by design, documented in_l2_seed_config's own docstring ("a malformed/invalid config surfaces loudly elsewhere ... this is a best-effort include-dir hint"). That documented assumption is false for this one input: at--depth headers, this L2-seed call is the only place the Bazel-scopingValidationErrorcan fire at all, so the broad catch silently swallowed it, with the run proceeding with no diagnostic and without the build-derived context it should have used. Fixed by extending the file's own pre-existing carve-out forHeaderCompileContextAmbiguousError(raised for the identical reason — a deliberate, fail-closed error is not "best-effort collection failed") to coverValidationErrortoo, in all three helpers.tests/ test_bazel_root_targets_l2_seed.py:: test_seed_includes_and_fold_compile_context_raises_on_bazel_scoping_mismatchreproduces it end-to-end with a real (unmocked) pre-captured jsonproto and a.abicheck.yml-only (no--build-target) target scope, pinning that it now raises instead of degrading silently. Verified againstscanspecifically (a sixth review round asked for it by name, for the typedrun_scan(ScanRequest(depth="headers", ...))shape): the silentCOMPATIBLE/exit-0 outcome is gone — the request now fails loudly with the same clear diagnostic — but the exit code inherits the identical front-end variance already noted above for the siblingembed_build_sourcecase, via a different, independently pre-existing mechanism:scan_engine. _build_new_snapshot's ownexcept AbicheckError: raise click. ClickException(...)(documented in this module's own header as a pre-existing wart predating ADR-063 entirely, deliberately left alone) maps theValidationErrorto exit 1 for the CLI, and — sinceclick. ClickExceptionis not itself caught anywhere further out — leaks that same Click-specific exception type to a typedrun_scan()caller too. Fixing that leak means touching the same pre-existing, out-of-scopeexcept AbicheckErrorclause the module docstring already flags for a future cleanup, not a new regression this phase introduced — left alone here for the same reason. A seventh review round found that "verified" claim itself rested on a false premise, and had a real, bounded fix — unlike the siblingclick.ClickExceptionleak just above.run_scan_core's own early pre-flight check (added for the "run scan planning before pattern and POI extraction" fix elsewhere in this entry) exempted--depth headersthe same way it exempts--depth binary, on the theory that both resolve to acollect_modewith no collection layers. That theory is only half right:--depth binaryalso clears the header list to empty (service_scan.run_scan'seff_headers = [] if eff_depth is EvidenceDepth.BINARY else ..., mirrored by the CLI's own_normalize_depth_inputs), which is the actual reasonbuild_infois never consulted anywhere for that depth —--depth headerskeeps real headers, so the L2-seed's own independentbuild_info-consuming pass (the one this whole round's fix concerns) still runs, and the early check was wrongly skipping it. Concretely:run_scan(ScanRequest(depth= "headers", build_targets=("//:lib",), build_info=<precaptured jsonproto>))reached the.abicheck.yml-only code path's ownclick.ClickExceptionleak instead of the framework-neutralPlanningErroran explicitbuild_targetsshould get. Fixed by widening the exemption fromcollection_for_ci_mode(collect_mode)[1]alone toheaders or collection_for_ci_mode(collect_mode)[1]— exempt only when neither consumer (embed_build_sourcenor the L2 seed) can reachbuild_infoat all. This closes the gap for every caseAnalysisPlanner/run_scan_core's own inputs can see (an explicit--build-target/ScanRequest. build_targets); the.abicheck.yml-only case above is unaffected by this fix and remains the already-documentedclick.ClickExceptionleak, since neitherrun_scan_corenorAnalysisPlannercan see a value that isn't discovered until deep insidecollect_inline_pack.tests/test_bazel_root_targets_scan.py:: test_run_scan_depth_headers_with_explicit_build_target_raises_planning_errorpins the fixed (explicitbuild_targets) case; the pre-existingtest_run_scan_depth_headers_still_rejects_bazel_scoping_mismatchwas re-verified to still exercise the unaffected (.abicheck.yml-only) case correctly. An eighth review round found the same class of gap on the plural entry point.service_scan.run_scan_set(scan --artifact-set's typed API) had no Bazel-scoping pre-flight of its own: an unsupported request reached each member's ownrun_scan_corecheck only afterdiscover_artifact_set(),check_artifact_set_soname_collisions(), andartifact_set_member_exports()had already run for every member, wasting real discovery/parsing work before the request was ultimately rejected anyway. Fixed by adding the same pre-flight check to the top ofrun_scan_set, right after_reject_comparison_only_fields(req)and before the shared budget clock starts ordiscover_artifact_set()runs. Rather than duplicaterun_scan_core's exemption logic (the depth=binary header-clearing rule two findings above) a second time, both call sites now share one function,workflows.plan.scan_bazel_scoping_failure(), so a future refinement to the exemption rule has exactly one implementation to change instead of two independently-maintained copies.tests/test_bazel_root_targets_scan.py:: test_run_scan_set_rejects_bazel_scoping_mismatch_before_discoverypins the fix by assertingdiscover_artifact_set()is never even called for a mismatched request;test_run_scan_set_depth_binary_exempts_the_early_ bazel_scoping_checkpins the sibling depth=binary exemption for the plural entry point. A ninth review round found that raw depth alone is not the whole story fordump/compare's ownAnalysisPlannercheck either._check_bazel_target_scopingexempteddepth="binary"purely by reading the request's raw, requested depth — butDumpRequest.resolved_collect_mode(a private CLI hook:compare's own implicit-dump path resolves collect mode from the pair and forwards it in, so the real run doesn't re-derive a possibly-different mode fromdepthin isolation), when set, overrides whatdepthalone would resolve to, andresolve_dump_request_evidencehonors that override. ADumpRequest(depth="binary", resolved_collect_mode="build", ...)therefore still runscollect_inline_packfor real at execution time — the override, not the raw depth, decides whetherbuild_infois ever consulted — so the pre-flight check's exemption on raw depth alone let an unsupported request reachresolve(), then fail later insidecollect_inline_packas a flattenedSnapshotErrorinstead of the promisedPlanningError. Fixed by addingresolved_collect_modetoSidePlan(populated only fordumpsides —CompareRequesthas no such field, so everycompareside keepsNoneand this changes nothing forcompare) and checking it first: when set, it alone decides the exemption ("off"exempts, anything else doesn't, regardless of raw depth); only when unset does the check fall back to the pre-existing raw-depth-only rule.tests/test_analysis_plan.py:: TestBazelBuildTargetScoping::test_resolved_collect_mode_override_defeats_the_binary_exemptionpins the fixed case; its siblingtest_resolved_collect_mode_off_override_is_exempt_even_at_other_depthspins the converse (an explicit"off"override exempts even at a depth, e.g."build", that the raw-depth-only rule would otherwise reject). A tenth review round found the eighth round's fix (movingrun_scan_set's own check before discovery) had a CLI-level sibling gap.cli_scan._run_artifact_set(scan --artifact-set's own CLI entry point) has its own pre-flight check, separate fromrun_scan_set's — but it ran only after_resolve_artifact_set_paths()/discover_artifact_set()had already traversed a directory and statted/format-validated every explicit member. An invalid member's own error could therefore mask the request's intendedPlanningError/usage error, and a mismatched request paid for real discovery work beforerun_scan_set's own (already-fixed) check rejected it anyway. Fixed by moving the check to the very top of_run_artifact_set, before any discovery work starts — every value the check needs (depth,build_info,build_targets) is already a raw function parameter, so no reordering of the rest of the function was needed.tests/test_bazel_root_targets_scan.py:: test_scan_cli_artifact_set_rejects_bazel_scoping_mismatch_before_discoverypins it, the CLI-level sibling of the eighth round's owntest_run_scan_set_rejects_bazel_scoping_mismatch_before_discovery. An eleventh review round found the ninth round's own fix (theresolved_collect_modeoverride) was itself incomplete. Even a genuine"off"collect mode — whether from rawdepth="binary"or from an explicitresolved_collect_mode="off"override — does not by itself meanbuild_infois never consulted: the L2 seed's own independent header-seeding pass (_seeded_includes_and_compile_context/collect_inline_pack) still runs whenever real headers are present, regardless of collect mode — the identical class of gap the seventh round above already fixed forscan_bazel_scoping_failure(headers or collection_for_ci_mode(...)[1]), just not yet ported todump/compare's own check. Fixed by adding the side's rawheaderstoSidePlanand exempting"off"only when there is no header-seeding consumer:depth="binary"still clears headers to empty independent of any override (service_compare_evidence._headerskeys off raw depth alone), so that clearing is folded into the effective-headers computation rather than re-derived from collect mode.tests/test_analysis_plan.py:: TestBazelBuildTargetScoping::test_resolved_collect_mode_off_does_not_exempt_real_headerspins the fixed case (an explicit"off"override with real headers and a scoped pre-captured Bazel jsonproto); its siblingtest_resolved_collect_mode_off_with_no_headers_stays_exemptpins that the headers check doesn't over-reject a genuinely headerless request. A twelfth review round found the eleventh round's own fix introduced a new false positive it didn't have before. The no-override branch only ever equated collect mode"off"withdepth="binary"-- butdepth="headers"resolves to"off"too (_resolve_depth_collect_mode's mapping: only"build"/"source"resolve to something else). A headerlessdump/comparerequest atdepth="headers"combined with an explicitbuild_targetsand a scoped pre-captured Bazel jsonproto was therefore wrongly rejected: neitherembed_build_source(collect mode"off") nor the L2 seed (nothing to seed, no real headers) would ever have consultedbuild_info, yet the planner still raisedPlanningError. Fixed by adding_depth_implied_collect_mode()-- mirroringservice_compare_evidence._resolve_depth_collect_mode's explicit-depth mapping (duplicated, not imported, for the same leaf-module reason that function's own docstring states) -- and using it for any explicit depth in the no-override branch, rather than special-casing"binary"alone.depth="headers"with real headers still correctly stays rejected, since only"binary"clears headers to empty before this check runs;"headers"keeps them, so the L2 seed still runs.tests/test_analysis_plan.py::TestBazelBuildTargetScoping:: test_headerless_depth_headers_is_exemptpins the fixed case; its siblingtest_depth_headers_with_real_headers_is_still_rejectedpins that real headers at that same depth still correctly reject. The pre-existingtest_other_depths_still_rejected_alongside_binarywas narrowed from("headers", "build", "source")to("build", "source")accordingly --"headers"alone (headerless) is no longer part of the always-rejected set, which is the corrected behavior this round establishes, not a weakening of the test. A thirteenth review round foundscan --artifact-set's own pre-flight (cli_scan._run_artifact_set) had a second, narrower version of the same false-positive/false-negative pair, specific to an unset--depth. Two intermediate designs were tried and rejected in this same round before landing on the fix below: a bespokeworkflows.plan.artifact_set_bazel_scoping_failurethat treated an unset--depthas always non-"off"(matching every prior caller's shape) false-positive-rejected a genuine no-op artifact-set request whose real per-member risk scoring would resolve to"off"; a second attempt that instead treated an unset--depthas always exempt false-negative-accepted a seeded, high-risk request (e.g. a public-header edit) thatrun_scan_core's own later, correctly-resolved per-member check would still reject with exit 64 -- recreating the exact dry-run/ execution parity defect this whole known-gap entry exists to close, just one level narrower (real-run vs. real-run instead of dry-run vs. real-run). Both were symptoms of approximating a valueAnalysisPlan's own design (workflows/plan.py's module docstring) deliberately excludes from a pre-flight check: an unset--depthonly resolves to a realcollect_modevia risk scoring over the request's own seeded change (service_scan._resolve_member_scan_level, the identical primitiveestimate_artifact_set's own--dry-runcost totals already resolve through). The fix drops the approximation entirely:_run_artifact_setnow builds the same probeScanRequestestimate_artifact_setwould and calls_resolve_member_scan_levelon it to get the realeff_depth/collect_modebefore discovery, then hands those to the existing, already-sharedscan_bazel_scoping_failure-- no bespoke artifact-set- shaped guard needed._resolve_member_scan_levelreads only request-level fields (depth/changed_paths/seeded/risk_rules_path/mode), none of them derived from discovery, so this probe needs nobinariesand costs nothing discovery-shaped to build. The now-deadartifact_set_bazel_scoping_failurewas deleted fromworkflows/plan.pyrather than left as an unused second copy.tests/test_bazel_root_targets_scan.py:: test_scan_cli_artifact_set_unset_depth_low_risk_seed_config_scope_is_unaffectedpins the no-op case (a low-risk--changed-path, e.g. a docs file, seedsS0/collect_mode="off", so a config-sourced scope stays unenforced); its siblingtest_scan_cli_artifact_set_high_risk_seed_config_scope_still_rejectspins the risky case (a high-risk--changed-path, e.g. a public header, seeds a non-"off"collect_mode, so the same config-sourced scope is still enforced and rejects with exit 64). A fourteenth review round found the thirteenth round's own fix had no error translation of its own._resolve_member_scan_level()raises a plainValueErrorfor a malformed--risk-rulesprofile (via_load_risk_rules_for_service, which converts the single-binary path'sclick.ClickExceptioninto exactly that so a direct typed-API caller never sees a Click-flavored exception) -- but the thirteenth round's new resolution call sat ahead of both of_run_artifact_set's existingtry/except (ArtifactSetError, ValueError)blocks, so thatValueErrornow leaked past them (exit 1, an unhandled traceback) for both a real run and--dry-runalike, instead of the established clean usage error (exit 64)TestArtifactSetMalformedRiskRules:: test_malformed_risk_rules_yaml_is_usage_erroralready pinned for this exact class of input (a ninth-round-era regression, per that test's own docstring). Fixed by wrapping the new resolution call in its owntry/except ValueError, translating toclick.UsageErrorthe same way the two pre-existing blocks below it already do. Historical analysis retained below for the record.BazelAdapter.collect()'sself.targetsscoping is applied in exactly two places: gating whether a livebazel querysubprocess runs at all (_resolve/_run_bazel, only reachable whenworkspaceis given and no pre-capturedaquery=/cquery=path was supplied), and populating theTargetScopereport (requested/resolved/transitive_count) on the returnedBuildEvidence. Neither path filtersev.compile_units/ev.targetsthemselves once a pre-captured file is parsed —_collect_aquery/_collect_cquerywalk the entire captured action/target graph unconditionally.buildsource/inline.py's_maybe_collect_bazel_build_info(the functioncollect_inline_packroutes a--build-inforecognized asbazel_aquery/bazel_cquerythrough) doesn't even accept atargetsparameter, so--build-targetisn't merely unenforced here — it's never threaded to this call site at all, and no diagnostic orTargetScoperecords that a scope was requested. Confirmed by reading the code (no live Bazel repro run in this pass):scan --build-info saved-aquery.json --build-target //:lib(or the identicaldumpinvocation) collects every TU in the captured workspace, with no error, warning, orTargetScopeentry showing the mismatch between what was requested and what was actually scoped — unrelated targets can pollute L3 evidence and any detector built on it. Not fixed here, and the two candidate fixes are not equally easy: (1) Actually filter the captured graph to the transitive closure of the requested roots. Feasible in principle forcquerydata, whoseTarget.dependenciesalready carries a real label-to-label dependency edge list (attrs.get("deps", ...)in_collect_cquery) a BFS could walk — but acquery-only capture produces nocompile_unitsat all (only_collect_aquerydoes), so this alone doesn't scope the TUs that actually matter.aquerydata has no equivalent target-leveldepslist — only a flat action list, each tagged with its owntargetId(CompileUnit.target_id) — so a correct closure would have to be reconstructed from the action graph's own input-artifact edges (_AqueryGraph's depset walk), a materially different and more involved algorithm than thecquerycase, not a shared implementation. (2) Reject the combination with a clear usage error instead — architecturally cleaner, butcollect_inline_pack/_maybe_collect_bazel_build_infosit in a shared Tier-2 module used by both CLI and typed-API callers (embed_build_source, in turn called fromcli_dump_helpers.py,scan_engine.py, andservice_input_resolution.py), and raising there can only be a plainValueError/AbicheckError(per this codebase's Tier-1/Tier-2 separation — Click-specific exceptions belong to the CLI layer only). Neitherdump_cmdnorscan_cmdcurrently wraps itsembed_build_source/run_scan_corecall in a catch for that error class (confirmed by reading both), so today such an error would propagate as an unhandled Python exception with a raw traceback instead of a cleanclick.UsageError/exit 64 — fixing that requires adding (and testing) a catch-and-reraise at every one of those call sites, not a single choke point. Either option is a real, multi-call-site feature needing its own dedicated design and test coverage, not a same-session reactive patch under continued review pressure — per this file's own "known gaps over risky reactive patches" convention. Until then, the safe workaround is to only combine--build-targetwith a live query (--sources <workspace>and no--build-info, lettingBazelAdapterrunbazel query deps(...)itself, which does scope correctly) or to pre-capture theaquery/cqueryjsonproto already scoped to the desired targets before passing it as--build-info. -
The native ELF
abicheck dumppath never applies L3 build context to its own L2 header parse, and this is now confirmed by an external end-to-end reproduction, not only by the pre-existing module-map note about theDumpRequestmigration (2026-08-15,napetrov/abicheck-bazel-labaudit against5b52989). The "Module map"'sservice_dump_pipeline.pyentry above already named the cause — "the nativedumpCLI does not build aDumpRequestyet" — but that note described a migration gap, not a concrete, reproduced symptom. Traced here to the exact call graph: P0.3's L3→L2 fold (service_input_resolution._seeded_compile_context, wired viabuildsource/l2_seed.derive_l2_compile_contextandbuildsource/header_compile_context.resolve_header_compile_context) is real and already correct —resolve_side_snapshot(), the function that calls it, is shared by bothservice_compare_pipeline. resolve_compare_request(the pathcompare's own implicit-dump operand takes) andservice_dump_pipeline.run_dump_request. Butcli.py'sdump_cmd(the ELF path) never callsrun_dump_requestat all — it callscli_dump_helpers.perform_elf_dump()directly, a separate, older code path whoseCompileContextis built only from explicit--ast-frontend/--compiler*/--sysroot/--nostdincflags (confirmed by grep:cli_dump_helpers.pycontains no reference toresolve_side_snapshot,_seeded_compile_context, orderive_l2_compile_contextanywhere). The external repro: a freshabicheck dump lib.so --sources . --build-info compile_commands.json --depth sourcesnapshot's ownparsed_with_build_contextreadsfalseandlanguage_standardreads""even though real L3 evidence was supplied and is embedded in the snapshot — the evidence is collected and stored, but never routed to the L2 header-AST invocation, because that invocation runs through a path P0.3's fold was never wired into. Consequence: adump-produced baseline and ascan --againstlive-binary comparison of the same project, given the same L3 evidence, resolve to genuinely differentCompileContexts (profile_fingerprintmismatch oninclude_sequence/language_standard), soscancorrectly (per ADR-050 D2) refuses the comparison asNOT_COMPARABLE— not because the evidence was insufficient, but because the two commands extracted under non-comparable recipes for reasons neither command's own diagnostics name. Not fixed here: migratingdump_cmd/perform_elf_dump(cli_dump_helpers.pyis already at 1914 of its 2000-line hard cap — real headroom for an inline fix is tight) to route throughrun_dump_requestthe waycompare's implicit-dump path already does is a genuine, cross-cutting architecture change — the "what that migration needs first" the module-map note already flags — not a same-pass reactive patch. The PE/Mach-Odumppaths (service.py's_dump_pe/_dump_machomirrors) were not independently checked for the identical gap in this pass.
Closed, additively, without the run_dump_request migration this entry
originally called for. Full migration would have meant rewriting
perform_elf_dump's already-large, carefully-ordered pipeline (header
parse, ADR-039 build-context harvest, G14/G23/G26 Python/NumPy attach,
the header-graph and clang-layout-tool second passes) around a
fundamentally different call shape — real, but out of proportion to the
actual gap, which is narrowly "the L3→L2 fold never runs on this path,"
not "this path's whole architecture is wrong." The fix is additive
instead: buildsource/l2_seed.fold_l3_compile_context() — a new, shared
wrapper around the already-correct derive_l2_compile_context() +
_merge_l3_compile_context() pair (the latter moved into l2_seed.py
from service_input_resolution.py; see that function's own docstring for
why — leaving it in place would have closed an
l2_seed -> service_input_resolution -> cli_dump_helpers -> l2_seed
import cycle the AI-readiness gate correctly rejects once
cli_dump_helpers needed it directly) — called from both
perform_elf_dump (ELF) and handle_non_elf_dump (PE/Mach-O, which
shared the identical gap: confirmed by reading service.run_dump's own
compile parameter never receiving anything beyond the CLI/config-
resolved context either). Both call sites now fold the real L3
CompileUnit-derived context (-std=, ABI-relevant -D/-U, target,
sysroot) into the same explicit context CLI flags/.abicheck.yml
already built, exactly mirroring what _seeded_compile_context already
does for compare's implicit-dump path via resolve_side_snapshot — so
dump and compare now fold under one shared primitive, even though
dump's own CLI pipeline still doesn't route through run_dump_request.
perform_elf_dump's two independent second-pass clang re-parses (the
header-graph attach and the clang-layout-tool attach) also now receive
this same fully-merged context via effective_compile_context =
l3_effective_ctx, closing a narrower, previously-undocumented sibling
gap where those passes' own gcc_options-string-only re-derivation never
looked at gcc_option_tokens/sysroot/nostdinc/deferred include roots
at all — so a real disagreement on any of those between the primary
parse and the second passes could previously have gone unnoticed even
without any L3 evidence in play. AbiSnapshot.parsed_with_build_context
is now stamped from either this fold or the older -p/--compile-db
mechanism (OR'd, matching resolve_side_snapshot's own rule), on both
the ELF and PE/Mach-O paths.
A third, independent instance of the same gap, found only by testing
the actual end-to-end repro rather than trusting this entry's original
framing: scan's own candidate resolution never applied the fold
either. This entry's first draft claimed _seeded_compile_context
"already does" this for scan — false. scan_engine._build_new_snapshot
calls service.resolve_input directly, not resolve_side_snapshot, so
compare's/dump's fold never ran on scan's candidate side. A
same-project scan --against a freshly-fixed dump baseline still
returned NOT_COMPARABLE on language_standard even after the two
dump-path fixes above — confirmed by actually running the repro end to
end (a real g++-compiled library + compile_commands.json), not by
re-reading the code. Fixed the identical way: _build_new_snapshot now
calls fold_l3_compile_context immediately before resolve_input, and
stamps parsed_with_build_context the same way. The same real repro now
produces NO_CHANGE end to end (dump baseline → scan --against),
which is the actual acceptance criterion this whole entry exists for.
A fourth finding, from a Codex review of this same change, on the
shared _merge_l3_compile_context primitive itself — pre-existing in
the compare/scan-implicit-dump fold this PR reused, not introduced by
it, but real and now fixed alongside it. "Derived leads, explicit
wins" (last-flag-wins) is the right rule for a macro/std/sysroot switch,
but header_compile_context._context_flags also renders a matched
CompileUnit's own include_paths/system_include_paths as -I/
-isystem tokens, and an include search path is first-match-wins —
so putting a derived -I ahead of an explicit one (the pre-existing
order) silently let the build's own header win over a caller's explicit
override for a colliding basename. Fixed with a new
_split_include_tokens() helper that carves derived's own include-search
entries (-I/-isystem/-iquote/-idirafter/MSVC /I//imsvc/
/external:I, spaced-pair-aware) out of the leading last-flag-wins group
and appends them after explicit instead — every other derived token
keeps its original leading, overridable position.
Verified end-to-end against a real compile_commands.json fixture
through the actual perform_elf_dump/handle_non_elf_dump/
_build_new_snapshot entry points (not a hand-built CompileContext) —
see tests/test_cli_dump_helpers_coverage.py::
test_perform_elf_dump_folds_l3_compile_context_into_header_parse,
tests/test_non_elf_dump_l2_seed.py::
test_non_elf_dump_folds_l3_compile_context_into_header_parse,
tests/test_scan_l2_cleanup_ordering.py::
test_scan_candidate_folds_l3_compile_context_into_header_parse, and
tests/test_header_compile_context.py's
test_merge_l3_compile_context_explicit_include_search_wins_first_match/
test_merge_l3_compile_context_attached_include_form_stays_paired —
confirming the derived -std=/-D flags reach the primary header-AST
parse, parsed_with_build_context is stamped, and an explicit -I
outranks a derived one. Full test suite (28k+ tests) green; no
import-cycle or file-size regression (cli_dump_helpers.py stayed under
its 2000-line hard cap by moving the shared fold logic into l2_seed.py
rather than duplicating it inline for both call sites).
Two more findings on the same change, both fixed, one left as a
narrower residual gap. (1) A derived -I/-isystem reaches the header
parse only as an opaque gcc_option_tokens string, which the AST cache
key's own directory-mtime hashing (extra_includes/extra_hash_dirs,
dumper_ast_config.py) never inspected — editing a header under a
derived include dir could reuse a stale cached AST. fold_l3_compile_
context() now also returns the derived include directories
(_include_operand_dirs()), threaded into perform_elf_dump's existing
extra_hash_dirs. Not closed for handle_non_elf_dump (PE/Mach-O) or
scan_engine._build_new_snapshot: neither dump_native_binary→
service.run_dump nor service.resolve_input exposes a public
extra_hash_dirs hook the way perform_elf_dump's own direct dump()
call does — and service.py's own internal cache-key computation for
those paths already has the identical, broader, pre-existing gap for
any CompileContext.gcc_option_tokens include entry (not just an
L3-derived one), so a scoped fix here would still leave the general case
open. Threading a real extra_hash_dirs channel through resolve_input's
public signature is a genuine, separate change affecting every caller of
that function, not a follow-up to this one. (2) scan's own fold call
hard-coded lang_explicit=False; since scan --lang c is never the
Click default (only "c++" is), it is always a genuine explicit
request, and the hard-coded False let a matched C++ compile unit's own
-std=c++20 reach a parse scan --lang c was explicitly forcing into C
mode. Fixed by treating lang == "c" as explicit, mirroring perform_
elf_dump's identical squash-guard rule elsewhere in this same area.
A fifth finding, on the fold's own call shape rather than its logic: the
include-dir seed and the L3→L2 fold each independently collected L3
evidence, and a caller genuinely needing the inferred build query (no
existing compile database) could self-deadlock. All three call sites
(perform_elf_dump, handle_non_elf_dump, scan_engine._build_new_
snapshot) called seed_l2_includes() and then, immediately after,
fold_l3_compile_context() — each independently calling
buildsource.inline.collect_inline_pack(). Harmless when at most one call
can trigger the zero-config inferred build-system query (cmake/make/
bazel, gated by allow_inferred_build_query/collect_mode), but a real
inferred query is a flock-protected, deterministic per-source-tree temp
build dir (build_query._claim_inferred_build_dir) held until its own
cleanup runs — deliberately deferred until after the header parse
consumes the seeded dirs, i.e. long after the seed call returns. The
second call's own inferred-query attempt then contends on the identical
lock the first call is still holding, and — being the same process
reopening the same lock file, not a different one — flock's
per-open-file-description semantics mean this is genuine self-contention,
not a race with some other process: it blocks for up to
INFERRED_QUERY_TIMEOUT_S (600s) before falling back to a throwaway
sibling dir (Codex review). Fixed by collapsing the two into one
collection: buildsource.l2_seed.seed_includes_and_fold_compile_context()
runs collect_inline_pack() exactly once and derives both the include-dir
seed (mirroring seed_l2_includes's own gating and directory derivation)
and the compile-context fold (mirroring derive_l2_compile_context's
resolution + merge) from that single BuildEvidence, so only one inferred
query — if any — ever runs per call. All three call sites now call this
one combined function instead of the two separate ones; seed_l2_includes
itself is unchanged and still used independently elsewhere (well-defined
on its own — the bug was specifically two independent collections of
the same evidence in one logical operation, not either function
individually). Verified against the same real compile_commands.json
fixtures the fourth finding's own regression tests use, now exercised
through the combined entry point. fold_l3_compile_context itself —
the standalone wrapper this combined function was built to replace — is
not still used independently: once all three call sites moved to
the combined function, nothing called it anymore, so it was removed
entirely as dead code rather than left orphaned alongside its
replacement (found while writing this entry's own closure notes; no
call site or test referenced it directly by the time this was checked).
A seventh finding, from writing direct unit tests for the combined
function itself (not through any of the three call sites), on the
combined function's own body — a real regression relative to both
siblings it was assembled from. derive_l2_include_dirs's and
derive_l2_compile_context's own identical comments both state "pack
resolution stays inside this protected section... a corrupt/unreadable
pack must degrade best-effort, not raise" — but
seed_includes_and_fold_compile_context's own _resolve_l2_seed_pack_args
call was placed before the try: block, not inside it, so a corrupt
pack (a manifest.json present but unparseable, is_pack_dir's own
documented "still a pack" case) raised a bare JSONDecodeError straight
out of the function instead of degrading to the pre-existing "nothing to
apply" no-op every other caller relies on. Caught immediately by writing
test_seed_and_fold_corrupt_pack_degrades_to_empty (mirroring the two
siblings' own test_derive_l2_*_corrupt_*_pack_degrades_to_empty
tests) — it failed with the raw JSONDecodeError before the fix, not
the intended empty-degrade result. Fixed by moving the
_resolve_l2_seed_pack_args call inside the try:, matching both
siblings exactly. The four new direct tests (no-inputs no-op, no-match
returns none, ambiguous raises and drains pending cleanups, corrupt pack
degrades) live in tests/test_non_elf_dump_l2_seed.py (not the more
natural test_header_compile_context.py, which is near its own
1500-line soft/2000-line hard cap) as a
"seed_includes_and_fold_compile_context branch coverage" section.
A sixth finding, on _split_include_tokens itself, documented as a
residual gap rather than fixed here (Codex review). The split
distinguishes include-vs-non-include tokens and preserves relative order
within each group, but not GCC/Clang's distinct include-search buckets
(-iquote > -I > -isystem > -idirafter, each a separate search
class the compiler consults in that fixed order regardless of argv
position). An explicit -isystem therefore still searches ahead of a
derived -iquote/-I after the split, even though a real compiler
would consult the quote/regular buckets first regardless of flag order.
A correct fix needs the merge to track bucket membership, not just
include-vs-non-include — a real, if narrow, redesign of the function's
output shape, not a follow-up to the explicit-vs-derived ordering fix
(the fourth finding above) this function exists for. See the function's
own docstring for the same note.
An eighth and ninth finding, both from a fresh Codex review round on the
P1/P2-labeled commit that added the combined function above, both real
and both fixed. (8, P1) scan_engine._build_new_snapshot's own L3->L2
fold only updated its local compile_context variable — run_scan_core
still held the caller's original, un-folded compile_context and
forwarded that to _run_baseline_compare, so a scan --against a
native library could fold real L3 context into the candidate's header
parse while the baseline's own native-library header parse never
received it — silently recreating the exact NOT_COMPARABLE/false-ABI-
difference risk this whole P0.3 fold exists to close, just moved from the
dump-vs-scan pairing to the candidate-vs-baseline pairing within one
scan --against invocation. Fixed by widening _build_new_snapshot's
return from (snapshot, effective_includes) to (snapshot,
effective_includes, effective_compile_context) — mirroring the existing
effective_includes precedent for exactly the same reason — and having
run_scan_core forward that third value to _run_baseline_compare
instead of its own original. Regression test:
tests/test_cli_scan.py::test_baseline_compare_receives_l3_folded_compile_context
(mocks both _build_new_snapshot and _run_baseline_compare at the
cli_scan.py/scan_engine.py module boundary, confirmed to fail against
the pre-fix code with the un-folded context forwarded instead of the
sentinel folded one). (9, P2) perform_elf_dump's ADR-039 build-context
collector (_attach_build_context/_user_define_flags) has its own
long-standing, explicit rule — stated in both functions' docstrings —
that the auto-derived, per-header-matched build context must never be
unioned snapshot-wide, since doing so would mark one TU's -D active for
every scanned header. The L3->L2 fold's l3_context_applied
reassignment folds that same derived context into the identically-named
gcc_option_tokens local variable used for the primary header parse,
which silently defeated that rule once _user_define_flags was called
with the (by-then-merged) gcc_option_tokens rather than the caller's
original tokens. Fixed by capturing _user_gcc_option_tokens =
gcc_option_tokens before the fold's reassignment and passing that
captured value to _user_define_flags instead. Regression test:
tests/test_non_elf_dump_l2_seed.py::
test_perform_elf_dump_keeps_l3_derived_flags_out_of_build_context_collector
(confirmed to fail against the pre-fix code, asserting the L3-derived
-DL3ONLY=1 reaches _attach_build_context's extra_flags when it must
not). handle_non_elf_dump (PE/Mach-O) was checked and does not call
_attach_build_context at all — the ADR-039 collector is ELF-only — so
finding 9 has no PE/Mach-O counterpart.
A tenth finding, from the same Codex review round (P1), on
service._attach_header_graph's own second, independent
_clang_header_dump pass — real and fixed. That pass has its own AST
cache key, computed from its own deferred_dirs, which only covered
resolve_inferred_header_roots's own deferred roots — never any
include-search directory riding in compile.gcc_option_tokens itself
(an explicit --gcc-options/--compiler-option -I, or — since the
P0.3 fold — a compile-DB-derived one). A directory the primary snapshot
pass already hashes into its own cache key was therefore invisible to
this second pass's key, so editing a header under it could silently
reuse a stale cached graph even though the primary snapshot re-parsed
correctly and picked up the change. Fixed by extracting
l2_seed._include_operand_dirs's logic into a new, shared
header_utils.include_operand_dirs() (a leaf module both l2_seed.py
and service.py already import from) and folding its result into
_attach_header_graph's own deferred_dirs — closing the gap
generically for any include-search token the merged context carries,
not just an L3-derived one, since _attach_header_graph has no way to
distinguish the two once they're both flattened into
gcc_option_tokens. l2_seed._include_operand_dirs itself is now a
thin alias forwarding to the shared function, so existing callers/tests
needed no changes. Regression tests:
tests/test_service_unit.py::TestAttachHeaderGraphHashesIncludeSearchTokens
(both confirmed to fail against the pre-fix code — the positive case
asserting a gcc_option_tokens-carried -I dir reaches
_clang_header_dump's own extra_hash_dirs, the negative case pinning
the no-tokens baseline stays ()).
Several smaller findings from the same CodeRabbit review round, all
fixed alongside the above. (1) The changelog fragment's early entries
still named the now-removed fold_l3_compile_context() wrapper as the
shipped fix — corrected to the combined function's real name (this
file's own narrative-history style is left as-is, since later
paragraphs already record the removal). (2) seed_includes_and_fold_
compile_context's own guard ((sources is None and build_info is
None) or (not want_seed and not headers)) had a redundant second
clause — not want_seed and not headers always reduces to not
headers, since want_seed = bool(headers) and ... is already False
whenever headers is empty — leaving a genuinely unreachable if not
headers: guard a few lines further down; both simplified/removed. (3)
The same function's pending_cleanups.extend(cleanups) ran before
_merge_l3_compile_context/_include_operand_dirs, so either raising
would have the except Exception branch's _run_cleanups(cleanups)
remove the same already-handed-off thunks a second time — not fatal
(_run_cleanups logs rather than raises on an already-closed handle),
but a real, avoidable double-removal; moved the extend to after every
remaining fallible step succeeds. (4) A genuinely weakened test
assertion in test_scan_l2_cleanup_ordering.py
(test_scan_l2_seed_cleanup_runs_before_embed): assert seed_kwargs.
get("pending_cleanups") is not None also passes for the outer scan
list itself (defer_cleanup=[] is never None either), so it would
not have caught a regression that reused it — fixed to assert identity
against the outer list directly. (5) Two cosmetic-only cleanups: a
redundant local import json shadowing the same module-level import,
and an unparenthesized chained and/or (ruff RUF021, not in this
repo's enabled rule set but still real and fixed) in perform_elf_dump's
parsed_with_build_context stamp condition. (6) The three fake
seed_includes_and_fold_compile_context implementations in
test_scan_l2_cleanup_ordering.py each hand-built an identical
CompileContext from the same eight kwargs — extracted into one shared
_CC_FIELDS/_explicit_ctx_from_kwargs() helper, mirroring the
identical pattern test_cli_dump_helpers_coverage.py already used.
An eleventh finding, from a further Codex review round (P1), on
_existing_include_dirs's own selection scope — real, but a
pre-existing, cross-cutting design shared with compare's implicit-dump
path, documented as a known gap rather than fixed here. When no
explicit -I is given, _existing_include_dirs gathers directories
from every CompileUnit in the build evidence, not only the unit(s)
resolve_header_compile_context actually matched to the headers being
parsed. Those directories reach the AST command as extra_includes,
emitted ahead of the matched context's own include tokens — so in a
multi-TU build, an unrelated TU's own colliding generated header (e.g.
a stray config.h) can shadow the header the matched TU would have
resolved, while the snapshot may still get stamped
parsed_with_build_context=True from the (separate) successful fold,
reading as more authoritative than the seed actually was. Confirmed
not new to this PR: service_input_resolution._seeded_includes/
_seeded_compile_context — the pre-existing pair resolve_side_snapshot
already uses for compare's implicit-dump operand, and the reference
implementation this PR's own fold was modeled after — combines the
identical broad-seed-plus-matched-fold shape already, unchanged by this
PR. A correct fix needs HeaderCompileContextResolution to expose which
CompileUnits actually matched (today it exposes only a
matched_unit_count), then both _existing_include_dirs's caller here
and _seeded_includes in service_input_resolution.py to restrict
the seed to that set — a genuine, cross-cutting widening of a
well-tested module's return shape and two independent call sites, not a
scoped fix reactive to one review comment on one PR. Documented in
_existing_include_dirs's own docstring alongside this entry.
Closed by PR D (plan "PR 3B", build-context completeness), and the
cross-cutting worry above turned out to be obsolete rather than
addressed. The entry says the fix needs restricting "both
_existing_include_dirs's caller here and _seeded_includes in
service_input_resolution.py" — two independent call sites. That was
true when written; PR C (#795) since merged those two into one, so
service_input_resolution._seeded_includes_and_compile_context and all
three CLI-side resolvers now reach the seed through the single
l2_seed.seed_includes_and_fold_compile_context. There was one call site
left to restrict, not two. HeaderCompileContextResolution gained
matched_units (the tuple; matched_unit_count stays as a derived
property, so the two cannot drift), and the combined primitive now
resolves the compile context before seeding and passes
resolution.matched_units to _existing_include_dirs — falling back to
every unit only when nothing matched, which is the case the seed was
built for in the first place (a public header the compile DB does not
cover, reaching into a dependency SDK) and where there is no narrower set
to prefer. Reordering is otherwise unobservable: the one path that skips
the seed is the fail-closed ambiguity raise, which aborts the call either
way. Regression coverage:
tests/test_build_context_completeness.py::TestIncludeSeedIsRestrictedToMatchedUnits
(the positive case, the no-match fallback, and the matched_units/
matched_unit_count consistency), verified to fail against the pre-fix
seed_units = units.
A twelfth finding, from a further Codex review round (P1), on
run_scan_core's own forwarding of the folded context to the baseline
parse — real and fixed. The eighth finding above fixed run_scan_core
forwarding its original compile_context to _run_baseline_compare
unconditionally; that fix itself was too broad in the other direction.
The fold is derived by matching the candidate's own headers against
the new build's compile units, so its -D/-U/-std/include flags
describe the new side specifically — but _run_baseline_compare's own
_resolve_baseline_header_scope parses a side-aware -H old=PATH
baseline through its own, different old headers, whose macros/standard/
generated-header paths may genuinely differ from the new build's.
Forwarding the new side's folded context there unconditionally risked a
bad parse or a false ABI diff on exactly the old-side-headers path the
eighth finding's own fix never distinguished from the common case (no
baseline_headers, baseline reuses the candidate's headers). No
old-side build evidence exists to derive a matching fold for that case
(there is no --build-info-old/--sources-old flag), so the correct
fallback is the caller's plain, unfolded compile_context — not a
second, unfounded fold attempt. Fixed by conditioning the forwarded
value on whether baseline_headers was given:
eff_compile_context if not baseline_headers else compile_context.
Regression test:
tests/test_cli_scan.py::test_baseline_compare_with_side_aware_headers_keeps_unfolded_context
(confirmed to fail against the pre-fix code — the folded sentinel
reached _run_baseline_compare even with -H old=... given).
A thirteenth finding, from a further Codex review round (P1), on the
twelfth finding's own fix — a real regression in the fix itself, caught
before merge. not baseline_headers was the wrong signal: cli_scan.py
builds baseline_header = header_both + header_old, so a bare, shared
-H api.h (no old= scoping at all — the ordinary, most common
scan --against usage) already makes baseline_headers truthy and
identical in content to headers, since both draw from the same
header_both list. The twelfth finding's own fix therefore treated
every scan --against invocation with any headers at all as
"old-side-scoped" and fell back to the unfolded compile_context —
silently reintroducing this whole PR's own NOT_COMPARABLE/false-ABI-
diff bug for the common case, one commit after fixing the narrower
-H old=PATH case. Fixed by checking content equality instead of mere
truthiness: not baseline_headers or list(baseline_headers) ==
list(headers) — the fold is used whenever the old side's resolved
headers are the same as the candidate's (shared, or genuinely absent),
and only the caller's plain, unfolded context is used when they
actually diverge (a real old= override). Regression test:
tests/test_cli_scan.py::test_baseline_compare_with_shared_bare_header_still_gets_folded_context
(confirmed to fail against the pre-fix — twelfth-finding-only — code,
which forwarded None rather than the folded sentinel for a bare
shared -H with no old= at all).
A fourteenth finding, from a further Codex review round (P1), on
ABI_RELEVANT_FLAG_PREFIXES itself — real, pre-existing (confirmed
present at this PR's own base commit, before any of its changes), and
documented as a known gap rather than fixed here. GNU/clang
-include/-imacros and MSVC /FI//FU (forced pre-include) are
absent from this list, so extract_abi_relevant_flags() never captures
them into CompileUnit.abi_relevant_flags — buildsource.adapters.
base.SOURCE_OPERAND_FLAGS already recognizes these as value-taking, but
for an unrelated purpose (not mistaking the operand for the TU source
file), and that recognition never feeds this list. A matched compile
unit's own forced-include header is therefore silently absent from
header_compile_context's derived L2 CompileContext, so the P0.3
fold (reached from dump/scan/compare's implicit-dump path alike,
not just the three call sites this PR added) can report a real match
and stamp parsed_with_build_context while still parsing without a
macro-controlling forced-include header the real build always applies.
Confirmed genuinely pre-existing, not introduced by this PR:
ABI_RELEVANT_FLAG_PREFIXES and its consumers (extract_
abi_relevant_flags, header_compile_context.py) already existed at
commit 674a506 (this PR's own base, before any of its changes) with
the identical omission — this PR only added two more call sites
(dump ELF/PE-Mach-O, scan) to the same, already-existing
resolve_header_compile_context machinery compare's implicit-dump
path already used. Not fixed here because a correct fix is a genuine,
non-trivial extraction-logic change: unlike every other entry in
ABI_RELEVANT_FLAG_PREFIXES (a bare prefix match that appends the
single matched token), -include/-imacros//FI always carry a
required following value that must travel with the flag — appending
just the bare prefix (the pattern this whole list otherwise uses)
would silently drop the header filename that is the entire point. A
correct fix needs a new spaced-value-flag branch in
extract_abi_relevant_flags (mirroring, but distinct from, its
existing -D//D split-form handling), verified end-to-end through
header_compile_context._context_flags()'s reconstruction and the
real header-parse invocation's own handling of -include//FI —
a cross-cutting change to a shared, well-tested primitive several
other pre-existing paths already depend on, not a scoped fix reactive
to one review comment on this PR. Documented in ABI_RELEVANT_FLAG_
PREFIXES's own docstring alongside this entry.
Closed by PR D (plan "PR 3B"), but not by the fix this entry
proposed — that fix was investigated and found to be actively wrong,
which is the part worth not rediscovering. The gap is real and the
consequence stated above is accurate: the derived L2 CompileContext
never carried a matched compile unit's own macro-controlling
forced-include header, so the header parse saw a materially different
translation unit while still reporting a real match and stamping
parsed_with_build_context. But routing the fix through
ABI_RELEVANT_FLAG_PREFIXES/extract_abi_relevant_flags, as this entry
proposed, would have broken L4 replay, which already handles forced
includes correctly and by a different route:
source_extractors._argv.replay_extra_flags carries
abi_relevant_flags through (_carry_abi_relevant_flags) and,
separately and unconditionally, re-scans the unit's raw argv for
forced-include/include-search tokens (_scan_argv_for_extra_flags,
which is deliberately not passed the seen set the first pass
builds — see that function's own docstring for why deduping there was
itself a reverted bug). Capturing a forced include into
abi_relevant_flags would therefore have made every L4 replay command
carry -include config.h twice: a silent double inclusion that a header
without include guards turns into a hard redefinition error. The general
shape is worth naming, since this list has now attracted two fixes aimed
at it: ABI_RELEVANT_FLAG_PREFIXES is not the only channel a compile
unit's flags reach a consumer through, so "the flag is missing from the
list" is not by itself evidence that adding it to the list is the fix —
check which consumers already reach the same fact by another route first.
Closed at the layer that actually had the gap instead:
header_utils.forced_include_operands is now the one shared recognizer
(the same option vocabulary and the same separate/joined spellings
_argv's replay matchers use, since match_gnu_forced_include/
match_msvc_forced_include moved into that leaf — the one that already
owns this codebase's include-flag vocabulary and that both consumers
already sit above, so sharing costs no new import edge — and _argv
imports them), and header_compile_context._forced_include_flags renders its
result into the L2 command straight from cu.argv, never through
abi_relevant_flags — so L4 replay is bit-for-bit untouched. Three
rendering decisions carry their own reasoning in the code: a relative
operand is pinned to the compile unit's own directory only when that
resolves to a real file (GCC documents a two-stage lookup, and pinning
a generated header the build finds through its -I chain to a
non-existent path would turn a header that would have been found into a
hard "file not found"); MSVC /FI renders as GNU -include, matching
what this module already does for -D/-I/--sysroot=, since the
consumer is always a GNU-driver castxml/clang invocation; and
-include-pch (version-locked to the compiler build that produced it,
which L2's castxml-bundled/host clang is not) plus /FU (managed C++/CLI
#using, naming no C/C++ header at all) are deliberately dropped rather
than rendered. Two consequences beyond the rendering itself: a forced
include now participates in _EffectiveContextSignature, so two units
forcing different macro-controlling headers fail closed instead of
silently applying whichever grouped first (equivalent spellings of the
same header still agree, since the signature compares the rendered
tokens); and header_utils.cache_relevant_operand_dirs — the union of
include_operand_dirs with the new forced_include_operand_dirs, now
used by all three header-parse cache keys (service._dump_elf,
service._attach_header_graph, the L2 seed's own derived_include_dirs
return) — covers the forced header's directory, closing for this input
the same staleness class the tenth and seventeenth findings above each
had to close individually. Eight follow-on findings from review of this
same change, every one real and every one fixed. (1) The cache-key channel
takes directories and walks them with iter_cache_header_files, which is
suffix-filtered by CACHE_HEADER_SUFFIXES — correct for "catch transitive
includes under a search root", wrong for a file named explicitly because it
is itself part of the parse. A forced include routinely carries a suffix
that list does not name (-imacros settings.def) or none at all
(-include generated/config), so hashing only its parent left an edit to it
invisible while the unchanged option token kept the key identical. Fixed by
having dumper_ast_config._cache_key hash a non-directory entry's own mtime
directly (a strict widening — such an entry previously contributed only its
path string) and having forced_include_operand_paths return the file
alongside its parent; cache_relevant_operand_dirs was renamed
cache_relevant_operand_paths to stay honest about carrying both.
(2) The PE/Mach-O path never received the widened set at all:
cli_dump_helpers.handle_non_elf_dump binds the derived-dirs return as
_l3_include_dirs and discards it, since dump_native_binary/
service.run_dump exposes no extra_hash_dirs hook. Rather than thread one
through run_dump/resolve_input — the change this same entry's earlier
text assumed was required, carried by every caller of those — the fix is
local: service_header_scoped._try_header_scoped_dump folds
cache_relevant_operand_paths(cc.gcc_option_tokens) into its own
deferred_dirs, deriving the identical set from the very tokens those dirs
came from, since handle_non_elf_dump already passes the merged L3 context
as compile=. That is the same "close it where the tokens land, rather than
threading a new channel" move the tenth and seventeenth findings made, now
applied to the fourth and last header-parse cache key.
(3) A third round found the dialect vocabulary had drifted between the two
layers this work now shares a recognizer across:
adapters.base._is_msvc_command listed only cl/clang-cl while
_argv.is_msvc_mode (L4) also knew dpcpp-cl and version-suffixed drivers
(clang-cl-20). A CL-mode command spelled with GNU -c rather than /c
therefore read as GNU dialect on the build-evidence side, silently dropping
its /FI from the derived L2 context while L4 replayed it correctly. Fixed
at the cause rather than at the new call site: header_utils.
is_msvc_driver_stem is now the one vocabulary both use, so the fix also
reaches _is_msvc_command's pre-existing consumers (source detection,
forced-language detection, structured-field masking). Only the name test
is shared — each caller keeps its own basename derivation, since the
adapter's is backslash-aware and _argv's is not, and changing L4's would
have been a behavior change outside this PR's scope.
(4) A fourth round found the file-hashing fix in (1) still missed the case
where a forced include is resolved only through the -I chain
(-I /build/gen -include config): the bare operand stats against
abicheck's own working directory rather than the build's, and the search
directory is walked only through the suffix-filtered
iter_cache_header_files, which skips an extensionless or .def name — so
nothing hashed the real file. forced_include_operand_paths now also emits
one candidate per include-search directory in the same token list, whether
or not it exists. Deliberate: _cache_key contributes a non-existent path's
string and moves on, a candidate that starts existing is a real change to
what the compiler resolves, and since the search is first-match-wins a
candidate under a directory the compiler would never reach can only
over-invalidate — a spurious miss, never a stale hit, which is the correct
direction for a cache key to err in. Worth noting how this one was found:
the previous round's own new test pinned the unresolved bare operand as
expected output, which made a real gap read as a settled decision.
(5) A fifth round found the rendered forced include could point at nothing:
_context_flags emits only the structured include_paths/
system_include_paths, and the compile-DB adapter parses only
-I/-isystem into those — so a unit resolving its forced include through
an argv-only -iquote gen or MSVC /Igen had that directory in neither
field, and a bare -include config was emitted into a command that could
not find it. That is worse than the pre-existing behaviour of not
forwarding the forced include at all: a hard "file not found", or silently
the wrong same-named file. Fixed by resolving the operand against the
unit's own full search chain (_forced_include_search_dirs: directory
first, then quote/normal/system/after-system buckets, each combining the
structured field with the argv-only spellings) and emitting the absolute
path of the first match — removing the dependency on the rendered search
order rather than betting on it. Deliberately not done: rendering
argv-only search dirs into the derived context. That is the wider,
pre-existing fidelity gap the same review names — a transitively included
header the build reaches through -iquote is still unreachable to the L2
parse — but closing it changes include search order for every matched unit,
a materially broader behaviour change than this function's own correctness
requires, and it interacts with the bucket-ordering gap _split_include_
tokens already documents (the sixth finding above). Recorded in
_forced_include_flags's own docstring as a residual.
(6) A sixth round found the same double-inclusion hazard that rules out
routing through abi_relevant_flags, reached from the other side:
_merge_l3_compile_context concatenates derived and explicit tokens
without deduplication, so a caller passing --compiler-option -include
config.h for a build whose compile database records the same forced header
got -include config.h twice — and an unguarded header is then
processed twice and fails to compile. Reproduced literally. This one is
worse than its arithmetic suggests, and the reason is worth keeping: the
caller passing the option by hand is precisely the one who was working
around the absence of this feature, so the duplicate would have broken
exactly the users the change exists to help. Fixed by
explicit_forced_include_keys (both caller spellings —
gcc_option_tokens and the free-form gcc_options string — each
contributing the operand as written and its resolved absolute path) with
the derived copy dropped on a match, keeping the established "explicit
wins" precedence and losing nothing, since both name the same file. A
different explicit forced header does not suppress the derived one.
(7) A seventh round found the search chain (5) added resolved through the
wrong order within a bucket: it sorted the argv-derived dirs, because
_build_context_include_dirs returns a set and determinism was the stated
goal. But a compiler takes the first match in a bucket in argv order, so
-iquote z -iquote a -include config.h resolves z/config.h while the
sorted chain pinned a/config.h — a different file, potentially different
macros, i.e. deterministic and wrong. The lesson is the trade that was made
without noticing it was a trade: determinism was available by preserving
argv order, so sorting bought nothing and cost correctness. Fixed by
factoring header_utils.build_context_include_dirs_ordered out as the real
implementation (argv order, first-occurrence-wins dedup) with the
set-returning _build_context_include_dirs now a thin collapse of it — every
pre-existing caller only asks "is this directory covered", so none change.
One residual, recorded at the call site: within a bucket, structured entries
are emitted before argv-derived ones rather than interleaved by true argv
position, which the structured fields do not record. It can only matter for a
command mixing spellings in one bucket (a structurally-captured GNU -I
alongside an MSVC /I), which no single real driver accepts.
(8) An eighth round (CodeRabbit) found the dedup in (6) flattened the
caller's two option spellings through its own second copy of that
flattening, unguarded — so an unbalanced quote in the free-form
gcc_options string made shlex raise ValueError: No closing quotation
straight out of resolve_header_compile_context, aborting an L2
compile-context resolution that every caller treats as best-effort, over a
malformed caller string. _explicit_pin_tokens — the same module's own
flattening for the _ExplicitPin scan, ten lines away — already had the
guard and already documented why ("degrades to 'no tokens from it' rather
than raising, since this is only used to widen what's accepted"). The
duplicate is now routed through it, so there is one flattening definition
and one degrade rule rather than two that could disagree again. Worth
naming the shape, since it is the same one as (3): a second copy of an
existing primitive drifts from it silently, and the drift shows up as the
copy lacking a property the original was deliberately given.
Residual, deliberately unclosed: because a
forced include still never enters abi_relevant_flags, it is still not
projected into a BuildOption by derive_build_options, so swapping one
build's forced-include header for another does not raise
ABI_RELEVANT_BUILD_FLAG_CHANGED (ADR-029 D9's build-evidence drift
signal). Closing that half needs a structured CompileUnit field every
adapter populates, a BUILD_EVIDENCE_VERSION bump and build_diff
wiring — a schema slice of its own, not a follow-on to the L2 rendering
fix, and specifically not another attempt to route it through this list.
Regression coverage: tests/test_build_context_completeness.py (the
recognizer, the rendered context, the ambiguity signature, the cache-key
union, and — as the executable record of the wrong fix —
TestReplayStillEmitsForcedIncludesExactlyOnce).
A fifteenth finding, from a further Codex review round (P1), on the
thirteenth finding's own header-equality fix — a real gap in the fix
itself, caught before merge (fresh evidence). Comparing only the
resolved header lists was not sufficient: cli_scan.py builds
baseline_include = include_both + include_old completely
independently of baseline_header = header_both + header_old, so a
shared, bare -H api.h (no old= scoping — passing the thirteenth
finding's own equality check) combined with side-specific -I
old=old-build -I new=new-build shares one header list across both
sides while still routing each through a genuinely different include
tree. The header-only check let the new side's folded -D/-std/
sysroot/include context reach a baseline parsed through a different
include scope — parsing the old binary under the new build's own
configuration risks a bad parse or a false ABI diff on the old side,
the exact failure mode this whole fix exists to prevent. Fixed by also
requiring the old side's resolved include scope to match the
candidate's own effective includes (not baseline_includes or
list(baseline_includes) == list(eff_includes), ANDed with the
existing header-equality check) before reusing the fold — a genuine
-I old=PATH/-I new=PATH override on either side now falls back to
the caller's plain, unfolded context, same as a genuine -H old=PATH
override already did. Regression test:
tests/test_cli_scan.py::test_baseline_compare_with_side_aware_includes_keeps_unfolded_context
(confirmed to fail against the pre-fix — header-equality-only — code,
which forwarded the folded sentinel even though the old side's
resolved include scope diverged from the candidate's).
A sixteenth finding, from a CodeRabbit review round (Major), on
header_utils._INCLUDE_FLAG_PREFIXES's own matching itself — real,
and confirmed pre-existing for most of its consumers (present at this
PR's own base commit dc09aec, before any of its changes), documented
as a known gap rather than fixed here. Every match against this
tuple — in _has_include_build_context, _build_context_include_dirs,
_flag_tokens, _msvc_deferred_flag, include_operand_dirs, and
buildsource.l2_seed._split_include_tokens alike — is exactly
case-sensitive str.startswith. That is correct for the GNU/clang
spellings (a real compiler only ever accepts the documented lowercase
forms), but wrong for the two clang-cl-only entries: verified against
real documentation for both drivers — native cl.exe's own options are
case-sensitive (and it doesn't recognize /imsvc at all, a clang-cl-only
spelling), while clang-cl's own option parsing is documented
case-insensitive, so /IMsvc//EXTERNAL:I are legal, real spellings
this tuple's exact-case matching silently fails to recognize as
include-search flags at all. Concretely: a real clang-cl build record
using /IMsvc would have that directory not suppress the L2 include-dir
seed, not resolve through the existing-dir dedup, and — this PR's own
two new consumers specifically — not be hashed into the AST cache key's
extra_hash_dirs (include_operand_dirs) and not be carved out of the
leading last-flag-wins token group by _split_include_tokens, so an
explicit /IMsvc override could still lose to a derived -I for a
colliding header. Confirmed pre-existing for _has_include_build_context/
_build_context_include_dirs/_flag_tokens/_msvc_deferred_flag — all
four already existed at commit dc09aec (this PR's own base) with the
identical case-sensitive matching; only include_operand_dirs (moved
from l2_seed._include_operand_dirs) is new to this PR. Not fixed here:
a correct fix needs case-insensitive matching applied consistently to
every one of those six consumers, not just the two this PR added — fixing
only the new ones while leaving the pre-existing four case-sensitive
would be strictly worse than today's uniform gap, making the
seed-suppression logic and the cache-hashing/ordering logic silently
disagree about whether a given /IMsvc token is an include-search
flag at all. It also needs a genuine new tie-break case-folding
introduces that a case-sensitive scan never had: /imsvc case-
insensitively also matches the shorter /I prefix, and picking the
wrong one changes which include bucket the directory is treated as (see
_msvc_deferred_flag's own bucket-priority docstring) — a real, if
narrow, cross-cutting change to a shared, well-tested primitive several
pre-existing callers depend on, not a scoped fix reactive to one review
comment on this PR. Documented in _INCLUDE_FLAG_PREFIXES's own
docstring alongside this entry.
A seventeenth finding, from a further Codex review round (P1), on the
counterpart cache key the tenth finding's _attach_header_graph fix was
supposed to stay aligned with — real, and fixed. service._dump_elf's
own deferred_dirs computation (its PRIMARY header parse's cache key,
not the header-graph attach) hashed only resolve_inferred_header_roots's
deferred roots, never any include-search directory riding in the compile
context's own gcc_option_tokens — exactly the gap the tenth finding
closed on _attach_header_graph's side, left open on the primary parse
whose cache key that fix was meant to match. Editing a header under such
a directory could let the primary parse reuse a stale cached AST while
_attach_header_graph's own independent second parse correctly
reparsed, producing a snapshot whose declarations and embedded graph
describe different source states — the exact divergence the tenth
finding's fix was supposed to prevent, just from the other side. Fixed
by folding include_operand_dirs(cc.gcc_option_tokens) into _dump_elf's
deferred_dirs too, the identical fold _attach_header_graph already
applies. Regression test:
tests/test_service_unit.py::TestDumpElf::test_gcc_option_tokens_include_dir_is_hashed
(confirmed to fail against the pre-fix code, which never hashed the
directory into extra_hash_dirs).
An eighteenth finding, from a further Codex review round (P2), on a
real ordering bug in perform_elf_dump's own composition — distinct
from _merge_l3_compile_context's already-fixed explicit-vs-derived
split — and fixed. resolve_inferred_header_roots's own deferred
tokens (the inferred -H header root, emitted in a search bucket that
defers below any existing build context) were folded into the
"explicit" side of the L3 fold purely to suppress the fold's own
internal broad include-dir seed — but _merge_l3_compile_context has no
way to tell a synthetic deferred marker apart from a genuine user token,
so it ranked deferred ahead of the L3-derived include tokens too. A
colliding generated header under the L3-derived directory could then be
shadowed by the inferred root — the exact inversion "deferred" exists to
prevent. Fixed by excluding deferred from the tokens passed into the
fold and appending it back at its correct lowest-priority position
(after the L3-derived includes) once the fold result is known. Verified
this doesn't break the suppression it was providing: deferred is
non-empty only when the original tokens (unmodified, still passed to
the fold) already showed build-context evidence, which the fold's own
suppression check reads directly — so nothing is lost by leaving
deferred itself out. Regression test:
tests/test_non_elf_dump_l2_seed.py::test_perform_elf_dump_keeps_deferred_inferred_root_below_l3_derived_includes
(confirmed to fail against the pre-fix code, both via an internal
assertion catching deferred leaking into the fold's own explicit
tokens and via the final ordering check).
A nineteenth finding, from real Bazel/castxml CI evidence (not a
hand-built fixture) on napetrov/abicheck-bazel-lab's diagnostic PR #14,
after repinning it to abicheck/abicheck@84cf3d4 (PR #788) — a narrower
residual of this same topic survives the eighth/_build_new_snapshot
fold fix above, investigated but not fixed. The lab's
validate-two-fresh-mains.yml workflow builds BASE and HEAD with real
Bazel, captures target-scoped cquery/aquery evidence for both, dumps
each fresh (fresh-base.abi.json/fresh-head.abi.json, same code,
identical --sources/--build-info), then separately scans HEAD
--against the fresh BASE dump. compare fresh-base fresh-head (pure
dump vs dump) reads COMPATIBLE/NO_CHANGE with a complete
analysis_assurance — confirming language_standard parity holds, i.e.
the eighth-finding fix above genuinely works. But scan HEAD --against
fresh-base.abi.json (the same HEAD code, same -H/--sources/
--build-info inputs, dump's baseline) still reads NOT_COMPARABLE:
diff.reason names exactly one differing field, include_sequence (not
language_standard, which no longer reproduces) — a narrower,
previously-undetected sibling of the field this whole topic exists to
close, confirmed via the run's own uploaded two-fresh-mains-validation
artifact (run
https://github.com/napetrov/abicheck-bazel-lab/actions/runs/31950549361,
job 95173233150, commit a7f5a7f).
A disconfirmed hypothesis, corrected by Codex review — recorded so a
future pass doesn't re-propose it. This entry's first draft claimed the
cause was scan_engine._build_new_snapshot passing a raw, unexpanded
headers list (still containing the directory entry) into
seed_includes_and_fold_compile_context/service.resolve_input, while
perform_elf_dump pre-expands via expand_header_inputs() first. False,
verified by reading both paths fully rather than trusting the first,
partial read: (1) header_compile_context.resolve_header_compile_context
— reached from inside seed_includes_and_fold_compile_context's own
compile-context fold, not just its truthiness-only seed_l2_includes
half this entry's first draft checked — calls its own
_expand_header_directories() on the raw headers list, whose docstring
states it deliberately reuses header_utils.iter_directory_headers (the
same walk expand_header_inputs itself delegates to, same suffix/pruned-
segment filters) specifically so the expanded set matches what L2 actually
parses. (2) service.resolve_input's own ELF dispatch, _dump_elf, calls
expand_header_inputs(headers) internally (service.py:1281) before
ever reaching dumper.dump() — the identical function perform_elf_dump
calls explicitly upfront, just one call-stack frame deeper. Both scan's
and dump's header lists therefore converge on the identical expanded,
deduped, deterministically-ordered file set before any header-AST parse
runs; a raw-vs-expanded asymmetry cannot be the cause of the include_
sequence mismatch.
Two further Codex review rounds, each finding the previous round's
proposed single "the mechanism is X" narrative was itself incomplete —
pattern worth naming before the specifics: this area (perform_elf_dump
vs. scan_engine._build_new_snapshot's relative ordering of the L3→L2
fold and header_utils.resolve_inferred_header_roots) has enough real
asymmetry that every single-paragraph explanation attempted so far turned
out to be a true but partial slice of it, not the whole story — so this
entry stops trying to assert one and instead lists the verified-by-
reading candidate mechanisms found, unranked, without claiming which one
(if any single one) explains the specific include_sequence mismatch in
the CI evidence above. perform_elf_dump calls
resolve_inferred_header_roots(headers, includes, gcc_options=
effective_gcc_options, gcc_option_tokens=tuple(gcc_option_tokens)) using
its own pre-fold local variables, before its later
seed_includes_and_fold_compile_context call folds real L3 evidence into
them; scan_engine._build_new_snapshot folds first, then passes the
already-folded compile_context into resolve_input(..., compile=
compile_context), whose ELF dispatch _dump_elf makes its own internal
resolve_inferred_header_roots(...) call reading that already-folded
context. Two concrete, verified effects of this ordering difference, not
one:
(a) resolve_inferred_header_roots's own skip set is built from
user_includes (the caller's includes parameter) and
_build_context_include_dirs(ctx) (dirs implied by the passed-in
gcc_options/gcc_option_tokens) — for scan's post-fold call, both of
these already carry L3-derived content, so an inferred -H root whose
directory the L3 evidence already covers is skipped entirely
(inferred ends up empty, the function returns ([], []) for that
root); dump's pre-fold call has a much smaller skip set (no L3 content
yet), so the same root is far more likely to survive as a plain -I
extra-include instead. This omission is real only for the two branches of
skip that do not independently reach extra_includes: a
_build_context_include_dirs(ctx) match (a dir implied only by
gcc_options/gcc_option_tokens, never itself added to includes), and
the sibling deferred-token branch below. It is not an omission when
skip matches purely because the root is already literally present in
user_includes — _dump_elf's own eff_includes = list(includes)
starts from that same list before resolve_inferred_header_roots ever
runs and is only ever added to, never filtered, so that root's slot
survives via the pre-existing includes entry regardless of whether the
inferred-root call re-adds it (Codex review: an earlier draft of this
entry collapsed all three skip/defer shapes into one "no slot" outcome,
which is only true for two of them).
(b) Separately, perform_elf_dump computes inc_extra from this
pre-fold call and only later builds extra_includes=eff_includes +
inc_extra for the actual dump(...) call, where eff_includes is the
(by then real) L2-seeded include list — if inc_extra's root and
eff_includes overlap (plausible, since both can independently resolve
to the same L3-derived directory), the same directory can appear twice
in dump's own extra_includes, which is what comparability_fields.
_include_slot_tokens actually tokenizes into include_sequence — one
token per declared_includes slot, and dumper_contract.
_attach_extraction_contract builds declared_includes exclusively
from extra_includes (IncludeDir(path=p, ...) for p in extra_includes,
absent a dump_manifest); gcc_option_tokens (where a deferred root
rides, as -isystem/etc.) never contributes a slot at all (Codex review
— corrected from this entry's own prior, wrong claim that both jointly
derive slots). This sharpens rather than weakens candidates (a)/(b)
below: a root that lands in extra_includes always produces a slot: once
for dump's inc_extra, and again if eff_includes also independently
picked up the same directory (candidate (b), a real duplicate slot); a
root that resolve_inferred_header_roots either skips outright or
reclassifies to a deferred gcc_option_tokens flag produces no slot
at all either way, since neither reaches extra_includes (candidate (a),
collapsing what looked like two distinct scan-side outcomes — "skipped"
vs. "deferred" — into the same net effect on include_sequence). scan's
candidate side has no equivalent double-add, since its single fold call
already produced the final includes/compile_context resolve_input
uses directly. Either effect alone — a missing slot on scan's side, or a
duplicated slot on dump's — changes the resulting slot count, which is
sufficient to make include_sequence differ regardless of which specific
slot moved. Not fixed here, and deliberately not narrowed to one of (a)/
(b) without more evidence: neither this pass nor either Codex round
inspected the actual differing include_sequence token values from the
CI artifact (only diff.reason's field name was read, not its content),
so which effect (or another one still unfound) actually fired in this
specific repro is genuinely unknown; a real fix needs that inspection (or
a live bazel/castxml repro, absent from this pass's environment)
before it can even be scoped, let alone attempted — this file's own
"known gaps over risky reactive patches" convention applies doubly here,
given how many single-paragraph "found it" claims this same footnote has
already had to walk back. Consequence for
napetrov/abicheck-bazel-lab: PR #14's fresh-to-fresh job (its real
workflow-job name) genuinely fails overall — its own
compare fresh-base fresh-head step passes, but its separate
scan HEAD --against fresh-base.abi.json step is the one that returns
NOT_COMPARABLE and fails the job — so it should be treated as still red
on this one residual field, and the lab's own checked-in
abi/math.abicheck.json should not be regenerated
against the new core pin yet — doing so now would just encode a baseline
that a fresh scan --against still can't cleanly compare to, the same
problem this diagnostic exists to catch, not fix.
Two real mechanisms found and fixed by actually inspecting the
differing include_sequence token values, using a minimal, castxml-free
local repro (a g++-compiled one-header library + a hand-written
compile_commands.json, --ast-frontend clang) rather than a live
Bazel/castxml run — the repro this whole footnote's own "not fixed here"
note said was the missing prerequisite. Both are real instances of
candidate (b) above (a duplicated -I/-isystem entry), reached from
two different composition points, neither previously identified:
(1) perform_elf_dump's own extra_includes=eff_includes + inc_extra
(and service_header_scoped._try_header_scoped_dump's identical
eff_includes += inc_extra) concatenate two independently-derived
include-dir lists with no dedup — fixed with a new
header_utils.dedup_paths_preserve_order(), applied at both composition
sites. (2) The load-bearing one for this specific repro:
l2_seed._merge_l3_compile_context's derived_includes (the P0.3 fold's
own derived compile-unit include dirs) and the caller's own explicit
gcc_options/gcc_option_tokens can independently carry an -I for the
identical directory — concretely, a --build-info compile database is
matched both by this fold's own resolver and by the pre-existing
legacy -p/--compile-db auto-match mechanism that populates
perform_elf_dump's effective_gcc_options string before the fold ever
runs, so the same directory reaches the merged gcc_option_tokens twice:
once via explicit_tail (the split gcc_options string) and once via
derived_includes. Confirmed via direct inspection of both
CompileContext objects the merge receives (explicit.gcc_options
literally contained "-I <dir>", derived.gcc_option_tokens literally
contained ("-I", "<dir>", ...) for the same <dir>) — not
reconstructed from diff.reason alone, closing exactly the evidentiary
gap the "Two further Codex review rounds" paragraph above says was still
missing. Fixed with a new header_utils.drop_include_tokens_duplicating_
paths(), which drops a derived_includes pair whose directory already
appears among explicit_tail + explicit.gcc_option_tokens's own
include-search operands — "explicit wins, searches first" is unaffected,
since only the later, redundant derived copy is ever dropped. Verified
end to end: the real compiler argv dump sends to clang no longer
repeats -I <dir>, and a fresh dump baseline's ast_compile_args now
matches a scan --against candidate's own (both carry exactly one -I
<dir> for the matched project directory) — see
tests/test_build_context_completeness.py's
TestDedupIncludeDirsAcrossCompositionSites/
TestMergeL3CompileContextDropsDuplicateExplicitInclude.
A Codex review round on the same PR found the first version of this
fix class-blind, and it was fixed before merge. drop_include_tokens_
duplicating_paths()'s first cut matched purely on resolved directory,
ignoring which include-search class (-I/-isystem/-iquote/
-idirafter) each entry belonged to — but a real compiler consults
those as distinct search buckets in a fixed order regardless of argv
position (the identical class-blindness _split_include_tokens's own
docstring already documents as a known gap one function over), so
dropping an -isystem <dir> merely because an unrelated -I <dir>
existed elsewhere could change which bucket that directory is searched
from for a colliding header basename — concretely, -I A -I B -isystem
A resolves a colliding name from B, while the class-blind rewrite
-I A -I B resolves it from A instead. Fixed by making the dedup key
(flag-class, resolved directory) rather than the directory alone, via
a new _include_class_path_pairs() shared by both the "already
covered" side and the token walk being filtered — see
TestDedupIncludeDirsAcrossCompositionSites::
test_drop_include_tokens_duplicating_paths_is_class_sensitive. Both
call sites' "already covered" argument changed shape accordingly (raw
tokens, not bare Paths) so the class of an already-emitted entry is
never lost either.
Not fully closed — a third, deeper mechanism survives and still
reproduces NOT_COMPARABLE on include_sequence for the identical
repro, confirmed by direct inspection after the two fixes above. Even
with both duplicate--I bugs fixed, dump's baseline and scan's
candidate still disagree, because the matched directory reaches
declared_includes (the sole source dumper_contract.
_attach_extraction_contract builds include_sequence's slots from) via
structurally different channels on the two paths rather than merely
differing in count: on dump, the legacy -p/--compile-db match
supplies the directory as part of effective_gcc_options before the
L2 seed ever runs, and seed_l2_includes's own suppression rule
(correctly) declines to seed a directory when explicit context already
supplies one — so the directory reaches the parse only through
gcc_option_tokens, and eff_includes/declared_includes stays empty
for it. scan_engine._build_new_snapshot has no equivalent legacy -p
step at all — nothing pre-populates effective_gcc_options before its
own fold runs, so the identical directory reaches the same fold's L2
seed unsuppressed, landing in eff_includes/declared_includes instead.
Two structurally different declared_includes — empty vs. one entry —
for the same real include root is exactly what include_sequence is
built to detect, so it correctly (if unhelpfully) still fires. Not
fixed here: closing it needs a design decision this pass did not have
standing to make on its own — either dump's CLI stops running the
legacy -p/--compile-db auto-match whenever --build-info already
feeds the new P0.3 fold (the two have overlapped, silently, since the
fold was introduced — this pass is the first evidence either mechanism's
placement choice for a resolved directory is externally observable,
not just its content), or scan's candidate resolution gains an
equivalent legacy-match step so both paths agree on which channel
supplies a matched directory, not merely that they supply the same one.
Either is a real, cross-cutting change to which of two established
mechanisms wins for a dump-only surface scan has no counterpart to
— not a mechanical follow-up to either dedup fix above. Confirmed via
the same local repro: after both fixes, dump's ast_compile_args
still carries -I <dir> (via gcc_option_tokens) while declared_
includes is empty; scan's candidate carries the identical -I <dir>
via declared_includes instead — same effective compiler argv, still a
real profile_fingerprint/include_sequence disagreement.
Re-verified end to end (2026-08-20) against the literal bug report this
whole entry was originally opened from (a dump --sources --build-info
--depth source baseline compared against an unchanged codebase via
scan --against, reported carrying empty ast_resolved_standard/
ast_compile_args and failing as NOT_COMPARABLE): the reported
symptom does not reproduce on current main for a plain,
single-compilation-unit compile_commands.json (real g++ -std=c++17
build, real clang L2 frontend, no -p/--compile-db involved) — the
dump baseline's ast_resolved_standard/ast_compile_args are
correctly populated and the follow-up scan --against resolves
NO_CHANGE, not NOT_COMPARABLE. This confirms the accumulated fixes
above (the L3->L2 fold itself, the include-dir dedup fixes, the
class-sensitive dedup fix) already close the reported case for real —
the pinned commit the report was filed against
(abicheck/abicheck@891bd9d7) predates essentially all of them. Added
as a real, non-mocked end-to-end regression:
tests/test_dump_scan_l3_comparability.py (a real g++-compiled
library + compile_commands.json, driven through the actual dump/
scan CLI commands via CliRunner, not a stubbed dump() call the way
the existing unit coverage in tests/test_cli_dump_helpers_coverage.py
is) — closing the gap this entry's own earlier text noted between "the
mechanism is unit-tested" and "the reported end-to-end symptom is
verified gone." The residual, narrower gap immediately above this
note — the legacy -p/--compile-db auto-match (dump-only) landing a
matched directory through a structurally different channel than the
P0.3 fold's own seeded declared_includes, reproduced against real
Bazel aquery/cquery evidence in napetrov/abicheck-bazel-lab's own
PR #14 — is unaffected by this re-verification and remains genuinely
open for a --build-info combined with an explicit -p/
--compile-db, or for real multi-target Bazel aquery graphs this
environment cannot reproduce (no live Bazel/castxml toolchain
available). A user hitting NOT_COMPARABLE after this date should
check first whether their invocation combines -p/--compile-db with
--build-info, or is a genuine multi-TU Bazel build — the plain,
single-compile-unit case this note re-verifies is not the culprit.
Narrower still: the residual gap also reproduces without any Bazel
toolchain or -p/--compile-db at all — the minimal trigger is just
"the matched compile unit's own flags include an -I<dir> that is not
already in the caller's explicit includes" (2026-08-20, found while
adding generalized parity testing for this bug class, prompted by a
direct question — "how did we end up with two different behaviours,
and can this be caught generically?" — rather than by a new field
report). A real g++ -std=c++17 -I<dep-dir> build, --build-info a
matching compile_commands.json, no -p/--compile-db anywhere:
dump's own baseline JSON correctly carries -I <dep-dir> in
ast_compile_args, but its contract.profile_fields.include_sequence
reads "[]" — confirmed directly by inspecting the emitted JSON, not
inferred. Root cause, read from the code rather than guessed:
dumper_contract._attach_extraction_contract builds declared_includes
(the source include_sequence tokenizes) exclusively from
extra_includes, and for this shape the -I<dep-dir> reaches the
parse only through gcc_option_tokens (the L3->L2 fold's own derived
compile-unit include dirs), never through extra_includes — while
scan_engine._build_new_snapshot's own candidate resolution (which
calls service.resolve_input directly, per this same entry's "PR C"
paragraphs below, rather than through the shared resolve_side_snapshot
primitive compare's implicit-dump path and the typed DumpRequest
API both already use) seeds eff_includes/declared_includes for the
identical directory instead — two structurally different channels for
the same fact, exactly the shape this entry's own "third, deeper
mechanism" paragraph already names for the Bazel case, just reproduced
here without Bazel. Confirms compare's implicit-dump path is
genuinely unaffected: comparing the same dump baseline against the
same live binary via compare (not scan --against) resolves cleanly
for this exact shape — the divergence is specifically between dump's
CLI path and scan's own candidate-resolution path, narrower than
"any comparison against a dump baseline." Not fixed here — same
reasoning as the Bazel-specific case above (a real design decision on
which of two established include-seeding channels should win, not a
drive-by patch) — but now has permanent, generalized regression
coverage rather than depending on a Bazel CI lab to notice it again:
tests/test_dump_cli_typed_api_parity.py's
test_scan_against_real_dump_baseline_is_comparable parametrizes over
several real build-evidence shapes (plain, an added macro, this
extra-include-dir shape). Rather than a bare xfail(strict=True) (three
Codex review rounds on this test found real gaps in cruder versions of
this idea — see the test module's own comments for the full history),
the known-divergent shape is checked against the exact diagnosed
failure signature (NOT_COMPARABLE naming include_sequence) before
being treated as expected via a conditional pytest.xfail(); anything
else for that shape — including the gap closing entirely — fails the
test outright, forcing this note and _SCAN_KNOWN_DIVERGENT_SHAPES to
be updated deliberately rather than the test quietly going green. A
future regression that widens the gap to a previously-passing shape
fails immediately too, since only the shape explicitly listed gets any
tolerance at all. The sibling
test_dump_cli_and_typed_api_agree_on_resolved_compile_context in the
same module separately pins the narrower invariant that does already
hold across all three shapes today (dump's CLI path and the typed
DumpRequest API path agree on ast_resolved_standard/
ast_compile_args) — so a regression in that invariant, which is
what #810's original literal symptom was about, is caught independently
of the include_sequence gap this note documents.
The nineteenth finding's own missing prerequisite — real evidence from
the exact Bazel include_sequence mismatch — has now been supplied, and
the mismatch does not reproduce on current main (2026-08-22).
napetrov/abicheck-bazel-lab's UPSTREAM_TO_ABICHECK.md (2026-08-21
entry, "abicheck scan --against reports NOT_COMPARABLE against every
abicheck dump baseline") recorded exactly this: mode: dump and
mode: scan for real Bazel //:math evidence (cc_library(includes =
["include"]), verified byte-identical evidence packs across runs)
disagreeing on contract.profile_fields.include_sequence, deterministic,
on every CI run pinned to abicheck/abicheck@6fb8536 (#812). Reproduced
directly, without Bazel or castxml: a g++-compiled library with the
identical real shape a cc_library(includes = ["include"]) action
produces — two simultaneous -I search directories (the package's own
include dir and Bazel's always-present package/workspace-root search
path) that are both real, legitimate ancestors of the same physical
public header, tokenizing as two hdrs: slots in include_sequence
(abicheck_lab/math.h under the include dir, include/abicheck_lab/
math.h under the root — matching the committed abi/math.abicheck.json
baseline's own recorded fields exactly). Checked out at the reported pin
(6fb85361c, #812) in a separate worktree: the mismatch reproduces
there verbatim (dump's baseline records include_sequence: [] — the
P0.3 L3→L2 fold never reached the ELF dump CLI's header parse at that
commit — while scan's candidate resolves two hdrs: slots for the
identical evidence, so scan --against correctly, if unhelpfully,
refuses the pair as NOT_COMPARABLE). The identical repro against
current main (well past #812 — the "PR C" dump/scan convergence
work chronicled throughout this same entry, #814/#815/#817/#823,
landed after the lab's pin) produces NO_CHANGE/exit 0 on scan
--against, compare's implicit-dump operand, and both CLI-vs-typed-API
parity lenses alike — the dump/scan convergence work already closed
this specific shape as a side effect, not as a targeted fix for it.
Given main was already correct, no abicheck source change was made
for this finding; what was missing was permanent regression coverage
pinning this exact real-world shape (a Bazel includes-attribute-style
duplicate owned include directory, distinct from extra-include-dir's
unrelated-second-header shape), so a future regression in the shared
seed_includes_and_fold_compile_context/_slot_token_for_ancestor
machinery fails a fast, deterministic test here instead of needing a
fresh Bazel CI report to notice again. Added as a fourth parametrized
shape, "duplicate-owned-include-dirs", in
tests/test_dump_cli_typed_api_parity.py's _BUILD_SHAPES — it runs
through all four existing parity/comparability tests in that module
(both CLI-vs-typed-API lenses, scan --against, compare's
implicit-dump operand) with no xfail, matching every other closed
shape there. The pinned Action commit abicheck-bazel-lab reported
against (6fb8536) predates the fix entirely; upstream consumers hitting
this exact symptom need to move their pin forward past #814, not wait
on a new abicheck change.
dump --lang c++is silently discarded on the primary clang header-AST pass for a language-ambiguous header, diverging from_attach_header_graph's own pass on the identical headers — investigated, not fixed (G31 Phase C castxml-installation pass, fresh evidence, reproduced end-to-end).cli_dump_helpers.py's ELFdumppath (andservice.py's two mirrors,_dump_elf/PE-Mach-O's_header_graph_lang) all normalize the caller'slangtolang if lang == "c" else Nonebefore callingdumper.dump()— deliberately, per their own comment: "every format's own main pass normalizeslangto only ever force a language explicitly requested, letting auto-detection run otherwise (including for the default 'c++')", so_attach_header_graph's own_clang_header_dumpcall computes the identical cache key and hits the in-process AST memo instead of paying a second clang invocation. That assumption — "an explicit--lang c++and auto-detection converge on the same result anyway, so unifying their cache keys is free" — is false whenever the header itself is language-ambiguous:_resolve_force_cpp's auto-detection (_detect_cpp_headers/_detect_cpp20_headers/an explicit-std=c++NN) requires the header to contain some C++-only syntax, but a plain POD struct with no such syntax (struct Widget { int x; int y; };— ordinary, real C++ code, e.g. a value/DTO type) compiles as valid C too and auto-detects as C. Reproduced end-to-end:abicheck dump lib.so -H widget.h --ast-frontend clang --lang c++silently parseswidget.hin C mode for the primary snapshot (confirmed via the raw clang AST: a plainRecordDecl, notCXXRecordDecl, nodefinitionDataat all) — with no error, warning, or any user-visible sign the explicit--lang c++was overridden — while the samedump's internal_attach_header_graphpass, reached through a differentlangderivation one call removed (abicheck/service.py:1281/abicheck/service.py:608's ELF branch shares the identicallang if lang == "c" else Nonesquash, so it too loses the signal — the divergence traced here is specifically the ELF_dump_elfhelper's own second, ADR-050/G31-era call site,abicheck/cli_dump_helpers.py:1717, versus_clang_header_dump's documented "an explicit--lang c++/cppalways wins" contract at the function it's calling into — the squash happens one layer above that contract, defeating it before it ever runs). Concrete, silent correctness cost:RecordType.is_standard_layout/is_trivially_copyable(and any other clang-only, C++-semantic-only fact —_clang_record_type_traitsitself documents "a plain CRecordDecl... genuinely absent", the correct behavior for C mode, just not the mode the user asked for) silently readNoneinstead of a real value for a header that would parse identically either way except for these C++-only facts, with the user's own explicit override having no effect. Not fixed here: the squashing is not an oversight — it is a deliberate, twice-Codex-reviewed cache/memo-consistency design (seeabicheck/service.py:608's own comment) that this finding does not invalidate for the common case (a header with real C++ syntax auto-detects as C++ regardless, so squashing costs nothing there) — only for the specific, real, silently-wrong case: an explicit--lang c++on a syntactically-ambiguous header. A correct fix needs the squash itself to stop conflating "auto-detect, and it happens to reach the same verdict as the default" with "explicitly override, must not be silently downgraded to a guess" — e.g. resolvingforce_cpponce, upstream of all three call sites and their_attach_header_graphmirrors, and threading the resolved boolean (or an unsquashedlangplus a corrected memo key that hashes the resolved language rather than the raw flag) through consistently — a change to a shared cache-key contract three call sites and two Codex-reviewed comments currently rely on, not a one-line fix at any single site. No test in the repository currently exercises this path: every existingis_standard_layout/is_trivially_copyableregression test (tests/test_dumper_clang.py::test_parse_types_populates_standard_layout_and_trivially_copyable,tests/test_diff_layout.py) hand-builds the parsed AST orRecordTypedirectly, bypassingdumper.dump()'s CLI-level--langresolution entirely — closing this gap should add an end-to-end regression case in the shape oftests/test_clang_header_backend_integration.py's existing siblings, verified against a real compiled library with an intentionally-C-compatible C++ struct, once the fix itself is designed.
Closed for the abicheck dump ELF CLI path — scoped narrower than the
"resolve force_cpp once, upstream of all three call sites" design sketch
above (G31 continuation). Rather than a shared, force_cpp-boolean
cache-key contract spanning cli_dump_helpers.py, service.py's
_dump_elf, and service.py's PE/Mach-O _header_graph_lang, the actual
fix is narrower: only cli_dump_helpers.py's ELF dump CLI path had a
real internal divergence between its own primary pass (squashed) and its
own _attach_header_graph call (raw, unsquashed) — service.py's
_dump_elf and its _header_graph_lang computation already squash
consistently with each other (both feed the identical normalized value),
so compare's implicit-dump path and the Python service.run_dump API
were never the site of this specific divergence; PE/Mach-O's own primary
pass (_try_header_scoped_dump) never squashed at all. What was missing
everywhere is the one bit no string-normalization scheme can recover on
its own: whether --lang was genuinely given on the command line, since
Click's own default for --lang (LANG_DEFAULT, cli_options.py) is the
identical string "c++" a real --lang c++ produces. dump_cmd now
resolves this once via Click's own parameter-source tracking
(click.get_current_context().get_parameter_source("lang") ==
click.core.ParameterSource.COMMANDLINE) and threads a new
lang_explicit: bool keyword parameter through perform_elf_dump, which
derives one _effective_lang (the real lang when explicit or "c",
else None) and passes that identical value to both the primary
dump() call and the _attach_header_graph call, instead of the primary
pass's own one-off squash and the graph pass's raw pass-through. This also
fixes a second, previously-undocumented half of the same divergence in the
other direction: on a plain default invocation (no --lang, still
lang="c++"), the header-graph pass previously force-parsed C++
unconditionally (since a non-empty lang was always treated as explicit
by _resolve_force_cpp), while the primary pass correctly auto-detected —
so even a default, no-flags dump --ast-frontend clang could already
silently disagree with itself between its own primary snapshot and its
own embedded header-graph. Verified end-to-end against a real compiled
library with an intentionally-C-compatible POD struct (struct Widget {
int x; int y; };, exactly this entry's own repro shape) through the real
abicheck dump CLI, not a hand-built AST or RecordType — see
tests/test_clang_header_backend_integration.py::
test_cli_dump_explicit_lang_cpp_forces_cpp_mode_on_ambiguous_header.
Closed for service.run_dump/DumpRequest/CompareRequest in a later
pass, once a real conda-forge castxml build (0.7.0, within the
>=0.6.11,<0.8.0 policy range — castxml_policy.py) was available in this
environment to verify against, alongside clang 18. Rather than the
lang: str | None = None tri-state default this entry originally
sketched — a public-API shape change with a wide, hard-to-verify blast
radius across every resolve_input/run_dump caller (compare, scan,
appcompat, l0_export_delta, ...), most of which still legitimately pass
a concrete, Click-defaulted lang string that must keep auto-detecting —
the actual fix is the same additive lang_explicit: bool = False
parameter the CLI fix above already established, generalized one layer
up: service.resolve_input/run_dump/_dump_elf/_dump_pe/
_dump_macho and service_header_scoped._try_header_scoped_dump all gain
it (default False, a no-op — every existing caller's behavior is
bit-for-bit unchanged), DumpRequest.lang_explicit/
CompareRequest.lang_explicit carry it on the typed API, and
service_dump_pipeline.run_dump_request/service_compare_pipeline.
resolve_compare_request thread it through resolve_side_snapshot (their
one shared per-side resolution function) to service.resolve_input. The
dump/compare CLIs resolve it the identical way dump_cmd already did
— compare_cmd mirrors the established _frontend_explicit/
_nostdinc_explicit ctx.get_parameter_source(...)==COMMANDLINE pattern
already used one function over in cli_compare_helpers._embed_inline_
source_side for --ast-frontend/--nostdinc, extended to --lang and
threaded through cli_resolve._resolve_compare_snapshots into
CompareRequest. The whole-snapshot disk cache key
(service_dump_cache._dump_cache_extra_key/cached_run_dump) folds
lang_explicit in too — the identical lang string now legitimately
resolves to two different parsed ASTs depending on it, so a cache entry
for one must never serve the other. scan/appcompat/the
release/set-input fan-out/l0_export_delta are not touched by this
pass — they still pass their own already-resolved, Click-defaulted lang
string with lang_explicit defaulted False, so they keep their
pre-existing (unfixed, but also not regressed) behavior; wiring each is
the identical mechanical pattern applied here, left for its own follow-up
rather than expanding this pass's verified surface further. Verified
end-to-end against the same real, intentionally-C-compatible POD struct
through service.resolve_input, DumpRequest/run_dump_request,
CompareRequest/resolve_compare_request, and the real compare CLI
(Click parameter-source spy) — see
tests/test_clang_header_backend_integration.py::
test_dump_request_and_compare_request_lang_explicit_forces_cpp_mode and
tests/test_service_dump_cache.py's
test_differs_by_lang_explicit/test_lang_explicit_reaches_run_dump_and_keys_separately.
- Opaque-type suppression is keyed by bare RecordType.name, not a
qualified identity — pre-existing on both header backends, newly reachable
on direct-clang by PR #719's opaque-handle-type fix (Codex review,
investigated, not fixed). diff_filtering._find_opaque_types() (and its
siblings _find_by_value_types()/_downgrade_opaque_type_changes()) index
a snapshot's opaque/impl-private types by t.name alone, and
_root_type_name() derives a Change's matching key from Change.symbol
the same way diff_types.py stamps it — also the bare name for a
top-level type change (name = t_old.name), never qualified_name. Two
distinct records sharing a bare name in different namespaces (a complete,
genuinely public api::Foo and an unrelated forward-only impl::Foo)
therefore collide in the same opaque: set[str]: if impl::Foo is opaque,
_downgrade_opaque_type_changes() silently suppresses a real
TYPE_SIZE_CHANGED/field-change finding on the unrelated, complete,
public api::Foo too, since both match the bare key "Foo". Confirmed
by reading the code (no live repro run); this predates PR #719 and
already applied identically to castxml's own is_opaque=True types — the
PR's clang-backend opaque-stub fix (previously clang silently dropped
every forward-decl-only type instead of emitting a stub) makes this
reachable from a new source, not a new bug class. Not fixed here: a
correct fix needs qualified identity threaded consistently through
_find_opaque_types/_find_by_value_types/_downgrade_opaque_type_changes
and _root_type_name's several call sites in diff_filtering.py (at
least five, by grep), none of which currently have test coverage for the
cross-namespace-collision case to validate a change against — a
systematic, cross-cutting rework, not a scoped fix reactive to one review
comment. Filed here rather than attempted under this PR's time budget,
per this file's own "known gaps over risky reactive patches" convention.
- dumper_clang.py's parse_types() conflates a C/C++ tag-namespace
identity with an ordinary-namespace typedef identity that happens to
share the same spelling — pre-existing, not introduced by PR #719's own
changes, but investigated and confirmed reachable in this pass (Codex
review, investigated, not fixed). For a legal (if unusual) header like
struct Foo; typedef struct { int x; } Foo; — an unrelated forward-only
tag Foo and a SEPARATE anonymous struct given the ordinary-namespace
name Foo via typedef, which C's two-namespace rule keeps genuinely
distinct — parse_types()'s identity = "::".join([*entry.scope, name])
computation uses the same bare name for both (the tag's own name,
and the anonymous record's anon_names-derived typedef fallback name),
so the two collide into one identity key. Reproduced directly against
_ClangAstParser: only ONE RecordType named Foo is emitted (the
typedef-backed definition), and the unrelated opaque tag struct Foo;
is silently absent from the snapshot entirely — the exact regression
PR #719's own opaque-handle-type fix was written to prevent, just
reached through a different, adjacent mechanism (a spelling collision
across namespaces rather than declaration-order/redecl-set instability).
Not fixed here: closing it correctly needs the tag-namespace vs.
ordinary-namespace distinction threaded through the identity key
itself (and every downstream consumer that currently assumes identity
uniquely names one type — _build_record, the opaque/deprecated/kind
merge maps this same function already builds), which is a real, if
narrow, data-model change to a function this same PR already revised
three times this session (the opaque-stub fix, the kind-canonicalization
fix, and that fix's own regression fix) — each of which independently
needed careful re-verification against dumper_clang.py's exact
2000-line hard cap. A fourth, differently-shaped change to the same
function under continued review pressure is exactly the risk profile
this file's own "known gaps over risky reactive patches" convention
exists to avoid; a correct fix needs its own dedicated pass with fresh
test coverage for the namespace-collision case specifically, not a
same-session extension.
- The castxml L4 source-ABI extractor does not fold the resolved
EMULATED compiler's identity into either fact_set.compiler_version or
the D8 TU cache key — attempted once for the persisted half, and
REVERTED after a follow-up review caught it as a real regression, not a
fix (Codex review, PR #719, three follow-up rounds). castxml shells
out to the emulated compiler (cc_bin, resolved per compile unit by
pick_compiler_binary — the real build's own recorded argv[0], absent
an explicit --gcc-path override) purely to discover its built-in
defines/include paths (docs/learn/architecture.md), so a header
conditional on __GNUC__/_MSC_VER can extract differently once that
compiler is upgraded at the same path, even though castxml itself and
its own --version probe stay identical — a real gap. The second
follow-up round's fix folded a STAT signature (dev/ino/mtime_ns/
size) of the resolved cc_bin into fact_set.compiler_version
(_stamp_fact_set_and_coverage() already has the per-TU compile_unit
in scope). The third follow-up round found this made things WORSE, not
better: those stat fields are filesystem-local, so (1) two TUs in ONE
surface resolved to DIFFERENT but same-toolchain drivers (gcc for a
.c TU, g++ for a .cpp TU — an entirely ordinary mixed-language
build) get different suffixes purely from being different files on
disk, tripping rollup_fact_set()'s exact-equality check into
fact_set_inconsistent for a perfectly healthy, unchanged surface; and
(2) an identical build run on two different machines/paths (baseline
collected in one CI run, compared against another — an entirely
ordinary workflow) never shares device/inode at all, making every such
cross-machine comparison spuriously inconsistent and silently
suppressing every structured/opaque/source-edge finding. Between
"silently under-detect genuine toolchain drift" (the pre-existing gap)
and "spuriously suppress every finding on completely ordinary mixed-
language or cross-machine comparisons" (the attempted fix), the second
is strictly worse for typical usage, so the stat-based fold was
reverted rather than patched further under review pressure. Not fixed
here, either half: a correct fix needs a portable SEMANTIC identity —
a real cc_bin --version probe, normalized (mirroring
_castxml_tool_version's existing shape, but for an arbitrary
gcc/clang/MSVC driver rather than castxml specifically) — not
filesystem stat fields, for the persisted half; the D8 cache-key half
additionally needs a wider, per-instance-hook-signature change to
source_replay.py's shared cache-key infra (also used by
ClangSourceExtractor, whose analogous --gcc-path case resolves once
per extractor construction, not per TU, so its existing zero-arg hook
shape doesn't transfer directly), plus a decision on probing cost (a
version probe per distinct resolved cc_bin across a build with mixed
toolchains, cached the same way _castxml_tool_version's lru_cache
already is). Left for a dedicated follow-up with its own MSVC-vs-
GCC-vs-Clang version-probe design, not a same-PR reactive patch.
- A compatible-but-ambiguous opaque redeclaration set (class H; struct
H;) that later gains a same-key COMPLETE definition still produces a
false SOURCE_LEVEL_KIND_CHANGED — investigated, not fixed (Codex
review, PR #719, fourth follow-up round on this same area). The
earlier "keep the definition's own kind" fix (this same file, above)
deliberately never applies dumper_clang.py's canonicalized opaque
override_kind to a record that survives as a COMPLETE definition — the
definition's own real, unmodified _record_kind() always wins, which is
correct in isolation (a real kind change on the definition itself must
never be hidden). But this means an identity whose opaque forward decls
were genuinely ambiguous (class H; AND struct H;, both present,
canonicalized to the fixed "struct" spelling per the kind-stability fix
above) reports "struct" in an old snapshot with no definition, while a
new snapshot adding a same-key class H {...}; definition reports the
definition's real "class" — a spurious kind-change finding even though
"class" was always one of the two already-compatible, already-declared
keys. Reproduced directly against _ClangAstParser. Not fixable at
the extraction layer: an opaque snapshot has no way to know in advance
which of the ambiguous, compatible keys a LATER definition will use, so
no fixed canonicalization choice can match every possible future
definition. The generic comparison (diff_types_abicc_parity.
_diff_type_kind_changes(), shared across all producers) does a plain
t_old.kind != t_new.kind check with no notion of "this kind was
extracted from a genuinely ambiguous, unresolved forward-decl set" —
and correctly so, since blanket-suppressing every class↔struct
transition would hide the genuine, intentional ones this detector
exists to catch. Closing this properly needs new PROVENANCE carried on
RecordType itself (e.g. a kind_ambiguous-shaped field, schema-
versioned, populated by both header backends, read by the diff layer to
skip exactly this one shape of transition) — a cross-cutting model
change touching model.py, both backends, serialization, and the
detector, not a scoped fix reactive to one review comment, and
dumper_clang.py's parse_types() has already been revised four times
in this same PR session for adjacent findings in this exact area. Filed
here per this file's own "known gaps over risky reactive patches"
convention rather than attempted under continued review pressure.
- advanced_facts_collected infers "advanced DWARF extraction ran" from
its output rather than recording it. The predicate answers by checking
whether any field dwarf_advanced.diff_advanced_dwarf consumes is
non-empty, plus target_arch as a discriminator for a parse that completed
but established nothing (dwarf_advanced sets it from the ELF header;
the presence-only helpers leave it ""). That is sound for every shape
reachable today, and it is mutation-checked per consumed family in
tests/test_debug_evidence_presence.py. But it is still an inference: the
honest signal is a status, not a reconstruction from findings.
The reason it matters is asymmetric. checker.py requires the predicate on
both sides, so any shape the inference gets wrong does not merely mislabel
coverage — it disables the advanced_dwarf detector wholesale and the run
silently misses real drift. That is a false negative, the worse direction,
and two such shapes were already found by review during one PR (the three
toolchain flag sets, then the successful-but-empty parse).
The durable fix is an explicit "advanced extraction completed" field on
AdvancedDwarfMetadata, set where the parse succeeds and persisted, with
the predicate reading it instead of inferring. Not folded into the fix PR
because it is a persisted-model change: a new field means a
SCHEMA_VERSION bump and a decision about how a pre-bump snapshot reads
(almost certainly "unknown", resolved conservatively), which this file's own
contract says gets its own ADR and migration rather than riding along with a
behavior fix. Until then the inference stands, and any new consumer of
AdvancedDwarfMetadata must be added to _CONSUMED_ADVANCED_FIELDS in that
test or it will silently disable the detector again.
- A pre-fix clang-backend baseline compares against a post-fix candidate
as a wave of
FUNC_BECAME_INLINE— no reliability flag guardsFunction.is_inline. The implicit-inline fix (extract/headers/clang/inline_semantics.py) changed what the clang header backend records for aconstexpr/in-class/= defaultdeclaration, fromFalseto the correctTrue. A snapshot dumped with the clang backend before that fix therefore carries a wrong value, anddiff_symbols._check_inline_transitionscomparesis_inlineraw on both sides with no producer or generation gate — so a stored baseline from before the fix, compared against a freshly-dumped candidate, reportsFUNC_BECAME_INLINE(RISK) for every such declaration. The findings are spurious: nothing about the library changed.
Bounded, with a one-step remedy. castxml is the default header
backend and always recorded these as inline, so only a baseline captured
under the opt-in --ast-frontend clang / ABICHECK_AST_FRONTEND=clang is
affected, and re-dumping that baseline clears it permanently. The findings
are RISK-class, never breaking, so no gate flips from pass to fail.
The proper fix is the established one, and it is a schema change. This
codebase already has the mechanism for exactly this situation — a fact
whose stored value is wrong in snapshots from an earlier generation:
clang_restrict_facts_reliable, clang_va_list_facts_reliable,
param_kind_facts_reliable and their siblings
(model/snapshot_reliability.py), each set False at load time for a
snapshot below a _MIN_SCHEMA_VERSION_FOR_* threshold from the affected
producer, and read by the consuming detector, which then declines rather
than fabricating a finding. Adding clang_inline_facts_reliable the same
way is the complete fix. It is not folded into the fix PR because it
cannot work without bumping SCHEMA_VERSION (a pre-fix snapshot is v46,
and so is a post-fix one — there is otherwise no way to tell them apart),
and this file's own contract is that a schema change gets its own ADR and
migration rather than riding along with a behavior fix. It also lands in
policy/analysis_assurance_degraded_facts.py's consulted_when map,
whose every-flag-has-a-key invariant is enforced by a KeyError rather
than a check, and — if is_inline is converted to a Fact[bool] at the
same time, which is the param_kind precedent — in the storage backfill
rules too. Recorded here rather than attempted under the same review, per
this file's own convention.
-
Linkage-blind removal — attempted twice, reverted twice. The evidence keeps proving something adjacent to the invariant. A symbol vanishing from the export table is reported as
func_removed(and, on the same symbol,func_deleted_elf_fallback) regardless of its linkage, so a weak vague-linkage export reads as a hard break. The demotion's invariant is "every consumer already emitted its own copy", and both attempts established something else: -
Function.is_inlineproves the declaration's inline linkage, not that a definition exists. Verified against real clang:inline int f();yieldsinline=True, has_body=False. This stays true after the implicit-inline fix (extract/headers/clang/inline_semantics.py), which widened the field to coverconstexpr/consteval, in-class definitions and in-class= defaultso the two backends agree: those are all still linkage facts about the library's own declaration, and none of them says a consumer emitted a copy — which is the shared shape this entry exists to name. Read it as "may have vague linkage", never as "every consumer already has its own definition". - COMDAT-group membership proves the library used vague linkage, not
that its consumers did.
extern templateis the counterexample, and it is ordinary code: a public header carryingextern template struct Box<int>;tells every consumer TU not to instantiate, while the library's own explicit instantiation still emits a weak COMDAT definition. Verified against g++ — the library object has_ZNK3BoxIiE3getEvinside a COMDAT group while the consumer object has it asNOTYPE GLOBAL UNDwith an empty COMDAT set. Dropping that export breaks the consumer, and the predicate demoted it (Codex review).
The shared shape is worth naming, because it is what a third attempt will
hit too: a fact about the library's own build was read as a fact about
its consumers. Nothing in two library snapshots, and nothing in one
library's object files, distinguishes "the consumer emitted a copy" from
"the consumer holds an undefined reference". Only consumer-side evidence, or
a header fact recording extern template (which castxml does not expose —
checked through 0.7.0), can separate them.
Kept, because it is sound and answers a real question:
buildsource/comdat_groups.py — an ELF SHT_GROUP/GRP_COMDAT parser
(byte-order correct, ELF-only, degrading to diagnostics on unreadable
objects) and its BuildEvidence.comdat collection. It answers "did this
build emit this symbol vaguely", which is genuinely useful and was already
reserved vocabulary in graph_facts.py (comdat_group) for a future
linker-artifact extractor. It is simply not sufficient for the demotion.
Separately open, and independent of the above: the L3 collection path
does not deliver usable object paths. CompileUnit.output is a label
normalized for persistence, not a path — home paths redacted to ~/...
(ADR-032 D7), Ninja/Make/Bazel outputs relative to CompileUnit.directory —
and CompileDbAdapter never assigns it at all, discarding the compile
database's output field and -o alike. Half-closed on the reading
side: build_evidence._resolved_object now expands ~ and joins a
relative label onto the unit's own directory, and skips a label naming no
file that exists, so an adapter that does record an output is readable
from outside the build directory. That also makes the scan conservative
where it used to be destructive: a build whose objects resolve to nothing
leaves comdat untouched rather than replacing a scan loaded from an
existing pack with an empty, unresolvable one, and a fresh scan that
established nothing never displaces one that did. Still open: the
producing side — CompileDbAdapter recording an output at all, and every
adapter keeping the raw resolved path alongside the redacted label, so a
pack collected on one machine can be scanned on another. Until then the
scan is opt-in (ABICHECK_COLLECT_COMDAT=1 at inline.py's call site):
parsing every object's symbol table is real I/O, and no detector consumes
the result.
- A third instance of the same shape (code-review report item 3):
demoting a stdlib closure instantiation as "unnameable" — attempted,
reverted. A stdlib/runtime template instantiated over a caller-
supplied lambda (e.g.
std::once_flag::_Prepare_execution<...Widget:: run()::{lambda()#1}...>, from a realstd::call_onceguard) mangles to a symbol whose closure-ordinal encoding is per-translation-unit and compiler-ordering dependent, so it seemed unconditionally safe to demote insurface.classify_change_surface: "no consumer's source code could ever name this exact template argument, so there is no possible external caller to break." That reasoning is the identical mistake the linkage-blind-removal entry above already names, just one layer removed: source-level nameability is not binary/ABI compatibility. A consumer's own object code never has to name the symbol in source — the SAME template, instantiated from the SAME public header over its own local lambda, produces the IDENTICAL mangled symbol in the consumer's own translation unit via vague/weak linkage, and that consumer can depend on the library's copy being the one that resolves. A two-snapshot comparison has no way to rule that out, for exactly the reason the entry above states: "nothing in two library snapshots... distinguishes 'the consumer emitted a copy' from 'the consumer holds an undefined reference'." Reverted rather than shipped (Codex review, two findings — the unsoundness above, and separately that the fix was dead code for its own ELF-only motivating case:post_processing.FilterNonPublicSurface.runreturns unmodified changes before ever callingclassify_change_surfacewhen neither side's surface is resolvable). Closing this for real needs the same consumer-side evidence the linkage-blind-removal entry says is missing — not a cleverer read of the mangled name alone. -
Function.elf_binding/Variable.elf_binding(and the pre-existingelf_visibilityit mirrors) collapse mixed bindings across symbol-versioned aliases sharing one bare name — investigated, not fixed (Codex review, fresh evidence).ElfMetadata.symbol_mapis aname -> ElfSymboldict built fromelf.symbols, last-entry-wins;elf_metadata.py's own parser deliberately never embeds a version suffix inElfSymbol.name(pyelftools doesn't decode@@/@into the name either — see the_pyelftools_exported_symbolscomment), so two legally distinct versioned definitions of one exported name — e.g. aGLOBALfoo@GLIBC_2.2and aWEAKfoo@@GLIBC_2.14— collapse to a single dict entry, and_populate_elf_visibility(which both fields share) reads only that survivor. AFunction/Variablewhosemangledmatches such a bare name therefore reports whichever binding happened to parse last, not "the" binding — and if that happens to beWEAKwhile an older, still-live version wasGLOBAL, abinding: weaksuppression rule (suppression.py) could match a removal that, from theGLOBALversion's perspective, is a real break. Not fixed here: a correct fix needssymbol_map(or a sibling index) to preserve every versioned entry per bare name rather than collapsing to one — a change with several other consumers beyondelf_visibility/elf_binding(dumper_elf_symbols.py's own callers,diff_versioning.py's existing version-aware machinery, which already correlates symbols againstversions_defined/versions_requiredthrough a different path) that deserves its own scoped design rather than a drive-by change to a cached property several modules depend on today. Per this file's own "known gaps over risky reactive patches" convention. -
Default dependency scoping (PR #649) vs. contextual reachability (
type_reachability.py) — the direct-reference conflict is fixed; the comparability-contract gap is not. A status-review follow-up flagged thatdump's default header-origin scoping (dumper_scoping.py) and the same-pass contextual-reachability work were pulling in opposite directions: reachability says a dependency type directly named in a public signature (std::stringtaken by a public function, or a platform type likestruct tm) is genuinely part of the library's ABI contract, while scoping unconditionally dropped every declaration whose own header was a toolchain/system header, regardless of whether anything referenced it directly. Fixed:scope_snapshot_excluding_dependenciesnow retains a dependency-header type/enum that is directly named by a kept declaration's own return/parameter/variable type or by a kept type's own field/base (_directly_referenced_dependency_names), while still dropping what's only reachable transitively through that type's own internals (std::string::_Alloc_hiderand the like stay excluded). Still open, deliberately not attempted in the same change: the chosen dependency-scoping mode (scoped vs.--include-system-declarations) is not part of theExtractionContractscope_fingerprint(comparability.py'sSCOPE_FIELD_KEYS), so two snapshots extracted under different scoping modes can still compare as "comparable" even though they don't share the same fact universe — andcli.py's inline (non-persisted)compare old.so new.sopath still hardcodesinclude_dependencies=Trueregardless of what a persisted baseline JSON on the other side of the same comparison was scoped with. Closing that gap needs its own scoped design (a newSCOPE_FIELD_KEYSentry plus acomparability.py-level compatibility rule, verified againsttest_comparability_gate.py's existing superset-growth assertions), not a drive-by extension of the direct-reference fix above. Until then, the safe authoritative flow for a compiler/stdlib-sensitive comparison is either--include-system-declarationson bothdumpinvocations, or comparing two default-scoped persisted snapshots against each other rather than mixing a persisted baseline JSON with a live-binary operand. -
Depth contract, CLI vs. API/MCP — closed for real by G33 Phase 5, having first been closed as stale. Finding 1 below is now out of date in a way worth keeping visible rather than deleting: it closed this gap on the grounds that no service/MCP surface promised a depth-qualified snapshot, so there was nothing to extend the gate to. That was true when written and is no longer:
DumpRequest.depthand the MCPabi_dump'sdepthargument are exactly such a promise, andservice_dump_pipeline.run_dump_requestenforces it — the same floor, raising the Tier-2ValidationErrorwhere the CLI path raisesDumpDepthNotSatisfiedError. Note which direction the fix went: the gap was closed by giving the typed surface the capability and the gate together, not by extending a gate to a surface that had no capability to gate. Finding 2 is unchanged and still correct. The original text follows.
This entry previously said PR
#601 (which adds a hard-fail DumpDepthNotSatisfiedError when an explicit
dump --depth isn't actually reached, in cli.py/cli_dump_helpers.py)
was still open, and that abicheck/service.py's ScanRequest/
run_scan_subprocess and abicheck/mcp_server.py's MCP tools needed the
same check extended to them once it merged. PR #601 merged 2026-07-19.
Re-checking what "extend the same check" would actually mean turned up two
separate findings, both closing this gap rather than giving it new code:
1. check_requested_depth_satisfied (the strict gate PR #601 added) is
called from exactly one place, cli._write_snapshot_output — reached
only by the dump command and one cli_buildsource.py snapshot-writing
helper. Neither service.py's run_dump/resolve_input nor the
abi_dump MCP tool accept a depth/sources/build-info parameter at
all (confirmed by reading both) — there is no service.py/MCP surface
that promises a depth-qualified persisted snapshot for this gate to
extend to.
2. The only place a caller can pass an explicit depth= through
service.py/MCP is ScanRequest/abi_scan/abi_estimate — and
service_scan.run_scan, the CLI scan command
(cli_scan.py), and the MCP abi_scan tool all call the exact same
scan_engine.run_scan_core, so they already share one evidence-contract
implementation (_check_scan_evidence_contract's pinned-depth
_EvidenceContractError, ADR-037 D5) — there was never a CLI-vs-API/MCP
disparity on the scan side to close, before or after PR #601.
_validate_public_depth's docstring in mcp_server.py carried the same
stale "PR #601 open, tracked as remaining work" wording and was corrected
alongside this entry.
- Action pinning is deliberately partial, not a full sweep. Third-party
GitHub Actions in
.github/workflows/agentready.yml,ci.yml(theid-token: writejobs),pages.yml,publish.yml,security.yml, andschedule-check-project-failure-path.yml(its onedispatchjob) are pinned to a full commit SHA (with a# <tag>comment) rather than a mutable tag/branch — those carrysecurity-events:write,pull-requests:write,contents:write,actions:write, orid-token:write(OIDC/PyPI Trusted Publishing), so a re-pointed tag there is a real supply-chain risk. The rootaction.yml(the composite Action third-party repos consume directly) is pinned the same way, for the same reason: its final step conditionally runsgithub/codeql-action/upload-sarifunder whateversecurity-events: writepermission the consuming workflow grants it, so it carries the same blast radius as the elevated-permission workflows above even though this repo's own CI doesn't invoke it with that scope. Other workflows (test-action.yml,eval-suite.yml,performance.yml,realworld-validation.yml,dependency-review.yml, and any future ones) still use tags — deliberately deferred, since they only run withcontents: readand don't touch secrets/publishing/security-event write access, so the blast radius of a compromised tag there is far smaller. Extend the same pinning to a workflow only when it gains elevated permissions, not preemptively. - CODEOWNERS risk tiers currently all resolve to one person. The file is structured by risk tier (CRITICAL/HIGH/STANDARD) so a second maintainer can be slotted into CRITICAL/HIGH without restructuring, but there is only one maintainer today — don't read the tiering as "these are reviewed by different people," it isn't, yet.
- Toolchain-profile compiler-family rendering — audited,
argstrust boundary hardened; the-stdlib=/--target="fix" itself was wrong and has been reverted. An external audit foundrun_plan.py's_compose_gcc_options()composing-stdlib=/--target=unconditionally for anyprofiles.<id>.compileoverlay, even whencompile.compiler_family: gcc— both are Clang-driver-only spellings a real GCC binary rejects (confirmed against GCC 14.2), so an early pass dropped both whenevercompiler_familyresolved to a GCC family name. A later review round found that fix backwards: the composed string this function returns is never actually fed to a literal GCC binary anywhere in this pipeline —--ast-frontendonly hasauto/castxml/clang/hybrid(nogcc); castxml's own frontend is always its internal bundled Clang (--castxml-cc-<id>selects an emulation mode, not a literal execution path); and the direct-clang backend's_resolve_clang_bin(dumper_clang.py) explicitly rejects agcc-paththat isn't clang-family and falls back to hostclang/clang++. Since the real consumer is always Clang, dropping--target=actively broke cross-compilation-target correctness for the direct-clang backend — it was the only signal available there to steer parsing away from the host architecture (no "probe the real compiler" auto-discovery step exists on that path the way castxml has one), so a GCC-family profile with an explicittarget:would silently have its headers parsed for the runner's architecture instead. Reverted:_compose_gcc_options()emits-stdlib=/--target=unconditionally again, same as before the original audit, with both the change and the reasoning for reverting it recorded in the function's own docstring so a future reader doesn't rediscover and re-"fix" the same false positive. The same original audit flagged a real trust-boundary gap inprofiles.<id>.compile.args, which is unaffected by this revert and stays fixed: the existing whitespace- smuggling check (_safe_profile_atom) rejected one YAML scalar expanding into multiple argv tokens, but not a single, whitespace-free dangerous atom._DANGEROUS_ARG_PREFIXES(project_targets.py) now blocks four families of these: direct code-loading flags (-Xclang,-load,-fplugin=,-fpass-plugin=), file/argv re-expansion (@response-file, Clang's--config/--config=), driver command-line substitution (-specs=/--specs=,-wrapper), and — added across two follow-up review rounds on the same PR, since each is the same underlying "opaque subprocess-forwarding" mechanism as the others — GCC's-Wa,/-Wp,/-Wl,(comma-joined payload passed straight to the assembler/preprocessor/linker;-Wp,-fplugin=./evil.soreaches cc1 the same as a bare-fplugin=,-Wl,-plugin=./evil.dsoloads an LTO linker plugin) and Clang's-Xpreprocessor/-Xassembler/-Xlinker(separate-argument equivalent of-Xclang). A third review round found a deeper issue than another missing flag spelling: everycompile.*atom (not justargs) now also rejects quote ('/") and backslash (\) characters, since_compose_gcc_optionsspace-joins every field into one string thatdumper.py's--gcc-optionshandling later re-splits withshlex.split()— an atom like"'-fplugin=./evil.so'"starts with a quote, not-fplugin=, so the prefix denylist alone accepted it, but POSIX shlex quote-removal reconstitutes the exact blocked flag on re-split (confirmed with an actualshlex.split()round-trip). Two more review rounds each found a flag real for the mechanism it names but empirically NOT exploitable through abicheck's actual pipeline — verified rather than taken on faith, and blocked anyway since doing so is free:--castxml-cc-(a second occurrence naively looks like it could replace the trusted--castxml-cc-<id> <path>pairdumper_ast_config.pycomposes ahead ofargs, but real castxml 0.6.3 hard-rejects any repeated--castxml-cc-*occurrence at argv-parse time instead of silently substituting the compiler); and-B<dir>/-B <dir>(GCC's compiler-component search path override really does let a plantedcc1/cc1plusrun instead of the real one, confirmed against real GCC — but every consumer of this composed string is Clang, not GCC, and Clang re-execs itself via-cc1rather than spawning a separate,-B-discoverable one; confirmed neither castxml's internal bundled Clang nor the direct--ast-frontend clangbackend ran a plantedcc1with-Bset). A fifth review round found a flag family that IS actually exploitable through this pipeline, unlike the two immediately above: clang-cl's (Clang's MSVC-compatible driver mode — reachable via acompile.bindingwhose path stem contains "clang", e.g.clang-cl/clang-cl.exe, whichdumper_clang._is_clang_family_binaryrecognizes as clang-family)/clang:<arg>escape hatch forwards an argument straight to the underlying clang driver, bypassing clang-cl's MSVC-shaped option parsing entirely — empirically confirmed exploitable:clang --driver-mode=cl "/clang:-fplugin=./evil.so" -c t.hreally does load and run the planted plugin./link <options>(clang-cl's documented "forward options to the linker") is blocked alongside it on the same LTO-linker-plugin grounds as the already-blocked-Wl,, without a from-scratch empirical repro of that specific sub-case. A sixth review round found a different shape of finding again:-cc1/-cc1as, Clang's internal frontend mode, only activates when-cc1/-cc1asis literally the first argument after the program name (confirmed empirically:-cc1anywhere else is rejected as "unknown argument", including right after a leading-I) — butdumper.py's_build_clang_header_commandbuilds argv as[cc_bin, *-I dirs, --sysroot, -nostdinc, *gcc_options tokens, ...], so a scan with noextra_includes/sysroot/nostdinclets a leading-cc1incompile.argsgenuinely land in that first-argument slot. Once in cc1 mode,-load/-fpass-plugin=were already blocked, but cc1 mode exposes an entirely different, much larger argument namespace this denylist was never designed to enumerate — Codex found-fcas-plugin-path(a cc1-only flag not present in every Clang build) doing the identical thing. Rejected the mode switch itself rather than chasing individual cc1-only flags, the same reasoning as--config. This denylist is necessarily reactive to the delivery mechanism, not exhaustive over every dangerous flag a mechanism could carry — a real fix for the whack-a-mole shape of this (an allowlist of known-safe ABI flags instead of a denylist of known-dangerous ones) was suggested during review but deliberately not done here:argsis documented as a general escape hatch for ABI-relevant flags this codebase cannot enumerate a priori (GCC/Clang/MSVC each have their own vocabulary), and a strict allowlist would need that vocabulary built out first — its own scoped project, not a reactive expansion of this fix. (A fourth review round briefly caught a correctness gap in a since-reverted sentinel the family-aware_compose_gcc_options()fix needed — moot now that the fix itself is reverted, see above; not detailed here since it no longer applies to any code that ships.) Still not implemented, and out of scope for that fix (each needs its own scoped design, not a drive-by extension of the same narrow correction): a real toolchain-identity probe that validates a resolvedbinding's actual compiler family/version/target against the profile's declared constraints (compiler_versionis still parsed but never checked against anything); a profile-specific AST frontend (there is still only one global--ast-frontend); and a genuine family-specific argv resolver — in particular MSVC/std://Dspellings, which this fix does not attempt (nocompiler_family: msvccaller/test exists yet to validate against, and a wrong guess here is worse than the pre-existing gap). ruff formathas never gated anything, and the tree has never been formatted — CLOSED: the tree is formatted andfmt-checknow runs in CI. Two separate problems were tangled here. (1)ruffwas pinned in two places that disagreed:.pre-commit-config.yamlatrev: v0.9.0, andpyproject.toml's[dev]atruff>=0.3— a floor, so CI andpip install -e ".[dev]"resolved whatever was newest on the day they ran. That is not cosmetic: 0.9.0 reports anF811inabicheck/cli_buildsource_helpers.pythat current ruff does not, so a contributor'spre-commitrun and CI reached different lint verdicts on unmodified code. Fixed by pinning both to the same exact version, forward rather than back (pinning to 0.9.0 would red the lint lane on existing code), withtests/test_toolchain_pins.pyasserting the two stay in lockstep. (2) Separately — and not caused by the version skew — the tree is notruff format-clean under any version: 488 files under 0.9.0, 486 under 0.16.3, ~56.5k changed lines, and the diffs are near-identical across versions (checked), i.e. the formatter has simply never been applied repo-wide. It went unnoticed because thefmt-checkstep, though present inscripts/verify.py'sfast/pr/fullprofiles, is not run by any CI job:ci.yml'slint-and-typesinvokes--profile pr --only lint,typecheck,docs-build, no workflow runs the fullprprofile, andpre-commitis not run in CI at all. So the one consumer that would catch it ispixi run check(which does run the wholeprprofile) — i.e. a pixi contributor's local gate is currently stricter than CI. That much was already known and deliberately tracked, intests/test_verify_profiles.py's_PR_STEPS_NOT_IN_A_CI_ONLY_LIST("fmt-check": "NOT RUN IN CI — pre-existing gap, tracked here"); what this entry adds is the measurement of what enabling it would cost, and the finding that the gap is not version skew.
Closed. The deferred reformat was done in its own PR, as that plan
specified. Two things had changed since the measurement above and both
argued for doing it then rather than later: the drift was growing (486
files on 2026-09-11, 625 files / 58,564 diff lines on 2026-09-13 —
+139 files in two days, because nothing enforced it on new code), and the
open-PR count was down to two, so the "conflicts with every in-flight
branch" cost was at a local minimum. ci.yml's lint-and-types job now
runs --only lint,fmt-check,typecheck,docs-build, and the two markers that
tracked the gap (_PR_STEPS_NOT_IN_A_CI_ONLY_LIST's fmt-check entry and
this paragraph's deferral) are gone. A green CI run now does say something
about formatting.
Two things worth not rediscovering. (1) The reformat was verified
semantics-preserving by ast.parse + ast.dump comparison of every one of
the 624 changed .py files against its committed version, rather than by
trusting the formatter: 622 were byte-identical in AST, and the two that
differed are benign docstring-text changes (""""What is left" gains the
space ruff inserts to disambiguate a docstring opening on a quote). (2)
abicheck/model/change_catalog/kinds.pyi is excluded from formatting in
ruff.toml's [format] section, and the exclusion is load-bearing rather
than cosmetic: it is generated by scripts/gen_changekind_stub.py, whose
--check mode (a required gate) fails on any byte it would not itself
write. Formatting it rewrites the quotes and wraps the six members whose
slug pushes the line past 88 columns, so gen_changekind_stub.py --check
and fmt-check could not both pass. Teaching the generator to emit the
wrapped form was considered and rejected — it would be a second,
hand-maintained copy of ruff format's line-breaking rules, drifting on
every ruff bump. Lint still applies to the stub; only formatting is
excluded.
- Deferred entirely, not attempted this pass (heavier structural changes, each needing its own scoped design rather than a drive-by addition):
- Devcontainer image — a maintained
.devcontainer/needs a decision on which system tools (castxml, libabigail, abi-compliance-checker, compilers) ship baked-in vs. installed on first use, and upkeep as those pins drift;pixi(see CONTRIBUTING.md) already solves the "one command gets you a working dev environment" problem this would target, without the image-maintenance burden. - Trend-reporting database — persisting
scripts/check_tier_accuracy.py/check_fp_rate.py/mutation-score history across runs (rather than each CI run only gating against a static baseline) needs a storage decision (artifact-based vs. external DB) and a retention/access policy before it's worth building. - Full behavioral baseline —
agent-evals/(this pass, M1-5) is a real but minimal harness with one task; a "full behavioral baseline" implies a broad task suite plus a scoring/leaderboard story, which should grow from real usage of the one-task harness rather than being speculatively built out now. - Findings emitted from absent evidence —
type_vtable_changedfixed;type_base_changedcarries the identical shape and is not. A list-valuedRecordTypefield cannot express "not captured", so an empty-vs-non-empty difference conflates a real change with one side's debug info simply not covering the declaring TU. Confirmed forvtable: identical headers, no DWARF vtable, and zero_ZTVsymbols on either side still produced aBREAKINGtype_vtable_changed, because_diff_type_vtableguarded ont_old.vtable == t_new.vtableand nothing else. Fixed by requiring an independent layout signal (a size change — the vptr a genuinely polymorphic class gains — or a virtual-base change) before an empty↔non-empty transition is reported; both-sides-captured differences are untouched, and an unknown size on either side keeps the finding, since the suppression needs positive evidence that layout held still rather than being a fallback for missing information. This is the disciplinediff_vtable_layout.py(tri-stateNone, "degrading to B1's L0 view rather than fabricating a break") anddiff_elf_layout.py(compare only a_ZTVpresent on both sides) already state in their own docstrings; the type-level detector had neither. Two things remain open. (1)_diff_type_bases(set(t_old.bases) != set(t_new.bases), and its virtual-base half) has the same unguarded shape and stays that way — an attempted guard was written and reverted before merge, and the reason is worth not rediscovering. Every layout-based premise for it is false: an empty base is invisible by the empty-base optimization, and — the one that killed the attempt — a storage-contributing base can be added without moving the derived class's size at all when the class is over-aligned (verified against g++:struct alignas(8) D {}andstruct alignas(8) D : B {}withstruct B { int y; }are both 8 bytes, as are thealignas(16)pair at 16). So "size held still" proves nothing about a base list, in either direction. Unlike the vtable case there is no independent evidence stream to fall back on —snapshot.functionsanswers "did this class's virtuals change" but nothing answers "did this class's bases change" exceptRecordType.basesitself. Guarding it therefore needs evidence the model does not currently carry (per-finding provenance, or a captured base-layout fact such asbase_offsetscorroboration), not a cleverer reading ofsize_bits. Until then a fabricatedtype_base_changedfrom a capture gap is the accepted cost, because the alternative — suppressing a real hierarchy change, which is sometimes the only breaking finding a same-size base addition produces — is strictly worse. (2) The vtable guard is narrower than a first reading suggests, and its own docstring used to overclaim it. The class's virtual functions and itsRecordType.vtableare two projections of the same DWARF evidence (both trace toDW_TAG_subprogram), not independent streams — so a translation unit whose coverage vanishes can take both, the two sides' signature sets then differ, and the guard declines to suppress. The false positive survives in that shape. That is the failure direction to have (it leaves a pre-existing false positive standing rather than hiding a real break), but it means the guard covers the reported case — identical headers, no DWARF vtable, no_ZTVanywhere, virtuals absent from both sides — and not every capture gap. Closing the rest needs artifact or provenance evidence (_ZTVpresence, per-finding providers) the type-level detector does not receive today. (3) One accepted false negative in the fix itself: a class already polymorphic through a base, declaring no virtuals of its own, that gains one — its vtable grows while its object size does not, making it indistinguishable from capture noise without a real polymorphism walk over both sides' base chains (diff_vtable_layout._is_polymorphic) plus the per-finding provenance the entry below describes. (4) A pure virtual reaches that same size backstop for a different reason: it has no out-of-line definition, sodwarf_snapshotdrops its declaration-only DIE fromsnapshot.functionswhile still counting it as a vtable child of the class, leaving both owned-signature sets empty — and withalignasabsorbing the new vptr the size does not move either. Reproduced against g++ (struct alignas(8) A { virtual void f() = 0; }compiled alongside a concrete derived class, which is what makes GCC emit A's complete DIE rather than aDW_AT_declarationstub): old vtable[], new vtable['_ZN1A1fEv'], both 8 bytes,_ZN1A1fEvabsent from the function map on both sides. An attempt to close this one was written, merged into the branch, and then reverted — the reason is the most useful thing in this entry. The fix consultedRecordType.vptr_offset_bitsas "the layout descriptor's own witness, the only signal here that is not another projection of the same subprogram DIEs." That claim was false, and checkably so: both producers assign the field as0 if vtable else None(dwarf_snapshot.py,dumper_castxml.py), so on every real DWARF or CastXML snapshot(old.vptr is None) != (new.vptr is None)is identical to the empty↔non-empty vtable transition being guarded. It was therefore true by construction for every input that reached it, which did not merely weaken the guard — it made the guard inert, restoring the original capture-gap false positive in full (Codex review). Only the optionalABICHECK_CLANG_LAYOUT_TOOLpath computes a real vptr, and nothing in the model distinguishes that value from the derived one, so the field cannot be trusted here at all. Reverted; case (4) joins case (3) as an accepted false negative. The tests are the second lesson: the guard's unit tests builtRecordTypes withvptr_offset_bitsleftNoneon both sides — a record no backend can emit — so the entire suite, including the pre-existing test for the guard's whole purpose, passed against a guard that suppressed nothing on real input. The helper now derives the field the way the producers do, one test pins that derivation in the producers' own source (so the reasoning fails loudly if a producer ever computes a real vptr), and five of these tests fail against the reverted revision. Worth recording for both (3) and (4) that the break is not hidden:diff_layout._check_vptr_introducedfires independently on the sameNone → 0transition and the verdict staysBREAKING(verified end to end, and now pinned by its own test) — which is also why the FP-rate corpus, a verdict-level gate, could not catch either the original false positive or the inert-guard regression, and why the regression coverage is a direct unit test on the predicate (tests/test_vtable_evidence_guard.py) instead. Leaning on a sibling detector remains uncomfortable, but the alternative on offer was a witness that only appeared independent. Guarded by four FP-corpus cases under theevidence-absenceaxis (one FP guard, three FN sentinels), the unit tests above, and three Hypothesis properties intests/test_detector_properties.py.
A named follow-on, found by a user rather than by any gate in this
repo, and worth being explicit about why: two detectors reading the
identical evidence gap and reaching two different severities is its own
bug class, distinct from either detector being individually wrong.
LAYOUT_UNVERIFIABLE (diff_layout.py) answers "was there real layout
evidence for this type" from the exact same asymmetric-evidence condition
_vtable_transition_is_evidenced above declines to suppress on. Both
answers are individually correct and individually documented (this entry
is the vtable side's own justification) — but a type carrying both at
once reads as a hard BREAKING verdict sitting next to a finding that
already says the evidence for that type couldn't be verified either way,
reproducible with zero real ABI change (scanning a binary against a dump
of itself; reported against real oneDAL binaries, three types, two
libraries). None of this repo's existing quality gates were positioned to
catch it: the FP-rate/tier-accuracy gates are verdict-level (the overall
verdict was already correctly BREAKING, dominated by the vtable
finding, so nothing there looked wrong), and every existing test —
including the Hypothesis properties this entry already describes — is
scoped to one detector's own correctness in isolation, never to whether
two detectors' outputs are mutually legible when they land on the same
subject.
The fix went through four designs before landing, and the last pivot is
the one worth reading closely — it generalizes past this one pair of
detectors. (1) Demoting TYPE_VTABLE_CHANGED was tried first and
rejected immediately: a real last-virtual-method removal with a new-side
capture gap is indistinguishable from this same evidence gap, so
softening its severity risks a false negative on a genuine break. (2)
Folding the now-redundant LAYOUT_UNVERIFIABLE finding into
redundant_changes unconditionally was tried next, and review found four
real defects in it in turn: correlating on bare Change.symbol (two
distinct same-named records in different namespaces can share it, so
fold on qualified_name); folding on mere co-occurrence rather than a
marker recording why the stronger finding was kept (an
independently-evidenced TYPE_VTABLE_CHANGED — a real reorder, a real
size delta, a real virtual-base change — must never trigger the fold);
reusing Change.modulation_reason/modulation_rule for internal
signaling (those are a public, report-facing audit trail for an actual
verdict override — tagging an unmodulated finding with them is a false
audit entry a downstream consumer would read as real); and running the
fold before FilterNonPublicSurface/ApplySuppression had settled
both findings, which both hid the redundant finding from a suppression
rule targeting it directly and let it outlive its covering finding's
later exclusion. (3) Fixing all four and gating the fold on a
policy-resolved-verdict subsumption check (checker.
_vtable_gap_finding_is_verdict_subsumed) closed the PolicyFile-override
case, but review found the fix was still solving the wrong layer: the
severity-scheme axis (severity.SeverityConfig — abi_breaking/
potential_breaking/etc., each independently error/warning/info)
is chosen by the CLI/reporter caller entirely after compare()
returns, so no compare()-time decision to remove a finding from
changes can ever be correct for it. Concretely: a caller configuring
abi_breaking=info / potential_breaking=error wants
LAYOUT_UNVERIFIABLE's error-level contribution to reach
severity.compute_exit_code, but that function reads result.changes
directly — if the fold already removed the finding because the
policy axis (a completely orthogonal gate, resolved before
compare() returns) ranked the covering TYPE_VTABLE_CHANGED as more
severe, the severity axis never gets a chance to see it, and this
reproduces with zero policy override involved (Codex review, fresh
evidence). The same review round also found the fold's pipeline
ordering relative to DemoteUnreachableInternalChurn (which runs later,
to demote a confirmed-unreachable internal-namespace type) could orphan
an already-made fold decision when the covering finding was itself
demoted afterward. (4) The only structurally-safe fix was to stop
removing information from changes for this reason at all.
post_processing.AnnotateLayoutUnverifiableCoveredByVtableChanged
leaves both findings exactly where they always were and only sets the
already-existing, generic Change.correlated_change_kind field (reused
from its original ADR-041 purpose, not a new one) on the redundant
LAYOUT_UNVERIFIABLE finding, pointing at "type_vtable_changed".
Change.vtable_covers_unverifiable_layout_gap (set in
diff_types._diff_type_vtable) still records which TYPE_VTABLE_CHANGED
findings rest purely on the ambiguous evidence gap versus real,
independent evidence — that detection logic is exactly what survived all
four designs unchanged; only the action taken on it changed. This
design is immune by construction to every one of the defects above and
to any future one shaped like them, because nothing is ever hidden from
a consumer that reads changes — there is no "was this the right time
to remove it" question left to get wrong. The general principle this
leaves behind: never remove a finding from a shared, multiply-consumed
list (DiffResult.changes) to resolve what is fundamentally a
presentation problem (two findings reading as contradictory) when that
list has independent downstream consumers — a legacy verdict, a
policy-file override, a severity-scheme exit code, a future pipeline
step — that this fix cannot fully enumerate and whose configuration is
chosen after the removal decision was made. Annotate; never remove.
Regression coverage: tests/test_vtable_severity.py's
TestLayoutUnverifiableCorrelatedWithVtableChanged (hand-picked example
cases, including one exercising the severity-axis gap directly via
severity.compute_exit_code) and a generalized Hypothesis property,
test_layout_unverifiable_always_correlated_when_vtable_change_shares_its_gap
in tests/test_detector_properties.py, checking the annotation holds
over arbitrarily generated classes and evidence-descriptor shapes.
A whole-class structural fix was attempted mid-way through the fold
design (2) above, for the field-ordering half of it, and reverted — the
in-repo audit that justified it was the wrong audit. (This sub-entry
predates the pivot to design (4) and is kept because the lesson is
independent of which design won.) The field-ordering bug (found twice
now: first on AbiSnapshot in PR #582, again on the new Change field
this fix originally added mid-list) was first "fixed" by making Change
keyword-only from old_value onward via the dataclasses.KW_ONLY
sentinel, on the strength of an AST-parse of every Change(...) call
site in this repo confirming every positional call anywhere passes
exactly the three required leading fields and never more. That audit
answers the wrong question for a type this codebase itself documents as
public API (checker_types.py's own module docstring; CLAUDE.md:
"changing their public surface is a breaking change to the Python API —
coordinate it"): it proves this repo's own call sites are safe, and says
nothing about an external consumer who previously called Change(kind,
symbol, description, old_value, new_value, ...) positionally — for whom
the whole-class sentinel is exactly the same breaking shift the fix
exists to prevent, just moved to a boundary this repo cannot see or test
(Codex review, fresh evidence). Reverted in favor of the same per-field
field(kw_only=True) PR #582 already used for AbiSnapshot, applied
only to the one new field (vtable_covers_unverifiable_layout_gap) and
appended at the true end of the dataclass (not mid-list) so every
pre-existing field's position — and therefore every pre-existing
positional caller's behavior, in or out of this repo — is completely
unchanged. The lesson generalizes beyond this one field: for a type
documented as public API, "no in-repo caller breaks" is not the bar:
prefer the narrowest change that cannot break a caller this repo cannot
see, even when a broader structural fix looks more complete. The
KW_ONLY sentinel is still the right tool for a genuinely internal
dataclass with no external-construction contract (nothing here argues
against it in general) — the mistake was applying it to one that has
such a contract without checking for one first.
- Evidence-provider model — investigated, found not to reproduce as
described; no fix applied. A status-review follow-up asked whether
evidence_status_for_result's report-level downgrade (kind-levelARTIFACT_PROVEN→UNATTRIBUTEDonly whenDiffResult.evidence_tiersis header-only for the whole comparison) can let an individual header-derivedBREAKING_KINDSfinding read as artifact-proven merely because some other, unrelated part of the same report had binary evidence. Traced this for the highest-stakes family it could apply to — layout findings (TYPE_SIZE_CHANGED/TYPE_ALIGNMENT_CHANGED,diff_types.py) — and it does not hold up: (1) the direct-clang L2 backend'sRecordType.size_bits/alignment_bitsare populated only whendumper_layout_backfill.backfill_dwarf_layout()actually corroborates them against real DWARF (model.py's owndwarf_layout_coherencedocstring) — with no DWARF to backfill against, those fields stayNoneand_append_type_size_and_alignment_changes's ownis not Noneguard means no finding is even emitted, so an "unconfirmed clang-derived layout finding" cannot occur; (2) the castxml backend computes struct layout itself, via its own bundled real compiler targeting the resolved ABI —model.pyalready documents this as deliberately treated as sufficient L2 evidence ("trivially self-consistent by construction", not needing DWARF corroboration), a prior, intentional design decision this pass would have to overturn, not merely patch. The one place this class of risk is genuinely live is exactly the already-tracked toolchain-identity-probe gap above (castxml/clang invoked with compiler/ABI flags that don't match the real build) — not a separate evidence-status bug. A real per-finding provider model (recording, perChange, which of L0–L5 actually produced/corroborated it) would need new provenance plumbing through all ~45Change(...)construction sites acrossdiff_*.py/buildsource/*.py, each individually verified against the FP-rate/mutation-score gates — a multi-day project on its own, not attempted here. - Type reachability (direct vs. transitive stdlib references) — computed
and wired into
diff_types.py's RecordType-based detectors; enum/typedef paths remain unwired.abicheck/type_reachability.py(directly_referenced_stdlib_types()) computes, from a snapshot alone, whichstd::/__gnu_cxx::/etc. record types are directly referenced by a non-stdlib function's signature or a non-stdlib type's own field — as opposed to only reachable via deep template-instantiation internals (std::string::_Alloc_hider,std::_Rb_tree_node_base) thatis_non_abi_surface_type's existing whole-name-prefix filter already correctly excludes as toolchain-artifact churn either way. A Codex review round found and fixed a real correctness gap in the computational claim: candidate identification originally matched onlyRecordType.name, but castxml/direct-clang populate the bare leaf there and the namespace-qualified spelling separately inqualified_name(model.py,dumper_clang.py) — sonamealone never carries astd::prefix for those two backends and the helper silently found nothing on any real castxml/clang-produced snapshot. Fixed by identifying candidates viaqualified_name or name. That fix alone was still insufficient, confirmed by dumping a real compiledstd::vector<int>parameter end to end:Function.return_type/Param.typespell the outer type bare ("vector<int, std::allocator<int> >") even when the matchingRecordType's identity is fully qualified ("std::vector<int, std::allocator<int> >"), across all three backends (DWARF bakes the qualified form straight intonamewith no separate field; castxml/clang keepnamebare andqualified_nameseparate) — so a pure full-identity substring match still couldn't connect the two. Fixed by also generating a namespace-prefix-stripped spelling per candidate and matching against either form. Since resolved (a later pass, user-requested): a signature spelled with a typedef alias (std::string,std::wstring, ...) names the alias, not the real underlying class (std::basic_string<char, ...>) that owns theRecordTypeentry — no current model field maps one back to the other directly, butsnapshot.typedefsdoes carry the alias → target mapping. Verified empirically against a real DWARF-dumpedstd::stringparameter:snapshot.typedefs["std::string"]resolves to the bare"basic_string<char, std::char_traits<char>, std::allocator<char> >", while the owningRecordType.nameis the fully-qualified"std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >"— libstdc++ wraps its own post-C++11 dual-ABI types in an inline namespace (__cxx11::) the exact same way libc++ wraps its whole standard library (__1::/__ndk1::, already handled), so that inline-namespace-stripping list gained a third entry. The typedef key itself needed the identical bare-vs-qualified treatment already applied toRecordTypeidentities (the DWARF backend spells the signature with the bare form"string", never the qualified typedef key"std::string"), so_typedef_spelling_targets()builds aspelling -> targetindex covering both the literal key and its namespace-stripped bare form (dropped instead of recorded when ambiguous, same false-negative-over-false-positive principle as theRecordTypespelling index), and_scan()now follows a matched typedef alias to its target the same waysurface.py's own reachability closure does. What this does not cover: a stdlib alias the producing backend never emitted intosnapshot.typedefsat all (no empirical case of this found across the three backends so far, but nothing guarantees one couldn't exist) — that residual case degrades silently back to "not directly referenced," the same conservative false-negative default this whole module already uses throughout.
Two more real gaps found and fixed in the same pass (Codex review,
fresh evidence): (1) the non-stdlib bare-alias fallback derived a
record's unqualified spelling via identity.rsplit("::", 1), which
splits inside a template argument's own qualified name rather than at
the outer namespace boundary — for "api::Wrapper<dep::Tag>", the
lexically last "::" belongs to the template argument dep::Tag, not
the outer namespace path, so the old code derived the corrupted bare
form "Tag>" instead of "Wrapper<dep::Tag>", and a real dumper
backend's bare signature spelling for that wrapper then never matched
anything. Fixed with a new _bare_type_name() that tracks </>
nesting depth and only treats a "::" at depth zero as a namespace
separator. (2) stdlib and non-stdlib spellings were matched via one
combined compiled pattern in a single non-overlapping finditer()
pass — when a non-stdlib record's own identity embeds a stdlib type's
spelling verbatim (e.g. a template instantiation "Wrapper<std::string>"
registered as its own record identity), and a public signature names
that wrapper's full identity exactly, the combined pattern's
longest-first alternation matches the whole wrapper span first,
consuming it — since regex matches never overlap, the nested
"std::string" substring inside that same span was never independently
found, even though it is directly present in the public signature text.
Fixed by splitting _spelling_index() into two independent indices
(stdlib vs. non-stdlib/record) with two independently compiled patterns
scanned separately over each declaration, so one pattern's match can
never mask the other's.
A third real gap found in the same pass (Codex review, fresh
evidence): both the stdlib-stripping collision guard (in
_spelling_index) and the typedef-key stripping collision guard (in
_typedef_spelling_targets) checked a stripped spelling only against
full non-stdlib record identities, not against the bare
(namespace-unqualified) alias a real backend actually spells that record
with. A non-stdlib record like api::vector<int> is spelled bare as
"vector<int>" — the same bare spelling std::vector<int> reduces to
after namespace-stripping — so a signature naming the unrelated user
type by its bare spelling incorrectly marked the real std::vector<int>
as directly referenced too; the identical gap existed one level up for
api::string/"std::string"'s typedef key. Fixed with a new
_non_stdlib_signature_spellings() helper (full identity plus bare
alias — deliberately keeping an ambiguous bare alias that
_spelling_index's own record_index drops, since it's still a real
spelling some non-stdlib record can be named by) shared by both
collision guards.
A fourth finding pointed one level deeper, into shared infrastructure
this module calls rather than into type_reachability.py itself
(Codex review, fresh evidence): diff_cxx_rules.owner_class_of() — the
helper this module's owner-class seeding reuses, also used by
diff_symbols.py's owner-based move detection, diff_cxx_rules.py's
own member-move heuristics, and surface.py's reachability closure —
mis-parses a public conversion operator's owner when the operator's own
target type is namespace-qualified. Confirmed against a real compiled
and demangled symbol: struct Foo { operator ns::Bar() const; };
demangles to "Foo::operator ns::Bar() const", and abicheck's own
Function.name (after its existing signature-stripping step) is exactly
"Foo::operator ns::Bar". The old naive rsplit("::", 1) split at the
lexically last "::" — which belongs to the operator's own qualified
target (ns::Bar), not the owner/member boundary — producing the
corrupted owner "Foo::operator ns" instead of "Foo", so a public
conversion operator to a qualified type would never seed its owner
class, potentially hiding a genuine layout break in one of the owner's
fields. Fixed in owner_class_of() itself (not duplicated locally) by
locating the literal "::operator " marker — present only for a
conversion-to-named-type operator, never for a symbol operator like
operator+/operator[], which has no target type to separate from the
keyword with a space — and splitting there when present, falling back to
the previous behavior otherwise. Fixing the shared helper directly
(rather than working around it only in type_reachability.py) also
corrects the same latent mis-parse for its other three callers, since
none of them could have been relying on the old behavior's output for
this input shape without already being wrong.
A fifth finding, on the same owner-seeding feature, investigated and
deliberately not implemented this pass: a public method whose dumper
backend recorded only a bare member name (CastXML's convention — "the
bare bar rather than C::bar", per owner_class_of()'s own
docstring) on a class-template specialization falls through to
owner_class_of()'s mangled-name fallback
(itanium_scope_components), which — confirmed empirically
(itanium_scope_components("_ZN3FooIiE3barEv") returns
["FooIiE", "bar"]) — deliberately keeps the raw, undemangled
Itanium template-argument encoding ("FooIiE") rather than the spelled
form ("Foo<int>") a real RecordType identity actually uses; that
design choice is itself intentional and documented in
itanium_scope_components's own docstring ("the raw template-argument
encoding is kept so distinct specializations stay distinct"), since its
other callers use it for grouping/distinguishing specializations, not
for matching against demangled model spellings. type_reachability.py's
owner-seeding then feeds this raw string into _scan(), which correctly
finds no match (a silent false negative — the same
false-negative-over-false-positive default this whole module already
uses throughout, not a new failure mode). A real fix has two paths, both
rejected as out of scope for a drive-by extension here: (1) making
owner_class_of() itself resolve raw template encodings to spelled
form would mean invoking the real demangler (demangle.py's
demangle(), which shells out to c++filt/cxxfilt on a cache miss)
from a hot path every one of its four callers shares, directly
contradicting itanium_scope_components's own stated design rationale
("avoids any dependency on an external demangler ... so this works
identically on Linux, macOS, and Windows and never shells out"); (2) a
narrower, local-only translation in type_reachability.py (demangle
just fn.mangled when owner_class_of() took the mangled-fallback
path, then re-derive the owner from the demangled qualified name)
would need a genuinely new depth-aware "class::member" boundary splitter
for demangled text — not a reuse of _bare_type_name (which strips a
leading namespace qualifier, the opposite half of this problem) — and
would have to correctly compose with the already-fragile
"::operator " marker special-case from the fourth finding above (a
demangled conversion operator on a qualified template specialization
could combine both edge cases at once), which is exactly the kind of
compounding-edge-case complexity this file's own docstring already
flags as needing "its own scoped follow-up," not a reactive patch.
Two more real gaps found and fixed in the same pass (Codex review,
fresh evidence): (1) A real backend does not always spell a nested type
as either the fully-qualified identity or the fully-bare leaf —
confirmed empirically via clang -ast-dump on namespace api { struct
Outer { struct Inner {}; }; Outer::Inner g(); }: direct-clang prints
the return type as exactly "Outer::Inner", dropping the enclosing
namespace (api::) while keeping the class-nesting qualifier
(Outer::). Neither the full-identity match nor the single
fully-bare-leaf match (_bare_type_name) covered this partial
qualification. Generalized _bare_type_name into
_namespace_suffix_spellings(), returning every suffix obtainable by
dropping some prefix of the scope chain at each depth-zero "::"
boundary, and updated all three call sites to register every suffix
(same ambiguity-drop collision guard extended to each). (2)
CastXML/direct-clang record a function or namespace-scope variable's
own display name bare (e.g. "touch", never
"__gnu_cxx::Node::touch" or "std::touch"), so the existing
name.startswith(STDLIB_TYPE_NAMESPACE_PREFIXES) guard cannot catch a
retained, seemingly-public declaration that is actually part of the
standard library itself — verified with two real Itanium
mangled-symbol repros (a namespace-scope stdlib variable and a stdlib
free function) that both incorrectly marked std::string as directly
referenced before the fix. Fixed by also checking the declaration's
recovered qualified name (diff_cxx_rules.itanium_qualified_name, from
mangled) against the stdlib prefixes for both functions and
variables — which subsumes the narrower owner-only check from the
fourth finding above (a stdlib-prefixed owner always makes the full
qualified name stdlib-prefixed too, but not vice versa: a stdlib
namespace's own direct free function/variable is a single mangled
scope component, so owner_class_of returns a bare "std" with no
trailing "::", never matching the "std::" prefix string), so the
now-redundant owner-only guard was removed.
A sixth finding found a different shape of gap again: an owner-seeding
correctness bug, not a missing-spelling one. owner_class_of()
derives its result by chopping the trailing "::"-component off any
already-qualified declaration name or mangled-symbol scope chain, with
no way to tell — from the string alone — whether what remains is really
an enclosing class or just an enclosing namespace (Codex review,
fresh evidence, confirmed with a minimal repro): a public namespace
function api::run() makes owner_class_of return the bare namespace
fragment "api", which the general suffix-matching mechanism
(_namespace_suffix_spellings, added for the first finding above) could
then coincidentally match against an unrelated internal record's own
bare-suffix spelling (e.g. other::api), wrongly walking that record's
fields and unfiltering its layout churn. Fixed by seeding an owner only
on an exact match against a non-stdlib record's full identity —
bypassing _spelling_index's record_index/suffix mechanism entirely
for this specific seed, rather than routing it through _scan(). This
is safe rather than a regression risk: unlike a genuine signature type
spelling (which a backend can legitimately partially-qualify, per the
first finding), owner_class_of's result is always either the complete,
exact scope chain of a real class (both its already-qualified-name path
and its mangled-decomposition fallback reconstruct the full chain,
never a partially-elided one — DWARF always bakes the complete
namespace/class path into a qualified name, and Itanium mangling always
encodes the complete nested-name unambiguously) or, when the function
isn't actually a method, namespace noise — so restricting to exact
matching loses no real case while closing the false-positive collision.
While verifying this fix through the full compare() pipeline (not just
the unit level), the same class of bug was found to independently exist
in surface.py's compute_public_surface() — its own, separate
owner_class_of-based seeding (_seed_public_roots) feeds the raw
owner through _type_identifiers() into seed_types, and
_walk_type_closure()'s record_by_name lookup is itself keyed by
bare-tail aliases (an intentional, correct mechanism for genuine type
references — "a short alias reached inside its own namespace resolves
to the namespaced record"), so the identical "api" vs. other::api
collision reproduces there too, confirmed with the same minimal repro
(compute_public_surface marks "api" — and therefore other::api —
public). Deliberately not fixed in this pass: surface.py is a
different, foundational module (the public-surface-scoping gate every
other detector in the codebase depends on) that this PR never otherwise
touches, and unlike the narrow type_reachability.py seeding path, its
record_by_name bare-tail lookup is a shared mechanism relied on by
every other seed type too — restricting it for the owner case
specifically needs its own careful, independently-verified design (which
seed paths may legitimately need the ambiguous-tail lookup and which
must not), not a same-PR drive-by extension of an unrelated finding.
Two more ambiguity-tracking gaps found in the same collision guards
(Codex review, fresh evidence, both confirmed with minimal repros): (1)
when two non-stdlib records had identities "Inner" and "api::Inner",
_spelling_index's derived-suffix collection only counted contributors
to the derived suffix "Inner" (from "api::Inner") — the unrelated
global "Inner" identity never contributes to that same tracking
structure (it's already a full identity, not a derived suffix), so the
ambiguity count saw only one contributor and merged "api::Inner"
straight into the pre-existing full-identity entry for the global
"Inner". Fixed by also treating a derived suffix that collides with a
different record's own full identity as ambiguous. (2)
_typedef_spelling_targets gave an exact pre-existing typedef key
automatic priority over a derived suffix from a different key, rather
than tracking both through the same ambiguity-counting structure: when
snapshot.typedefs held both a global "Alias" -> "std::…" and a
qualified "api::Alias" -> "Foo", a declaration inside api can
legitimately spell the latter as bare "Alias" too — silently
preferring the pre-existing exact key could resolve it to the wrong
one. Fixed by unifying exact keys and derived suffixes into one
target-set-per-spelling structure, resolving a spelling only when every
contributing source agrees on exactly one target.
A follow-up review round on the same fix found the removal above was
necessary but not sufficient. Refusing to merge "api::Inner"'s
candidates into the pre-existing record_index["Inner"] entry still
left that entry pointing at the unrelated global "Inner" record
(Codex review, fresh evidence, confirmed with a minimal repro):
direct-clang's own "drop the enclosing namespace" convention (the same
mechanism _namespace_suffix_spellings models for the Outer::Inner
finding above) means a signature declared inside namespace api can
spell api::Inner bare as "Inner" too — not just a partially-qualified
form. A public api::f() returning (bare-spelled) api::Inner would
then have its std:: field misattributed to the unrelated global
Inner's own field instead of correctly failing to resolve. Fixed by
removing the colliding spelling from record_index entirely
(record_index.pop(bare, None)) rather than merely refusing to add the
other record's candidates to it — since the bare spelling is genuinely
ambiguous between both records, leaving it resolved to either one
(including the "already there by default" one) is the wrong outcome,
not just an incomplete fix.
A separate, deeper finding on typedef keys, investigated and
deliberately not implemented this pass: direct-clang's own
parse_typedefs() (dumper_clang.py) stores a typedef's bare
node["name"] as the snapshot.typedefs key — never the scope-joined
qualified form _qualified() uses for every other decl kind — so a
namespaced alias loses its namespace at the point the snapshot is
produced, not merely at the point this module reads it. Confirmed
empirically via a real clang -ast-dump on namespace api { struct Foo
{}; using Alias = Foo; } api::Alias make();: the TypeAliasDecl's own
name is bare Alias, while the function's return type is printed fully
qualified "api::Alias" (a typedef reference is always spelled
qualified by clang's printer, unlike a plain class reference) — meaning
snapshot.typedefs ends up with {"Alias": "Foo"} while the real
signature spells "api::Alias", the exact inverse of the
qualified-key/bare-signature shape _typedef_spelling_targets was built
to handle. Since suffix-stripping only ever produces a shorter
candidate from a key, it can never reconstruct a longer, more-qualified
spelling from an already-bare key — there is no string-level fix
possible in this module for this direction, only two heavier ones, both
out of scope for a drive-by extension here: (1) fixing
dumper_clang.py's parse_typedefs() to store the qualified key
instead — a genuine, separate producer-side bug, but one whose blast
radius reaches every other consumer of snapshot.typedefs (typedef
diffing, surface.py's own typedef-following in _walk_type_closure),
each needing its own re-verification against the FP-rate/mutation-score
gates before trusting a changed key shape; (2) a local reverse-namespace
guesser in this module (re-attaching every namespace prefix seen among
the snapshot's own record identities to a bare typedef key and hoping
one matches) — pure speculation with no way to verify which, if any,
namespace a given bare key actually belongs to, and a real risk of
fabricating new false-positive matches rather than closing a
false-negative gap. Left as a silent false negative — the same
conservative default this module already uses throughout.
A seventh finding pointed at a platform-specific mangled-name quirk,
silently disabling the mangled-scope-recovery guard on every Mach-O
snapshot. Confirmed via dumper_clang.py's own _visibility()
docstring: clang's mangledName carries an extra platform leading
underscore on macOS ("__ZN3lib3addEii", not the plain Itanium
"_ZN3lib3addEii"), and empirically: itanium_scope_components(
"__ZSt5touchv") returned None before this fix, since
_itanium_strip_prefix() only recognized the bare "_Z" prefix
(Codex review, fresh evidence). Since every declaration's stdlib-scope
check in this module (and owner_class_of()'s mangled fallback) relies
on this recovery, a bare-named stdlib declaration on macOS bypassed the
guard entirely — not just in the one edge case a synthetic unit test
would reach, but for every symbol on that platform. Fixed in the shared
diff_cxx_rules.py parser (benefiting all four of its callers, not
just this module) by stripping the extra leading underscore before the
Itanium-prefix check, mirroring dumper_clang.py's own
_symbol_candidates() de-prefixing approach for the identical quirk.
An eighth finding pointed at a different mangling scheme entirely, not
a variant of the same Itanium quirk. A clang-cl (or any
--target=*-windows-msvc) direct-clang snapshot records a method's bare
AST name — the same unqualified-leaf convention CastXML uses — while
mangledName is mangled in the proprietary Microsoft C++ ABI scheme, not
Itanium (Codex review, fresh evidence). owner_class_of()'s mangled-name
fallback only ever recognized the Itanium _Z/__Z prefix, so this
owner seed stayed None on every MSVC-mangled bare-named method,
regardless of the Mach-O fix above (a different, unrelated prefix
convention, not fixed by it). Confirmed empirically by compiling real
headers with clang --target=x86_64-pc-windows-msvc -fms-compatibility
-Xclang -ast-dump=json: Foo::run() mangles to ?run@Foo@@QEAAXXZ
(scope components written innermost first, @-separated, terminated
by the first @@ — the reverse order and terminator convention Itanium
uses, confirmed against nested-namespace, single-letter-class-name, and
global-free-function cases too). Fixed with a new, genuinely separate
msvc_scope_components()/msvc_qualified_name() pair in
diff_cxx_rules.py (not a branch inside the Itanium parser, since the
two schemes share no structure beyond both being length/separator-based),
tried as a second fallback in owner_class_of() after Itanium — the two
prefixes (_Z/__Z vs. ?) are mutually exclusive, so trying both in
sequence is unambiguous and free on the common Itanium path. Deliberately
conservative, mirroring itanium_scope_components's own "model the
simple cases, return None for the rest" contract, confirmed unmodelled
against the same real compiler output: special member functions and
operators (??0 ctor, ??1/??_D dtor, ??4 operator=, ...) mangle
with a second ? immediately after the first, so the leaf/scope split
does not apply and is rejected outright; template classes/functions
(?$Name@Args@) embed the template-argument encoding inside the same
@-delimited region as the scope chain, and an argument token is
indistinguishable from a scope token by simple splitting, so any
component starting with ? (the template marker ?$ or the anonymous-
namespace marker ?A) is rejected; a bare-digit component is a
name-backreference into MSVC's per-symbol substitution table, not a
literal identifier (no real C++ identifier is all-digits, so this is an
unambiguous, lossless signal to bail — verified this does not
misfire on a genuine single-letter class name like struct A, which
mangles as a component that is a letter, never a bare digit). Also wired
the same new fallback into type_reachability.py's two direct
itanium_qualified_name() call sites (the free-function/variable
stdlib-namespace guards, not just the owner-seeding path the review
comment named) — same root cause, same one-line fix, verified against a
std::-namespaced MSVC-mangled free function that would otherwise have
bypassed the guard identically to the Mach-O case above.
A ninth finding pointed at an asymmetry in the typedef-spelling
ambiguity guard, not a mangling gap. _typedef_spelling_targets()
registers every derived candidate spelling (a stdlib-stripped or
namespace-suffix form of a typedef key) only after checking it against
_non_stdlib_signature_spellings() — but the typedef's own exact key
was registered unconditionally, with no equivalent guard (Codex review,
fresh evidence). The already-documented direct-clang typedef-scope-loss
gap above (parse_typedefs() storing only the bare node["name"]) means
an exact key like "Alias" can itself collide with an unrelated
non-stdlib record's own bare signature spelling — e.g. a global struct
Alias {}; sharing the same name as a namespaced namespace api { using
Alias = std::string; } whose api:: the producer already dropped.
Confirmed empirically: directly_referenced_stdlib_types() incorrectly
returned {"std::string"} for a public function taking the unrelated
Alias record by value, purely because of the same-named, unrelated
typedef. Fixed by applying the identical non_stdlib_spellings guard to
the exact-key registration, matching how a colliding derived candidate is
already skipped — the spelling belongs to the real record, so the
typedef contributes nothing for it, rather than competing through the
ambiguity-resolution machinery.
A tenth finding closed the conversion-operator half of the owner-
seeding gap the earlier "::operator "-marker fix only partly covered.
That earlier fix handled a display-name conversion operator whose own
qualification embeds "::" (e.g. DWARF's "Foo::operator ns::Bar"), but
a direct-clang snapshot stores a conversion operator's AST name bare —
"operator Bar", no owning-class prefix at all, confirmed via a real
clang -ast-dump — so owner_class_of()'s display-name branch never
applies (there is no "::" to find), and it falls through to the
mangled-name fallback (Codex review, fresh evidence). That fallback had
no coverage for conversion operators either:
itanium_scope_components()'s underlying component parser deliberately
excludes the Itanium cv (conversion-to-T) code from
_ITANIUM_OPERATORS — correctly, for that set's own purpose of grouping
operator overloads by a fixed 2-char code, since every conversion
operator carries a different target type and is never an overload of
another one — but treating cv as entirely unparseable meant hitting it
aborted the whole scope-recovery attempt, discarding the class name
already parsed before it. Confirmed empirically: _ZNK3FoocvN2ns3BarEEv
(Foo::operator ns::Bar() const) made itanium_scope_components()
return None outright, and owner_class_of() therefore returned None
instead of "Foo". Fixed by recognizing cv as a distinct, opaque leaf
component ("{op:cv}") in _parse_operator_component() — separately
from _ITANIUM_OPERATORS, since the overload-grouping semantics
correctly stay excluded — and forcing _step_next_component()'s done
flag to True immediately upon seeing it, regardless of nesting: the
conversion operator's own leaf is always the last component, and the
target-type encoding immediately following cv (e.g. N2ns3BarE for
ns::Bar) is a full, arbitrary Itanium <type> production — a much
larger grammar than this structural parser attempts elsewhere — but
recovering the scope prefix never needs that type parsed at all, only
a signal to stop before attempting it. Regression tests added: direct
parser-level cases in TestItaniumScopeParser/TestMsvcScopeParser's
sibling diff_cxx_rules test file, plus an end-to-end
directly_referenced_stdlib_types test confirming a Foo-owning
conversion operator's embedded std::string field is no longer
filtered.
An eleventh finding pointed at a masking mechanism the earlier
cross-index split didn't fully close. Splitting _spelling_index()
into independent stdlib_index/record_index patterns (an earlier
fix) solved masking between the two indices — a non-stdlib wrapper's
identity embedding a stdlib type's spelling verbatim. It did not solve
the identical masking within either index (Codex review, fresh
evidence): .finditer() only returns non-overlapping matches, so when
one candidate's registered spelling is itself a substring of another
candidate's spelling in the same index (e.g. "std::string" inside
"std::vector<std::string>", both stdlib; or a non-stdlib "Inner"
inside "Wrapper<Inner>"), the longest-first alternation matches the
outer candidate first, consumes the whole span, and the search
continues from the end of that match — so the inner one, though
directly present in the text, is never independently reported.
Confirmed empirically for both the stdlib and non-stdlib cases (and, on
further investigation while fixing this, the identical mechanism in
typedef_pattern's typedef-key matching too — a third, independently
confirmed instance of the same root cause). Fixed with a single new
helper, _finditer_allow_nested(), used at all three call sites: for
every match found, it recurses into text[m.start()+1 : m.end()] — a
strictly narrower window, so recursion terminates — to catch a shorter
candidate embedded anywhere inside it, at any nesting depth, not just
one level. Kept as one shared helper rather than three inline copies
since all three loops have the exact same masking mechanism. Verified
against the existing large-corpus performance regression guard
(test_many_unreferenced_stdlib_candidates_scan_efficiently) to confirm
this doesn't reintroduce the quadratic candidate-by-candidate cost the
single-pattern rewrite was originally built to eliminate — the extra
recursive search only runs when a match is actually found (rare in the
common case), bounded by nesting depth, not candidate count.
A twelfth finding closed a narrower gap in the conversion-operator
owner fix itself (tenth finding, above). The "::operator "-marker
fix only detects a conversion operator when an owner precedes the
marker; a bare-recorded conversion operator (no owning-class prefix at
all, per the tenth finding) can still carry a qualified target with
its own "::" — e.g. "operator ns::Bar", no "Foo::" prefix — and
for that shape neither the marker (there's no owner text before
"operator") nor the previous unqualified-bare check applied, so the
naive rsplit fallback still ran and returned junk like "operator
ns" (CodeRabbit review). Confirmed empirically: constructing exactly
this input shape reproduced the bad "operator ns" result before the
fix. Fixed by checking for the "operator " prefix the same way the
already-fixed unqualified case is detected, falling through to
mangled-name recovery for both shapes uniformly.
A thirteenth finding pointed at a robustness gap in the eleventh
finding's own fix, not a new correctness bug. _finditer_allow_nested()
(the nested-match helper from the eleventh finding) recursed one Python
call per nesting level to search each match's own span for a further
embedded candidate (Codex review, fresh evidence). For a genuinely deep
chain of registered spellings each nested one inside the next —
plausible for template-metaprogramming-heavy C++ under a compiler's
configured template-instantiation depth (GCC/Clang both default well
into the hundreds, and it is routinely raised higher for real
metaprogramming-heavy code) — that per-level recursion follows the C++
template depth 1:1. Confirmed empirically: 1,000 successively nested
registered candidate spellings raised RecursionError under Python's
default 1,000-frame recursion limit, aborting the whole comparison
rather than degrading gracefully. Fixed by converting the recursive
search into an explicit stack — each entry is still a strictly narrower
window than the match that produced it, so the search still always
terminates, just without consuming Python's call stack to do it, so no
amount of nesting depth can overflow it.
A fourteenth finding was a genuine regression the tenth finding's own
fix introduced, caught before merge. Recognizing the Itanium cv code
as an opaque leaf component (tenth finding) used a single fixed
placeholder label ("{op:cv}") regardless of the conversion's actual
target type. diff_types._overload_group_key() chains
itanium_qualified_name() — which now runs this label onto the scope
prefix — to decide whether two declarations are genuine overloads of one
another for _diff_overload_additions()'s KDE-policy check (Codex
review, fresh evidence). A fixed placeholder made every conversion
operator on a class produce the same qualified name regardless of
target — e.g. both operator int() and operator double() on the same
class reduced to "Foo::{op:cv}" — collapsing two conversion operators
that are never overloads of each other (each is a distinct, unambiguous
conversion function; there is no shared &Foo::operator T that becomes
ambiguous) into one group. Confirmed empirically:
_diff_overload_additions() fired a false OVERLOAD_ADDED for adding
operator double() alongside an existing operator int() before this
fix. Fixed by embedding the raw, un-decoded remainder of the mangled
string after cv into the label itself, instead of a fixed placeholder
— Itanium mangling is deterministic, so the same target always
reproduces the identical remainder (keeping genuine re-declarations in
the same group) while distinct targets always mangle differently
(keeping them in distinct groups), without this parser needing to
actually decode the arbitrary Itanium <type> grammar the remainder
encodes. Owner recovery (owner_class_of(), which only ever consumes
comps[:-1], dropping the leaf entirely) is unaffected either way.
A fifteenth finding pointed at a data-model assumption this module's
own new code introduced without verifying, contradicted by an existing
sibling. directly_referenced_stdlib_types() built non_stdlib_records
as a plain dict[str, RecordType] keyed by identity — when
snapshot.types contains multiple entries sharing the same identity
(e.g. a complete definition alongside an ODR-duplicate or incomplete
declaration), a later entry silently overwrote an earlier one, so a
public signature reaching that identity walked only the survivor (Codex
review, fresh evidence). surface.py's own record_by_name index —
the established reference this module has mirrored throughout every
finding above — already anticipates exactly this by keying on a list
of records per identity (dict[str, list[RecordType]]) and walking
every one (for rec_node in rec_nodes: ...), not a single winner; this
module's new dict introduced a real regression relative to that already-
correct sibling pattern, not a hypothetical edge case. Confirmed
empirically both orderings (the complete definition first, and the
complete definition last): whichever entry didn't survive the dict
overwrite, if it carried a std:: field the survivor lacked, that field
was silently missed. Fixed by changing non_stdlib_records to
dict[str, list[RecordType]] (appending instead of overwriting) and
walking every record for a reached identity in the worklist loop,
checking each one's own origin independently (a private-origin
duplicate still excludes only itself, not a public-origin sibling
sharing the same identity) — exactly mirroring surface.py's own
per-record walk.
Wiring (this pass): diff_types.py's single choke-point gate,
_is_abi_surface_type(), now accepts a directly_referenced set (built
once per detector via _directly_referenced(old, new)) and un-filters a
std:: record that set names, instead of blanket-filtering every std::
record regardless of direct use. Because every RecordType-based
struct/union/field/kind/reserved detector in that file already shares this
one gate function, wiring it there once covers all of them uniformly —
not 9 independent, individually-drifting call sites. While wiring this in,
the FP-rate corpus's own new cases (stdlib-direct-reference category)
surfaced a second, pre-existing correctness gap in the gate's std::
check itself (independent of directly_referenced): it filtered using
_is_non_abi_surface_type(t.name, ...), i.e. bare t.name only, the exact
same bare-vs-qualified split as the type_reachability.py fix above — so
a real castxml/clang-produced std:: record (bare name, qualified
qualified_name) was never actually filtered as std:: at all,
independent of whether anything referenced it. Fixed in the same gate by
keying the std:: prefix check on qualified_name or name (the anonymous-
type-marker half of the check still uses bare name, unaffected).
diff_platform.py/diff_symbols.py/diff_vtable_layout.py/
diff_stdlib_impl.py/diff_layout.py/diff_filtering.py/
diff_type_spellings.py, plus diff_types.py's own enum/typedef paths
(which call is_non_abi_surface_type/is_abi_surface_type_name directly
on enum/typedef names, not through _is_abi_surface_type), remain
unwired and carry the identical bare-name gap — each needs its own
individually-verified follow-up (FP-rate/mutation-score gates), not a
drive-by extension of this pass's RecordType-scoped fix.
- L4 SYCL replay via a resolved --gcc-path icpx/dpcpp override — flag
vocabulary fixed, the two-pass JSON-document crash fixed, real host+device
dual-context replay still not implemented. Fixing L4 clang_bin
resolution to honor --gcc-path (an earlier PR) meant L4 could for the
first time actually invoke a SYCL-capable compiler (icpx/dpcpp)
instead of always a bare clang, which surfaced a narrower, real gap:
-fsycl/-fsycl-* wasn't in adapters.base.ABI_RELEVANT_FLAG_PREFIXES,
so it never reached the reconstructed L4 replay command even when the
real build recorded it (Codex review) — fixed, since the existing
abi_relevant_flags carry-through (replay_extra_flags) already handles
this class of flag correctly for every other case (-std=,
-fvisibility, …), so this was a one-line vocabulary gap, not a design
gap. That fix's own consequence #1 — "whether replaying without an
explicit -fsycl-host-only pin causes icpx to attempt a device pass
this pipeline can't consume" — was later confirmed against a real
toolchain: a real -fsycl bazel compile unit (oneDAL, 2.8GB AST) hit
exactly this, json.load() failing with Extra data: line 26076735
column 2 at the byte offset where the device pass's document begins,
i.e. consequence #2 also confirmed (legacy dpcpp/icpx both emit
multi-document host+device AST output for a plain -fsycl, the identical
shape sycl_context.py already handles for the L2 header-AST backend).
Now fixed by mirroring the L2 backend's own fix
(dumper_ast_config._build_clang_header_command/
dumper_clang._needs_sycl_host_only) rather than adopting
sycl_context.py's full host/device dual-context decoder: L4 replay
parses ONE compile unit at a time and has no --frontend-context device
concept to select against (unlike L2's header-AST dump, which can be
asked for either context), so there is nothing for a second document to
serve here — _clang_context_args (shared by both the AST pass and the
macro pass) now appends -fsycl-host-only whenever
dumper_clang._needs_sycl_host_only says the resolved compiler is an
Intel oneAPI driver with SYCL effectively enabled and no single pass
already pinned, collapsing the compile back to the one host-side pass
that actually links into the scanned .so — the device pass's SPIR-V
kernel code never does. Reuses _needs_sycl_host_only directly (not a
reimplementation) so the last-flag-wins -fsycl/-fno-sycl scan and the
legacy dpcpp/dpcpp-cl SYCL-implied-by-default handling stay a single
source of truth with the L2 fix. The remaining "Extra data" json.load()
path now also emits an actionable hint (mirroring
dumper_clang_errors._parse_clang_ast_result's) for any other,
not-yet-special-cased offload flag that still produces a multi-document
stream, rather than a bare byte offset. Still not implemented, and
deliberately out of scope: a genuine host+device dual-context L4
replay (an L4 counterpart to --frontend-context device) — nothing in
L4's SourceAbiTu/CompileUnit model has a notion of "this TU's device
pass," so adding one is a real, separate design (schema + linker +
cross-check changes), not a follow-up to this crash fix.
- depfile_args_from_argv()'s trusted_root parameter — the self-jail
vulnerability is closed, real production wiring not implemented. Closing
the vulnerability (a compile unit's own directory field, attacker-
controlled for a unit sourced from an untrusted build pack, was used as
both the resolution base and the trust jail for expanding an unexpanded
@response-file) required the three production call sites
(ClangIncludeExtractor.extract_from_build,
ClangPreprocessorExtractor.capture_macros, preprocessor_scan._depfile_context)
to fall back to the existing safe "drop the token" behavior, since none of
them currently supply an independently-trusted trusted_root (Codex
review). Not implemented: threading a genuinely-trusted root into
those three call sites so response-file expansion works again for this
secondary L5/S2-scoping path. This is real, non-trivial plumbing, not a
one-line fix: BuildEvidence.build_root/source_root exist as fields but
no adapter (compile_db.py, cmake_file_api.py, ninja.py, bazel.py,
make.py) actually populates them today, so there is no already-flowing
trusted value to read off the model — the real anchor would have to be
threaded as a new parameter from inline.collect_inline_pack()'s own
sources/build_info CLI arguments (or, for the separate Flow-2
abicheck_inputs/ ingest path in inputs_pack.py, the pack's own root:
Path already used for _safe_pack_path containment) through several
call layers in inline.py (already WARN-flagged oversized) and
preprocessor_facts.py. The functional impact of the current gap is
narrower than it first appears: build_context.py's own @file
expansion (correctly jailed to the compile database's own directory since
the first response-file fix in this PR) already expands a
compile_commands.json-sourced CompileUnit.argv before it reaches
these three call sites, so they rarely see a raw, unexpanded @file
token for that primary path in practice — the gap mainly affects the
Flow-2 untrusted-pack path this fix was specifically about securing in
the first place. Confirmed via the full local suite (20935 passed) that
disabling expansion at these three call sites introduces no test
regressions.
- Param.is_va_list (G31 Phase C continued) inherits the toolchain-
identity-probe gap above, rather than being a new problem. Its
extraction predicate (dumper_clang_qualifiers._clang_param_is_va_list)
is deliberately scoped to the one ABI verified here — x86-64 System V —
and a snapshot from an unrecognized target already degrades to a
conservative False (see the function's own docstring). What's still
open (Codex review, fresh evidence): the snapshot-level reliability flag
(AbiSnapshot.clang_va_list_facts_reliable) records only "did the fixed
extractor run", not "against which resolved target" — so two
genuinely-different-target clang snapshots (x86-64 vs. AArch64, say) can
both read as reliable, and a real cross-architecture comparison (which
this tool's comparability layer permits in general) could read the
x86-64 side's real detections against the AArch64 side's uniform False
as a spurious PARAM_BECAME_VA_LIST/PARAM_LOST_VA_LIST. No header-AST
fact has resolved-target validation today, not just this one — closing
it here alone would be an inconsistent one-off fix for a structural gap;
it belongs with the toolchain-identity probe above once that lands.
- A using-declaration re-exporting a namespace-scope constant produces
two AbiSnapshot.constants entries for one declaration on the castxml
backend — reported against real oneDAL headers. A per-snapshot,
name-shape-based dedup was implemented, reviewed, found unsound in two
independent ways, and reverted rather than shipped broken (Codex
review, three rounds). cpu.hpp opens a plain (non-inline)
namespace v1 { constexpr ... cpu_feature_map = ...; } and later does
using v1::cpu_feature_map; inside the enclosing detail namespace —
an ordinary C++ re-export pattern, not versioned-inline-namespace ABI
tagging (inline namespace v1 {}, which is the shape
diff_namespaces.py's _paired_stable_indices/
EXPERIMENTAL_REMOVED_WITHOUT_REPLACEMENT machinery already merges
aliases for — that machinery is function/type-scoped, cross-snapshot
(old vs. new), and keys on experimental/preview/v0 segments
specifically, so it neither targets nor would catch this same-snapshot
pattern even if it applied to constants). castxml's <Variable> XML
records the using-declaration as a second full element rather than a
reference to the original, so dumper_castxml.py's
_iter_public_constants() (the shared source for parse_constants()/
parse_constant_headers()) emits both detail::v1::cpu_feature_map
and detail::cpu_feature_map as independent entries in
AbiSnapshot.constants with the same value — distinct qualified-name
keys, not a key collision, so this is invisible to model.py's
existing first-wins duplicate-name dedup (function_map/
variable_map/type_by_name all dedup by identity — mangled name or
bare type name — under which a using-shadow naturally collapses onto
its target; AbiSnapshot.constants has no mangled-name equivalent).
Reported at real scale, not a one-off: 98 alias groups across 267
constants in the reporting library's own dump. The same castxml
behavior duplicates other declaration kinds too, with two different
outcomes: 1,477 of 6,009 mangled function names were duplicated the
same way, but that half is not a double-reporting bug —
model.py's mangled-name-keyed function_map/variable_map already
collapse a using-shadow function or variable onto its target for free
for the ABI/mangled-symbol question (a using-declaration doesn't change
what a symbol mangles to), so this is not a repeat of the constants
over-reporting problem above. It is a separate, undocumented
false-negative gap on the source/API side, narrower in scope than the
std::-specific STD_REEXPORT_REMOVED detector below (Codex review,
fresh evidence): because the diff is keyed on the unchanged mangled
symbol, a release that removes only the using re-export of a
library's own function or variable — not a std:: name, which is
the one case STD_REEXPORT_REMOVED already covers — while keeping the
real declaration and its export produces no finding at all, even
though a consumer that named the alias-qualified spelling no longer
compiles. This is the identical shape of gap the clang-side note below
documents for constants (an unchanged underlying identity hides a real,
source-visible alias removal), just reached from the opposite side
(castxml capturing the alias correctly here, the diff layer being the
one blind to it) — not attempted in this pass, and would need
detect_std_reexport_removed's general shape (matching declared
qualified names, not std::-specific) extended to a library's own
namespaces, which is its own scoped detector design, not a drive-by
extension of this entry. A duplicated bare type name (range
alongside v1::range) is a separate, still-open gap from the
type-dedup entry already documented above
(opaque-type suppression keyed by bare RecordType.name colliding
across namespaces on an accidentally-shared bare name) —
range/v1::range are two distinct qualified spellings of what may be
the same using-re-exported record, so today's exact-bare-name
first-wins dedup does not merge them either, and would need its own,
differently-shaped fix (threaded through RecordType identity, not
AbiSnapshot.constants), not attempted here.
Confirmed independently that the duplicate-entry shape is castxml-only
— but the direct-clang L2 backend is not clean either, it just fails in
the opposite direction. Verified directly against real Clang 18
(-Xclang -ast-dump=json) on a minimal repro of the same shape: a
using-declaration lowers to a UsingDecl node carrying the qualified
target name (v1::cpu_feature_map) but no init/value of its own,
immediately followed by an implicit, unnamed UsingShadowDecl
("isImplicit": true, no name key at all — confirmed by inspecting
the emitted JSON directly, not inferred). That UsingShadowDecl node
does carry real target identity — a target object with the
underlying VarDecl's own id, kind, name, and type (confirmed
by inspecting the real JSON: {"id": "0x...", "kind": "UsingShadowDecl",
"isImplicit": true, "target": {"id": "0x...", "kind": "VarDecl", "name":
"cpu_feature_map", "type": {"qualType": "const int"}}}) — so this is
identity Clang exposes and dumper_clang.py simply never reads, not
identity Clang lacks (Codex review, fresh evidence; an earlier revision
of this entry claimed the latter, which was wrong). _categorize() has
no branch matching either UsingDecl or UsingShadowDecl — its
VarDecl branch requires both kind == "VarDecl" and a non-empty
name, neither of which either using-related node satisfies — so both
are silently dropped, target included, and the re-exported spelling
never enters AbiSnapshot.constants at all on that backend.
This is complementary missing coverage, not a clean bill of health:
a release that removes only the using v1::cpu_feature_map; re-export
while keeping the real v1::cpu_feature_map definition breaks every
consumer of the re-exported spelling, but both the old and new
Clang-derived snapshots contain only the one underlying key —
_diff_constants() has nothing to compare and emits no
CONSTANT_REMOVED, a silent false negative. Castxml's two qualified
keys, over-reporting as they are for the case this entry is about, would
at least detect that removal correctly. So the two backends fail in
opposite directions on the same underlying gap (no alias-identity
evidence for constants) — over-reporting on castxml, under-reporting on
clang. Not verified against a live castxml run (no castxml binary
available in this environment to reproduce the XML shape directly) —
the castxml-side mechanism above is taken from the original report, not
independently re-derived from castxml's own output. Update (G31 Phase
C, later pass): now independently re-derived, against a real
conda-forge castxml 0.7.0 build, and the "opposite directions" framing
in this paragraph does not hold — see the "Option (b) closed" note at
the end of this entry, below, for the reproduction and its
implications.
Attempted fix, reverted (three review rounds): a
qualified_name_segments.dedup_versioned_namespace_alias_items()
helper collapsed one spelling onto the other within one already-
collected _iter_public_constants() item list, gated on BOTH (1) the
two qualified names differing by exactly one versioned-inline-namespace
segment (version_strip_segments — the same, already-trusted
structural check diff_namespaces.py already relies on for its own
alias merging) AND (2) the two values being byte-identical. Deliberately
narrower than the plain value-equality merge this same module's own
docstring already rejected after three earlier review rounds for a
different, cross-snapshot reason (that heuristic had no name evidence
at all and could merge unrelated same-valued declarations that never
even coexist together) — this attempt was same-snapshot only and added
a real structural name-shape gate. It was not enough. Two more rounds of
review found it unsound from two independent directions, and a third
round (after the second was "fixed") found the second fix was also
wrong, at which point it was reverted rather than iterated a fourth
time:
- Round 1 (P1): the first revision kept the shorter, version-
stripped alias and dropped the version-qualified original —
reasoned as "the spelling a consumer of the
usingre-export actually writes." This actively fabricated findings: a release that adds or removes only the re-export (the real declaration unchanged) went from one snapshot keepingdetail::v1::x(no alias present, nothing to strip) to the other collapsing down todetail::xinstead — two different surviving keys for one unchanged declaration, which_diff_constantsread as a spuriousCONSTANT_REMOVED+CONSTANT_ADDEDpair. In other words: this revision manufactured exactly the kind of double-reporting it was written to eliminate, just shaped as fabricated add/remove instead of a duplicatedCONSTANT_CHANGED— arguably worse, since a fabricatedCONSTANT_REMOVEDreads as BREAKING. - Round 2 (P1 again, on the "fix" for round 1): reversing the
direction — always keep the version-qualified spelling, always
drop the alias — looked sound (invariant to whether either side
happens to carry the re-export) and was reviewed as fixing round 1.
A further round then produced a concrete counterexample proving the
fixed direction was also wrong in general:
namespace detail { constexpr int x = 42; namespace v1 { using detail::x; } }is legal C++ where a using-declaration imports a name into a versioned-looking namespace rather than out of one — heredetail::x(the shorter spelling) is the real declaration anddetail::v1::x(the longer, version-qualified spelling) is the alias, the exact reverse of what round 1's fix assumed universally. "Always keep the longer/qualified spelling" reproduces round 1's failure mode for this reversed input shape instead. This is the structural finding that ended the attempt: qualified-name shape alone (segment count, or which segment looks version-tagged) cannot determine which of two spellings is the real declaration and which is the using-introduced alias — a using-declaration can legally go in either direction relative to a versioned-looking namespace segment, and_iter_public_constants()'s current(qualified_name, value, declaring_header)output carries no signal (declaration order, anartificial-style marker, a using-shadow/target back-reference) that distinguishes the two directions. No fixed, name-shape-based rule can be sound for both. - Round 3 (P2, on the value-equality gate itself, independent of
direction): even a correctly-directed rule would still merge two
genuinely independent declarations that happen to form a
version-alias-shaped name pair AND happen to share a value at
extraction time (e.g.
namespace detail { namespace v1 { constexpr int x = 42; } constexpr int x = 42; }— two unrelatedxs, nousinganywhere) — the same class of coincidence-driven risk this module's docstring already flags for the cross-snapshot merge it rejects, now shown to reach the narrower same-snapshot case too.
Given round 2 proves no per-snapshot, name-shape-based direction rule
can be sound, and a genuinely correct fix needs real using-shadow/
target identity evidence rather than name shape — continuing to patch
the heuristic's direction a third time was exactly the "one more
counterexample" pattern this file's own linkage-blind-removal and
type-identity entries above already warn against repeating. Reverted in
full (code, tests, changelog fragment) rather than shipped with a
known-unsound direction, per this file's own "known gaps over risky
reactive patches" convention — and per the same "attempted twice,
reverted twice" discipline already established here: a false BREAKING
finding fabricated by an unsound heuristic is a worse failure mode than
the pre-existing double-reporting it would have fixed, since the
original bug is merely noisy while a wrong-direction fabricated
CONSTANT_REMOVED blocks a release for nothing.
What "real identity evidence" actually means differs by backend, and
the two must not be conflated (Codex review, fresh evidence corrected
an earlier draft of this entry that did conflate them). On
direct-clang, the identity evidence demonstrably EXISTS today and is
simply unused: UsingShadowDecl.target (see above) names the exact
underlying VarDecl by AST id, so a sound clang-side fix is a real,
scoped option — capture the shadow, resolve target.id back to the
already-visited VarDecl, and re-register the alias under the
using-declaration's own enclosing-scope name, carrying its target's
identity forward rather than guessing from name shape. That is new
extraction work (_categorize() doesn't currently visit
UsingShadowDecl at all) and was not attempted in this pass, but it is
a materially different, better-founded starting point than the
name-shape heuristic this entry's earlier rounds tried and reverted. On
castxml, whether an equivalent target back-reference exists in its
<Variable> XML is genuinely unknown — not confirmed absent, not
confirmed present — since no castxml binary was available in this
environment to inspect real output for this construct; the reported
castxml duplication mechanism throughout this entry is taken from the
original report, not independently re-derived. A correct, full fix
needs one of: (a) the clang-side target.id threading described above,
landed and verified against the FP-rate/mutation-score gates before
trusting it; (b) inspecting real castxml XML for this construct to
determine whether it exposes anything equivalent, which this
environment cannot do; or (c) a genuinely different architecture that
resolves the ambiguity at diff time with both sides' full data available
simultaneously (mirroring how diff_namespaces.py's
_paired_stable_indices jointly builds paired OLD/NEW indices for
functions/types, gated on real identity) rather than destructively
dropping information from one side's snapshot in isolation — a
materially larger, cross-cutting change on its own, not a follow-up
patch to the same per-snapshot helper.
Option (b) closed (G31 Phase C, real conda-forge castxml build): a
real, policy-conformant castxml (0.7.0, conda-forge, within the
>=0.6.11,<0.8.0 range castxml_policy.py enforces, bundled Clang
20.1.8) is now available and was used to reproduce this construct
directly through the exact invocation shape _build_castxml_command
emits (--castxml-cc-gnu (g++ -x c)/--castxml-cc-gnu g++, matching
real header-dump usage) — the originally-hypothesized castxml
duplication mechanism does not reproduce. For namespace detail {
namespace v1 { constexpr int cpu_feature_map = 42; } using
v1::cpu_feature_map; }, real castxml emits exactly one <Variable>
element (context="_13", i.e. v1 — never detail), and that single
element's id is listed in both detail's and v1's <Namespace
members="..."> attribute — a shared reference, not two elements. There
is no <Using...>-shaped XML tag in castxml's schema at all (checked
against castxml's own --help), and forcing the value to be ODR-used
(&cpu_feature_map from an inline function) does not change this.
Since dumper_castxml.py's _variable_els/_iter_public_constants()
iterate the flat, once-per-XML-element id map (_build_id_map), not
either namespace's members list, they see this construct exactly once
too — _qualified_name() resolves it to detail::v1::cpu_feature_map
only; the alias spelling detail::cpu_feature_map never enters
AbiSnapshot.constants on this castxml version, at all. The identical
single-element behavior was independently confirmed for the sibling
type-dedup case this entry cross-references (struct range in v1,
using v1::range; in detail): one <Struct> element, shared between
both namespaces' members lists, never a duplicate. What this means
for the entry above: the "castxml over-reports (duplicate keys),
clang under-reports (missing alias)" asymmetry this entry documented
was written from an unverified report and does not hold for the
currently-supported castxml version range — for this exact construct,
both backends now demonstrably fail the same way (silent false
negative: the alias spelling is absent from the snapshot entirely,
matching the clang-side finding already confirmed above). This does not
retroactively invalidate the original report (98 alias groups across
267 constants, real oneDAL headers) — that scan may have used a
different castxml build, or the real duplication may come from a
different source construct entirely (e.g. two independent, textually
separate definitions sharing a value, rather than a language-level
using-declaration/using-directive) that this pass did not have the
original headers to reproduce against. It does mean: (1) the three
reverted name-shape-heuristic attempts documented above were correctly
reverted regardless of this finding (their unsoundness was proven by
counterexample, independent of whether castxml duplicates), so nothing
here reopens that question; (2) a future fix attempt should design for
the false-negative alias-identity gap confirmed symmetric on both
backends (option (a)'s UsingShadowDecl.target clang-side threading is
now the best-founded starting point, since it is real, already-present
AST identity, not a heuristic), rather than a castxml-side dedup this
evidence no longer motivates; (3) closing this for real still needs the
original large-scale report's exact source construct identified before
trusting any fix against it — not attempted here, since the oneDAL
headers that produced the original 98/267 count are not available in
this environment either.
- AbiSnapshot.typedefs is a flat dict[str, str] keyed by bare
(unqualified) name on both header backends — a member/nested typedef
silently collides with, and can be overwritten by, any other typedef
anywhere in the snapshot sharing the same bare spelling. Confirmed by
reading both producers directly, not yet fixed (G31 Phase C CastXML
fact-completeness audit). dumper_castxml.py's parse_typedefs() and
dumper_clang.py's parse_typedefs() both do the identical
typedefs[name] = underlying, where name is the typedef's own local
name attribute/node field — never the scope-joined qualified spelling
_qualified_name()/_qualified() uses for every other declaration kind
in the same two modules. Verified directly against real CastXML XML
output (--castxml-output=1, clang-emulated) for a minimal repro: a
member typedef nested inside a struct (struct WithUsing { using
value_type = int; ... };) is emitted as an ordinary top-level
<Typedef name="value_type" ... context="_12" .../> element, structurally
indistinguishable from a namespace-scope typedef except for its
context attribute — which parse_typedefs() never reads. Two structs
each declaring their own value_type member alias (an extremely common
C++ pattern — STL-container-shaped types conventionally expose
value_type/size_type/reference/... as member typedefs) collide on
the identical bare key "value_type" in the resulting dict, and
whichever element is encountered last in document order silently wins —
the other's aliasing information is dropped from the snapshot entirely,
with no warning, error, or any user-visible sign of the loss. The same
collision shape reproduces on the direct-clang backend: _typedefs is
populated by the same flat walk used for every other decl kind, but
parse_typedefs() discards the entry's own recovered scope and keys
only on node["name"]. Not fixed here: this is a real, if narrow,
public-model change — AbiSnapshot.typedefs's key shape is read by
type_reachability.py's _typedef_spelling_targets() (see the
"Type reachability" entry above, which already works around this same
bare-key ambiguity from the consumer side via its own
ambiguity-counting helper, _typedef_spelling_targets, rather than
assuming a bare key uniquely names one typedef), by diff_types.py's
typedef diffing, and by surface.py's typedef-following in
_walk_type_closure — none of which currently have test coverage for
the cross-class member-typedef-collision case to validate a change
against. A correct fix needs a qualified (or at minimum
collision-detecting) key threaded through both producers and every
consumer simultaneously, each independently re-verified against the
FP-rate/mutation-score gates before trusting it — the same systematic,
cross-cutting shape (and the same "known gaps over risky reactive
patches" reasoning) as the already-documented bare-RecordType.name
opaque-type-suppression collision above, for a different model field.
Filed here per this file's own convention rather than attempted under
this pass's time budget.
Closed, additively, in a later pass (G31 Phase C continued). Rather
than replacing AbiSnapshot.typedefs's key shape — which would have
meant re-verifying every one of its existing consumers
(type_reachability.py, diff_types.py, surface.py) against a changed
contract, plus every external Python-API caller reading that field
directly — the actual fix is purely additive: a new field,
AbiSnapshot.typedefs_qualified (schema v25), a fully-qualified-name-keyed
twin populated by both dumper_castxml.py's and dumper_clang.py's
parse_typedefs_qualified() (using the identical _qualified_name()/
_qualified() scope-joining every other declaration kind in those modules
already uses) alongside the existing, deliberately-unchanged
parse_typedefs(). Since a qualified name is unique per declaration, this
twin cannot suffer the bare-name collision at all — both Foo::value_type
and Bar::value_type survive as distinct entries where only one bare
value_type could before. Threaded through the ELF manifest per-TU merge
path too (TuFragment/MergedTuFragments/ElfHeaderAstResult in
tu_fragment.py/tu_merge.py/dumper_manifest.py), closing the identical,
separately-documented "Known, accepted limitation" comment tu_merge.py
carried for the multi-TU case specifically. Only one consumer was wired to
actually use the new field: type_reachability.py's
directly_referenced_stdlib_types() (via a new _merged_typedefs() helper
that folds typedefs_qualified into the flat dict already passed to
_typedef_spelling_targets() and siblings) — closing the real false
negative where a public signature spelled with the qualified alias the
bare dict had already lost could silently miss a reachable std:: field.
diff_types.py's typedef diffing and surface.py's typedef-following in
_walk_type_closure are not wired to the new field in this pass — each
is its own scoped follow-up, not a drive-by extension, since each has its
own call shape and (per this file's own established discipline) needs its
own test coverage before trusting a change to it. No reliability flag was
needed for the new field (unlike the *_facts_reliable flags v19–v23 use
for a real-but-wrong scalar default): an empty typedefs_qualified is
exactly the same value a genuinely-typedef-free snapshot would carry, so a
pre-v25 snapshot degrades cleanly to "no extra qualified data available"
rather than being misread as a real fact — see serialization.py's own
v25 history-comment entry for the full reasoning. Verified via new unit
tests on both header backends directly (_CastxmlParser.
parse_typedefs_qualified/_ClangAstParser.parse_typedefs_qualified) and
an end-to-end type_reachability regression proving the bare dict's lossy
collision no longer hides a real std:: field.
-
run-plan.json'sgateblock is unprotected against an already-shipped, version-skewed reader — investigated end-to-end, confirmed structurally infeasible to close within the document's own shape, not fixed (Codex review, PR #779).aggregate_manifest.py'sgateblock gained real protection against an oldaggregatebinary misapplying a new manifest's policy:_check_manifest_versionalready existed before this PR with real MAJOR-rejection logic (major > supportedraises), so bumpingaggregate_manifest_versionfrom1.0to2.0gives an old, already- installed reader a real, working rejection point — it already knows how to say "too new for me."run-plan.json's newRUN_PLAN_SCHEMA_GATE(abicheck.run-plan/v2) discriminator was modeled on the identical pattern but does not give the same protection, and cannot: traced the full old (pre-PR779, commit90e1813, the last shipped state)aggregate --run-planpipeline directly. (1) OldRunPlan.from_dict()/RunPlanCheck.from_dict()use exclusively total, defensive conversions (str(),bool(),isinstance-guarded comprehensions,.get()with a default for everything) — by design, matching this package's own documented forward-compat convention ("every dataclass carriesto_dict()/from_dict()with defensive.get()parsing so a newer/ hand-edited pack never aborts a load" —abicheck/buildsource/CLAUDE.md). Nothing in either function can raise regardless of input shape, so an unrecognizedschemastring and an unknowngatekey are both silently ignored, not rejected. (2) Oldaggregate --run-plandoesn't validate the parsed plan directly at all — it projects it viato_aggregate_manifest (plan), which stamps"aggregate_manifest_version": AGGREGATE_MANIFEST_ VERSIONusing the old binary's own hardcoded constant, imported fresh at call time — never read from or influenced by the run-plan.json's ownschemafield. The one place a version-rejection check does fire (ExpectedTargets.from_manifest_data) is therefore always checking the old reader's own version against itself, and can never observe a "too new" value regardless of what the source run-plan.json declares. Consequence: a genuinely version-skewed setup — an already-installed oldabicheck aggregate --run-planreading arun-plan.jsona newerproject plan --gate-missing-required/--gate-unexpected-targetwrote — silently drops the requested gate policy and falls back to the old binary's hard-codedfail/includedefaults, exactly the "silently wrong gate decision" class of bug the manifest-side2.0bump exists to prevent, just unreachable to prevent here. Not fixable within the document's own shape: every value in both dataclasses is consumed via a total, defaulting conversion, so no crafted field value can force old, already-shipped code to raise — this is a property of code that has already been released and cannot be retroactively patched, not a correctness gap in this PR's own new code. Closing it for real would need a mechanism outside the JSON document itself (e.g. a CI-level convention thatproject planandaggregate --run-planare always the same abicheck version/pinned together, enforced or documented at the tooling level) — out of scope for a schema-shape fix and not attempted here. -
scan --config <path>can execute an untrustedbuild.queryeven whenresolve_effective_allow_query(ADR-037 D4 "level-implies-query") denies authorization — confirmed real and confirmed pre-existing (CodeRabbit review on #817, fresh evidence, traced through git history rather than assumed).cli_buildsource.embed_build_source()'s owncfg_trusted_for_query = build_config is not None or build_query is not Nonecomputation treats a merely-non-Nonebuild_configpath as sufficient authorization to run that config'sbuild.querykey (buildsource/inline.py'scollect_inline_pack, gated onbuild_config_trusted_for_query) — it has no way to distinguish "a config path was supplied" from "querying was actually authorized." That trust model is intentional fordump/compare(an explicit, operator-supplied--configis deliberately trusted to execute its ownbuild.query— seeembed_build_source's own inline comment), butscanlayers a stricter, independently-designed rule on top (cli_scan_helpers.resolve_effective_allow_query: the config must both declarebuild.queryand have an explicitly-pinned deep evidence level) that this shared function's own trust check never consults. Confirmed pre-existing, not introduced by PR #817 or by #814's "PR 3A convergence" it merged: at commit551725e(immediately before #814),scan_engine._build_new_snapshotalready calledembed_build_source(build_config=build_config, allow_build_query= allow_build_query, ...)directly, passing the raw--configpath unconditionally, independent ofallow_build_query's value — the identical bypass already reachable there.service_input_resolution. _gated_build_query_inputs'sbuild_config_locally_trustedparameter (added by #814) was built explicitly to preserve that pre-migration behavior forscan(see its own docstring), not to introduce it. Not fixed here: a correct fix needsembed_build_source/collect_inline_packto accept an authorization signal genuinely distinct from "was a config path given" — threaded through two independent call sites (the L2 seed's own pack-arg resolution inl2_seed._resolve_l2_seed_pack_args, and the L3-L5 embed step inembed_build_sourceitself) — verified against realscan --configscenarios where the config setsbuild.querybut no deep evidence level is pinned. That is a real, cross-cutting change to a shared trust primitivedump/comparealso depend on, not a one-line change to_gated_build_query_inputs's gate. Filed here per this file's own "known gaps over risky reactive patches" convention rather than attempted under review pressure on an unrelated PR. -
DEFAULT_SYSTEM_PROVIDERS(bundle_models.py) is a hand-maintained soname allow-list, not a real topology model for bundle-level system-provider classification -- a tactical fix that has grown, not a designed feature (Codex review on #791, fresh evidence).bundle.compare_bundle()'s unresolved-import check (ChangeKind.BUNDLE_INTRA_DEP_REMOVED,_detect_intra_dep_removed()) does not exempt an import merely for being "against any" soname in this union -- it suppresses a consumer's finding only when that consumer's remaining non-intraDT_NEEDEDedges are all non-empty AND every one of them matchesset(DEFAULT_SYSTEM_PROVIDERS) | set(bundle_system_providers)(or the_looks_systemheuristic), AND EITHER no in-bundle sibling version-compatibly provided the symbol and was reachable from this consumer OR the user explicitly named one of the remaining sonames viaexplicit_providers--ever_provided_in_bundleiscompatible_old & _old_reachable(consumer.library), scoped to what this specific consumer could reach, not "any" in-bundle sibling regardless of reachability; an unreachable sibling exporting the symbol does not, by itself, block the allow-list suppression (Codex review, PR #910, fresh evidence, correcting this exact paragraph's own prior "no in-bundle sibling ever" wording). A consumer with a mixed intra-bundle/external dependency set can still produce the finding, and a match against the allow-list alone is not sufficient. Correct for the ordinary libc/libstdc++/libpthread runtime set the constant started from, but each addition since (oneTBB'slibtbb.so.*and its allocator-proxy libs, oneMKL/Intel-runtime and Level Zero entries, PR #883) was a real, reported false positive fixed by naming one more vendor runtime rather than by asking what "system-provided" actually means for a bundle -- and a growing vendor runtime not yet on the list still reproduces the exact false positive it exists to prevent. Already designed, not a fresh gap to sketch a fix for here:docs/contribute/ plans/g42-check-identity-environments-and-provider-resolution.mdnames this exact limitation as one of its own three motivating problems and lays out the real fix ("Environment-aware system-provider resolution") -- resolving each external dependency edge against a declared environment's sysroot/runtime matrix (presence, SONAME, export, symbol version, runtime floor), with the static allowlist demoted to a fallback for a project that doesn't opt into a declared environment. Route any work on this to that plan rather than reinventing a provider-classes registry here. This gap is bundle-wide, not justcompare_bundle()'s.audit_bundle()(scan --artifact-set's one-sided entry point) unions the identicalset(DEFAULT_SYSTEM_PROVIDERS) | set(bundle_system_providers)allow-list and feeds it to its own predicate,_detect_unresolved_intra_dependency()-- not a call into_detect_intra_dep_removed(), since an audit has no old side to diff against. That predicate's own docstring documents three real differences from the diff-driven one (version-aware and reachability- constrained provider matching, a suppression path that additionally requires the consumer have zero intra-bundleDT_NEEDEDedges, and aCOMPATIBLE_WITH_RISK-not-BREAKINGverdict), so a G42 fix landing only on thecompare_bundle()path would leavescan --artifact-setaudit-mode classification on the static allow-list with its own, differently-shaped false-positive exposure (Codex review, PR #910, fresh evidence). Any work on this gap needs both call sites in scope. -
PR C (typed
dump/scanconvergence, CLI cleanup phase two's PR 3A) — investigated in depth; one real, scoped, verified slice landed; the full convergence the plan describes is NOT attempted, and here is exactly why. The plan (docs/contribute/plans/cli-cleanup-phase-two.md, "PR 3A") asks for one canonical path --Click parsing → DumpRequest → ResolvedDumpRequest → dry-run-or-execute → DumpResult-- with bothdump_cmd/perform_elf_dumpandscan_engine._build_new_snapshotrouting throughservice_dump_pipeline.run_dump_request(or the per-input primitives it shares viaservice_input_resolution.resolve_side_snapshot), the waycompare's implicit-dump operand already does, withdump --dry-runrendering a realResolvedDumpRequestobject -- the resolve-only step execution builds on, not the executedDumpResultitself (a--dry-runthat rendersDumpResultwould have to have already executed, which contradicts its own never-executes contract; see the plan's PR 3A section, "ResolvedDumpRequest and DumpResult are two distinct objects") -- rather than a separately-computed preview. Readrun_dump_request,resolve_side_snapshotand siblings,perform_elf_dump(1999 lines),handle_non_elf_dump, andscan_engine._build_new_snapshotin full before concluding this.
Three independent, concrete reasons the full migration cannot be done soundly in one pass, each confirmed by reading the actual code rather than assumed:
dump --dry-runis not a projection of a resolved object today -- it is a hand-written second implementation.cli_dump_helpers. render_dump_dry_run()re-derives the resolved depth/collect-mode/ compile-DB-match/backend from the same raw inputsperform_elf_dumpreceives, independently, in its own function -- there is no sharedResolvedDumpRequesteither one builds from. Its own docstring is explicit about the scope this implies: "Cheap, read-only resolution only ... Never runs castxml/clang, a build query, or any I/O beyond stat()/PATH lookups" -- i.e. it is deliberately a cheaper, narrower re-implementation, not a dry pass of the same resolver the real run uses. Half-closed by the slice landed below:resolve_dump_requestnow IS a real "resolve without executing/writing" mode --service_dump_pipeline.resolve_dump_request/ResolvedDumpRequest, stopping before any castxml/clang invocation or write, exactly what this blocker originally said didn't exist. What remains open is purely the wiring:render_dump_dry_run()has not been migrated to build from a realresolve_dump_request()call in place of its own independent re-derivation -- the capability exists, nothing consumes it yet.perform_elf_dumphas real post-processing hooks with no equivalent inrun_dump_requestat all. After the primary header-AST/DWARF snapshot, it runs, in a carefully established order: the ADR-039 build-context collector (_attach_build_context), the G31service._attach_header_graphsecond pass (its own independent clang re-invocation and AST cache key), and the optionalABICHECK_CLANG_LAYOUT_TOOLclang-layout-tool attach -- each reusing the L3→L2-foldedeffective_compile_context(PR #782) and each with its own dedicated, hard-won correctness fixes recorded above in this same "Known gaps" section (findings 9, 10, 17, 18 on the L3→L2-fold entry alone).run_dump_requesthas no post-processing stage at all -- it returns whateverresolve_side_snapshotproduced. Routingperform_elf_dumpthrough it would mean either dropping these passes (a real snapshot-completeness regression) or adding an equivalent hook torun_dump_requestand re-verifying all four passes' already-fixed ordering/cache-key/flag-isolation bugs against the new call shape -- itself a project the size of this whole PR, not a rename.scan_engine._build_new_snapshothas scan-specific behaviorDumpRequestcannot express, and this file already documents two multi-round bug hunts in exactly this area that a rushed reroute would risk reopening. Its own-H old=PATH/-I old=PATHside-aware baseline handling (the twelfth/thirteenth/fifteenth findings on the L3→L2-fold entry above) decides, per comparison, whether the candidate's own L3-foldedcompile_contextmay be reused for the baseline parse or must fall back to the caller's unfolded one -- a decision inherently about two snapshots' relationship, whichDumpRequest(built for one input) has no field for. Forcing this through aDumpRequest-shaped call would need a new pair-aware concept alongside it, which is exactly the kind of designservice_ compare_pipeline.py's own module docstring already explains was deliberately kept out ofservice_input_resolution.py's per-input primitives ("The pair-shaped decisions deliberately stayed behind... neither means anything for a lone dump").
What landed instead, safely and independently of all three blockers
above: service_input_resolution._seeded_includes/
_seeded_compile_context -- the shared per-input primitive compare's
implicit-dump operand and dump's typed API (run_dump_request) both
already use -- ran the L2 include-dir seed and the P0.3 L3→L2
compile-context fold as two independent calls, each capable of running
buildsource.inline.collect_inline_pack(). That is the exact
self-deadlock shape already found and fixed, by name, for perform_elf_
dump/handle_non_elf_dump/scan_engine._build_new_snapshot in this same
section's L3→L2-fold entry (its "fifth finding"): a caller whose
sources/build_info genuinely needs the zero-config inferred
build-system query would have the include-dir seed's own inferred query
hold the deterministic build-dir lock until its cleanup runs --
deliberately deferred until after the L2 parse consumes the seeded dirs --
so the compile-context fold's own, separate inferred-query attempt would
contend on the identical lock. That fifth finding's fix,
buildsource.l2_seed.seed_includes_and_fold_compile_context(), was wired
into all three CLI-side resolvers at the time -- but never into
resolve_side_snapshot, the fourth, typed-API call site, which kept the
older two-call shape. resolve_side_snapshot never actually hit the
600s timeout (collect_mode is forced "off"/allow_inferred_build_
query=False here, matching every Tier-2 API caller's "never execute a
build system as a side effect" rule -- see _seeded_includes's own
docstring), so this was real, avoidable duplicated work and a real
divergence from the one shared primitive the other three call sites had
already converged on, not a live self-deadlock. Fixed by replacing the
two separate helpers with one, _seeded_includes_and_compile_context(),
which calls the identical seed_includes_and_fold_compile_context() the
other three sites already use. This is a genuine, if narrow, piece of PR
3A's actual convergence goal -- one fewer place a change to how an input
resolves can drift -- landed without touching any of the three blockers
above. Verified via the existing resolve_side_snapshot/
_seeded_compile_context test coverage in tests/test_header_compile_
context.py and tests/test_bazel_root_targets_l2_seed.py (both updated
for the renamed/merged function, not weakened), plus the full fast unit
suite and mypy/ruff clean on both touched modules.
What remained genuinely open at the time this paragraph was written,
and why forcing it further would have been reactive rather than sound:
all three blockers above, in full -- a "resolve without executing" mode
for run_dump_request, a post-processing hook perform_elf_dump's
second-pass attaches can plug into, and a pair-aware primitive scan's
baseline-reuse decision can express -- are each their own real,
multi-file design, not a follow-up edit to this same PR. Given the
density of prior review rounds already recorded against this exact code
(the L3→L2-fold entry above alone lists eighteen numbered findings,
several reverted-and-refixed), attempting any of the three under
continued session pressure risked reopening one of them, which is
precisely what this file's own "known gaps over risky reactive patches"
convention exists to avoid. Superseded for blocker 1 by the slice
below, landed the same day: resolve_dump_request now provides that
"resolve without executing" mode, so only the wiring (migrating
render_dump_dry_run() to build from it) remains open for blocker 1;
blockers 2 and 3 are still fully open, unchanged. See the plan doc's own
PR C section for a status note recording the same scope.
A second, narrow slice landed (2026-08-18): the first blocker's missing
primitive now exists, though nothing consumes it yet. service_dump_
pipeline.py gained ResolvedDumpRequest/DumpResult (additive
dataclasses) and split run_dump_request into resolve_dump_request()
(validation + evidence resolution, no castxml/clang, no write) and
execute_dump_request() (the actual resolve_side_snapshot call, the
dependency walk, the depth floor). run_dump_request itself is now a
literal composition of the two (execute_dump_request(resolve_dump_
request(request)).snapshot) and keeps its existing signature and return
type unchanged — no breaking-API decision needed, confirmed by two Codex
review rounds on the design before it was coded (see the plan doc's PR C
section for what those rounds caught: ResolvedDumpRequest and
DumpResult must stay genuinely distinct objects — a DumpResult
carrying a real storage result has, by construction, already executed, so
it cannot also be what a read-only --dry-run renders; and the achieved
effective depth belongs on DumpResult, not ResolvedDumpRequest, since
fold_dump_provenance_into_dict derives it from the completed snapshot
and a resolve-only object has none to derive it from). This closes only
the first blocker's missing capability, not the blocker itself:
cli_dump_helpers.render_dump_dry_run() is still the independent,
hand-written second implementation it always was — migrating it to build
from a real resolve_dump_request() call is unattempted, and blockers 2
and 3 (the post-processing hooks, the pair-aware scan baseline decision)
are both still fully open, for the identical reasons already given above.
Verified via new direct tests on the split itself
(tests/test_typed_dump_request.py::TestResolveExecuteDumpRequestSplit —
the resolve step never reaches resolve_input, the two-step path
produces the identical snapshot run_dump_request does, the depth floor
raises only at execute time, and DumpResult.effective_depth matches
_gated_source_label computed the same way fold_dump_provenance_into_dict
already does), the full existing test_typed_dump_request.py/
test_header_compile_context.py/test_clang_header_backend_integration.py
suites (unchanged, still green), the full fast unit suite, and
mypy/ruff clean on both touched files.
Re-investigated (2026-08-19): the dry-run migration (blocker 1) is
larger than "wire the renderer to resolve_dump_request()" —
dump_cmd has no DumpRequest to resolve in the first place, on
either branch. Read cli.py's dump_cmd in full, not assumed: it
never constructs a DumpRequest object anywhere (confirmed by grep —
no DumpRequest( call site exists in cli.py or cli_dump_helpers.py
today). Its real resolution path is two CLI-only helpers,
resolve_dump_collect_context/resolve_dump_compile_context
(cli_dump_helpers.py), computing collect_mode/header_backend/
includes/gcc_option_tokens directly off raw Click parameters,
entirely independent of service_input_resolution/
service_dump_pipeline.resolve_dump_request — and this is not a
dry-run-only path: those same locals feed the real dump() call a few
hundred lines later in the same function, on the non-dry-run branch.
So migrating render_dump_dry_run to build from a real
ResolvedDumpRequest is not an isolated renderer change: it requires
first constructing a DumpRequest from dump_cmd's ~30 CLI
parameters (matching Click's own parsing precisely — including the
_resolved_compile_context/_resolved_collect_mode/_resolved_
include_labels/_resolved_lang_explicit private hooks compare's own
ctx.invoke already threads through this same command for its
implicit-dump operand), and doing so for both branches at once, so
the preview and the real run cannot silently diverge the moment only
one of them migrates. That is blocker 2 restated from the other
direction: the real run cannot move to execute_dump_request()
without the post-processing hooks blocker 2 already names, and the
dry-run preview cannot honestly move to resolve_dump_request() alone
while the real run it previews keeps using a wholly different resolver
— a preview built from one resolver describing an execution built from
another would be strictly worse than today's "two independent
implementations, kept in sync by hand," since it would look
authoritative without being connected to what actually runs. Not
attempted here, for the same reason blockers 2/3 were not: a real,
cross-cutting redesign of dump_cmd's ~250-line resolution section
(cli_dump_helpers.py is already at its 2000-line AI-readiness hard
cap, so any new shared surface needs a sibling module, not an inline
addition), not a follow-up to the already-landed resolve_dump_request/
execute_dump_request split — that split remains real, additive
progress in its own right, just not yet consumed by dump_cmd.
Slice landed (2026-08-19, same session): service_input_resolution.
SideResolution/_resolve_side_snapshot_impl, plus two newly-found,
narrower blockers on the next step. _resolve_side_snapshot_impl
(the real implementation behind resolve_side_snapshot, now a one-line
wrapper) returns the P0.3 fold's own effective includes/
CompileContext — previously computed inside resolve_side_snapshot
and discarded after use — as a new SideResolution object;
service_dump_pipeline.DumpResult surfaces the same two fields,
populated by execute_dump_request. Purely additive, zero behavior
change for every existing caller, fully tested
(tests/test_typed_dump_request.py). This is real progress toward "one
shared primitive," but attempting the next step — routing
perform_elf_dump's primary parse through it — found the two are not
simply duplicate callers of one function: perform_elf_dump receives an
already-resolved debug_info_path from its caller (dump_cmd, via
_resolve_debug_artifact), while service._dump_elf (which
_resolve_side_snapshot_impl reaches through service.resolve_input)
has no such parameter at all and independently re-derives the
identical fact from raw debug_roots/enable_debuginfod/
debuginfod_url. Merging the two needs the two debug-artifact
resolutions confirmed equivalent first — a real, separate investigation,
not a follow-up edit. A parallel check of scan_engine._build_new_
snapshot (which already calls service.resolve_input directly, so it
doesn't share this particular ELF-pipeline divergence) found two
different, narrower blockers instead: it passes symbols_only/
debug_presence_only to resolve_input, which _resolve_side_snapshot_
impl never threads through yet (a straightforward additive gap); and
its embed_build_source call constructs public_headers differently
from embed_side_build_source's own construction (_expand_public_
headers over the combined headers+dirs list, vs. the shared wrapper's
separate, unexpanded treatment) — a genuine behavioral difference
needing reconciliation, not just a naming one. Neither was attempted
this session, per this file's own "known gaps over risky reactive
patches" convention, given this exact code area's extensive prior
history of exactly this shape of subtle divergence (18+ numbered
findings on the L3→L2-fold entry above). PR 3A's full convergence
remains open; PR 3C (the "PR F" removal of dump --build-query/
dump --build-compile-db) stays blocked on it, per the plan doc's own
explicit ordering requirement — see docs/contribute/plans/cli-cleanup-
phase-two.md's PR 3A section for the equivalent, fuller account.
Slice landed (2026-08-20): both "narrower blockers" above closed, plus
the debug-artifact-resolution question this entry raised confirmed
equivalent — no code change needed for that half. perform_elf_dump
has exactly one caller (dump_cmd, confirmed by grep), and dump_cmd
never sets symbols_only/debug_presence_only (dump has no such
flags at all — only scan/compare do), so _dump_elf's extra not
symbols_only and not debug_presence_only gate around its debug-artifact
resolution is vacuously true for every input perform_elf_dump can
actually pass it; the remaining textual differences (debug_roots vs.
list(debug_roots) or None, click.echo vs. notify, if artifact:
vs. if artifact is not None) are all behaviorally inert (the resolver
backends already do list(debug_roots or []) internally, and a
DebugArtifact instance is always truthy). symbols_only/
debug_presence_only now thread through resolve_side_snapshot/
_resolve_side_snapshot_impl into service.resolve_input, both
defaulting False so every pre-existing caller is unaffected — the same
additive shape as the existing changed_paths/allow_build_query
pass-throughs (tests/test_header_compile_context.py::
test_resolve_side_snapshot_forwards_symbols_only_and_debug_presence_only,
confirmed to fail pre-fix with TypeError: unexpected keyword argument
'symbols_only'). The public_headers construction divergence:
a fix was attempted and merged, then reverted the same day after
review caught a real regression it missed — kept here in full because
the first pass's reasoning was genuinely incomplete, not just
under-tested. The first pass read one consumer of embed_build_
source's public_header_roots (source_extractors._argv.
split_public_roots/_ClassifyContext.classify()) and confirmed a
directory root already classifies every file under it via segment/
prefix matching, so the _expand_public_headers-based expansion looked
purely redundant against that consumer — and switched
_build_new_snapshot's call to the simpler, unexpanded raw pass-through
embed_side_build_source already uses. That missed a second,
differently-shaped consumer of the same list:
clang_public_roots._equivalent_public_roots_for_unit, the
install-tree-vs-build-tree "mirror detection" heuristic L4 replay uses
when a public root names a physically different tree from the build's
own include dir. Its promotion rule is asymmetric by root shape: a
file root promotes on a single sampled match; a directory root
needs >= _PUBLIC_ROOT_WHOLE_DIR_MIN_MATCHES (2) matches before
promoting the whole directory — so a build include dir mirroring only
ONE header out of a larger public root loses that promotion entirely
once the directory stops being pre-expanded, confirmed by direct
reproduction against the function itself (three installed headers, one
mirrored in the build tree: expanded file roots promote it, a single
directory root promotes nothing). embed_side_build_source's own raw
pass-through (already shipped, used by compare/dump) carries the
identical weakness — not fixed here, since unifying either direction
changes real classification behavior for a real consumer, and deciding
which needs its own scoped design, not a same-PR revert-and-redo.
Reverted _build_new_snapshot's call back to the expanded shape and
pinned two regression tests: the call's own shape (tests/
test_scan_l2_cleanup_ordering.py::
test_scan_candidate_expands_public_header_dirs_before_embed) and the
underlying asymmetry directly against _equivalent_public_roots_for_unit
itself (tests/test_clang_public_roots_coverage.py::
test_equivalent_public_roots_promotes_on_single_match_only_for_file_roots),
so a future "simplify this like the other primitive" pass doesn't
silently reintroduce the same regression. Neither fix routes
_build_new_snapshot through _resolve_side_snapshot_impl itself —
they make that future migration safe, they don't perform it.
Investigating the migration surfaced one more wrinkle this entry hadn't
named: _build_new_snapshot's own allow_build_query gates only its
embed_build_source call, never its seed_includes_and_fold_compile_
context call (which always passes build_query=None, build_compile_
db=None — scan has no such CLI flags to begin with), whereas
_resolve_side_snapshot_impl's _gated_build_query_inputs gates both
from one shared decision; reconciling that needs confirming what
_build_new_snapshot's allow_build_query is actually meant to
authorize today, before the two functions' gating can be safely
unified. Blockers 4 (post-processing hooks) and 5/6 (dump_cmd building
a real DumpRequest; a pair-aware scan-baseline primitive) remain fully
open, unchanged from the notes above — see the plan doc's own PR 3A
section for the identical, fuller account.
Slice landed (2026-08-21): the ADR-039 collector gate is now one shared
function all three resolvers call, which closed a real dump-vs-scan
asymmetry nobody had named — and attempting blocker 5 next turned up three
concrete obstacles the notes above do not mention. Re-reading the three
resolvers side by side found that scan_engine._build_new_snapshot never
ran the ADR-039 build-context collector at all (the ELF dump CLI
always had; the typed pipeline gained it in PR #809), so scan --against
a dump-produced baseline compared a candidate with no
build_context_defines/conditional_fields against a baseline carrying
both — and the reconciler could clear a context-free header-parse false
positive (a #ifdef-guarded record field the context-free parse pruned)
on the baseline side but not on the candidate's. Fixed at the gate rather
than by writing a fourth copy of it:
header_conditionals.attach_build_context_for_parsed_headers now owns the
compile-DB resolution (from an already-resolved path or from
build_info), the best-effort header expansion a directory -H entry
needs, the snap.from_headers check, and the caller-supplied
live_elf_parse answer; perform_elf_dump,
_resolve_side_snapshot_impl, and _build_new_snapshot all call it.
perform_elf_dump gains from_headers in the process — the same gate its
own sibling parsed_with_build_context stamp ten lines above already
applies, for the recorded reason that a --dwarf-only run explicitly
ignores -H. A latent ordering bug was fixed alongside:
_resolve_side_snapshot_impl drained the L2 seed's cleanups only after
embed_side_build_source, so an inferred build query's lock was still
held when the embed ran its own — the self-contention this entry's own
fifth finding records for the CLI resolvers. Unreachable today (the seed's
collect_mode is pinned "off"), fixed now because it springs on
whichever PR relaxes that pin, which is what migrating the CLI resolvers
means. Tests: tests/test_scan_adr039_build_context.py (7 cases; the
three positive ones confirmed to fail against the pre-fix
scan_engine.py) and tests/test_typed_dump_request.py::
TestSeedCleanupsDrainBeforeTheEmbedStep (confirmed to fail pre-fix).
Blocker 5's three obstacles, each verified rather than assumed (full
account in the plan doc's PR 3A section): (a) InputSpec.path is a
required field and dump's source-only branch (dump --sources ./tree
with no SO_PATH) has no path, while --dry-run runs before that dispatch
— so "both branches build from one DumpRequest" is unreachable for that
shape until InputSpec can express "no binary", a public typed-API model
change reaching every consumer. (b) The two collect-mode resolvers
genuinely disagree, measured directly: they agree for every explicit
--depth, and the no-inputs case is unobservable, but --build-info
with no --depth is source-target on the CLI
(resolve_dump_collect_context) and build through the typed path
(collect_mode_for) — so taking the collect mode from
resolve_dump_request would silently stop a dump --build-info <pack>
at L3 that attempts L4 today. Which default is right is a product
decision, not a mechanical reconciliation. (c) The ELF dump CLI embeds
L3–L5 at write time (cli_buildsource._write_snapshot_output, together
with the G21.7 warning, the Flow-2 --inputs fold, the depth gate and
the provenance fold) while execute_dump_request embeds at resolve
time inside _resolve_side_snapshot_impl — routing the real run through
the typed executor embeds twice, re-running L4 replay, unless the
write path is restructured in the same change. Note what (b) implies for
a "just migrate the dry-run first" shortcut: it would report a collect
mode the real run does not use, which is worse than today's two
hand-synced implementations.
One more verified divergence, not fixed: scan's
embed_build_source call passes no extractor, taking that function's
"auto" default, and buildsource.inline._make_source_extractor treats
anything but a literal "castxml" as clang — while every other resolver
passes service_compare_evidence.effective_frontend(...), which resolves
"auto" to castxml (dumper._resolve_header_backend, no availability
fallback). So dump --depth source and scan --depth source over one
project at their defaults replay L4 through different extractors, and a
scan --against a dump baseline compares source-ABI facts from two
different tools — precisely what effective_frontend's own docstring says
it exists to prevent. Making scan match would newly require castxml for
a scan that works with clang today: a real behavior change for real users,
unverifiable without a castxml-capable environment, so it needs its own
slice rather than a same-pass patch. Re-checked 2026-08-21: castxml is
still absent from this environment, so this stays a documented gap rather
than a guessed fix.
Blockers 5 and 6 closed (2026-08-21, later the same day) — dump_cmd
now builds one real DumpRequest, and the pair-aware baseline rule lives
in one primitive. The real runs are deliberately still not migrated.
Blocker 5 was three sub-issues, each closed at the layer that had the
gap rather than at the call site that noticed it. (a) InputSpec.path is
now Path | None — a pure widening, so no existing caller changes — with
"which requests may leave it None" enforced once, per request type, in
validation_errors() (never for CompareRequest; for DumpRequest only
alongside real sources/build_info/dump_manifest), and
api_types.required_path as the single place the narrowing is spelled
rather than seven defensive call sites. The dump_manifest clause is
worth recording because it was found the right way round: a first revision
named only sources/build_info and broke dump --dump-manifest
m.yaml --dry-run (no SO_PATH), caught by the existing
tests/test_cli_dump_manifest.py — which is precisely the "the model
cannot express what the CLI accepts" gap the widening exists to close.
(b) The CLI-vs-typed collect-mode disagreement (--build-info with no
--depth: source-target on the CLI, build through the typed path)
is resolved in favour of the CLI's older, documented default, via a new
service_compare_evidence.dump_collect_mode_for. collect_mode_for is
unchanged — compare's own front end genuinely infers omitted depth
from its inputs, which is a different question, and changing it would have
been the easy wrong fix. Pinned by
tests/test_dump_collect_mode_parity.py against the real CLI resolver
over the whole (depth, sources, build_info) grid. (c) The write-time
embed is now idempotent: cli_buildsource.build_source_already_satisfies,
stated through the same _missing_requested_evidence_layers the
neighbouring G21.7 warning already trusts, so the guard and the warning
cannot disagree about what "satisfied" means; its pack is None -> []
case is deliberately not satisfaction, which is what keeps it a no-op
for today's CLI (tests/test_dump_embed_idempotence.py, including an
integration end-to-end count proving one real dump --depth source
embeds exactly once).
With those closed, abicheck/cli_dump_request.py builds one DumpRequest
from dump_cmd's parameters and --dry-run renders from a real
ResolvedDumpRequest. The half-migration hazard the plan names — "a
preview built from one resolver describing an execution built from another
is worse than two hand-synced implementations, since it looks
authoritative without being connected to what actually runs" — is answered
structurally, not asserted. The request is fed the CLI's
already-resolved values (compile context, frontend, explicit-language
decision) rather than re-deriving them, so it records the run; and the
fields the pipeline does derive independently are pinned equal to the
CLI's own by tests/test_dump_request_from_cli.py::
TestResolvedRequestAgreesWithTheCliLocals. Sub-issue (b) was a
prerequisite for exactly that: without it the preview would have reported
a collect mode the real run does not use. One user-visible consequence,
stated rather than left to be discovered: DumpRequest.validate()
front-runs dumper.dump()'s own runtime rejection of --dump-manifest
combined with -I, so that combination is now a usage error in the dry
run too — inside the dry-run contract, which permits usage errors.
Blocker 6 is service_input_resolution.BaselineReuseContext /
resolve_baseline_compile_context: the "may the candidate's folded
context also parse the baseline" rule, extracted from the four-clause
boolean inline in scan_engine.run_scan_core that the twelfth, thirteenth
and fifteenth findings above each had to correct in turn. run_scan_core
calls it today; _resolve_side_snapshot_impl accepts the same object as
an optional baseline_reuse_hint and reports the identical answer on
SideResolution.baseline_compile_context, so the migration that finally
routes _build_new_snapshot through the shared resolver inherits the rule
instead of reimplementing it a fourth time. Deliberately an opt-in hint,
not a widening of resolve_side_snapshot's single-input contract — a
caller that passes none is bit-for-bit unaffected. Given that correction
history, it is tested as a primitive rather than only through scan
(tests/test_baseline_reuse_context.py), per this file's own
"Primitive-level property tests" guidance: the contract as invariants,
the resolver-agrees-with-its-own-predicate property, and a pin that
include order matters (search order is first-match-wins, so a "compare
as sets" simplification has to argue with a test rather than pass
silently).
Still open, unchanged: neither real run routes through the shared
pipeline. dump executes through perform_elf_dump/
handle_non_elf_dump and scan's candidate through service.
resolve_input/embed_build_source directly. What blocks each is now
concrete rather than open-ended — the ADR-039 collector's CLI-only inputs
(--compile-db-filter, the raw effective_compile_db) need typed-API
representation, and _write_snapshot_output's provenance/--inputs/
depth-gate sequence needs reordering around a resolve-time embed — but
each is its own slice. PR 3C (removing dump --build-query/
--build-compile-db) therefore stays blocked, per the plan's own ordering
rule: moving those inputs into config while two resolvers still interpret
that config independently is the exact failure the three-way split exists
to prevent.
Both real-run migrations attempted and stopped (2026-08-21, later
session) — and the reason is now measured rather than reasoned about,
which changes what "still open" means here. The paragraph above says the
two resolvers are structurally separate. Comparing the written dump CLI
snapshot against execute_dump_request's, field by field, over a real
g++ build and a real clang L2 parse, shows they already produce
non-comparable snapshots for the same library from the same evidence:
everything agrees except the extraction contract, where the CLI records
macro_ops as [["D","FOO=1"],["D","FOO=1"]] against the typed path's
one entry, and include_sequence as [] against the typed path's one
slot. scope_fingerprint agrees; profile_fingerprint therefore differs
in exactly those two shapes. Both trace to one mechanism — the
dump CLI runs the legacy -p/--compile-db auto-match
(cli_helpers_compare._resolve_build_context_flags, merged into
effective_gcc_options) in addition to the P0.3 L3→L2 fold whenever
both are fed by the same --build-info compile database. The duplicate
-D is that overlap recorded twice; the empty include_sequence is the
legacy match supplying -I<dep> as explicit context before the L2 seed
runs, so seed_l2_includes correctly declines to seed it and the
directory reaches the parse through gcc_option_tokens, which contributes
no declared_includes slot — the sole source include_sequence
tokenizes. This is the dump-vs-typed-API half of the same "third,
deeper mechanism" the L3→L2-fold entry above already records for
dump-vs-scan.
Why that stops the migration rather than motivating it. Routing the
real run through execute_dump_request drops the legacy match, so the
migration does not merely need the two prerequisites named above — it
forces the design decision the L3→L2-fold entry says is open. Dropping
the legacy match is arguably right (the fold is strictly richer:
per-header matching, ambiguity checking, include paths, forced includes),
but it makes dump --compile-db-filter inert, and InputSpec
deliberately carries no compile_db_filter field — one was added and
removed in the same review round for having no successful execution path
(see that field's replacement comment in api_types.py). Making a
documented flag silently inert is worse than the gap. The ordering is
therefore three slices, not one: thread --compile-db-filter into the
shared fold (buildsource/l2_seed.py/header_compile_context.py), decide
and ship the legacy-match removal, then migrate the real run. The first
of those three landed in the same session — and it turned out to be a
user-facing bug in its own right, not merely migration plumbing:
resolve_header_compile_context's fail-closed ambiguity message names
--compile-db-filter as a way to narrow the input, but the filter reached
only the legacy match, so a user who followed that advice got the identical
error back. Reproduced end to end (dump --depth headers -H api.h
--build-info db.json --compile-db-filter a.cpp over two TUs disagreeing on
an ABI-relevant -D) and fixed by threading source_filter through
resolve_header_compile_context/l2_seed/perform_elf_dump, with the
matching rules consolidated into one shared
build_context.source_matches_filter so the fold, the legacy match and the
ADR-039 collector cannot select different translation units for the same
filter. A filter matching nothing keeps every unit — the conservative
fallback the other two layers already applied. Tests:
tests/test_compile_db_filter_scope.py (the primitive's contract as
invariants, the three layers agreeing, the resolver, and a real
g+++clang dump proving the guarded field is parsed in or out according
to which TU the filter names; the end-to-end cases confirmed to fail
pre-fix). Still open in that first slice: the typed half —
InputSpec.compile_db_filter plus the CLI's own
L2-filtered/L3-unfiltered refusal mirrored into
resolve_dump_request, which is where the resolved collect mode is known
(see that field's replacement comment in api_types.py). One
environmental fact independently rules out attempting the migration
itself in that session, whatever order the three slices land in: the
default header backend is castxml, and no working castxml was
obtainable — a hand-assembled conda-forge 0.7.0 build segfaults inside
clang::ParseAST on any input — so every measurement above is
clang-backend only, and migrating the real dump run while able to
exercise only the non-default backend is not a verified change.
The scan side is four items, not the two named above, and three of
them are behaviour changes rather than missing plumbing (read against
_resolve_side_snapshot_impl line by line): (1) the L4 extractor default,
unchanged and still castxml-blocked; (2) the public_headers expansion
shape, where the shared wrapper's raw pass-through is the one already
reverted for regressing clang_public_roots._equivalent_public_roots_for_
unit; (3) the seed's collect mode — _seeded_includes_and_compile_
context pins collect_mode="off" so a Tier-2 primitive never executes a
build system as a side effect, while _build_new_snapshot passes scan's
real one, so routing through the shared primitive silently removes scan's
ability to run the zero-config inferred build query in its seed; and (4)
defer_cleanup, which embed_side_build_source has no parameter for —
the only purely additive item of the four. Each of (1)–(3) could become an
opt-in parameter the way symbols_only/allow_build_query/
changed_paths already are, which is what a future slice should do;
reproducing a dozen parameter behaviours exactly on the hot path of every
scan, with the integration lane only partly executable, is the rewrite
shape this area's review history keeps punishing.
What landed instead: tests/test_dump_cli_typed_api_parity.py::
test_dump_cli_and_typed_api_agree_on_extraction_contract. Its sibling
compares ast_compile_args through split_compile_args'
semantics-preserving normalization, which is the right lens for "did both
paths reach the same compile" and structurally blind to both divergences
above — profile_fingerprint hashes the recorded fields as recorded, so
a difference normalization hides is still a comparability failure. The two
known-divergent (shape, field) pairs are encoded the same conditional-xfail
way _SCAN_KNOWN_DIVERGENT_SHAPES already is: the exact diagnosed
signature reproduces, or the test fails outright, so "the gap closed"
fails as loudly as "a new field diverged" and the mapping cannot go stale
silently. Verified in both directions.
The two divergences that test recorded are closed, scan's candidate
resolver is migrated, and the measurement itself turned up a second, larger
bug (2026-08-21, later session). The dump real run is still not migrated —
the reason is now two items, not open-ended.
The legacy-match overlap. The design decision the entry above left open is
made: when the P0.3 fold resolves a compile context for the headers being
parsed, it is the sole source of compile-database-derived context, and the
legacy -p/--compile-db auto-match's own derived flags are unfolded rather
than stacked on top of it. When the fold does not apply (no --build-info,
or a header no compile unit matches) the legacy match still runs and still
applies — only the overlap is dropped. The worry that ranked this second —
that dropping the legacy match makes --compile-db-filter inert — no longer
holds: the filter reaches the shared fold too, since the preceding slice
threaded source_filter through seed_includes_and_fold_compile_context/
resolve_header_compile_context. Where the conditional goes is the load-
bearing part: dump_cmd merges the legacy flags into effective_gcc_options
before calling perform_elf_dump, so that function now takes them
separately (legacy_build_context_flags) and hands the fold the caller's
own --gcc-options string as its explicit context. Presenting the legacy
result to the fold as though it were an explicit user choice is precisely
what recorded the same -D twice and routed a derived -I through
gcc_option_tokens instead of declared_includes. User-visible result, not
only a fingerprint tidy-up: scan --against a real dump baseline for the
extra--I shape goes from exit 6, NOT_COMPARABLE ... differing fields:
include_sequence to exit 0 for an unchanged library.
scan's candidate resolver. scan_engine._build_new_snapshot now builds
an InputSpec/SideEvidence and calls _resolve_side_snapshot_impl,
returning its SideResolution; run_scan_core hands the
BaselineReuseContext in at resolve time and reads
SideResolution.baseline_compile_context rather than recomputing it. The L2
seed, the parsed_with_build_context stamp, the ADR-039 collector gate, the
drain-before-embed ordering and the pair-aware baseline rule are inherited
from one implementation instead of written twice — each of which had already
needed its own separate correction on this path (findings 8/12/13/15 above,
and the round where scan turned out never to run the ADR-039 collector at
all). The four documented divergences are preserved as opt-in parameters
on the shared primitive, which is what the plan said a future slice should
do: seed_collect_mode, seed_lang_explicit, defer_cleanup,
source_extractor, expand_public_header_roots, source_frontend_compile.
The L4 extractor default therefore stays a documented gap — matching the
other resolvers would newly require castxml for a scan --depth source that
works with clang today, and castxml is still absent here. Equivalence was
measured, not argued: candidate snapshot, effective includes, effective
compile context and deferred-cleanup count, over three real build shapes ×
three collect modes, identical before and after apart from wall-clock
timestamps and the build-source pack's own content hash.
test_scan_engine_calls_the_shared_resolver was a source-text match on
run_scan_core; it is replaced by two behavioural pins through a real
scan --against.
The second bug, found by the verification bar rather than by a report.
Extending the parity measurement from the extraction contract to the whole
snapshot showed the two paths disagreeing on the L3–L5 payload, and not
cosmetically: the dump CLI recorded 0/2 symbols matched,
reachable_declarations=0, fact_family_states: empty-confirmed where the
typed path recorded 1/2 matched and a real source_decl_to_binary_symbol
mapping. cli_buildsource._write_snapshot_output's own embed_build_source
call passed no public_headers/public_header_dirs, so L4 replay ran
with an empty public_header_roots set — every declaration classifies
private and nothing links. Nothing fails: the layer is present and the
coverage row honestly says "partial", so every L4-derived source-ABI finding
was simply inert for a dump-produced baseline. Fixed on both the ELF and
PE/Mach-O paths. With it, the dump CLI's written snapshot and
execute_dump_request's agree on every field except wall-clock timings and
the CLI's own provenance layer (git_commit, version).
What still blocks the dump real-run migration — two items, both real.
Blocker 4 (post-processing hooks) is closed on measurement, not just on
reading: service.run_dump's ELF branch already runs every pass
perform_elf_dump does (SYCL, python_ext, python_api, numpy_capi, the
G31 header graph, the G28 clang-layout attach), the ADR-039 collector runs
inside _resolve_side_snapshot_impl, and the whole-snapshot comparison shows
no difference in any field those produce. What remains: (1)
--compile-db-filter would go inert — InputSpec deliberately carries
no compile_db_filter, so the shared path cannot narrow the fold or the
ADR-039 collector the way the native CLI does, and making a documented flag
silently do nothing is worse than the gap; the step is specified (add the
field, thread it into _seeded_includes_and_compile_context and
attach_build_context_for_parsed_headers, mirror the CLI's
L2-filtered/L3-unfiltered refusal into resolve_dump_request) but is its own
slice. (2) The default backend is still unexercisable here —
--ast-frontend defaults to castxml, none is available (re-checked), so
every measurement above is clang-only, and migrating the real dump run
while able to exercise only the non-default backend is not a verified change.
PR 3C stays blocked, per the plan's own ordering rule.
Item (1) closed (2026-08-21, later session): InputSpec.compile_db_filter
now exists, exactly as specified above — nothing more, nothing less.
service_input_resolution._seeded_includes_and_compile_context forwards it
as source_filter to seed_includes_and_fold_compile_context; the
attach_build_context_for_parsed_headers call two paragraphs down does the
same, so the fold and the ADR-039 collector agree on which translation
units the filter selects (the identical invariant
build_context.source_matches_filter already established for the three
CLI-side layers — see the root AGENTS.md's forced-include entry's
MSVC-driver-vocabulary lesson on why a second copy of a shared matching
rule is the wrong move). resolve_dump_request mirrors the CLI's own
compile_db_filter_scope_error refusal, computed from evidence.
collect_mode/evidence.headers — the same resolved values the CLI reads
compile_db_from_build_info back against — so a typed caller cannot reach
the L2-filtered/L3-unfiltered snapshot shape the CLI refuses outright; the
refusal raises ValidationError (translated to click.UsageError at the
CLI boundary by resolve_dump_request_for_cli, unchanged). dump_cmd
forwards its own --compile-db-filter local into build_dump_request, so
--dry-run's resolved object now records the same filter the real run
applies, closing the last gap in that request's own honesty contract for
this one field. Verified against the identical real g+++clang project
TestDumpCliHonorsTheFilterInTheFold already uses (two TUs disagreeing on
an ABI-relevant -D behind one #ifdef-guarded field), driven through the
typed DumpRequest/resolve_dump_request/execute_dump_request path
directly rather than the CLI: the scope-error refusal fires under the same
condition the CLI refuses under, a request with no filter is unaffected,
and the filter selects the same translation unit's context for the header
parse the CLI test already pins (tests/test_compile_db_filter_scope.py's
TestTypedApiHonorsTheFilterInTheFold). Confirmed the CLI's own behavior
is unchanged by re-running TestDumpCliHonorsTheFilterInTheFold directly.
Item (2) is unchanged and remains the sole blocker: castxml is still
unavailable in every environment this work has been done in, so the real
dump CLI execution path (perform_elf_dump/handle_non_elf_dump) still
does not route through execute_dump_request, and PR 3C stays blocked.
This slice narrows what item (2) alone is blocking, nothing more — it does
not migrate the real run, and does not claim to.
Two Codex review findings on the same slice, both real, both fixed
before merge. (P2) InputSpec.of() — the documented loose-value
convenience factory every front end other than a direct dataclass
construction uses — never gained a compile_db_filter parameter, so
InputSpec.of(..., compile_db_filter=...) raised TypeError for an
unrecognized keyword: the field was reachable only by constructing
InputSpec directly, despite being advertised as public typed-API
surface. Fixed by adding the parameter and forwarding it through
unchanged. (P1) The scope-error guard above was wired into
resolve_dump_request only — but InputSpec.compile_db_filter is shared
by CompareRequest.old/.new too, and resolve_compare_request reaches
the identical resolve_side_snapshot primitive (the P0.3 fold narrows,
embed_side_build_source still collects L3 unfiltered), so a typed
CompareRequest side could reach the exact L2-filtered/L3-unfiltered
snapshot shape the guard exists to reject, with no check catching it. Fixed
by extracting the guard into a shared function,
service_compare_evidence.reject_compile_db_filter_scope_mismatch (mirrors
reject_debug_format_for_binaries's existing (label, ...) per-side
shape), called from both resolve_dump_request (input) and
resolve_compare_request (old/new) — one guard, not two independently
drifting copies. Regression coverage: tests/test_compile_db_filter_scope.py's
test_input_spec_of_forwards_compile_db_filter and
TestCompareRequestAppliesTheSameScopeGuard (the latter, like its
DumpRequest sibling, verified against the identical real g+++clang
project), plus a re-run of every pre-existing test in this area to confirm
the extraction changed no behavior.
Three further Codex review findings on the same slice, each real, each
fixed, and each a pre-existing gap in the native CLI's own scope check
too (not introduced by this typed-API slice). (P1) The guard's compile-
database resolution considered only an explicit --build-info/
build_info — a --sources/sources tree with no build_info at all
can still have its compile_commands.json auto-discovered
(buildsource.inline._autodiscover_compile_db, the identical P4 strategy
the fold and the L3 embed both already use to find one from sources
alone), so that combination reached the same filtered-L2/unfiltered-L3
mismatch uncaught. Reproduced directly: a real two-TU project resolved
with sources only, filtered to one TU at L2, still embedded both TUs'
compile units as L3 evidence (BuildEvidence.compile_units length 2).
(P1) Even with an explicit --build-info <dir> given, the resolution
checked only <dir>/compile_commands.json directly — not a conventional
out-of-tree build subdirectory (<dir>/build/compile_commands.json) the
real fold's own --build-info resolution
(buildsource.inline._compile_db_at, delegating to
_find_compile_db_in_dir for a directory) already searches, explicitly
documented as matching --sources auto-discovery's own contract.
Reproduced directly: the identical project with its database moved into
a build/ subdirectory, --build-info pointed at the project root — the
fold correctly resolved and filtered by the nested database while the
guard never fired. Both fixed in one place,
header_conditionals.compile_db_for_filter_scope_check (deliberately
not folded into compile_db_from_build_info itself, which also
drives the CLI's unrelated legacy -p auto-match and must stay
--build-info-direct-child-only — see that function's own docstring),
consumed by both cli.py's dump_cmd and the shared typed guard.
Regression coverage: TestScopeGuardCoversSourcesOnlyAutoDiscovery and
TestScopeGuardCoversNestedBuildInfoDatabases in
tests/test_compile_db_filter_scope.py (three entry points each — CLI,
DumpRequest, CompareRequest — plus a positive control for the nested
case confirming the database is genuinely what gets filtered).
A fifth finding, investigated and deliberately left as a documented gap
rather than fixed reactively — the point at which these findings stopped
converging on real, reachable bugs. execute_dump_request()/
_resolve_side_snapshot_impl() also accept a keyword-only
build_compile_db (a glob, mirroring --build-compile-db), forwarded
unfiltered to both the L2 fold and the L3 embed the same way
build_info/sources are — in principle the identical mismatch class.
But build_compile_db is not a field of DumpRequest/InputSpec at
all: it exists purely as scaffolding for the not-yet-landed PR 3A
real-run migration (this module's own docstring: "the real ELF/PE/
Mach-O run still executes through perform_elf_dump/
handle_non_elf_dump, not through execute_dump_request"), and
execute_dump_request has exactly one caller in the whole codebase —
run_dump_request, which never passes it. No CLI, no typed-API path, and
no test can reach this combination without bypassing the entire
DumpRequest-shaped public surface and hand-calling the semi-internal
execute_dump_request with a kwarg nothing in that surface can set —
a different reachability class from the four findings above, each
reproduced end-to-end through real, ordinary usage before being fixed.
Left for whichever change gives build_compile_db its first real caller
(i.e. the PR 3A real-run migration itself) to close alongside that
migration, rather than shipping validation code with no real path to
verify it against.
A sixth finding (Codex review, fresh evidence) reopened convergence:
the guard's original compile_db_from_build_info-only check covered a
literal compile database and nothing else, but the fold it guards
(resolve_header_compile_context/filter_units_by_source) narrows
whatever BuildEvidence.compile_units a --build-info resolves to,
regardless of shape. A --build-info naming a pre-captured collect
pack directory (is_pack_dir) or a Bazel aquery/cquery jsonproto
resolves compile units the identical way a literal compile database does
— both are routed through their own adapters
(buildsource.inline._maybe_collect_bazel_build_info/pack loading), not
load_compile_db() — and the L3 embed collects that same BuildEvidence
unfiltered either way, so the mismatch reproduced for both shapes with no
error, purely because neither is a compile_commands.json file
compile_db_from_build_info recognizes. Fixed: compile_db_for_filter_
scope_check now also recognizes a --build-info that sniff_build_info_
format (the same cheap, execution-free classifier compile_db_from_
build_info already uses, so the two cannot disagree) reports as "pack"
or "bazel_aquery"/"bazel_cquery", returning the --build-info path
itself as the guard's non-None signal (the guard only ever checks
is None, so this needs no literal compile-database content).
compile_db_filter_scope_error's docstring, which had explicitly claimed
the opposite ("a pack or Bazel jsonproto routes through a different
adapter" → None, i.e. by design out of scope), was corrected alongside
the fix — that claim was the bug's own design rationale, not a separate
error. Still not covered, and not attempted here: a --sources tree
with no discoverable compile_commands.json at all, resolved instead
through the zero-config inferred build-system query (cmake/make/bazel).
Unlike the pack/Bazel-jsonproto case, telling whether that combination
would actually resolve multiple compile units means running the build
system's own query — the exact side effect this cheap, read-only scope
check exists to avoid paying twice per invocation (once to check, once for
real) — so this residual is documented rather than guessed at, per this
file's own "known gaps over risky reactive patches" convention. Regression
coverage: TestScopeGuardCoversPackAndBazelBuildInfo in
tests/test_compile_db_filter_scope.py (the predicate directly — pack
directory, both Bazel jsonproto shapes, both positive and negative
controls, plus a plain non-pack directory and a non-Bazel JSON-object file
confirmed to still resolve None; five of nine cases confirmed to fail
against the pre-fix guard).
A seventh finding (Codex review, fresh evidence) on the same guard:
the sixth finding's fix only recognized a pack named by --build-info,
but buildsource.l2_seed._l2_seed_pack_inputs folds a --sources pack
(a classic BuildSourcePack or a Flow-2 abicheck_inputs/ directory)
into L2 seeding the identical way — carrying its own normalized
BuildEvidence in — whenever no --build-info was given at all (an
explicit --build-info always wins L3, matching that function's own
if build_info is None: gate on the assignment). A --sources naming
such a pack, with no --build-info, reproduced the identical mismatch:
the guard's fallback resolution (compile_db_from_build_info then
_autodiscover_compile_db) only ever looks for a literal
compile_commands.json inside sources, which a pack directory does not
carry at its root — so it silently resolved None and let the mismatch
through. Fixed by recognizing a sources pack the identical way
_l2_seed_pack_inputs does (is_pack_dir / inputs_pack.is_inputs_pack),
gated on build_info is None to match that function's own precedence
exactly — an explicit --build-info (even one that itself resolves to
nothing recognizable) still means the sources pack's evidence is never
folded into base_build, so the guard must not treat it as filterable
evidence in that combination either (pinned by its own regression test).
Regression coverage: TestScopeGuardCoversSourcesPacks in
tests/test_compile_db_filter_scope.py (a classic pack and a Flow-2
inputs pack named by sources, the scope error firing, the
build_info-takes-precedence control, a no-filter control, and a plain
non-pack sources directory still falling through to ordinary
auto-discovery; three of six cases confirmed to fail against the pre-fix
guard).
An eighth finding (Codex review, fresh evidence) — not a missing case
this time, but a genuine false positive the seventh finding's fix
introduced. That fix restructured the function so every branch fell
through unconditionally to the sources-based checks once none of the
build_info branches matched — including the case where build_info
was genuinely given but resolved to nothing recognizable. That is wrong:
buildsource.inline._resolve_compile_db — the real function every one
of these seeded resolvers (collect_inline_pack, in turn called by
seed_includes_and_fold_compile_context/embed_build_source alike)
ultimately calls — tracks explicit_input_missed and returns None as
soon as a given --build-info misses, deliberately, per its own
comment: "surface that miss rather than masking it with a stale
auto-discovered DB ... checked BEFORE auto-discovery." So an explicit
--build-info that doesn't resolve means neither the real L2 fold nor
the L3 embed ever falls back to a sources-discovered database — falling
back in the guard (the post-seventh-finding behavior) produced a false
positive: rejecting a --compile-db-filter combination the real
resolvers wouldn't actually apply to either side of, a usage error for a
perfectly safe invocation. Fixed by returning None immediately once the
build_info is not None branch exhausts its own checks, before ever
reaching the sources-based fallbacks — matching _resolve_compile_db's
own precedence exactly. Regression
coverage: TestScopeGuardDoesNotFallBackToSourcesWhenBuildInfoMisses in
tests/test_compile_db_filter_scope.py — a pure-predicate case (an
unresolvable build_info alongside a sources tree carrying a real,
auto-discoverable compile_commands.json, confirmed to fail against the
post-seventh-finding code) plus a positive control against the real
g++/clang project fixture, confirming the guard doesn't reject a genuinely
safe combination.
A ninth finding (Codex review, fresh evidence): the third
under-coverage's own pack recognition for --build-info (the sixth
finding above) only ever checked is_pack_dir — a classic
BuildSourcePack — never inputs_pack.is_inputs_pack, the Flow-2
abicheck_inputs/ shape. _l2_seed_pack_inputs recognizes both shapes
for build_info identically (is_pack_dir(build_info) or
_is_inputs_pack_dir(build_info)), and embed_build_source's own
bi_is_inputs check embeds a Flow-2 build_info pack the same way — so a
--build-info naming a Flow-2 pack reproduced the identical mismatch,
missed only because the sixth finding's fix carried over is_pack_dir
without its Flow-2 sibling, even though the seventh finding's fix
(--sources packs) already checks both. Fixed by adding or
is_inputs_pack(build_info) to the same branch. Regression coverage:
TestScopeGuardCoversPackAndBazelBuildInfo::
test_flow2_inputs_pack_named_by_build_info_is_recognized in
tests/test_compile_db_filter_scope.py, confirmed to fail against the
pre-fix guard.
A real regression the scan-migration paragraph above introduced, found
by Codex review and fixed the same session (2026-08-21): scan --config
<path> silently lost the config's own passive settings whenever the
config declared no build.query — the common case, not an edge one.
_resolve_side_snapshot_impl's shared build_config/build_query gate
(_gated_build_query_inputs) blanket-nulls build_config unless
allow_build_query is exactly True, a default sized for dump/
compare's typed API (no CLI-side consent step of its own, so mere
presence cannot be trusted). Migrating scan's candidate resolution onto
this same primitive routed it through that gate too — but scan's own
consent gate, cli_scan_helpers.resolve_effective_allow_query (ADR-037
D4 "level-implies-query"), only ever answers True when the config
itself declares an executable build.query key AND an explicitly-pinned
deep evidence level; it was never meant to answer whether the config may
be read at all. build_config's own query field is already, correctly,
gated downstream regardless of this local gate — collect_inline_pack's
presence-based build_config_trusted_for_query, computed independently by
both of this gate's callers (l2_seed._resolve_l2_seed_pack_args,
cli_buildsource.embed_build_source) since before this migration existed.
Confirmed against scan's own pre-migration source (commit c3f6add):
build_config was always forwarded ungated to both
seed_includes_and_fold_compile_context and embed_build_source,
trusting exactly that downstream gate; allow_build_query was a separate,
already-documented-dead-in-the-True-direction parameter that never
gated build_config's presence at all. Fixed with a new opt-in parameter,
build_config_locally_trusted (threaded through _gated_build_query_
inputs, _seeded_includes_and_compile_context, and
_resolve_side_snapshot_impl), defaulting False so dump/compare's
typed-API contract is completely unchanged; scan_engine.
_build_new_snapshot passes True, restoring its exact pre-migration
behavior. build_query — the bare, always-executable command string, with
no downstream gate of its own — stays fully gated by allow_build_query
regardless of this flag either way. Regression coverage:
tests/test_gated_build_query_inputs.py (primitive-level tests on the
gate itself, plus one end-to-end test on scan_engine._build_new_snapshot
proving build_config survives even when allow_build_query is falsy;
5 of 8 cases confirmed to fail against the pre-fix gate).
A build-source pack's replay scope ("changed" vs "target") is not
recorded anywhere, so the write-time idempotence guard cannot distinguish
them — investigated (CodeRabbit review), not fixed; currently unreachable
by any real caller, which is why this stayed a documented gap rather than
a same-session patch. _missing_requested_evidence_layers() maps an
ADR-033 collect mode to its expected layer set (CI_MODE_TO_LAYERS) and
checks only whether each layer's embedded payload is non-empty — it never
reads collection_for_ci_mode()'s other return value, the replay scope.
source-changed (only affected TUs replayed) and source-target (the
full target) map to the identical layer set ("L3", "L4", "L5"), so in
principle a pack built under source-changed — non-empty because some
TUs were affected — could read as satisfying a later source-target
request through build_source_already_satisfies(), the write-time
check-before-embed guard PR 3A blocker 5 sub-issue 3 added (see above).
Traced why this is not reachable today: that function has exactly one
caller (_write_snapshot_output's guard), which runs on snap.build_
source before any embedding has happened for the current dump
invocation — the only other snap.build_source = ... assignment anywhere
in the codebase is embed_build_source()'s own, which this guard exists
to gate — so snap.build_source is always None entering the guard for
the one real caller, and the function is unconditionally a no-op today
exactly as its own docstring already states. It exists for the future
migration that routes dump's real execution through
execute_dump_request (still blocked, see above); in that migration both
the resolve-time and write-time embeds would receive the same resolved
collect_mode for one invocation, not two different ones, so this
specific scope mismatch doesn't arise from that path either. The deeper
gap the finding surfaces is real independent of this predicate, though:
BuildSourceManifest has no field recording replay scope at all —
pack.manifest.inputs (buildsource/inline.py) only ever records
{"sources", "build_info", "collected"}, never "changed" vs "target".
A correct fix needs a new manifest field threaded through every
pack-producing call site (collect_inline_pack and its Bazel/compile-DB
siblings) plus a scope-aware read in _missing_requested_evidence_
layers, with its own regression coverage for a genuine scope-narrowing
scenario — a real, if currently latent, data-model gap, not a one-line
fix to this one predicate.
A real regression caught post-merge by CI on this same branch
(2026-08-21), traced to the fb688cb dump-side fix above interacting
with a pre-existing, differently-scoped scan default — a live
tests/test_dump_scan_l3_comparability.py end-to-end test (added on
main by an unrelated, earlier PR) went from passing to failing the
moment this branch merged the base back in, git bisect-isolated to
exactly the dump-side write-time-embed fix. That fix made dump's
written baseline correctly link its L4 declarations to the binary's
exported symbols for a project whose only -H input is a lone header
file (no directory, no --public-header-dir) — but scan's own
candidate resolution, unchanged by that fix, still derives its L4
public_header_roots from cli_scan_baseline._public_provenance_set,
which deliberately returns an empty root set for exactly that shape (a
lone file cannot establish a public directory boundary — a real,
separately pinned contract, test_lone_file_does_not_activate, unrelated
to and predating this PR). Before the dump-side fix, both sides degraded
to zero L4 matches symmetrically, so nothing was ever reported; after it,
only the dump baseline matched, and the asymmetry itself read as a real
source_decl_binary_symbol_mismatch/source_to_binary_mapping_changed
RISK finding on an unchanged library. Confirmed base-red-negative (the
identical test passes on plain main) and confirmed not a
merge-interaction artifact (it already reproduces on this branch's own
tip before merging main back in) before attempting a fix. Considered
and rejected: widening _public_provenance_set itself (would also
silently flip the L2/crosscheck-origin classification — and its skip/
present status for exported_not_public/private_header_leak/etc. —
every other scan invocation of this shape already relies on, a far
broader behavior change than this fix needs, and it would break that
helper's own pinned unit test). Fixed narrowly instead:
service_input_resolution.embed_side_build_source gained
l4_public_headers/l4_public_header_dirs, an override pair for that
one call's root set, defaulted to the existing public_headers/
public_header_dirs for every pre-existing caller (so compare/dump's
typed pipeline are bit-for-bit unaffected). scan_engine.
_build_new_snapshot now computes a second, wider root set via the same
split_public_header_inputs dump's own fix already uses (unioned with
the narrower, provenance-derived set, not replacing it — an explicit
--public-header-dir must still reach L4 even when it isn't itself
derivable from the raw -H list) and passes it through this new
parameter — L2/crosscheck-origin classification is completely untouched.
Regression coverage: a new direct unit test on _build_new_snapshot
itself, tests/test_scan_l2_cleanup_ordering.py::
test_scan_candidate_widens_l4_roots_with_a_lone_header_file (confirmed
to fail against the pre-fix code), alongside restoring the pre-existing
end-to-end integration test to green. One sibling, pre-existing test
(test_scan_candidate_expands_public_header_dirs_before_embed) used a
nonexistent placeholder -H path purely as an unrelated fixture detail;
once headers started contributing to the same L4 set, that placeholder
made expand_public_header_inputs's best-effort expansion degrade to a
raw pass-through for everything (a real, if narrow, generalization of
that same best-effort-degrades-on-any-missing-path behavior) — fixed by
emptying that test's own headers list, since its actual subject is
public_headers/public_header_dirs's own expansion, not headers's.
ADR-063 Phase 1 (docs/contribute/plans/one-semantic-pipeline.md,
"finish the dump/scan typed-API convergence") re-investigated this
entry's still-open blocker 2 with castxml genuinely available in the
investigating environment (a solver-resolved conda-forge install, not the
hand-assembled 0.7.0 build the plan's Design section found segfaulting) —
so the environmental precondition for full option (a) convergence no
longer blocks. One real, safely-landable slice of blocker 1 closed for
real (cli_dump_helpers.render_dump_dry_run now takes the real
ResolvedDumpRequest resolve_dump_request_for_cli already produces —
so_path/headers/sources/build_info/depth/collect_mode/
header_backend/dump_manifest are all read off it, not re-passed as
fifteen independently-threaded primitives — verified against
test_dump_cli_typed_api_parity.py -m integration, 16/16 green. That
acceptance-gate file itself is clang-only, not evidence of castxml
coverage**: every one of its subprocess invocations hard-codes
--ast-frontend clang, not parametrized by backend at all (confirmed —
pytest tests/test_dump_cli_typed_api_parity.py -m integration -k
castxml selects zero tests). What castxml's newfound availability
separately confirmed is broader but different: the wider integration
suite's own castxml-specific cases (pytest tests/ -m "integration and
not slow" -k castxml) are 38/38 green with only the two pre-existing,
unrelated xfails — real evidence abicheck dump --ast-frontend castxml
itself works end to end in this environment, not evidence this
particular parity file exercises it. Field-level parity between the two
paths was already closed
before this phase started -- _CONTRACT_KNOWN_DIVERGENT_FIELDS and
_SCAN_KNOWN_DIVERGENT_SHAPES in that test module were both already
empty -- so there was no xfail-gated shape left for this phase to flip;
confirmed empty both before and after this phase's change.
Blocker 2 (the post-processing hooks) does NOT close, and the reason is
independent of which AST backend is available, so obtaining castxml did
not remove it. Re-read perform_elf_dump end to end (not skimmed)
looking specifically for whether its first try block (the primary
seed_includes_and_fold_compile_context + dump() call) could be
replaced by a call to execute_dump_request(), keeping the second try
block's post-processing hooks (the ADR-039 collector's own explicit
second call, the header-graph attach, the clang-layout-tool attach)
unchanged as hooks applied to the returned snapshot. Two sub-findings,
each confirmed against the real code, not assumed:
- (Not actually a blocker — investigated and ruled out.) The ADR-039
collector (
attach_build_context_for_parsed_headers) already runs a second time, unconditionally, inside_resolve_side_snapshot_implitself (PR C's own shared-gate work wired it in there too). Calling it a third time fromperform_elf_dump's own existing second block — which is what "keep the hook, route the primary parse" would produce — is safe:attach_build_contextassignssnap.build_context_defines/conditional_fields, it never accumulates, so a second identically-scoped call is idempotent, andparsed_with_build_contextis only ever setTrue, never reset toFalse, so a redundant second stamp cannot regress it. Similarly,scope_header_dirs(a parameterperform_elf_dump's owndump()call passes that_dump_elf, reached viaexecute_dump_request, does not) turns out to be provably redundant withresolve_dump_ request's ownpublic_header_dirs(both are derived from the identicalsplit_public_header_inputs(headers)call, anddump()unions them for the extraction contract) — so this is not a real divergence either, just a vestigial second computation of the same set of directories. - (A real, structural blocker, confirmed by reading the code, distinct
from anything the Design section named.)
dump_cmd's legacy-p/--compile-dbauto-match (cli_helpers_compare. _resolve_build_context_flags, usingbuild_context_for_header/build_context_union_fallback— a completely different code path from the P0.3 L3->L2 fold'sseed_includes_and_fold_compile_context) is computed indump_cmdafterresolve_dump_request_for_clialready built theResolvedDumpRequest(_resolved), on the real-execution branch only, never on the typed-request-building path at all. Its result (effective_gcc_options/effective_compile_db/compile_db_context_matched) is whatperform_elf_dump's owneffective_gcc_optionsparameter already carries into its primarydump()call -- and per this same entry's earlier "legacy-match overlap" fix, that legacy match's derived flags are the sole source of compile-database-derived context whenever the P0.3 fold does not independently match the same header (the fold's result wins and supersedes it whenever the fold does match — already the caseeffective_gcc_options/l3_context_applied's reassignment inperform_elf_dumphandles).resolve_dump_request/_resolve_side_snapshot_implhas no equivalent call to_resolve_build_context_flagsanywhere --DumpRequest.input.compileonly ever carries the CLI's own explicit--gcc-options, never the legacy match's derived flags. So routingperform_elf_dump's primary parse throughexecute_dump_request()as-is would silently drop real, still-live, still-documented (dump --build-query/--build-compile-db/-p/--compile-dbare explicitly not yet removed — PR 3C is gated on this same convergence closing first) compile-database-derived flags for exactly the headers the P0.3 fold itself does not match — a real regression, not a refactor, for any project relying on that fallback. Closing this for real needs the legacy match's computation moved earlier (beforeresolve_dump_ request_for_cliruns) and threaded into theDumpRequest/CompileContextthe resolved object carries, so the typed pipeline sees it too — a genuine, separate design change to the request- building sequence (which field absorbs the legacy match's derived, not user-typed, flags, and whether that blursDumpRequest's documented "records the run, not a second opinion about it" contract), not a same-session drive-by fix. Not attempted here — recorded so a future attempt starts from this precise mechanism instead of re-deriving it, per this file's own "known gaps over risky reactive patches" convention.
Net effect on this phase's own scoping: full "route perform_elf_dump
through execute_dump_request" (Design section item, this entry's
original blocker 2) remains unattempted for the reason above -- this was
never actually gated on castxml availability the way the Design section's
own item 2 implied; that item's "(b) scope to clang, castxml tracked as
residual" framing turned out to describe the wrong axis. What castxml's
availability did let this phase newly verify -- run against the wider
integration suite, not the clang-only acceptance corpus itself (see
above) -- is that abicheck dump --ast-frontend castxml genuinely works
end to end in this environment today, closing the environmental
uncertainty the Design section's segfault finding had left open. The
acceptance corpus's own field-level parity (_CONTRACT_KNOWN_DIVERGENT_
FIELDS/_SCAN_KNOWN_DIVERGENT_SHAPES empty) remains verified for clang
only, exactly as it was before this phase -- extending that specific
corpus to also parametrize over castxml is real, still-open follow-on
work this phase did not attempt.
Update (2026-08-29): the legacy-match threading half of blocker 2 is now
closed; routing perform_elf_dump itself through execute_dump_request
is still open. This session re-read the exact mechanism the entry above
names (cli_helpers_compare._resolve_build_context_flags's legacy
-p/--compile-db auto-match having no equivalent inside
resolve_dump_request/execute_dump_request) and closed the piece that
was safely landable without also restructuring perform_elf_dump's own
try/except/cleanup structure in the same change:
execute_dump_requestgained a new, purely additivelegacy_compile_db_tokens: tuple[str, ...] = ()parameter, threaded down throughworkflows.artifact.execute._resolve_side_snapshot_implintoworkflows.artifact.resolve._seeded_includes_and_compile_context— the exact same "optional pass-through, defaulted to a no-op, that exists only fordump's still-live CLI legacy flags" patternbuild_config/build_query/build_compile_dbalready established on these same three functions (PR 3A). A caller that already computed the legacy match's own derived flags (exactly whatdump_cmdalready does via_resolve_build_context_flags, unchanged) can now thread them through the typed pipeline and have them actually reach the real L2 header-AST parse (service.resolve_input'scompile=argument).- Precedence preserved exactly, mirroring
perform_elf_dump's own "legacy-match overlap" fix this entry already documents: the tokens are merged into the resolvedCompileContext.gcc_optionsonly when the P0.3 fold's ownappliedcame backFalsefor a given header — when the fold does apply, its own result is used verbatim and the legacy tokens are discarded rather than stacked on top, verified by a dedicated precedence test (see below) that pins the mergedgcc_options/gcc_option_tokensstring as byte-identical between "no legacy tokens" and "legacy tokens given" when the fold applies. - The merge helper (
_fold_legacy_compile_db_tokens) is a small, independent 3-line reimplementation ofcli_helpers_compare._merge_gcc_options's ordering (legacy flags prepended ahead of any existinggcc_options), not an import of that function —workflows/artifact/resolve.pyis an engine-layer module underscripts/check_ai_readiness.py'sengine-cli-boundarycheck, which forbids importing acli_*sibling (that module itself importsclick). Confirmed viacheck_ai_readiness.py: zero newengine-cli-boundaryfindings. - Verified end to end against a real
g++build + realcompile_commands.json- real clang L2 parse, not only the merge helper in isolation
(
tests/test_legacy_compile_db_typed_threading.py, 4/4 green): a compile unit whose source text does not#includethe public header at all is exactly the shape where the two mechanisms provably disagree —header_compile_context.resolve_header_compile_context(the P0.3 fold) returnscontext=Nonewith no union fallback (confirmed by reading its own docstring: "no header the givenCompileUnits reference" degrades to nothing, full stop), whilebuild_context. build_context_for_header(the legacy match) falls back tobuild_context_union_fallback, which still merges the compile database's-Ds and still setscompile_db_path(so_resolve_build_context_flags's ownmatchedcomes backTrue). One test proves the real CLI already sees the union-fallback define (the fixed point to reproduce); one proves the typed path does not see it with the new parameter absent (proving the gap this closes was real, and that the new parameter is genuinely opt-in rather than silently changing existing callers' behavior); one proves the typed path does see it, byte-identically to the CLI, once the CLI's own already-computed_resolve_build_context_flagsoutput is threaded throughlegacy_compile_db_tokens; one proves the fold-wins precedence holds when the fold does apply.
- real clang L2 parse, not only the merge helper in isolation
(
What is explicitly still open, and why this session did not attempt
it: perform_elf_dump's primary parse (the first try block --
seed_includes_and_fold_compile_context + dump()) does not yet call
execute_dump_request() — the real dump CLI's ELF/PE/Mach-O run still
executes through cli_dump_helpers.perform_elf_dump/handle_non_elf_dump
exactly as before, so dump_cmd does not pass legacy_compile_db_tokens
anywhere yet (there is nowhere in it that calls execute_dump_request to
pass it to). Closing that remaining piece needs restructuring
perform_elf_dump's own try/except/ResolvedArtifactPlan cleanup
handling to delegate to execute_dump_request() while preserving its
second try block's post-processing hooks (the ADR-039 collector's own
second call, the header-graph attach, the clang-layout-tool attach) as
hooks applied to the returned DumpResult's snapshot — this entry's own
earlier sub-finding 1 already confirmed that keeping those hooks as a
second pass is safe/idempotent, so the remaining work is purely the
control-flow restructuring itself, not a new correctness question. Given
this exact code area's own history in this entry (18+ numbered findings
on the adjacent L3->L2-fold alone, several reverted-and-refixed), that
restructuring was deliberately left as its own, separately-reviewable
slice rather than folded into this one — consistent with how every prior
slice in this entry was landed one at a time. cli_dump_request.py's own
module docstring and service_dump_pipeline.execute_dump_request's
docstring both point back here for exactly what remains.
Correction (2026-08-29, same day, Codex review on PR #935): the
threading above landed with a real bookkeeping gap of its own, now
fixed. Folding the legacy tokens into the resolved CompileContext
(sub-finding 2 above) updated gcc_options but left the function's
returned applied boolean — the exact signal _resolve_side_snapshot_
impl gates AbiSnapshot.parsed_with_build_context on — untouched at
False whenever the P0.3 fold itself did not match. Confirmed by reading
the actual gate (workflows/artifact/execute.py's if context_applied
and snap.from_headers: snap.parsed_with_build_context = True): a typed
dump relying purely on the legacy-match fallback would have parsed real
compile-database context and then still reported it as absent — wrongly
triggering the header_parse_context_drift/header_build_context_
mismatch advisory findings and wrongly failing a --depth build gate
that the real CLI's own perform_elf_dump path (whose compile_db_
context_matched OR l3_context_applied condition already handles this
correctly) would have satisfied for the identical evidence. A second,
distinct problem in the same spot: an empty legacy_compile_db_tokens
tuple is indistinguishable from "the legacy match never ran" — so a
compile unit the legacy match genuinely matched, but which legitimately
derives zero castxml flags, had no way to signal that it was matched.
Fixed by adding a second, independent parameter, legacy_compile_db_
matched: bool = False — mirroring perform_elf_dump's own compile_db_
context_matched parameter exactly, the second element of
_resolve_build_context_flags's own return — threaded through the
identical three-function chain (execute_dump_request →
_resolve_side_snapshot_impl → _seeded_includes_and_compile_context).
_seeded_includes_and_compile_context now returns applied=legacy_
compile_db_matched (not the fold's own, already-False applied) in
the branch where the P0.3 fold did not match, in both its early-return
path (no sources/build_info, or no headers) and its main path — so
a real match sets parsed_with_build_context regardless of whether any
tokens were actually derived, while an unmatched call (the default, and
every pre-existing caller) stays exactly as it was. Both new parameters
default falsy, so this remains purely additive.
Verified with four fast, monkeypatch-based unit tests (no compiler
needed — tests/test_legacy_compile_db_matched_signal.py): matched with
zero tokens still sets applied=True; unmatched with zero tokens stays
applied=False (the pre-existing default behavior, pinned unchanged);
matched with real tokens sets both the folded gcc_options and
applied=True; the fold-applies-wins precedence (sub-finding 2 above)
holds regardless of what the legacy-match parameters claim. Confirmed
each of the four fails with TypeError: unexpected keyword argument
'legacy_compile_db_matched' against the pre-fix code (the parameter
did not exist), not merely that they pass now.
Second correction (2026-08-29, same day, second Codex review round on
PR #935): the fix above still under-counted a real call shape. A caller
may thread non-empty legacy_compile_db_tokens while leaving the new
legacy_compile_db_matched parameter at its default False — exactly the
shape tests/test_legacy_compile_db_typed_threading.py's own end-to-end
caller uses. _seeded_includes_and_compile_context still returned
applied=legacy_compile_db_matched alone in that case (both the
early-return and main-path branches), so applied stayed False even
though non-empty tokens are themselves proof a legacy match derived real
flags — reproducing the identical parsed_with_build_context under-report
the first correction above closed, just reachable from a different call
shape.
Fixed via a shared _legacy_compile_db_achieved(matched, tokens) -> bool
helper: matched or bool(tokens). Both branches now call it instead of
reading legacy_compile_db_matched directly. legacy_compile_db_matched
remains necessary on its own for a genuinely matched compile unit that
legitimately derives zero flags (an empty token tuple can't represent that
case); non-empty tokens are sufficient evidence on their own, independent
of whether matched was also passed.
Verified with two new fast unit tests in the same file
(test_tokens_alone_without_explicit_matched_flag_still_marks_applied,
covering the main path; test_early_return_path_also_honors_tokens_alone,
covering the early-return branch) — both confirmed to fail against the
pre-fix code (assert False is True) via git stash, and to pass after.
Full fast unit suite re-run clean (33836 passed, 129 skipped, 4 xfailed,
0 failed) after this second correction.
Third correction (2026-08-29, same day, third Codex review round on PR
#935): a distinct, real correctness bug in the same function, found by
reading the actual token shapes involved rather than assumed.
_fold_legacy_compile_db_tokens used to merge tokens into
CompileContext.gcc_options via " ".join(tokens) — but tokens are
already-split argv entries (build_context.to_castxml_flags()'s own
return, e.g. ("-I", "/opt/SDK Files/include"), one element per argv
position, never pre-joined), and every consumer of gcc_options
re-splits it via _compiler_options.split_gcc_options before handing it
to the real castxml/clang subprocess. A token containing embedded
whitespace — a Windows SDK include path with a space, or a compile-db
-DNAME=a b define — silently split back into the wrong number of
tokens on that second pass, corrupting the derived include path or macro
value the moment a typed dump relying on the legacy match actually
reached the real parse. Confirmed real, not theoretical: to_castxml_
flags() genuinely emits -I/<path> as two separate list elements
(flags.extend(["-I", str(inc)])), so any compile-database include path
with a space reaches this function in exactly the corrupting shape.
Also confirmed to be pre-existing, shared debt, not novel to this
PR: cli_helpers_compare._merge_gcc_options — the real CLI's own
legacy-match merge path, which _fold_legacy_compile_db_tokens's own
docstring already documented as byte-for-byte mirroring — has the
identical " ".join(build_context_flags) pattern feeding the identical
CompileContext(gcc_options=...) field, so the real, unconditional
dump -p compile_commands.json CLI path carries this same corruption
today for a compile-database entry whose derived flags include
whitespace. Not fixed here — this correction's scope is the typed
pipeline this session's own work introduced; _merge_gcc_options is
pre-existing, live, widely-exercised code with its own blast radius, and
changing it needs its own dedicated review pass rather than riding along
inside an unrelated correction. Recorded here as a known, real,
reproducible gap: an include path or define value containing a space in
a compile database used with dump -p/--compile-db (no --dry-run
involved — this is the real-execution path) can silently corrupt the
derived castxml flags.
Fixed in the typed pipeline by routing tokens through
CompileContext.gcc_option_tokens (verbatim argv entries, a field that
is never re-parsed by split_gcc_options) instead of the gcc_options
string. Precedence preserved exactly: since the combined-token order
always places gcc_options ahead of gcc_option_tokens (later wins),
and the legacy match must still lose to an explicit, caller-supplied
value, ctx's own gcc_options string is split once here — with the
identical split_gcc_options splitter every consumer already applies to
it downstream, so this changes no token list, only where the split
happens — and the combined tuple built as (*tokens, *split(ctx.
gcc_options), *ctx.gcc_option_tokens): legacy first (lowest
precedence), then whatever ctx already carried, in its original
relative order.
Verified with five new fast unit tests
(TestWhitespaceBearingTokensSurviveTheFold in
tests/test_legacy_compile_db_matched_signal.py): a whitespace-bearing
include path and a whitespace-bearing define value both survive intact;
an explicit ctx.gcc_options still outranks a conflicting legacy token;
an explicit ctx.gcc_option_tokens still outranks a conflicting legacy
token; an empty token tuple remains a true no-op (ctx returned
unchanged by identity, not merely by value). Three of the five confirmed
to fail against the pre-fix code via git stash (the two whitespace
tests, and the gcc_option_tokens-precedence test — the gcc_options-
precedence test and the no-op test already held under both versions).
The three pre-existing tests this correction's field change touched
(test_matched_with_tokens_folds_flags_and_marks_applied,
test_tokens_alone_without_explicit_matched_flag_still_marks_applied,
test_early_return_path_also_honors_tokens_alone) were updated to
assert gcc_option_tokens instead of the now-unused gcc_options
string; tests/test_legacy_compile_db_typed_threading.py's own
precedence test (test_fold_wins_over_legacy_tokens_when_it_applies)
needed no change, since it already read the combined effective token
sequence across both fields rather than pinning gcc_options alone.
Fourth correction (2026-08-29, same day): this entry's own claim that
"the remaining work is purely the control-flow restructuring itself, not a
new correctness question" is WRONG, and is retracted here. A dedicated
session set out to do exactly the routing that claim scoped —
perform_elf_dump's primary parse calling execute_dump_request() instead
of seed_includes_and_fold_compile_context() + dumper.dump() as two
independent steps — read every function on both sides end to end
(perform_elf_dump, handle_non_elf_dump, service_dump_pipeline's
resolve_dump_request/execute_dump_request/ResolvedDumpRequest/
DumpResult, workflows/artifact/execute.py's
_resolve_side_snapshot_impl/enforce_requested_depth,
workflows/artifact/resolve.py's _seeded_includes_and_compile_context,
service.resolve_input, service_dump_native._dump_elf,
cli_buildsource._write_snapshot_output, cli_dump_request.
build_dump_request, and frontends/cli/commands/dump.py's real call
sites) and built the parameter-by-parameter parity map the routing needs.
Neither function was converted. Two structural blockers were found
that the claim above did not anticipate, both distinct from the legacy
-p/--compile-db mechanism the earlier sub-finding 2 named and closed.
Several previously-suspected blockers were, by contrast, ruled out for
real; both lists are below so a future attempt starts from measured facts.
Blocker A (ELF only, and it is exactly DumpResult's own documented
"Lifetime caveat" made live rather than latent). perform_elf_dump
passes the CLI's real collect_mode to
seed_includes_and_fold_compile_context, which sets
allow_inferred_build_query=collect_mode != "off" (buildsource/l2_seed.py)
and therefore genuinely returns non-empty pending_cleanups — the
temporary build directory a zero-config inferred build-system query
seeded, whose generated headers the seeded include dirs point at.
perform_elf_dump drains that plan in a finally placed deliberately
after its two post-processing second passes (service._attach_header_
graph and workflows.extraction.attach_clang_layout), and its own inline
comment states why in as many words: "the header-graph pass above (when
requested) reuses the same seeded include dirs the main dump() parse
used, so cleanup must wait until it ... has run". _resolve_side_snapshot_
impl drains in a nested finally immediately after
service.resolve_input, and its own comment states, equally explicitly,
why it must: embed_side_build_source runs its own inferred query inside
the same call, and an undrained seed still holds the deterministic
per-source-tree build dir under an exclusive flock, so a later drain
makes the second query self-contend for up to INFERRED_QUERY_TIMEOUT_S
(600s) — the identical self-contention shape recorded as the fifth finding
on the L3→L2-fold entry. The two requirements are in direct conflict
the moment perform_elf_dump's parse routes through that primitive: today
they don't conflict only because perform_elf_dump runs no embed inside
its own plan. Deferring the seed cleanups back out to the CLI caller (an
additive defer_seed_cleanup pass-through, the obvious-looking fix) is
precisely what re-creates the 600s contention; draining them where the
shared primitive does is precisely what deletes the directories the two
second passes still need to re-parse headers under. DumpResult's own
docstring already names this ("safe for identity or comparison ... a
caller intending to re-read a file under one of these paths ... cannot yet
do so safely"), and already scopes the fix as PR 3A's pair-aware/lifetime
redesign — a separate piece of work, not a control-flow rewrite. Weakening
or disabling either second pass to dodge it was considered and rejected:
each exists because of its own recorded Codex-review regression (a second
clang pass silently degrading to a declaration-only graph, and a
dump --ast-frontend clang baseline silently carrying no layout-tool
facts).
Blocker B (both ELF and PE/Mach-O). execute_dump_request is a
resolve + embed + enforce pipeline: _resolve_side_snapshot_impl runs
embed_side_build_source (L3-L5) inline, service.resolve_input →
run_dump applies dumper_scoping.resolve_dependency_scope from
InputSpec.include_dependencies, and execute_dump_request then calls
enforce_requested_depth. The dump CLI does all three of those things
after the parse and after provenance stamping, in
cli_buildsource._write_snapshot_output: embed_build_source (guarded by
build_source_already_satisfies), then check_requested_depth_satisfied,
then resolve_dependency_scope(snap, include_dependencies, header_roots).
Routing the primary parse through execute_dump_request therefore reorders
all three relative to the CLI's own post-parse pipeline, with three
concrete consequences, none of them cosmetic: (1) the ADR-039 build-context
reconciliation, the header-graph attach and the clang-layout attach would
run over an already dependency-scoped snapshot (--include-system-
declarations defaults off, so InputSpec.include_dependencies=False is
the common case, and the inner scope call has no access to the write path's
header_roots set at all); (2) the depth floor would be enforced against
only the inner embed's result, before _write_snapshot_output's own
embed — the one that actually fills L3-L5 for a dump today — has run; and
(3) that floor raises ValidationError where the CLI's own
check_requested_depth_satisfied raises a Click error, a different
user-facing message and exit code for the identical input. Making this
safe means either suppressing three behaviors inside a shared Tier-2
primitive for one caller (inventing a code path, which this entry's own
convention forbids) or moving the CLI's write-time embed/enforce/scope
stanza to resolution time — a real, separately-reviewable redesign of
_write_snapshot_output's contract, not part of the routing.
Ruled out, with evidence, so they are not re-litigated. (i) The
P0.3 fold's fourth return value (l3_include_dirs, which
perform_elf_dump folds into extra_hash_dirs) is not lost: the folded
context's own tokens carry those dirs, and service_dump_native._dump_elf
independently recomputes the same set via
cache_relevant_operand_paths(cc.gcc_option_tokens). (ii) The P3
inferred-header-root derivation (resolve_inferred_header_roots →
inc_extra/deferred/deferred_dirs) is not lost either — _dump_elf
performs the identical derivation itself. (iii) debug_info_path is not
lost: _dump_elf resolves it from debug_roots/enable_debuginfod, both
of which build_dump_request already puts on the InputSpec (it would be
resolved twice, once by dump_cmd for its echo and once here, which is
wasteful and double-logs but is not a correctness gap). (iv) The
whole-snapshot cache (resolve_input → cached_run_dump, which
perform_elf_dump's bare dump() bypasses) does not newly activate:
build_dump_request always sets InputSpec.compile to the CLI's resolved
CompileContext, and service_dump_cache._dump_is_cacheable refuses to
cache any call with a non-None compile. (v) follow_deps on PE/Mach-O
is not a divergence: populate_side_dependency_info is documented and
implemented as an ELF-only no-op. (vi) ast_memoize_scope()/
suppress_streaming_prune() are trivially preservable — the caller can
wrap the execute_dump_request call itself. (vii) handle_non_elf_dump
has no Blocker A: it runs no post-processing second pass and already
drains its plan in a finally immediately after the parse, exactly where
the shared primitive does. It is blocked by B alone, which is why
converting "the small one first as a warm-up" does not in fact isolate a
safely-landable slice.
Net: the remaining piece of this entry is not control-flow-only.
Closing it needs (a) PR 3A's already-scoped pair-aware/lifetime redesign of
the L2 seed's cleanup ownership, so a caller with post-parse hooks can keep
the seeded dirs alive without the embed step self-contending on their lock,
and (b) a decision about where dump's L3-L5 embed, depth enforcement and
dependency scoping belong — resolution time (matching the typed pipeline)
or write time (matching today's CLI) — since the two cannot both be true of
one code path. Recorded at this precision, per this file's own convention,
so the next attempt starts from the mechanism rather than re-deriving it.
The real ELF dump run is migrated (CLI cleanup phase two, PR C).
Decision (b) above is resolved as split, not uniform: only the L3-L5
embed moves to resolution time (execute_dump_request); depth enforcement
and dependency scoping stay at write time, unchanged, in
_write_snapshot_output. This became possible only because two
prerequisites this entry's own "Blocker A"/"Blocker B" analysis called
for were separately closed first, in the plan doc's own PR 3A subsection
(2026-08-27/28, "Investigated further"/"Update"): a real, end-to-end test
(tests/test_dump_write_after_resolve_time_embed.py) confirmed the
depth-gate/provenance/dependency-scope half of _write_snapshot_output's
sequence already handles a resolve-time-embedded snapshot correctly with
no code change (the two depth checks share the identical
evidence_depth.gated_source_label/depth_rank primitives, so calling
both is redundant, not wrong), and the Flow-2 --inputs pack fold was
separately verified safe layered on top of a resolve-time embed too. That
closes Blocker B's "resolve + embed + enforce" concern for the
embed alone — depth enforcement is not moved, so its own reordering
hazard never applies; Blocker A (the seed-cleanup self-contention) is
independently a non-issue for dump's real invocation shape, because a
dump CLI request's own header_backend/allow_build_query inputs never
produce a resolve-time seed with non-empty pending_cleanups for a
request that also carries a compile database or a pack --sources/
--build-info value under the shapes this migration's own tests exercise
(build_source_already_satisfies already accounts for the common,
compile-DB-backed case) — the two ELF-specific post-processing second
passes (_attach_header_graph, attach_clang_layout) that Blocker A
worried about run inside service.resolve_input's own ELF dispatch
(service_dump_native.py), not as a separate stage execute_dump_request
adds on top, so they already see the seeded dirs before that dispatch's
own cleanup drains them — the same ordering perform_elf_dump itself
used to hand-maintain, now owned by one implementation instead of two.
One structural nuance from Blocker B's own concern (1) is real and
not separately reasoned away here, only measured: service.run_dump's
own choke point (dumper_scoping.apply_dependency_scope_to_run_dump_
result) already dependency-scopes the snapshot before
_resolve_side_snapshot_impl's own ADR-039 collector/header-graph/
clang-layout attaches run on it (this is the shared pipeline's own
existing, pre-migration behavior for compare/scan, not something this
migration introduces) — so _write_snapshot_output's own, unchanged
resolve_dependency_scope call at the end is a second, write-time pass
over an already-once-scoped snapshot rather than the sole pass it used to
be. Confirmed idempotent for every shape this migration's own parity
suite exercises (both calls scope against the same effective header-root
set), not proven idempotent in general.
frontends/cli/commands/dump.py's real (non---dry-run) ELF branch calls
a sibling module, frontends/cli/dump_execute.py (split out purely to
keep dump.py under the architecture gate's 800-line production-file cap
— ADR-061 freezes the flat cli_*.py root family's member list, so a
genuinely new module goes into its responsibility-package tree, alongside
runtime.py/artifact_set_dry_run.py, instead), which builds a second,
execution-scoped ResolvedDumpRequest from the same
DumpRequest --dry-run already resolves — re-pointed at the
post-linker-script-following so_path (resolve_dump_request's own
detect_binary_format call runs before any such following, so feeding it
the pre-follow path risked a wrong fmt for a symlink-to-linker-script
input) and with requested_depth nulled out (so execute_dump_request's
own enforce_requested_depth — a different, more generically-worded
ValidationError than check_requested_depth_satisfied's
DumpDepthNotSatisfiedError — never fires; _write_snapshot_output's own
call stays the sole, unchanged enforcement point for this case, preserving
tests/test_depth_vocabulary.py's pinned message) — and calls
execute_dump_request(exec_resolved, legacy_compile_db_tokens=...,
legacy_compile_db_matched=...), threading the legacy -p/--compile-db
auto-match through as the explicit pass-through ADR-063 Phase 1 already
built for exactly this purpose (execute_dump_request's own docstring)
rather than porting it into InputSpec as a first-class field — the
pass-through already implements the P0.3-fold-wins precedence rule
perform_elf_dump's own _fold_explicit_gcc_options hand-rolled, so
adding a second, dataclass-shaped representation of the identical fact
would be a second place for it to drift, not a cleaner one.
perform_elf_dump itself is retired from this call site (still defined,
in case any other caller depends on it, but dump_cmd no longer imports
it); handle_non_elf_dump (PE/Mach-O) is untouched — no PE/Mach-O
toolchain was available in this environment to verify a migration
against, so it stays exactly where this entry's "Blocker B (both ELF and
PE/Mach-O)" heading already scoped it: open.
Update (2026-09-01, PR #980): PE/Mach-O is now migrated too, closing
this entry's remaining half. The design this entry already worked out
for ELF (null out requested_depth before calling
execute_dump_request, keep _write_snapshot_output's embed/enforce/
scope stanza as the sole enforcement point, thread the legacy -p/
--compile-db auto-match through as an explicit pass-through) carried
over to PE/Mach-O mechanically, with no second structural investigation
needed — execute_dump_request/_resolve_side_snapshot_impl were
already format-generic (is_elf=True if fmt == "elf" else None,
attach_build_context_for_parsed_headers/embed_side_build_source
called unconditionally regardless of format), the same pipeline
compare's implicit-dump operand and scan's candidate resolution
already used for PE/Mach-O input. handle_non_elf_dump is retired from
the CLI's real dispatch the same way perform_elf_dump was (still
defined, for its own direct unit tests). Verified only via mock-based
CLI/unit tests, not a real end-to-end parity run — no PE/Mach-O
toolchain was available in this environment either, so unlike ELF's own
test_dump_cli_typed_api_parity.py corpus, there is no byte-for-bit
confirmation against a real compiled DLL/dylib. AGENTS.md's own
service_dump_pipeline.py entry is updated to match.
One real, user-visible behavior change falls out of the migration rather
than being a side effect nobody decided: dump's L4 source-extractor
default flips from an accidental clang (perform_elf_dump forwarded
the bare, unresolved header_backend straight to the write-time embed,
which treats anything but the literal string "castxml" as clang) to
castxml (the shared pipeline's effective_frontend resolution,
matching compare's implicit-dump operand, the typed DumpRequest API,
and dump's own L2 header-AST default) — this is the plan's own
"item 3" castxml L4 phantom-implicit-member bug's actual payoff: that fix
(Function.is_compiler_generated, elsewhere in this file) is exactly what
makes this default safe to inherit now, where an earlier investigation in
this same entry explicitly deferred it for being unsafe before that fix
existed. --ast-frontend clang recovers the previous default for a caller
that needs it. The sibling scan-vs-dump/compare L4 extractor default
divergence (the plan's own "item 2") is unchanged by this migration —
scan_engine._build_new_snapshot still hardcodes source_extractor="auto"
(resolving clang) via its own opt-in override on the shared primitive,
deliberately preserved when scan's candidate resolution was migrated
onto the same primitive; that remains its own separate, deferred decision.
Update (2026-09-02): item 2's explicit-request half is now closed; only its unflagged-default half remains. The paragraph above stays accurate as a record of that migration, but the hardcoded
source_extractor="auto"it describes is gone —scan_engine's call site now passesservice_compare_evidence.explicit_source_extractor( compile_context) or "auto". Full account, including why this was safe where the earlier "flip it toeffective_frontend" attempt (recorded above) was not, and exactly what is still open: the plan's own item 2 incli-cleanup-phase-two.md, which was the narrative owner for this item's status. Update (2026-09-06): that plan is closed and this item dissolves rather than closing — ADR-068 retiresscan, so the unflagged-default half disappears withscan_engineitself; the full investigation narrative is in that file's git history (last full-length revisiona92a4a89). The one thing worth keeping here, since it is what this file exists for: the reverted attempt was reverted for surfacing the castxml L4 phantom-implicit-member bug, and that bug (Function.is_compiler_generated, elsewhere in this file) being fixed is what made the request half tractable at all.
Verified: the full fast unit suite; the real-toolchain (g++/clang/
castxml) integration suite for this area
(test_dump_cli_typed_api_parity.py's 16 cases — _CONTRACT_KNOWN_
DIVERGENT_FIELDS stays empty, i.e. zero remaining divergence between the
migrated CLI path and the typed pipeline — plus test_dump_scan_l3_
comparability.py, test_dump_write_after_resolve_time_embed.py,
test_dump_embed_idempotence.py — updated to count the resolve-time embed
call site too, not just the write-time fallback — test_compile_db_
filter_scope.py, test_dry_run_contract.py, test_dry_run_build_query_
contract.py, test_l2_seed_flow2_packs.py,
test_scan_adr039_build_context.py, test_castxml_l4_phantom_members.py,
test_dump_depth_provenance.py, test_depth_vocabulary.py — 2 xfails,
the same pre-existing, already-documented _SCAN_KNOWN_DIVERGENT_FRONTENDS
signature, unchanged); mypy/ruff clean on the touched modules.
Update (Block 7, PR C's own tail): the explicit-
--configdry-run/execution parity residual named throughout this entry is now closed.api_types.InputSpecgained abuild_configfield mirroringScanRequest.build_config—cli_dump_request.build_dump_requestsets it fromdump's own--config, andcli_compare_helpers/frontends.cli.commands.compare's inline--old/new-sourcesembed path (its own nestedctx.invoke(dump_cmd, ...)) sets it fromcompare's own raw--configvalue too.workflows.plan.SidePlan.build_configcarries it into_check_bazel_target_scoping, which now callsbazel_target_scoping_failure(..., build_config=side.build_config, ...)instead of always passingNonefordump/compare— so the sharedAnalysisPlanner.resolvechokepoint both--dry-runand the real run go through sees the same explicit configembed_build_sourcealready honored at real-execution time via its owncfg_path = build_config or discover_build_config(raw_sources)precedence, instead of only ever seeing whatever.abicheck.ymlauto-discovery found atsources. Does not change what actually executes:embed_build_sourcestill receives the CLI's own raw--configvalue out-of-band, exactly as before; this only widens what the pre-flight resolution (and therefore--dry-run) can see. The--dry-runbuild.querytrust receipt (add_build_query_dry_run_section) was already correct before this change and is untouched.A security review on the PR landing this (#1089) caught a real regression in the first version of the fix, worth recording precisely since it is exactly the class of bug ADR-032 D5 exists to prevent. The first attempt forwarded
compare's already-resolvedcfg_path(config if config is not None else discover_project_config()— conflating an explicit--configwith_resolve_compare_config's own auto-discovery fallback) into the nesteddump_cmdinvocation'sbuild_configparameter, instead of the raw, explicit-onlyconfigvalue.embed_build_source'scfg_trusted_for_query = build_config is not None or build_query is not Nonetreats any non-Nonebuild_configas operator authorization to executebuild.query— so that version would have let acompare --sources old=<tree>invocation with no--configat all execute an arbitrarybuild.querycommand from a.abicheck.ymlmerely auto-discovered from the working directory or the sources tree being compared, i.e. from content a reviewed PR's own checkout can control. Fixed by forwarding the rawconfigparameter (run_compare's own un-resolved--configkwarg —Noneunless the operator actually typed it) instead ofcfg_path. Verified viatests/test_compare_inline_embed_config_trust.py'stest_auto_discovered_config_is_not_forwarded(fails against the vulnerable version — assertsbuild_configstaysNonewhen only an auto-discovered config exists) andtest_explicit_config_is_forwarded(positive control — an operator-supplied--configstill reaches the nested invocation, so the fix doesn't overshoot into never forwarding one at all).dump's own--configwas never subject to this: itsbuild_configlocal is always the literal, explicit-only Click flag value, with no auto-discovery fallback at that layer.Verified via
tests/test_analysis_plan.py'stest_dump_request_honors_explicit_build_config_over_auto_discovery/test_compare_request_honors_explicit_build_config_over_auto_discovery(request-level, throughAnalysisPlanner.resolvedirectly) andtests/test_bazel_root_targets.py'stest_dump_cli_explicit_config_scoping_matches_dry_run_and_real_run(end to end through the CLI, asserting--dry-runand the real run agree); the full fast unit suite;mypy/ruffclean on every touched module. Directory/packagecompare's release fan-out was left untouched — out of this residual's stated scope (the plan namesdump/compare, the single-pair commands).
- Lambda-closure churn survives at the function level after the type-level
fix — investigated, deliberately not patched (oneTBB flow-graph report,
fresh evidence).
name_classification._ANONYMOUS_TYPE_MARKERSdid not recognize clang's own closure spelling ((lambda at <path>:<line>:<col>), or the(lambda:<file>:<line>:<col>)formstrip_anonymous_type_locationnormalizes it to), so a template instantiated over a closure —raii_guard<(lambda:task_group.h:522:26)>— was treated as ordinary ABI surface. That half is fixed: the marker list now covers it, and an unrelated edit that merely shifts the lambda's line no longer produces atype_removed/type_addedpair. Two residuals were reproduced and are not closed.
(1) Function-level findings on closure-parameterized symbols. A
destructor of that instantiation is still reported as a BREAKING
func_removed (plus func_added for the shifted spelling), and a public
function taking the closure-parameterized type by value still produces
func_params_changed/template_param_type_changed, because the
mangled symbol and the parameter type spelling both embed the
closure's source coordinates. Reproduced directly through compare()
with the two spellings differing only in the line number.
is_non_abi_surface_type is a type-name predicate and is not consulted
on either path, so the marker fix cannot reach them. Demoting the
removal is not the fix, and the reason matters: this codebase already
states the correct reading in change_registry's own
unnamed_type_in_public_abi entry ("the Itanium mangling of unnamed
types is per-translation-unit and compiler-ordering dependent ... so
exporting one is an ABI time bomb: a rebuilt consumer can fail to resolve
the symbol"). A consumer already linked against the old numbering
really does fail at load when the numbering shifts — so the removal is a
genuine break, and softening it would hide a real one. That is the same
direction of error the linkage-blind-removal entry above was reverted
twice for. Nor is "strip :<line>:<col> before comparing two spellings"
a free win: strip_anonymous_type_location's own docstring records why
the coordinates are kept (two unrelated lambdas in one header collapse to
one key, silently overwriting an entry in every name-keyed map that
consumes the spelling), and this pass has no real oneTBB snapshot to
check a narrower "normalize only for pairwise spelling comparison, never
for keys" variant against. What a correct fix needs is the annotation
route ADR-style precedent already establishes elsewhere in this file
("Annotate; never remove"): correlate such a finding with the existing
unnamed_type_in_public_abi RISK signal so a reader can see why the
symbol churned, rather than removing it from changes or lowering its
verdict. That is a new correlation pass with its own identity question
(which finding covers which), not a marker-list edit.
Update: the ctor/dtor half of (1) is now closed via binary evidence,
not annotation. A later report against real oneTBB 2021.13.0 →
2022.3.0 binaries reproduced exactly this shape at scale:
demote_lambda_closure_unexported_findings (diff_templates.py) had
already been added to demote a FUNC_PARAMS_CHANGED/
TEMPLATE_PARAM_TYPE_CHANGED/TEMPLATE_RETURN_TYPE_CHANGED finding
whose reported symbol is confirmed absent from BOTH sides' real ELF
exported symbol table (never escalates; fails closed when no ELF
evidence exists on either side) — but it deliberately excluded every
castxml-synthesized ctor/dtor key
(dumper_castxml.is_synthetic_ctor_key/is_synthetic_dtor_key), on the
correct-as-far-as-it-went grounds that such a key is never itself a
real exported symbol (castxml synthesizes it specifically because it
could not produce one), so a membership check against the key text
would always read "confirmed absent" — vacuously, not because anything
was actually verified. That exclusion left every destructor/constructor
of a closure-parameterized instantiation permanently un-demotable,
which is exactly the residual (1) describes and exactly what the oneTBB
report reproduced: 5 breaking func_removed findings, all on synthetic
ctor/dtor keys naming
tbb::detail::raii_guard<(lambda:task_group.h:522:26)>/
try_call_proxy<...>/task_arena_function<...>/
delegated_function<...>, paired 1:1 with 5 compatible func_added
findings differing only in the lambda's line number.
Fixed by asking the binary a question the synthetic key's text can
answer honestly, even though the key itself is not a real symbol: is
the owning class/class-template exported under ANY instantiation at
all, on either side?
finding_identity_ctor_dtor.synthetic_ctor_dtor_template_base_name
recovers the owning scope from the key (that same module's own
synthetic_ctor_scope for a ctor key's
depth-aware scope(params) split; a plain prefix strip for a dtor key),
reduces it to the bare, template-argument-stripped template name via
type_reachability._bare_type_name plus a top-level < scan
(raii_guard<(lambda:...)> → raii_guard), and
finding_identity_ctor_dtor.itanium_source_name_token renders that name
as its Itanium <source-name> encoding ("raii_guard" → "10raii_guard",
keyed on the identifier's encoded UTF-8 byte length rather than its
Python character count, so a non-ASCII class name still produces the
correct token) — a literal substring every real mangled symbol naming
that class as a scope component must contain (Itanium C++ ABI §5.1.1),
checked directly against the raw exported names with no external
demangler invoked, the same "structural, not textual" preference
diff_cxx_rules.itanium_scope_components already established for this
codebase. Both helpers live in finding_identity_ctor_dtor.py, not
diff_templates.py itself (which only keeps the classification/
modulation entry point, demote_lambda_closure_unexported_findings):
diff_templates.py is one of ADR-061's debt.yaml-tracked
no-growth-baselined legacy files, finding_identity_ctor_dtor.py already
owns every other castxml synthetic-ctor/dtor-key helper and had headroom
under the flat 800-line production limit, and a brand-new flat diff_*
sibling module is explicitly rejected by scripts/check_architecture.py's
frozen_root_families list (confirmed by trying it — [frozen-root-family]
and [root-module] findings) — while a real policy/ package migration
for just this one function was investigated and found to cascade into
unclassified-import findings for every one of its ~8 currently-flat,
not-yet-layer-classified dependencies (checker_policy, diff_symbols,
dumper_castxml, elf_symbol_filter, type_reachability,
name_classification, ...), which is its own separate, much larger
ADR-061 migration slice, not a follow-up to this fix. A template with
zero exported members under any instantiation on either side is demoted
(Verdict.COMPATIBLE_WITH_RISK,
modulation_rule="lambda_closure_never_exported", same ADR-025 hook,
never removed); a template that does export some other instantiation
is left exactly as severe as the detector made it, since this check
cannot rule out that the specific closure-parameterized instantiation
was the one a consumer actually linked against.
Deliberately narrower than reconstructing the exact per-instantiation
mangling: the real Itanium mangling of a closure-type template argument
uses the compiler's own unnamed-type encoding (Ul<parameter-types>E_),
never castxml's (lambda:file:line:col) spelling, so there is no way to
derive the precise mangled substring for one specific instantiation from
the snapshot text alone — checking the template's own name is the safe,
strictly more conservative substitute this function's whole design
(fail closed, never escalate) already calls for. See
tests/test_lambda_closure_function_demotion.py's
TestSyntheticCtorDtorKeysDemotedWhenTemplateNeverExported/
TestSyntheticCtorDtorKeysNotDemotedWhenTemplateIsExported for both
directions, verified against the exact class names from the oneTBB
report.
A review round on the same fix found a real gap in the substring
search itself, not in the demotion logic around it. Six std:: names
(allocator, basic_string, basic_istream, basic_ostream,
basic_iostream) carry a fixed, mandatory Itanium ABI substitution
(Sa/Sb/Si/So/Sd, C++ Itanium ABI §5.1.2) — the mangler always
emits the abbreviation instead of the literal source-name, even on the
first occurrence in a symbol. A real std::allocator<int>::allocator()
mangles to _ZNSaIiEC1Ev, never to anything containing the literal
substring "9allocator" — so a synthetic std::allocator<(lambda:...)>
finding's literal-token search would read that class as "never
exported" regardless of the truth, silently demoting a genuine removal.
Fixed with finding_identity_ctor_dtor.itanium_standard_substitution_
token, checked alongside the literal token whenever the owning class's
qualified name is exactly one of the six; see
tests/test_lambda_closure_function_demotion.py's
TestStdAllocatorSyntheticKeyNotFalselyDemoted for the exact
counterexample from review, reproduced and fixed.
Two related residuals from the same report. The compatible func_added
churn is ordinary add/remove pairing (not itself a bug), left as-is.
~~The risk declaration_renamed findings elsewhere in the same report (7
further ones) is pure noise from the lambda's identity embedding its
source line:col rather than an ordinal position — closing that needs
changing how a closure is identified, a materially larger change to
name_classification/the castxml and clang backends' own closure naming
than this fix's binary-evidence check, and is not attempted here.~~
Update: fixed, narrower than the originally-scoped "change how a
closure is identified" (fresh report, real oneTBB 2021.12.0 -> 2023.0.0,
139 declaration_renamed L5 findings against oneDNN's 3 for a comparable
corpus size). Tracing a representative sample confirmed the same
mechanism this entry already names: buildsource.graph_reconcile's
structural-context tier correctly PAIRS an unrelated-edit-shifted
closure with its own unchanged predecessor (that matching evidence never
reads the coordinate text at all), but _classify_outcome then compared
the two nodes' literal qualified-name spelling — which embeds the
closure's own :line:col — to decide "renamed", so every closure an
unrelated header edit merely shifted read as a rename. Rather than the
originally-scoped identity-naming rewrite (which risks the exact
same-header-collision trade-off strip_anonymous_type_location's own
docstring documents for matching), the actual fix is narrower and
provably safe: model.graph_identity.closure_location_free_identity
drops a closure/anonymous-tag marker's basename+coordinates entirely (not
merely the checkout-root prefix _normalize_graph_identity already
strips) for the outcome-classification comparison only, never for
matching/merging — the pair was already established by tier evidence that
never consults this text, so no new merge rides on it, and a genuine
cross-file move is still caught by the separate, coordinate-text-blind
old_file/new_file check right below it (reclassifying it
declaration_moved instead of the misleading declaration_renamed,
never hiding the file change). See
tests/test_graph_reconcile_closure_rename.py — both the oneTBB-shaped
regression cases and a standalone property-test class for the new
primitive (idempotence, location/basename invariance, parenthesized/bare
spelling agreement, marker-kind non-collapse, never-raises on arbitrary
text) per this file's own "Primitive-level property tests" guidance,
since this is a reusable identity-comparison helper, not a one-off
patch. The separate, still-open gap about the L5 graph's own node ids
not sharing the flat snapshot's ordinal renumbering (this file's own
"The L5 source graph's own node identities are never renumbered..."
entry, elsewhere in this file) is unaffected by this fix — it is about
node-id/flat-field spelling agreement, not the rename-vs-not-renamed
classification this fix corrects.
And a public-surface filter gap (ELF-only, mangled-only symbols
such as std::once_flag::_Prepare_execution<lambda>'s internal guard
thunks, which surface.py cannot scope-classify because it never
demangles) is a separate detector, not this one, and is likewise not
addressed here.
(2) A constructor key rendering a literal ? parameter. Traced to two
legitimate "type not recoverable" sentinels rather than to a formatting
bug: dwarf_snapshot._process_param returns Param(type="?") for a
DW_TAG_formal_parameter carrying no DW_AT_type, and
dumper_castxml._type_name returns "?" when a referenced type id does
not resolve in the XML (a closure class declared inside a function body
is exactly the shape that goes unemitted). Both are honest unknowns, and
which one produced the reported key cannot be determined without the
original snapshot, which this pass does not have. Guessing a
substitution here would replace a visible unknown with a fabricated
spelling, which is strictly worse; closing it needs the real artifact (or
a live castxml/DWARF repro of a closure-parameterized ctor) first.
- The L5 source graph's own node identities are never renumbered
alongside the flat snapshot's closure markers (Codex review on PR #868,
fresh evidence).
renumber_anonymous_closure_identitiesrewrites a closure's:<line>:<col>discriminator to a stable#Nordinal acrossAbiSnapshot.functions/variables/types/enums/typedefs/constants/fact_provenance(_LAMBDA_IDENTITY_FIELDS), butservice_header_graph_attach._attach_header_graph's embeddedbuild_source.source_graphis built by a genuinely separate clang parse (buildsource.header_graph), whose node ids (graph_facts._decl_node_id/_type_node_id) are derived directly from the raw, un-renumbered identity string -- confirmed by reading both functions, which apply no renumbering at all. A closure-parameterized declaration therefore reads asFoo<(lambda:file.h#1)>in the flat snapshot butFoo<(lambda:file.h:20:5)>in its own source-graph node, so any consumer trying to correlate the two (e.g. matching a flat finding back to its graph neighborhood) sees two different spellings for the same entity. Not fixed here: a correct fix needs the ordinal mapcollect_anonymous_type_ordinalscomputes from the flat fields to also be applied to every graph node id/name and every edge'ssrc/dstreference to it -- and the source graph can name a closure the flat ABI-surface fields never mention at all (an internal-linkage helper visible only in the L5 graph), which the flat-only ordinal map has no entry for, so naively reusing it risks leaving some graph-only closures unrenumbered while their flat-visible siblings are. A correct fix likely needs the ordinal collection widened to scan the graph's own node/edge strings too, verified against a case that actually mixes flat-visible and graph-only closures in one header -- a real, cross-cutting change to two independently-evolving modules, not a same-PR reactive patch.
Same gap, also reachable from the load path (Codex review, fresh
evidence). storage.snapshot_load_normalization.normalize_anonymous_
type_spellings_on_load (added to close a sibling bug: a raw pre-strip
on-disk baseline was left completely unrenumbered on load, see this
file's own git history) rewrites the identical flat
_LAMBDA_IDENTITY_FIELDS only, called after snapshot_from_dict has
already decoded a schema-v29+ document's AbiSnapshot.surface_graph --
so a loaded raw-marker baseline's attached graph keeps its own
un-stripped node/edge identities even once the flat fields are
normalized. Not fixed here, for the same reason as above: it needs the
same graph-aware widening this entry already calls for, not a second,
independent patch on the load side.
-
~~Neither
scan's dry-run report validates--abi3applicability before reporting success~~ Fixed (CLI cleanup phase two, PR 5 follow-up). Bothfrontends.cli.scan_dry_run.render_scan_dry_run(single-binary) andfrontends.cli.artifact_set_dry_run.render_artifact_set_dry_run(--artifact-set) now run a cheap, binary-only extension probe (python_ext.detect_python_extension_from_binary-- container-only ELF/ PE/Mach-O read, no DWARF/AST parse, the same "binary export table parse" already priced under the L0_binary dry-run row) and route a non-qualifying candidate throughDryRunResult.block()(exit 1), matching the real run'sEVIDENCE_CONTRACT_ERROR. The message text is shared withscan_engine._run_abi3_audit's real precondition failure viapython_ext.abi3_precondition_message()so the three callers cannot independently drift. Seetests/test_scan_dry_run_abi3.py. -
~~A pinned depth backed only by a query-declaring
--config(no--sources/--build-info) prices L3/L4/L5 at zero TUs/zero cost~~ Fixed (CLI cleanup phase two, PR 5 follow-up)._estimate_total_tusgained aquery_onlybranch: whenreq.build_configdeclares a realbuild.queryand no--sources/--build-info/compile DB is given, the L3 note is flagged[UNKNOWN: build.query declared, ...](the same "annotate honestly rather than fold a floor into the summed total" shape_UNSCOPED_TU_NOTE_SUFFIXalready uses for the sibling--build-targetundercount case) instead of the confident-looking0every other "nothing given" case reports._source_layer_estimatescarries the same marker onto the derivedL4_source_abi/L5_source_graphnotes too (Codex review, fresh evidence: an earlier revision of this fix only flagged L3's own row, so--depth source/--depth graphstill priced the derived layers as a confident zero), andestimate_artifact_set's 4th return value (unknown_layers) lets the--artifact-setaggregate renderer apply the same per-layer treatment rather than a single project-wide flag hardcoded toL3_build. Reaches both dry-run paths uniformly, since both callestimate_scan(). Seetests/test_scan_dry_run_abi3.py::test_estimate_total_tus_query_only_config_marks_count_unknownand itstest_estimate_scan_propagates_unknown_tu_count_to_l4_and_l5/test_estimate_artifact_set_reports_unknown_layers_per_layersiblings. -
Two function/method template overloads distinguished only by a
requires-clause still collide under ADR-063 Phase 2'sEntityIddiscriminator (Codex review, PR #943, fresh evidence).template<class T> requires C1<T> void f();and the same declaration constrained byC2<T>instead share scope, leaf name, an identical ordinary parameter list, and an identicalfunction_template_param_kindsresult (("type",)), so they collapse onto oneEntityIdeven after the parameter-kind/packness/dependent-rename fixes landed for this discriminator. Confirmed by direct compilation that clang's ownConceptSpecializationExprnode (aFunctionTemplateDeclchild appearing right after the constrainedTemplateTypeParmDecl) carries no concept name or resolvable reference to one anywhere in its own JSON subtree -- every key on the node and itsImplicitConceptSpecializationDeclchild was inspected directly, and neither carries anything but synthetic AST ids and dependent-type placeholders (type-parameter-0-0). Not fixed: recovering the concept's actual name would need either a different clang AST-dump mode/flag or the raw header source text sliced at the node's ownrangeoffsets, and_ClangAstParser(abicheck/dumper_clang.py) deliberately consumes only an already-parsed JSON tree with no source text available to it -- a fragile source-offset hack was rejected rather than attempted. A correct fix needs either threading the header's raw source text through to this parser (a larger architectural change outside this discriminator's own scope) or a clang invocation change that emits a concept reference here, verified against a real build before landing either way. Seedocs/contribute/plans/one-semantic-pipeline.md's Phase 2 section for the full investigation. -
An out-of-line member (function or static data member) template definition gets a different
EntityIdscope than its in-class declaration, colliding one entity into two (Codex review, PR #943, fresh evidence).struct A { template<class T> void f(T); }; template <class T> void A::f(T) {}-- confirmed by direct compilation (clang -Xclang -ast-dump=json) that clang emits TWOFunctionTemplateDeclnodes forf: one lexically nested insideA's ownCXXRecordDecl(the in-class declaration), and one at the ENCLOSING namespace's own lexical level (a sibling ofstruct A, not a child of it) carrying aparentDeclContextIdpointing back atA's own node id -- clang's own signal for "this out-of-line definition's real semantic owner isA, even though it isn't lexically nested inside it."_ClangAstParser._walkcomputes bothscope/scope_pathpurely from LEXICAL nesting, with noparentDeclContextIdhandling anywhere in this codebase, so the out-of-line definition getsscope=()while the in-class declaration getsscope=(Record("A"),)--parse_functionsparses BOTH nodes into separateFunctionentries with disagreeingEntityIds for what is really one entity. The review further confirmedparse_variableshas the analogous gap for an out-of-line class-template static data member definition. Not fixed: unlike this phase's other fixes (each a small, local addition to one already-threaded parameter), closing this properly needs a NEW general-purpose facility this codebase doesn't have yet -- a typed,ScopePath-valued sibling of the existingdumper_clang_expr._index_decl_id_qualified_names(which already indexes every decl id to a FLAT qualified-name string for a different consumer, but a flat string cannot be losslessly converted back into a typedScopePath-- collapsingRecord-vs-Namespace-vs-InlineNamespaceis exactly the ambiguityScopePathwas built to prevent). That index would need building once per parse, threading through bothparse_functionsandparse_variables, and reasoning through further edge cases this investigation did not exhaustively enumerate (an out-of-line member of a NESTED class, an out-of-line member of a class-template SPECIALIZATION, and whether castxml's owncontextresolution has the identical gap for parity). A correct fix needs that index plus verifying each edge case against a real compilation before landing, not a narrow patch for only the one reported shape. -
scan_abi3_resolve.pyis a new flatworkflows-legacy root module whose own docstring says its placement exists specifically soscripts/check_architecture.py'sunclassified-importcheck won't inspect its dependency onserialization.py(Codex review, PR #951, fresh evidence). The module needs bothpython_ext(detect_python_extension_from_binary) andserialization(load_snapshot, for the snapshot-input fallback) to answerscan --dry-run --abi3's candidate-recognition question the same way the real run'sservice.resolve_inputdoes;serialization.pyalready importspython_ext(forPythonExtMetadata/thedetect_python_extensionbackfill), sopython_extimportingserializationback would form a real two-module cycle, and a small module living outside both, importing each, is the correct general shape. Codex is right that keeping it flat rather than moving it under the migratedabicheck/workflows/package (alongsidescan_abi3_dry_run.py, its only caller) sidesteps a check rather than satisfying it. Investigated the real fix and it doesn't fit in this PR:serialization.pymatchesstorage's own described responsibility (AGENTS.md's module map: "snapshot serialization ... the public compatibility surface"), so classifying it underarchitecture/modules.yaml'sstoragelayer looks like the natural move -- butstorage'smay_importis["model"]only, and that reclassification immediately surfaces two already-latent violations a purely-unclassifiedserialization.pycurrently hides fromcheck_architecture.py'sdependency-directioncheck entirely (an unclassified import target is skipped by that check, by design):abicheck/probe_harness.py(classifiedcompare, whose ownmay_importis["model"]only) already callsserialization.snapshot_to_dict/snapshot_from_dictat runtime, andserialization.pyitself importspython_ext(classifiedextract) at runtime for the same reasonscan_abi3_resolve.pydoes. Unlikecheck_ai_readiness.py'simport-cycle-growthcheck,dependency-directionhas no allowlist mechanism to grandfather either violation while the reclassification lands. Both fixed.serialization.py's ownpython_extcoupling closed first (ADR-061 gap E closure package 5, slice 1, 2026-09-07): thedetect_python_extension()backfill moved out ofserialization.pyintoabicheck/workflows/snapshot_load.py::backfill_python_ext_from_evidence(), called as an explicit post-load step --serialization.pyno longer importspython_extat all (it importsworkflows.snapshot_loadinstead, andpython_ext_from_dictfrom.model, a plain dataclass parser, not theextract-classifiedpython_ext.py). Theprobe_harness.pyhalf closed second (slice 2, 2026-09-08): its snapshot round-trip (.to_dict()/.to_json()/.from_dict(),write_matrix_snapshot/load_matrix_snapshot) moved out ofprobe_harness.pyentirely intoabicheck/workflows/findings.py, which may legitimately import bothcompare(for the dataclasses) andserialization.probe_harness.pyno longer callsserialization.snapshot_to_dict/snapshot_from_dictat all. Neither latent violation this entry named is still open.serialization.py's own ~1500-linesnapshot_to_dict/snapshot_from_dictcodec proper has since been given a real owner too (ADR-061 gap E closure package 6): it moved tostorage/snapshot_codec.pyand four siblings (snapshot_schema_versions.py,snapshot_encode.py,snapshot_decode_declarations.py,snapshot_reliability_flags.py), each kept under the ADR-061 new-file production line ceiling since a brand-new file gets no adoption-debt baseline exemption.serialization.pyitself is confirmed (not just documented) to staypublic_root_surfaces-listed permanently, for the same "no single layer" reason ADR-061 gap B's own closure status already established forchecker_policy/contract_gating/reclassify: it is the one legal route through whichworkflows.snapshot_load.backfill_python_ext_from_evidenceandpolicy.analysis_assurance_degraded_facts.degraded_reliability_factsrun betweenstorage.snapshot_codec.decode_snapshotandstorage.snapshot_codec.finalize_snapshot-- neither call is legal from astorage-classified module (may_import: [model]only), anddependency-directionresolves a dynamicimportlib.import_modulecall against a classified target the identical way it resolves a static import (only an unclassified target, like this facade, is skipped by design), so there is no bridge-module trick available for either. Verified against the full architecture gate (scripts/check_architecture.py) with zero new findings. Leftscan_abi3_resolve.pyin its current, self-documenting flat-legacy placement (its own docstring already states the reason and the precedent it follows) as accepted debt -- that placement was never aboutserialization.py's own classification, so this closure does not change it. -
[Superseded 2026-09-01 for
surface.py's own half — see the correction below; the node-id-namespace half is still accurate.] ADR-063 Phase 3 (D5) lands the public-surface-as-graph-query infrastructure without migratingsurface.py/export_surface.py's own traversal algorithms onto it, and without unifying the new graph builder's node ids with the pre-existing L5 graph's — both deliberate, documented scope boundaries, not oversights.policy/public_surface.py'sPublicSurfaceQuerydelegates tosurface.compute_public_surface()/export_surface.compute_export_surface()unchanged rather than reimplementing either as a literal graph traversal: both are exactly the kind of intricate, multi-round-corrected logic this same page's_paired_stable_indicesincident (see "Primitive-level property tests" inAGENTS.md) shows costs several review rounds to get right even once already, and reimplementing one from scratch inside the same phase that also had to build every piece of graph infrastructure underneath it was judged materially higher-risk than landing the infrastructure now and migrating the algorithm as its own later, narrowly-scoped phase. Consequences:compute_public_surface()'s signature was never changed to accept a structuredresolutionparameter, so there is no lazy, graph-reading legacy-snapshot backfill path;type_reachability. directly_referenced_stdlib_types()was not migrated intopolicy/public_surface.py(doing so would reclassifytype_reachability.pyinto thepolicylayer, which would introduce a genuine newpolicy -> extractarchitecture violation — that module imports two already-extract-classified siblings); andcompare/surface_graph.py's own node ids (canonical_key(occurrence_id)/approx::/typedef::fallbacks) are a namespace fully independent ofbuildsource/ header_graph.py's pre-existing L5 node ids (decl://<identity>/type://<identity>) — one sharedSourceGraphSummaryinstance carries both builders' nodes (real, tested —service_header_graph_attach. _attach_header_graph()), but the two schemes do not dedup onto a common node for a declaration both builders see. Each of these is a real, separate follow-up migration, not silently-abandoned scope — see the implementation plan's Phase 3 "Landed"/D5 "Amendment" notes (docs/contribute/plans/one-semantic-pipeline.md; ADR-063 itself no longer carries a duplicated per-phase status block, removed by PR 0, 2026-09-02) andcompare/surface_graph.py's/policy/public_surface.py's own module docstrings for the exact reasoning each carries.
Correction (2026-09-01): the traversal-migration half of this entry's
own title is now stale — the algorithm was migrated after all, just not
in this phase's first landing. A later round did reimplement
surface.py's closure walk as a real traversal rather than leaving it
in place: _index_surface_types/_seed_public_roots/
_walk_type_closure/_walk_exact_type_closure/_record_exact_identities/
_record_nested_in_known_record/_record_is_confirmed_public_seed and
the PublicSurface type moved to policy/public_surface.py (dataclass +
indexing) and policy/public_surface_closure.py (the walk itself, plus
resolve_public_surface()), and surface.py's own copies were
deleted, not kept alongside — surface.compute_public_surface() is
now a thin re-exporting wrapper. export_surface.py's own root-seeding
stayed in place, but its final type-closure step now calls the same
migrated _walk_type_closure the header domain uses, so that domain
became graph-native for free. This is the risk the paragraph above
named and chose to defer, not a different fix — it just didn't stay
deferred through the whole phase. The next two bullets below give the
full, three-review-round account of what that migration actually needed
to get right (and what it does not touch — snap.surface_graph/
GraphNode.attrs, in the design that finally shipped). What is still
correctly described by the paragraph above, unchanged: export_surface.py's
own root-seeding logic, type_reachability.directly_referenced_stdlib_types()
staying unmigrated (same policy -> extract reason), and the two node-id
namespaces not deduping onto one node. See the implementation plan's
Phase 3 "Landed"/D5 "Amendment" notes for Phase 3's final accounting —
ADR-063 itself no longer carries this per-phase detail.
- ADR-063 Phase 3 (D5)'s traversal migration went through three review
rounds before landing on a design that reads
AbiSnapshot.surface_graphnever at all for the closure walk — the history is worth keeping because each round's fix created the next round's bug. Round 1 (the original migration):policy/public_surface_closure.pyread a graph node'sreferenced_identifiers/identifiers_collisionattrs, stamped once at graph-build time bycompare/surface_graph.py. Round 2 (Codex, PR #979):snap.surface_graphbeing non-Nonedoes not mean its nodes carry those attrs at all —service_header_graph_attach._attach_header_graphinstalls an L5 graph on essentially every real dump without ever populating them — so trusting an attrs-less node as "references nothing" silently collapsed the transitive closure on the ordinary, default dump path. The fix (resolve_surface_graph_nodes()unconditionally callingbuild_public_surface_facts()to enrich/backfill the graph before reading it) introduced two further problems of its own: (a) a genuine, measured 30-100%+ performance regression againstscripts/ benchmark_scaling.py's "Baseline regression (PR vs base)" CI gate, becausechecker.compare()'s defaultscope_to_public_surface=Truecalls this path twice per compare (once per side) and building realGraphNode/GraphFactobjects through the ADR-046 evidence-merge machinery for every declaration is meaningfully more expensive per-call than the deleted regex-based re-parse it replaced; and (b) Round 3 (Codex, second security-focused round): even with enrichment, a schema-v29 (or otherwise untrusted/adversarial) snapshot could carry a stale or craftedreferenced_identifiersfact at a confidence this module's own freshly-registered fact (alwaysCONF_UNKNOWN, the lowest rank inmodel.graph_vocabulary._CONFIDENCE_RANK) cannot outrank —model.graph_facts.merge_graph_facts's per-key precedence would let the stale/poisoned value silently win over the correct, current one, reproducing the exact same collapsed-closure failure mode as round 2, just reachable through the round-2 fix instead of around it. An identity-keyed cache was also tried, purely to close the perf regression from (a), and reverted separately: it broketests/test_export_surface.py::TestUnresolvedTypeEdges:: test_a_scope_lost_alias_key_is_followed_to_its_target, which mutates a snapshot'stypedefs/typesin place between two calls and correctly expects the second to see the new content.
Fixed, for real, by removing the graph from this computation
entirely rather than trying to make trusting it safe. Both the
attrs-staleness hazard (round 2) and the evidence-merge-precedence
hazard (round 3) share one root cause: trusting anything cached on the
shared, evidence-mergeable graph for a value that has exactly one
legitimate source (the snapshot's own current declarations) and no
legitimate second producer to reconcile evidence with. Once that was
understood, the fix stopped being about merge precedence or caching at
all: compare/surface_graph.py's own referenced_identifiers_by_node()
(renamed public, alongside its ReferencedIdentifiers return type) was
already a pure function of the snapshot's declarations, computed
before any GraphNode is even built — policy/
public_surface_closure.py and export_surface.py's closure-walk entry
points now call it directly and thread the result through
(_referenced_identifiers/_node_identifiers_or_collision/
_seed_public_roots/_walk_type_closure/_walk_exact_type_closure all
take a ReferencedIdentifiers now, not a dict[str, GraphNode]), never
touching snap.surface_graph or GraphNode.attrs at all.
resolve_surface_graph_nodes() (the round-2 enrichment function) had no
remaining caller once both call sites switched, and was deleted rather
than kept as unused surface. This closes the security concern outright
(nothing is ever merged, so there is no precedence for an adversarial
fact to win) and, as a direct consequence, removes essentially all of
the GraphNode/GraphFact/evidence-merge construction cost from the
hot path too — an ad hoc local re-run of scripts/benchmark_scaling.py
after this fix showed add_remove@2000 and type_churn@1000 (two of
the scenarios the perf gate had flagged) back in line with or better
than the pre-migration baseline numbers quoted in the gate's own
failure output. Confirmed by CI itself, not just the local re-run:
the Performance workflow's own "Baseline regression (PR vs base)" job
(PR #979, commit 5544540) completed with conclusion: success — the
gate's own noise-controlled PR-vs-base measurement, not an ad hoc local
timing, so this entry is resolved rather than an open gap. Left in this
history for the multi-round record: three review rounds on one commit
chain, each fix closing the previous round's hazard while (in round 2's
case) introducing this one.
-
action/run.sh'sextra-argsparsing performs pathname expansion (globbing), not just word-splitting, and no site disables it — investigated, deliberately not fixed (CodeRabbit review, PR #998, ADR-064's effective-format-override fix)._effective_format()(added by that PR),_extra_args_has_write_flag(),_extra_args_write_json_path(), and the real command assembly (CMD+=($INPUT_EXTRA_ARGS)) all read$INPUT_EXTRA_ARGSvia an unquotedset --/array-append expansion, which bash expands for both word-splitting AND filesystem globs. A craftedextra-args: '*'(or any value containing a bare*/?/[...]) run in a workspace that happens to contain a file whose name looks like a CLI flag (e.g.--format=json) would have that filename silently substituted in as a real argument -- an unintended, workspace-content-dependent flag injection.add_flag's sibling_split_legacy_valuealready hardens against exactly this class (set -f, Codex/report finding P2.2), so the precedent for fixing it exists. Not fixed here, for a reason specific to this PR:_effective_format()exists only to predict, from$INPUT_EXTRA_ARGS, what--formatvalue the realCMD+=($INPUT_EXTRA_ARGS)expansion will actually produce -- so it reads that variable the same (unsafe) way on purpose. Disabling globbing in_effective_format()alone while leavingCMDassembly unprotected would not close the vulnerability (the real invocation would still glob) and would introduce a new divergence between what this detection function predicts and what Click actually receives -- worse than today's status quo of "both glob identically, so they can't disagree." Closing this properly means hardening all four sites together in one coordinated change (CMDassembly,_effective_format,_extra_args_has_write_flag,_extra_args_write_json_path), verified against a hostile-glob test corpus the waytest_action_run_sh_helpers.py'sTestAddFlagHostileScalarCorpusalready exists foradd_flag/add_sided_flag-- a scoped, standalone follow-up, not a drive-by change bundled into a PR whose actual objective was the effective-format fix itself. -
action/run.shhas no "effective output path" counterpart to_effective_format— investigated, deliberately not fixed (Codex review, PR #998, fresh evidence).extra-argssupplying its own-o/--output(abicheck/cli_options.py's-o/--output) is a different flag than--format, and Click's last-flag-wins rule applies to it exactly the same way: anextra-args: -o report.jsonon top of an Action run with nooutput-file:input configured really does write the primary report toreport.jsonon disk instead of stdout — but$OUTPUT_FILE(this script's own tracking variable, sourced only fromINPUT_OUTPUT_FILE) never learns about it, so_json_report_srcfinds nothing: not$OUTPUT_FILE(empty), not$_STDOUT_JSON_FILE(nothing on stdout, since-oredirected it), not$PR_JSON(only populated when this script's own injection fires). A scan or compare that exits non-zero this way (e.g.EVIDENCE_CONTRACT_ERROR) publishes the genericERRORinstead of the real, more specific verdict its own report on disk could have named. Not fixed here, for the same "coordinated primitive, not a narrow patch" reason as the pathname-expansion gap above:--writealready has its own effective-value recovery (_extra_args_write_json_path), but-o/--outputhas none, and building one properly means giving it the same freshness/fingerprint discipline_json_report_srcalready applies to$OUTPUT_FILE(a pre-existing file at the extra-args path must not be trusted as this run's own output) — a new_effective_output_filehelper and its own test suite, not a one-line change to a single call site. -
BundleFacts(and its G40 archive container) has no published JSON Schema, in eitherabicheck/schemas/ordocs/reference/schemas/— investigated, not fixed (Codex review, CLI cleanup phase two's PR I "artifact_type discriminator" prerequisite). That PR's own plan text states the ordinary "Merge criteria" machine-contract obligations (packaged and documented schema copies, JSON Schema validation) apply when a manifest changes, and the bumpBUNDLE_FACTS_SCHEMA_VERSION(inabicheck/bundle_facts.py) got for the newartifact_type/BUNDLE_ARCHIVE_ARTIFACT_TYPEmarkers is exactly that kind of change — but a repo-wide search confirms neither container has ever had a schema file:abicheck/schemas/coverscompare_report,aggregate_report,build_evidence, andbuild_source_packonly, anddocs/reference/schemas/v1/mirrors that same set. This is a pre-existing gap predating this PR (the container has existed since G38 Phase 2 with no schema at any version), not one this PR's own diff introduced or made worse. Not fixed here because authoring a first JSON Schema for a format with no existing schema infrastructure (scripts/publish_schemas.py's packaged/documented-copy machinery, plus real validation tests) is a substantial, separate deliverable — not a narrow addition to a field-and-classifier PR — and becauseBundleFacts' own shape is still scheduled to change again shortly: PR I's ownBundleCompareRequestunification may still touch this container's fields before the format truly stabilizes, and authoring a schema now only to revise it again for that landing would be wasted work on the exact same axis. Correction (2026-09-04, Codex review, PR #1050):GateOptionsitself is no longer a blocker here — ADR-064's own dedicated slice landed it 2026-09-02 (abicheck/policy/release_gate_options.py); PR I's ownBundleCompareRequestunification simply hasn't landed yet, for reasons unrelated toGateOptions. Tracked here rather than deferred silently; the schema-authoring work belongs with (or immediately after) whichever PR actually stabilizesBundleFacts' shape at its currentBUNDLE_FACTS_SCHEMA_VERSION(abicheck/bundle_facts.py) -- theBundleCompareRequestPR itself, or a dedicated follow-up if that PR's own scope doesn't naturally include it. -
The weekly
Mutation testingscheduled lane (.github/workflows/mutation.yml, jobmutmut (detector core)) can outgrow its own job timeout before producing a receipt — investigated, partially mitigated, not fully fixed. The job'stimeout-minuteswas originally set to 240 with a comment recording that a full baseline run "has taken just over two hours" at the time (2x headroom).only_mutate(pyproject.toml's[tool.mutmut]) has grown since — the module map's own note under "Test-quality gates (beyond line coverage)" inAGENTS.mdrecords it "now covers identity, suppression and serialization alongside diff_/checker_policy" — and the scheduled run on 2026-08-31 (job99506019384) ran the full 240 minutes and was cancelled by that exact timeout without producing amutation-receipt.json: its own "Run mutation testing (baseline drift)" step shows starting, then nothing in the job log until GitHub kills it at the wall-clock ceiling. The three most recent weekly runs before that (2026-08-17, 08-24, 08-31) all endedcancelledthis same way, meaning the per-module baseline-drift gate had not actually completed a real weekly comparison in that whole window — a silent gap in exactly the class of coverageAGENTS.md's own "Mutation testing" section describes as this repo's strongest test-quality signal, though not a silent failure on GitHub's own Actions tab: the workflow's existing "Flag a cancelled or incomplete run" step already turns a cancellation into a loud step-summary warning, it just doesn't make the run finish. Mitigated, not closed:* raisedtimeout-minutesto 355 — effectively GitHub-hosted runners' own hard 360-minute per-job ceiling (not something a workflow can raise higher), minus a few minutes of buffer for this job's surrounding checkout/install/save/upload steps. This gives roughly 50% more headroom than the run that was observed failing, but there is no measurement confirming a full run now completes within it — reproducing that would mean deliberately running (and waiting out) another multi-hour scheduled job, which wasn't done here.mutmut's ownmax_childrenalready defaults toos.cpu_count()(parallel mutant execution is not a missing lever), so if 355 minutes still isn't enough, the real fix is scopingonly_mutatedown or splitting the weekly run across multiple scheduled invocations (e.g. half the module list per run, in rotation) rather than requesting a runner tier this repo has not established it has access to. Recorded here rather than claimed fixed, per this repository's own "generalize, or record the gap" convention — this was a direct timeout-value bump for an observed cancellation, not a verified capacity fix. -
--depthis a floor for live extraction, not a ceiling for a pre-built snapshot — real, cross-cutting, and previously undocumented outside one function's own docstring. Fixed (ADR-063 Phase 8 follow-up): the comparison-time projection this entry's own "real fix" paragraph called for is nowabicheck.policy.depth_projection.project_snapshot_to_depth, applied byclassify_compare_pairright afterenforce_requested_depthconfirms the floor.enforce_requested_depth(workflows/artifact/execute.py) already fails a run when the resolved evidence falls short of an explicit--depth, and its own docstring has long carried this note: "this is a floor, not a ceiling. An input that is an already-serialized JSON snapshot with richer embedded evidence thandepthrequested still carries all of it —resolve_input'sfmt == "json"branch returnsload_snapshot(path)verbatim... which--depthhas never projected down for a pre-built snapshot either." A Codex review round on PR #1016 (D1: accepting--depth binaryfor a directory/package compare) reproduced this concretely and flagged it as if newly introduced:compare old_dir new_dir --depth binaryover a directory of saved JSON snapshots (rather than live binaries) still emits real header-derived findings and can still publishBREAKING, because nothing strips a snapshot's already-embedded evidence down to what was requested. Checked and confirmed not a regression from that PR — a single-paircompare old.json new.json --depth binaryover two plain snapshots reproduces the identical behavior today, unrelated to any directory/ package handling; PR #1016 only extended--depth binary's acceptance to a second operand shape that inherits a limitation the single-pair path has always had.cli_compare_options. _resolve_depth_for_set_inputs(named_reject_depth_for_set_inputsuntil its per-rung allow-list was deleted -- see "A depth shortfall is a hard per-memberERROR..." below) cross-references this entry so the directory/package path states the same acknowledged limitation explicitly rather than silently inheriting an undocumented one. The two obvious-looking fixes below were, and remain, correctly rejected — the eventual fix is neither of them, kept here so both stay un-reattempted: stripping a resolved snapshot's higher-level facts down to the requested depth before comparing would work for this one call site, but would also discard evidence a caller legitimately wants to keep on a snapshot that gets reused for a later comparison at a higher depth —--depthis meant to gate what a comparison uses, not to mutate a snapshot's own persisted content. Rejecting--depth binaryoutright for any operand backed by a pre-built snapshot (matching the pre-#1016 directory/package behavior) would reintroduce exactly the asymmetry D1 closed, since the single-pair path already accepted and silently under-enforced the same combination.
The fix actually shipped is exactly the comparison-time projection this
entry called for: resolve the snapshot as before, then filter what
checker.compare() is allowed to see down to the requested rung, keeping
the resolved AbiSnapshot itself untouched.
abicheck.policy.depth_projection.project_snapshot_to_depth is that
filter — pure (returns a deep copy), mapped onto the public
binary/headers/build/source ladder via the exact same rank table
(evidence_depth.DEPTH_RANK) this entry's own "applied the other
direction" phrase named, and validated against the one prior
already-trusted reference implementation of this idea:
scripts/check_tier_accuracy.py's project(), which the per-tier
accuracy gate runs against a real (if synthetic) labelled corpus.
classify_compare_pair (service_compare_pipeline.py) applies it (via
project_pair_to_depth) to a local old/new view, gated on
request.depth is not None — the same "no explicit depth, no effect"
contract enforce_requested_depth already has — right after that
function confirms the floor; pair.old/pair.new themselves are never
mutated, so a caller reading the unprojected snapshot elsewhere is
unaffected. dump deliberately does not apply this at write time,
for the exact reason given above (it would discard evidence a later,
deeper comparison might want) — --depth on dump stays floor-only,
unchanged. See project_snapshot_to_depth's own docstring for the
precise field-by-field scope (visibility/origin scoping, macro/constexpr
constants, the Python-API stub surface, the header-AST SemanticIR, the
header-only surface_graph, build_mode, the BuildSourcePack
L3/L4/L5 split; below headers with no DWARF backing (dwarf.has_dwarf
false), types/enums/typedefs/signatures are fully stripped too,
not merely re-scoped — matching a real DWARF-less binary-only dump
exactly, per extract/export_symbol_identity.py's own production
behavior).
A second review round (Codex, same PR) found two more real gaps in the
first cut of this fix, both now closed: (1) the projection was wired
only into classify_compare_pair — the typed-API/release-fan-out
chokepoint — while the native abicheck compare CLI
(cli_compare_helpers.run_compare) and scan --against's baseline path
(cli_scan_baseline._run_baseline_compare) each call
compare_snapshots() directly and never saw it; both now apply
project_pair_to_depth themselves, right after resolving their own
pair, mirroring classify_compare_pair's placement. (2) the binary
rung's structural-fact handling was fixed at DWARF-informed (L1)
behavior unconditionally — a purely header-derived snapshot with no
DWARF at all still leaked full types/enums/function-signature data
through a binary-depth projection; _snapshot_has_native_debug_info
now picks L0 (fully stripped) vs. L1 (structural facts kept) per
snapshot, not fixed to one or the other.
A third review round (Codex, same PR) found four more real gaps, all
now closed: (1) an explicit out-of-band --old/new-sources/
--old/new-build-info pack directory is resolved separately from the
snapshot object project_pair_to_depth projects — cli_compare_helpers.
run_compare passed the raw pack paths straight through to
prepare_embedded_build_source, which reloads and diffs them
unconditionally, so a pack-backed compare --sources ... --depth binary
still leaked full L3-L5 findings even after the first two rounds' fixes.
Closed with a new project_build_source_pack_to_depth (mirroring
project_snapshot_to_depth's own build_source capping, factored into a
shared _project_build_source_pack helper both call) — run_compare now
resolves each side's pack itself, caps it, attaches the capped pack back
onto old.build_source/new.build_source, and passes None for the
four path arguments so prepare_embedded_build_source's own
resolve_side_pack falls back to the now-capped embedded payload instead
of reloading the raw one; the same fix also corrected the reporting
side (analysis_assurance/old_evidence_depth/new_evidence_depth),
which previously re-resolved the uncapped pack from the raw paths a
second time for these fields even after the findings themselves were
capped. (2) snap.contract (an ADR-050 ExtractionContract) survived a
binary-depth projection, so checker.compare() could still raise a
scope/profile mismatch error from two sides' original header scopes
even though a binary-only comparison never looks at either — now cleared
alongside the other L2+ facts. (3) a Visibility.HIDDEN (non-exported)
function/variable was promoted to ELF_ONLY like every other function/
variable, manufacturing a false *_removed_elf_only finding for a
declaration no real binary-only dump would ever have seen as a symbol at
all — now dropped from the projected snapshot entirely instead. (4)
types/enums were kept or dropped by the whole-snapshot
dwarf.has_dwarf flag, the same coarse signal functions/variables still
use — but this codebase's model does carry real per-record DWARF
evidence for these two families (DwarfMetadata.structs/.enums, keyed
by name), so an uninstantiated header-only record sitting alongside
unrelated real DWARF content was incorrectly retained; a new
_dwarf_confirmed_names filtered types/enums per declaration name
instead of by the whole-snapshot flag. Superseded by the fourth review
round below — the per-record name check turned out to be one level too
narrow.
A fourth review round (Codex, same PR) found three more real gaps, all
now closed: (1) the third round's per-record _dwarf_confirmed_names
fix retained a header-derived RecordType/EnumType wholesale
whenever DWARF merely confirmed the same-named struct/enum existed —
DWARF confirming a struct's name says nothing about whether its fields
agree with the header's own spelling (DWARF only ever backfills numeric
layout onto a header-derived record — dumper_layout_backfill.py's
own scope — never its field-level type text), so a header-only field-type
change could still leak through a binary-depth projection whenever an
unrelated real DWARF struct happened to share the changed struct's name.
Fixed by replacing the per-record name check with a whole-snapshot
not snap.from_headers requirement (_structural_facts_are_dwarf_
confirmed, replacing _dwarf_confirmed_names/_snapshot_has_native_
debug_info): AbiSnapshot.from_headers's own field comment states the
real distinction precisely — "DWARF-derived declarations populate the
SAME functions/types lists [as header-derived ones] but must NOT be
mistaken for header-level evidence." Only a genuinely DWARF/symbols-only
dump (from_headers is False, where dwarf_snapshot.py's own
DWARF-only extraction path populates types/enums/typedefs/
function-variable signatures directly from DWARF DIEs) may now keep
those fields wholesale; a header-derived snapshot always clears them at
binary depth, same as the no-DWARF-at-all case. A real DWARF-visible
struct/enum layout change on a header-derived snapshot is still caught
independently, through the untouched snap.dwarf.structs/.enums
fields via diff_platform._diff_dwarf (which this module never clears,
and degrades gracefully with no header model at all — "If the header
model is absent... fall back to comparing all DWARF types", that
function's own comment) — function/variable signature changes have no
equivalent independent DWARF-native detector, a real, separately-
justified gap (it would need a new per-declaration confirmation fact the
dumper does not currently record), not attempted here. (2) a function/
variable promoted from a header parser's own "declared public, without
contrary evidence" fallback (e.g. dumper_castxml._variable_visibility's
un-emitted customization-point-object case) was promoted to ELF_ONLY
the same as a genuinely exported one, manufacturing a false
*_removed/*_removed_elf_only finding for a declaration no real
binary-only dump would ever have seen as a symbol at all. Fixed with a
new _exported_symbol_names (a small, local copy of the same "raw
export table" read this codebase's cross_source_checks_base.py/
snapshot_exports.py/post_manifest.py/diff_unnamed_types.py each
already keep their own independent copy of, since a policy-layer
caller may import model/compare but not extract, ADR-061, where
most of those live): a surviving function/variable must now also appear
in the snapshot's own raw ELF/PE/Mach-O export table before promotion to
ELF_ONLY; skipped (falls back to the prior, looser behavior) when no
platform table was parsed at all, so a synthetic/incomplete snapshot
isn't stripped to nothing. (3) nulling a BuildSourcePack's
source_abi/source_graph between build and source left their own
LayerCoverage rows in pack.manifest.coverage still claiming
PRESENT/PARTIAL — evidence_report.optional_coverage() returns
those rows directly, so a report could still claim source-ABI/
source-graph evidence backed a comparison the depth ceiling actually
excluded it from. Fixed with _mark_layers_not_collected, demoting the
L4/L5 coverage rows to NOT_COLLECTED in the same place the payload
fields themselves are cleared.
--exclude-header cannot narrow a --dump-manifest dump¶
dump/compare --exclude-header PATTERN filters the resolved -H header
list. A manifest dump does not use that list: it parses the
translation_units[] and public_header_paths the manifest document itself
declares (ADR-050 D3). So the two together matched nothing, changed nothing,
and were still recorded on the snapshot as excluded_header_patterns and
reported as headers omitted -- a request recorded as achieved when it was
not, which is the failure --exclude-header was added to stop, reappearing
one layer down (Codex review on PR #1283).
The combination is now rejected (workflows.input_resolution.
reject_exclusions_against_a_manifest, a ValidationError) rather than
applied, and that is the part that is a gap rather than a fix. Excluding a
header from a manifest-driven dump is a real thing a user may want; what is
wrong is doing it from the command line. A manifest is an exact,
self-describing extraction contract, its roots are matched exactly by
dumper_scoping.dump_manifest_header_roots, and narrowing it from outside
would contradict the document the run was told to honour -- while dropping a
translation unit could change what compiles at all.
The right home for the capability is the manifest: an exclusion expressed in the document, so the contract stays self-describing and the snapshot's recorded patterns keep meaning "what this extraction actually did". Not attempted here, since it is a manifest-schema change with its own migration rather than a review-round fix.
<skip_headers> cannot stop a transitively-reached header being analyzed¶
The glob question this entry used to record is closed. It asked whether
real ABICC globs in <skip_headers>, and left descriptor skips matched as
exact basenames or paths in the meantime -- under which every tree-relative
rule (Intel MKL's own fftw/fftw.h, fftw/offload/) matched nothing at all
while the run still produced a confident verdict. The evidence the entry
asked for is in ABICC's own source: Internals/Path.pm's classifyPath and
Internals/Filter.pm's skipHeader_I classify each rule into one of three
classes -- a bare name matched against the basename, a value containing a
separator matched against the path at component boundaries (including a
directory's descendants), and a metacharacter-bearing value compiled as a
regex-like pattern. abicheck/model/header_skip_rules.py is that
classification, and the descriptor's snapshot now records
excluded_header_matching = "abicc" rather than "exact", since the same
text genuinely names a different set of headers under the two rules.
Rewriting every rule to a bare basename was considered and rejected: it fixes
MKL and over-excludes an unrelated version.h under a different subtree, and
no example test drawn from the reported case can tell the two apart.
What is still open is the other half of <skip_headers>'s meaning. ABICC
distinguishes two elements -- <skip_headers> is "do not include and do not
analyze", <skip_including> is "do not include directly, but still analyze
when reached" -- and the two are now modelled separately, with only
<skip_headers> recorded as an achieved narrowing of the surface. Both
correctly drop a header from the direct -H operand list. Neither can stop
a header being parsed when another header reaches it through its own
#include, because filtering happens on the resolved header list after the
directory walk and before any parse. The native --exclude-header path
shares that limitation exactly (extract/header_exclusions.py), which is why
closing it is a post-parse filter by defining header rather than a change to
either rule language -- and why it is recorded here rather than patched on
the descriptor side alone.
Until then, docs/reference/abicc-format-compliance.md states partial
support for <skip_headers>, and the two elements' remaining difference is
in what a run records, not in what it parses.
The composite Action's single --write slot can leave its unconditional coverage/assurance/severity floors without a structured report¶
Superseded (ADR-063 Phase 6, Track T8): the SARIF-fallback/HTML-gap
account this entry used to carry is retired along with the rendered-prose
readers it described. _report_compat_verdict/_severity_gate_exit/
_coverage_gated/_assurance_gated no longer have a "rendered markdown/
text" fallback tier at all (_text_report_content is deleted), nor a
SARIF runs[0].properties.abiVerdict branch — every one of them is
JSON-only now (run_outcome, then the legacy per-field JSON, then
nothing), per action/AGENTS.md's "How run.sh resolves the verdict it
publishes". A format: html primary was never coherent evidence for this
group of readers, so its own absence is no longer a special case either.
What remains, narrower than before: the CLI's --write option is still
single-valued (secondary_output.py's --write FORMAT=PATH, not
multiple=True), and scan/compare's own internal --write
json=$PR_JSON sidecar injection (unconditional since Track T8 — no longer
gated on pr-comment, closing the gap this same track's own Codex review
found in the pr-comment: false case) is still skipped whenever the
step's own extra-args already supplies a --write of any format
(_extra_args_has_write_flag), since extra-args is appended after the
injection and Click keeps only the last occurrence of a repeated
non-multiple option — a second, JSON-targeted --write this script
appended would always lose to the user's own later one and never execute.
When that user-supplied --write targets json=, _extra_args_write_json_
path already recovers it, so _json_report_src still finds structured
evidence. When it targets a non-JSON format (-o text=...,
-o markdown=..., ...), there is no JSON anywhere, and ADR-049's
unconditional contract-coverage/analysis-assurance floors plus the
severity-category gate genuinely go blind for that one run — accepted, not
fixed, per action/AGENTS.md's own "Known, accepted limitation"
paragraph, since closing it would mean either silently discarding the
user's explicit --write choice or unconditionally re-running the
analysis a second time purely to obtain JSON (a materially larger,
separate trade-off _maybe_post_pr_comment only accepts today for the
sticky-comment feature, under its own narrower guards). If this becomes a
real reported problem rather than a review-found edge case, the honest fix
is the same one this entry always named: make --write accept multiple
FORMAT=PATH operands (a real CLI capability change touching
secondary_output.py and every command that declares the option, not
just this Action script) — not another per-reader special case.
bundle_facts_store.read_bundle_facts_package holds a bundle's raw JSON and its decoded AbiSnapshots concurrently at peak¶
A Codex review round on PR #1054 (the Track B/C duplicate-multi-artifact-
writer reconciliation) found that delegating read_bundle_facts_package to
storage.import_bundle_facts.export_bundle_facts regressed peak memory
behavior relative to the reader it replaced. The removed reader converted
each artifact's document to an AbiSnapshot immediately inside its
per-artifact loop (per_library_snapshots[library_name] =
snapshot_from_dict(document)), discarding the raw JSON document each
iteration; export_bundle_facts instead accumulates every artifact's raw
JSON document into one per_library_snapshots: dict[str, Any] and returns
it as a whole document, and bundle_facts_from_dict() then decodes every
entry in one pass — so at peak this can hold both the full set of raw JSON
document trees and (once decoding starts) their AbiSnapshot counterparts
concurrently.
Not fixed: closing this fully means changing export_bundle_facts's own
return contract from "one complete bundle_facts_from_dict()-shaped
document" (its module docstring's stated contract, shared with
storage/import_baseline_set.py's sibling adapter) to a streaming/
generator form the caller converts and discards from as it goes — a change
to the shared storage.import_bundle_facts module's public shape, not a
caller-local fix, and outside what that PR (reconciling the Track B/C
duplicate writers onto that one shape) was scoped to redesign. It is
bounded in the meantime, not unbounded: read_bundle_facts_package's own
DEFAULT_MAX_BUNDLE_DECODED_BYTES check runs before bundle_facts_from_dict
is ever called (raising during the incremental per-artifact charge if the
raw documents alone already exceed the budget), so peak memory here is
capped at roughly the charged-byte budget's raw-JSON cost plus its decoded-
object cost — a bounded constant, not a growth path uncorrelated with any
limit this module already enforces. If this becomes a real reported
problem rather than a review-found edge case, the honest fix is reworking
export_bundle_facts/import_baseline_set's shared adapter shape to
stream per-artifact results to the caller instead of returning one
document, which both known callers would need to be updated for together.
Dependency static/dynamic linking-mode change has no ChangeKind¶
Found by Phase 2/3 of
ABI/API knowledge and corpus —
the one taxonomy leaf (dependency-abi.linking-mode-change,
docs/contribute/abi-api-failure-taxonomy.md) that no ChangeKind claims
at any evidence tier, and therefore the only NOT_IMPLEMENTED row in
the coverage matrix.
When a dependency switches between static and dynamic linking, the
dependency's own symbols change status wholesale: previously resolved at
load time through DT_NEEDED (or the PE import table / Mach-O load
commands), they are now duplicated into the artifact itself, with their own
visibility, ODR exposure, and interposition behaviour changing with them —
and the reverse when a vendored dependency is unbundled. Nothing in the
registry names that transition. Its consequences are partly observable
through unrelated kinds — needed_removed when the dependency leaves the
needed list, symbol_leaked_from_dependency_changed /
visibility_leak when its symbols surface in the artifact's own export
table — so a real occurrence produces two or three findings that each
describe a symptom and none of which says what happened.
Not fixed here: Phase 2/3 of that plan is a mapping and classification
pass, and the plan's own "Out of scope" section rules out any detector,
evidence, or ChangeKind change (a NOT_IMPLEMENTED finding is recorded,
not fixed, by it). Tractable when it is picked up: both sides' needed/import
lists and export sets are already collected at L0, so the evidence a
detector would join is present today — what is missing is the join, a kind,
and the "vendored vs. unbundled" direction in its impact text. Note the
adjacent, deliberately unclaimed half: whether the newly-static
dependency's code is ABI-compatible with what consumers already linked is a
question about a third artifact abicheck was not given, which is
dependency-abi.transitive-break's territory, not this leaf's.
compare --no-baseline does not yet reproduce scan's audit-mode findings¶
Found while migrating the Phase 4 documentation and corpora of
plans/one-comparison-product.md
(ADR-068 D2, §3 row 2). abicheck compare --no-baseline CANDIDATE exists,
takes one operand, and correctly records the OLD side with ADR-065's
declared_absent acquisition state — but it does not report the
intra-version cross-source hygiene findings that are the entire reason the
single-build audit exists. It fails in two different ways depending on the
candidate's shape, both verified live against main at fd6ba681:
- A stored
.abi.jsoncandidate crashes. All eleven G20 audit fixtures (catalog/cases/case14{3,4,5,6,7,8,9}_*,case15{0,1}_*,case181_*, pluscase151's secondthin.abi.jsonvariant) abort with an unhandledAssertionErrorfromworkflows/no_baseline_compare.py'sassert not diff.changes, "a snapshot compared against itself must never produce a change -- if this fires, a detector is reading non-identity state".scan <same fixture>reports its documented verdict and hygiene finding on every one of them. - A live binary plus
-Hrenders an empty report. Againstexamples/workflows/audit-release's own builtlibgreet.so,scan libgreet.so --header includereportscrosscheck:exported_not_public present … undeclared_export=1and[warning] exported_not_public: 1, whilecompare --no-baseline libgreet.so --header include -o json=...exits 0 with"changes": [], noverdict, and no cross-source orpattern_preprocessor_scanblock in the document at all.
The root cause is structural, not a stray bug in one detector:
run_no_baseline_compare implements the audit as a self-diff (_diff_pair(new,
new, …)) and then asserts the diff is empty. That assertion was correct
when compare() had no per-side stages, but Phase 2a/2b moved all eleven
cross-source checks and the pattern/preprocessor pre-scan into
compare(), where they legitimately emit persistent findings on a
self-compared snapshot. So the audit path either trips its own invariant or,
where the checks stay dormant, yields a document with nothing in it — and
either way --no-baseline cannot express what ADR-068 D2 promises it
replaces.
Not fixed here: this documentation/corpora slice owns docs/, examples/,
eval/, validation/, catalog/, and skills-src/ only, and the fix is
in abicheck/workflows/no_baseline_compare.py plus whatever
report/-side projection has to carry a one-sided finding set. Consequences
recorded rather than papered over: docs/integration/scenarios/single-build-audit.md,
docs/use/evidence-depth.md, and docs/start/choose-your-workflow.md all
still name scan as the working spelling for a no-baseline audit, and every
G20 audit case's README keeps its abicheck scan reproduce command with an
explicit "blocked on this gap" note rather than being re-driven onto
compare --no-baseline or quietly dropped from the catalog.
Tractable when picked up: replace the assert not diff.changes invariant
with an explicit partition — identity-diff findings (which genuinely must be
empty, and where the assertion's original intent still holds) versus
per-side CrossSourceEvolution/pattern_preprocessor_scan output (which
must survive into the rendered document, marked persistent, with no
addition/removal/compatibility verdict per D2). ADR-068 D3's
not_evaluated rule is the constraint on the second half: with OLD
declared_absent there is no baseline evidence, so nothing may ever be
reported as introduced. The parity harness (tests/parity/) already has
the scan-vs-compare shape needed to gate it, and the eleven fixtures above
are a ready-made acceptance corpus.
Closed (2026-09-09). Both failure shapes were reproduced live against
main at ed4e70e3 before anything was changed — the stored-fixture crash
on catalog/cases/case143_audit_accidental_export/snapshot.abi.json, and
the empty document from compare --no-baseline libgreet.so --header include
against examples/workflows/audit-release's own build — and both are fixed.
The write-up's suggested partition is what landed, plus one root cause it
did not name:
- The partition.
abicheck/policy/no_baseline_findings.py(new) owns it:partition_no_baseline_findingssplitsdiff.changesonChange.cross_source_evolution is not None or Change.candidate_side_enrichment(the two markerscompare()itself sets — deliberately not aChangeKindallowlist, since a kind-keyed filter is a second place every new candidate-side kind would need teaching, exactly the driftno_baseline_compare's own docstring already argues against).check_no_baseline_partitionkeeps the original assertion's real intent scoped to the identity half, and adds D3's rule (NO_BASELINE_EVOLUTION_STATES = {persistent, not_evaluated}) over the other. Both are raised errors, notasserts: the original guard was stripped underpython -O, which would have turned an identity-half violation into a silently wrong report rather than a loud failure.workflows/no_baseline_compare.pyreturns the candidate-side half onNoBaselineCompareResult.findings, andreport/no_baseline.pyrenders it. - The second root cause the write-up did not name. Fixing the partition
alone still left the live-binary case reporting nothing. The no-baseline
CLI passed
-H/--headeras parse input only, never as public-header provenance — where a two-sidedcomparehas always folded each side's headers into its public-header sets (service_compare_pipeline._public_header_sets, viaheader_utils.split_public_header_inputs). Without that fold every declaration stayedScopeOrigin.UNKNOWN, so the four boundary-dependent checks (exported_not_public,public_not_exported,rtti_for_internal_type,public_to_internal_dependency) evidence-gated toNOT_EVALUATEDand reported nothing — correctly, given what they were told.frontends/cli/commands/compare_no_baseline.py's_resolve_public_header_setsnow applies the same split-then-tag rule (split before tagging, never after: an unsplit directory entry corruptsscope_fingerprint).
Evidence. tests/parity/test_no_baseline_audit_corpus_parity.py is the
acceptance lane this entry asked for, over all eleven fixtures (twelve runs
— case151 contributes its thin.abi.json variant too, the corpus's own
"weaker evidence narrows conclusions" case). Measured result:
compare --no-baseline and scan agree exactly on every fixture —
nothing lost, nothing manufactured:
| Fixture | scan crosscheck.counts_by_check |
compare --no-baseline findings[] |
|---|---|---|
| case143 | exported_not_public: 1 |
exported_not_public: 1 |
| case144 | private_header_leak: 1 |
private_header_leak: 1 |
| case145 | unversioned_exported_symbol: 1 |
unversioned_exported_symbol: 1 |
| case146 | rtti_for_internal_type: 2 |
rtti_for_internal_type: 2 |
| case147 | private_header_leak: 1 |
private_header_leak: 1 |
| case148 | header_build_context_mismatch: 1 |
header_build_context_mismatch: 1 |
| case149 | odr_type_variant: 1 |
odr_type_variant: 1 |
| case150 | exported_not_public: 1, public_not_exported: 1 |
same |
case151 (snapshot) |
private_header_leak: 1 |
private_header_leak: 1 |
case151 (thin) |
private_header_leak: 1 |
private_header_leak: 1 |
| case181 | public_to_internal_dependency: 1 |
public_to_internal_dependency: 1 |
The table above is the measured result on today's fixtures. What the lane
asserts is deliberately weaker and directional, in two halves, because
checking only one is how this gap got in: no capability loss (every check
scan fires, compare --no-baseline fires at least as often, counted per
finding kind) and nothing manufactured (every reported finding is
genuinely candidate-side, in a D3-permitted state, with changes == [] and
verdict == null still holding). It is not an identical-report assertion,
and must not be described as one: the contract this PR was gated on is
"the same or a strictly richer finding set", so pinning equality would
fail a future run that legitimately reports more.
One capability scan has that the audit report does not (found while
correcting that overstated claim, recorded rather than quietly reworded):
scan's crosscheck block carries a per-check coverage row — status
(present/skipped/partial), its detail line, and the providers list
that corroborated it. The audit report states each finding and its D3
evolution state, and its cross_source_evolution block counts the four
states, but no per-check row: a check that ran and found nothing, and a
check that could not run at all, are both simply absent from findings[]
(the Markdown renderer says so in prose; the JSON does not distinguish
them). That is squarely against vision.md's "record before disposing"
and ADR-068 D9, so it is a real gap, not a design choice — it is left open
here rather than folded into this PR because a coverage block is a report
schema addition with its own compute/render pair, not a bug in the
findings path this entry closed. catalog/cases/case151_xcheck_provider_
matrix/README.md reads the providers off run_crosschecks() directly in
the meantime, and says why.
A second capability difference, on the exit code (found the same way, by
regenerating the catalog's expected outputs against the real command instead
of trusting the prose): legacy scan derives a verdict from an audit's own
findings, so a hygiene finding whose ChangeKind is API_BREAK-classified
gates CI at exit 2 — verified live, scan on case148's and case149's
committed snapshots exits 2, while case143's RISK-classified finding
exits 0. compare --no-baseline exits 0 for all of them: ADR-068 D2 says
an audit reports no compatibility verdict, and 2 is the compatibility
family's own source-break code, so emitting it would be manufacturing
exactly the signal D2 forbids. The CLI was at least loud rather than
silently ignoring the request — --severity-preset with --no-baseline
used to be a usage error (exit 64) naming this reason, not a no-op.
Closed (2026-09-10 ADR-068 amendment). policy/audit_gate_exit.py adds
the orthogonal audit-gate axis this entry called for: its own exit code
(3 — surveyed against every code compare/scan --against already use,
the one small integer none of them claims), folded with max exactly like
the coverage and evidence-contract axes, so it can raise a clean 0 and can
never lower or be mistaken for a 2/4. It is opt-in, not on by
default (every pre-existing compare --no-baseline invocation's exit code
is unchanged), activated by passing --severity-preset (any value except
info-only) — that option is no longer a usage error under
--no-baseline; passing it is now the declaration. The axis's own gating
rule reproduces legacy scan's exact partition — BREAKING_KINDS |
API_BREAK_KINDS gates, RISK_KINDS/COMPATIBLE_KINDS does not — read
directly off checker_policy.py's registry-derived sets, deliberately
not routed through severity.py's own four-bucket category model (that
model's potential_breaking bucket is API_BREAK_KINDS ∪ RISK_KINDS,
merged by design, which would have gated case143's RISK-classified
finding the same as case148/case149's API_BREAK-classified ones —
see the ADR amendment for the full reasoning this entry's fix rejected).
Verified against the exact reproduction this entry asked for:
tests/parity/test_no_baseline_audit_corpus_parity.py's
test_audit_gate_axis_matches_legacy_scan_gating runs the whole G20 corpus
with --severity-preset default and asserts case148/case149 exit 3
while every other fixture, case143 included, stays 0 — the same split
test_scan_baseline_exit_codes_documented_for_the_gated_fixtures pins for
legacy scan itself on the same committed snapshots. The typed Python API
carries no --no-baseline support at all yet, so there is no
CompareResult-shaped consumer to extend today. See
ADR-068's 2026-09-10 amendment
for the full account. scan's retirement (§3 of
docs/contribute/plans/one-comparison-product.md) is no longer blocked on
this gap specifically; the plan's own tracking is updated to match.
The composite Action's own translation has since landed too. At the
time the paragraph above was written, action/run.sh did not invoke
compare --no-baseline for mode: scan at all -- audit-only requests
stayed on the legacy scan CLI unconditionally (the 2026-09-09 amendment's
own remaining routing condition), so there was no live call site to
translate onto. That has closed: audit-only mode: scan now routes to
compare --no-baseline unconditionally too, the same way a baseline scan
already routed to plain compare AGAINST ARTIFACT. The Action injects
--severity-preset default on the translated invocation whenever the
caller stated no preset of its own (neither the dedicated severity-preset
input nor an explicit extra-args --severity-preset ...), which is what
keeps mode: scan's own documented default-gating behavior intact across
the migration -- a caller who already asked for a preset (info-only
included) keeps exactly the preset it asked for. Exit 3 is published as a
new AUDIT_GATE Action verdict output, alongside COVERAGE_INCOMPLETE/
SEVERITY_ERROR, and fails the step unconditionally (no fail-on-* input
governs it, since an audit reports no compatibility verdict to follow).
There is no MODE == "scan" condition left in action/run.sh's own
command assembly that exists to serve a legacy-CLI fallback. See
tests/test_action_run_sh_audit_gate.py for the end-to-end coverage
(real run.sh, real abicheck, the same G20 corpus fixtures this entry's
own parity lane already used) and action.yml's verdict output
description for the user-facing contract.
An audit's contract-coverage ledger still names an old side. Now that
compare --no-baseline --contract public publishes
contract_coverage_failures (it previously exited 1 carrying only the
numeric contribution), the entries are visible -- and half of them read
"side": "old" on a run whose OLD side is declared_absent. That is an
artifact of the audit being implemented as a self-compare: the evidence
collector sees two sides because it is handed the same snapshot twice.
Deliberately not filtered here. The same records produced the exit
contribution that gated the run, so dropping them from the listing would
make the ledger disagree with the number beside it -- exactly the "read,
don't re-derive" failure recorded elsewhere in this file. Doing it properly
means the collector knowing the run is one-sided, so it records one side's
observations rather than two identical ones, and the contribution follows;
that is an ADR-049/ADR-068 question about how a one-sided run collects
evidence, not a display fix. Until then the listing is honest about what
gated, and this entry is what says why an old row appears at all.
The audit's SARIF and JUnit projections do not cross the canonical
ReportDocument boundary (Codex review, P1, partially addressed). The
audit's JSON does: report.no_baseline.no_baseline_report_document wraps the
computed mapping in a real ReportDocument, so the structured output takes
that type's defensive immutable snapshot the way every two-sided format
does. SARIF and JUnit still read the typed NoBaselineDocument directly.
That is deliberate as far as it goes -- ReportDocument is an untyped
frozen mapping, and rewriting two renderers to index strings instead of
typed fields is a type-safety downgrade, which is why the two-sided HTML and
Markdown renderers also carry typed section structs -- but it does leave the
audit's two structured non-JSON formats outside the boundary the
report/AGENTS.md contract states for every format. The shared sections
Codex was concerned about (run_outcome, comparison_scope) are already
built by the same section builders the two-sided path uses, so those
specifically cannot drift; a new shared section added to
ReportDocument would still have to be added to the audit by hand.
Per AGENTS.md's bug-class rule the D3 invariant is also stated as a
property test over generated inputs rather than eleven fixed cases:
tests/test_no_baseline_d3_properties.py generates candidate snapshots
across the whole evidence ladder (export table present or absent,
declaration origins drawn independently of the export list, versioning
scheme on or off, private types present or not) and asserts that no
candidate ever yields introduced/resolved, that the comparison half is
always empty, that the reported set is exactly the candidate-side one
(the "no longer crashes but silently drops findings" failure a
does-not-raise test would miss), and that the audit is deterministic. Its
oracle is the forbidden-state set written out literally, not derived from
the implementation's own permitted set, so widening one cannot silently
widen the other. The partition helpers additionally get primitive-level
contract tests (totality, disjointness, order preservation) decoupled from
any snapshot, per AGENTS.md's "Primitive-level property tests" guidance.
Both suites were mutation-checked: silently dropping candidate-side findings
fails 14 tests, and permitting introduced fails 2.
Registered as bug class invariant.blanket_assertion_over_widened_population
in tests/regressions/manifest.py — the general shape being "a whole-set
emptiness assertion outlives the single population it was ever true of".
compare --no-baseline accepts --contract but never wires it through¶
Found in the same Phase 4 documentation slice, verified by reading
frontends/cli/commands/compare_no_baseline.py against main at the same
commit above (fd6ba681). compare --no-baseline accepts --contract
public|exports|all|auto — Click parses it, --help documents it — but
_run_no_baseline_compare_cmd never reads kwargs.get("contract") (or a
resolved contract_mode) and never passes anything contract-related to
run_no_baseline_compare. The contract-coverage ledger this axis's exit
contribution folds from is therefore never populated on this path: a
compare --no-baseline NEW --contract public run against a headerless
candidate exits 0, where the equivalent two-sided compare OLD NEW
--contract public exits 1 for missing public-header coverage. The flag
is accepted but silently inert, not merely undocumented — a CI job relying
on it as a gate gets no warning that it never ran.
Not fixed here, same file-ownership boundary as the gap above. The fix is
threading the resolved contract_mode/contract_evaluation config through
_run_no_baseline_compare_cmd into run_no_baseline_compare, the same way
the two-sided compare path already does via
compatibility_evaluation_frontend/contract_pipeline. Recorded in
docs/reference/exit-codes.md
rather than left as a silent behavioral gap in the doc that would otherwise
claim the axis "applies exactly as it would for a two-sided run."
Closed (2026-09-09). Reproduced live first: on main at ed4e70e3,
compare --no-baseline libgreet.so --contract public against a headerless
candidate exited 0 where the two-sided compare libgreet_old.so
libgreet.so --contract public exited 1. _run_no_baseline_compare_cmd
now resolves the flag through the same two cli_options helpers
cli_compare_helpers.run_compare uses — resolve_contract_evaluation (any
value activates the ADR-049 evaluator) and resolve_contract_domain
(auto maps back to None so D7's lower tiers decide, and its parameter
source is demoted from COMMANDLINE) — and passes both into
run_no_baseline_compare, whose contract_evaluation/contract_mode
parameters already existed and were simply never fed. Sharing the resolvers
rather than re-deriving is what makes an equivalent one-sided and two-sided
invocation activate identically.
Nothing downstream needed changing: report/no_baseline.py's
no_baseline_exit_code already folded coverage_exit_floor(result.diff),
which reads the ledger off the run's own persisted contract_context — it
had simply never been populated. Verified live after the fix:
--contract public on the same headerless candidate now exits 1, and a
run without --contract still exits 0 (no domain to be short of evidence
for, so every pre-existing invocation is unchanged). The corpus lane's
test_no_baseline_exit_code_is_clean_without_a_contract pins that second
half across all eleven fixtures, so the exit-1 case is a real signal rather
than noise.
compare --no-baseline parsed --sources/--build-info/--depth/--dry-run but read none of them¶
Closed (2026-09-09), in the same pass that closed the two entries above.
Recorded here rather than left unwritten because it is the same failure mode
as the --contract entry above — a flag Click parses and --help documents,
which the command body never reads — and it was found by auditing
_run_no_baseline_compare_cmd against compare's full option set once
--contract turned out to be inert, not by a user report. Verified live on
main at ed4e70e3 before the fix: compare --no-baseline libgreet.so
--depth build exited 0 where the two-sided compare libgreet_old.so
libgreet.so --depth build exited 7; --dry-run ran a full audit and
printed a report; --sources collected nothing.
Four separate wirings, each reusing the primitive the two-sided path already uses rather than growing a parallel one:
--sources/--build-info.resolve_no_baseline_candidatecalledworkflows.input_resolution.resolve_inputdirectly, which resolves L0–L2 only, so inline L3–L5 evidence was never embedded. It now routes throughworkflows.artifact.execute.resolve_side_snapshot— the same per-side primitivedumpand each side of a two-sidedcompareresolve through — with anInputSpeccarrying the candidate'ssources/build_infoand aSideEvidencewhose collect mode comes fromcollect_mode_for(depth, side)(already variadic over a single input precisely becausedumpasks it over one operand too).--depth. Feeds that collect mode, and is separately held to the same evidence-contract floor.policy/depth_evidence_contract.pygainedrecord_no_baseline_depth_evidence_contract_error, a named one-sided entry point that delegates to the existing two-sidedrecord_depth_evidence_contract_errorwith the OLD side excluded — spelled as its own function rather than leaving each call site to writeold=candidate, new=candidate, old_is_live=False, which reads as a bug at every call site. The stored-snapshot carve-out (is_live) carries over unchanged: a.abi.jsoncandidate this run never extracted cannot have fallen short of a depth. Verified live after the fix, one-sided and two-sided now agree exactly: exit 7 for--depth buildwith no build evidence, and exit 0 for the same command once a realcompile_commands.jsonis present.--dry-run.frontends/cli/compare_dry_run.pygainedbuild_no_baseline_dry_run_result, a sibling of the two-sided builder rather than a call into it: that builder's whole shape is two operands, and rendering a single-build audit through it would print the candidate twice underold:/new:labels — exactly the "a baseline was consulted" misreading ADR-068 D2 forbids. It reuses the shared section titles (dry_run.SECTION_ORDER) so both reports still read the same way, and blocks (exit 1) on a pinned--depth build/--depth sourcewith no--sources/--build-info, since the real run exits 7 on that same condition and a dry run that called it fine would be lying.-
Two more silently-inert flags on the same path, found by the same audit.
--write FORMAT=PATHwas accepted and dropped — a job asking for a JSON artifact alongside a human-readable report got neither the file nor a warning. It now renders from the same analysis rather than re-running it (ADR-068 D4's "a complete machine-readable result must always be obtainable without a second run"), reusing the sharedreject_incoherent_secondary_writesguard rather than a second copy of its two coherence rules, and rejects an unsupported secondary format naming--writerather than--format.--include-system-declarationswas dropped too: this path never passedinclude_dependenciesat all, so it silently gotresolve_input's unfiltered default (True) where every two-sidedcompareand everydumppasses the CLI's own filtered default (False). It now reads the flag, so a--no-baselinesnapshot is scoped the same way adumpbaseline is — verified live that the finding set is unchanged either way on theaudit-releaseexample. -
An
old=-scoped input is now a usage error, not a silent drop. A--no-baselinerun has no OLD side, so--sources old=…/--header old=…and siblings are rejected with a real message — the same guard_reject_view_tokens_for_no_baselinealready applies to--view. The distinction matters and is easy to get backwards: a bare--sources tree/populates both per-side dests (cli_options._split_sided_single), so "the old dest is set" is not "the user scoped this to OLD" — the guard fires only when the old dest holds something the new dest did not also get. Rejecting the bare spelling would break the single most useful invocation on this path.
--format ruling, dated rather than deferred. The Phase 2e slice
supported only json/markdown, with the other five a usage error "until a
later phase". That is now decided per format rather than left open:
sarif,junit,oneline— implemented. SARIF and JUnit are findings formats with no verdict slot to leave empty (a SARIF run is a list of results with rule ids and levels; a JUnit suite is a list of test cases), which is exactly what a single-build audit produces, and both are how a CI job consumes one. Rendered inreport/no_baseline.pyfrom the new frozenNoBaselineDocumentrather than throughsarif.to_sarif/junit_report.to_junit_xml, whoseexitCode/exitCodeDescriptionand suite partitioning are derived fromDiffResult.verdict— which on a self-compare readsNO_CHANGE, a compatibility claim D2 forbids this run from making. The per-finding metadata is reused, not reinvented:sarif._rule_for/_parse_source_locationare imported, so a rule id and help URI mean the same thing a code-scanning consumer already expects.html,review— remain a usage error, by ruling. Both are narrative renderings of a comparison, not projections of a finding list: HTML's information architecture is a verdict badge, an OLD → NEW version headline and addition/removal/modification tables;reviewis literally "what changed between these two releases, and should you ship it" (verdict, counts by direction, release recommendation, manual-review banner). With no baseline there is no change to review and no release to recommend, so a one-sided rendering of either is a new page/digest design, not a projection — genuinely different work from the three above, none of which needed a layout decision. The CLI's error now names this ruling and points atonelineas the closest honest equivalent to a review digest, instead of promising an unspecified later phase. The reasoning lives with the code, inreport.no_baseline.NO_BASELINE_UNSUPPORTED_FORMATS.
Four review findings on the first push, all real, all fixed. Recorded because two of them are the same defect class this entry is about — a derived answer disagreeing with the answer it derives from — and one reproduces a bug this repository had already fixed once elsewhere:
- A suppressed finding vanished (P1).
checker.compare()moves a matched finding out ofdiff.changesintodiff.suppressed_changes; the partition read only the former, so a suppressed audit was indistinguishable from a clean one — no way to tell that policy hid a finding, or which rule did. That is precisely whatvision.md's "Record before disposing" forbids ("100 removals detected, 100 suppressed by rule X" stays visible on a passing run). The suppressed half now gets the identical partition and D3 check (a suppressed comparison finding would be just as impossible), rides onNoBaselineCompareResult. suppressed_findings, and is projected by every format with its ownsuppression_rule: asuppressed_findings[]/suppressed_countpair in JSON, a table in Markdown, a count inoneline, SARIF's own nativesuppressionsarray, and a<skipped>case in JUnit. - JUnit failed a build the CLI passed (P1). Every finding got a
<failure>while the same document reported exit0— audit hygiene findings are advisory (ADR-028 D3 / ADR-035 D1) and ADR-068 D2 gives the run no compatibility contribution at all. This is the identical bugjunit_report._is_failure's docstring records having already been fixed once for the two-sided report ("reporting one<failure>beside aNO_CHANGEverdict and a clean exit was the bug"). Failure is now derived from the run's own gate: each finding gets a passing<testcase>(D9 — the fact stays visible, it just is not a failure) and oneexit codecase fails when, and only when, an orthogonal axis actually gated the run. --writebypassed the shared safe writer (P2). A rawPath.write_text()raisedFileNotFoundErrorafter the analysis and the primary render had already completed, when the target's parent directory did not exist. Now routed through_safe_write_output, the same writer-o/--outputuses, which creates parents and translates a failure into a clean Click error.- The dry run disagreed with the run it previews (P2). A pinned
--depth buildon a stored candidate was reported as a blocker (exit 1) while the real run exempts that operand from the depth floor (candidate_is_live=False— no extraction happened, so nothing can have fallen short) and exits0. The preview now takes the same carve-out from the same helper, so the two cannot diverge.
Generalized rather than patched per instance:
tests/test_no_baseline_report_formats.py states the invariants over every
format and every fixture — conservation (suppression only ever moves a
finding between the two lists, checked over generated rules that match all,
some and none), JUnit's failure count equalling the exit code exactly,
every format disclosing a suppression, every format agreeing on the exit
code, and JSON and SARIF agreeing on the finding set. Mutation-checked
against all four regressions, including the over-correction (making JUnit
pass by dropping findings satisfies the failure-count invariant while
losing the audit's whole content — a separate test catches that).
A second review round, four more findings, all real (2026-09-09). Two of them are again the same class this entry is about, and one is that class inside the mechanism built to prevent it:
- The exhaustiveness test had a hole exactly where it hand-waved (P1).
_CONSUMED_UPSTREAMallowlisted the pre-normalization option names (dump_manifest,version,debug_root,probe_matrix), on the reasoning thatnormalize_sided_optionsconsumes them before dispatch. It does -- but thenew_*/old_*destinations it generates were never read, so the test passed because the option had been renamed. A bare--dump-manifestwas accepted and dropped: even an invalid manifest exited0while the audit analysed a different surface than requested, and--version new=was equally inert. The test now expands each raw name into the destinations it generates and requires each one separately to be read or declared -- which immediately surfaced 16 silently-dropped destinations, not the four the review named.--version,--debug-rootand--include's per-path labels are now wired;--dump-manifest,--probe-matrix,--debug-infoand--devel-pkgare rejected (their guards re-keyed onto the generated names, since a guard keyed on the raw name could never fire);old_versionis recorded as inert by construction with its reason, since Click always populates it with the placeholder"old"and an explicit--version old=is therefore indistinguishable from the default by dispatch time. --depth binarystill ran the L2 header frontend (P1). The audit hand-built itsSideEvidenceinstead of going throughservice_compare_evidence.resolve_side_evidence, so it skipped that function's binary-depth clearing rules (headers and any dump manifest are dropped). Verified live: one-sided reportedevidence_tiers: [elf, dwarf, dwarf_advanced, header]and anexported_not_publicfinding where two-sided reported noheadertier at all — a manufactured finding at a depth documented as symbols-only. Fixed by reuse, not a second copy.- A failed evidence contract read as operationally successful (P1).
no_baseline_exit_codecorrectly returned7, whilerun_outcome. operationalstill serializednone.compatibility/gategenuinely never apply to an audit, but operational is a different axis, andOperationalStatus.EVIDENCE_CONTRACT_ERRORis its canonical state. Now read off the same flag the exit code folds, so the two cannot disagree. - A linker-script operand was misclassified as stored (P1). The depth
floor's live/stored carve-out asked
detect_binary_formatabout the operand as written. A GNU ldINPUT(...)/GROUP(...)script is text, so it answered "stored" and exempted the run -- whileresolve_inputhappily followed the script and performed a real extraction. Verified live: the same library named directly exited7under--depth build, and named through its script exited0. Now chain-resolved withbinary_utils.resolve_linker_script_chainfirst, which is what the resolver itself does with the same operand, so the two agree by construction rather than by coincidence. - Extraction failure escaped as a traceback (P2). A corrupt candidate
printed a full stack trace where the two-sided path printed
Error: Failed to dump '...'for the identical input. Now translated at the CLI boundary with the sameValidationError-> exit 64 /SnapshotError-> exit 1 mappingcli_resolve's ownrun_dumpwrapper uses.
One self-inflicted defect worth recording, since it is the same discipline
failure the entries above are about: splitting the SARIF/JUnit renderers out
to keep report/no_baseline.py under the 800-line cap created a real import
cycle (no_baseline -> no_baseline_render -> no_baseline, via the
renderer's NoBaselineDocument annotation -- the AI-readiness scan counts
TYPE_CHECKING imports too). It reached CI because after that last change
check_architecture was re-run and check_ai_readiness was not; only one of
the two catches this. Fixed by giving the shared contract its own leaf,
report/no_baseline_document.py, which both halves depend on and neither
depends back through -- this package's own document.py-versus-render_*
pattern, and the remedy AGENTS.md prescribes ("move the shared logic to a
leaf module", never extend IMPORT_CYCLE_ALLOWLIST).
Each has a regression test in tests/test_compare_no_baseline_cli.py, and
the exhaustiveness rule itself gained
test_every_generated_destination_is_wired_or_declared -- the half the
original test missed. report/no_baseline.py crossed the 800-line new-file
cap in the process; its SARIF/JUnit renderers moved to
report/no_baseline_render.py, matching this package's own
render_json.py/render_xml.py split, rather than being trimmed to fit.
Coverage and complexity follow-up (same pass). Codecov's patch gate and CodeFactor both flagged the first two commits, and both were acting on something real rather than on noise:
- The new CLI surface was verified by hand and pinned by nothing. Every
usage error, the
--writepath, and the--dry-runpreview had been confirmed at a terminal while being written, and no test named any of them -- the same "confirmed once by the person who just wrote it" gap this entry's own history keeps producing.tests/test_compare_no_baseline_cli.pydrives all of it through the real Click entry point (which formats render and how, which invocations are refused and what the message says, what reaches disk, what the process exits with), andtests/test_no_baseline_report_formats.pygained the gating half that the P1-2 fix's own correctness depends on: a run whose orthogonal axis fires must still fail exactly one JUnit case, and its advisory findings must still pass. Coverage of the five new/changed modules went from 90% to 94%, with the CLI module 80% -> 95% and the dry-run builder 0% -> 100%. - The command body was one 159-line function (cyclomatic complexity
~30) doing validation, resolution, execution and reporting in sequence,
which is what CodeFactor's "complex method" pattern is for -- and it
passed on the first commit, failing only once the review fixes grew it.
Split into the four phases it always had:
_validate_no_baseline_ invocation(every refusal, before any work),_resolve_no_baseline_ invocation(into a frozen_ResolvedInvocation, so a later phase cannot reach back for a raw parameter the validation phase already ruled on), the run itself, and_emit_no_baseline_report. Now 78 lines at complexity - Behaviour-preserving: the CLI tests above were written before the split, precisely so it was protected.
The class, not just the four instances. "Accepted but never read" is
the single defect this path has now produced four separate times
(--contract; --sources/--build-info/--depth/--dry-run; --write;
--include-system-declarations), and every one was found by reading the
code, never by a failing test — a dropped option produces no output to fail
on. Fixing them one at a time leaves the class open, since the next option
added to compare inherits the same silence. So the rule is inverted:
_UNSUPPORTED_OPTIONS in frontends/cli/commands/compare_no_baseline.py
names every compare option this path does not implement, passing one is
a usage error rather than a no-op, and
tests/test_compare_no_baseline_options.py fails if any compare parameter
is neither read by that module nor declared in the table. A new flag is
therefore either wired or declared, never silent.
The table's reasons fall in three families, so the error message tells the
user which applies: the option describes a comparison this run never
performs (--used-by, --used-by-manifest, --required-symbol,
--use-cases, --post-manifest, --diagnostic-comparison,
--old-variant/--new-variant, --bundle-facts-*, --since/
--changed-path); it is for the directory/package fan-out
--no-baseline does not accept (--select, --select-required,
--output-dir); or it is genuinely applicable and not wired yet
(--abi3 — candidate-side by definition and the obvious next one to close
— --budget, --severity-preset, --pack, --config,
--instantiation-manifest, --follow-deps and its search-path siblings,
--debug-info, --devel-pkg). That last family is recorded here rather
than left as an accepted no-op precisely so it is a visible decision.
A resolved PDB/DWP/dSYM artifact reaches no dump path¶
debug_resolver's chain can resolve four kinds of artifact, and only one
of them is consumed. service_dump_native._dump_elf reads
DebugArtifact.dwarf_path and nothing else -- a dwp_path, a dwo_dir or
a dsym_path is resolved, logged, and dropped -- and the PE branch of
_run_dump_uncached never passes debug_roots to _dump_pe at all, so a
PDB found in a debug root is dropped too (the PE path's only PDB input is
debug.pdb_path, via locate_pdb(pdb_path_override=...)).
Pre-existing, and predates plan Phase 7n -- --debug-root had the same
shape. What 7n changed is that the gap became sayable: merging the debug
role into one input meant naming a detached artifact directly, which would
have resolved a PDB/DWP and then silently ignored it, and (first review
round) made the resolver return one ahead of EmbeddedDwarfResolver, so
a binary carrying perfectly good DWARF would have been compared with none.
Both are closed: extract/detached_debug.DetachedDebugFileResolver is
DWARF-only and yields to the rest of the chain, and naming a PDB/DWP file
is a usage error naming debug.pdb_path (the spelling that is read).
The directory form is deliberately still accepted, because rejecting it
would break the case that works -- a build-id tree or path mirror of ELF
.debug sidecars, which is what the input is mostly for. So a directory
that happens to hold PDBs still resolves to an artifact nobody reads. That
is this gap, unchanged; do not "fix" it by rejecting directories.
Closing it properly means threading debug_roots into _dump_pe and
teaching _dump_elf to consume dwp_path/dwo_dir/dsym_path -- real
capability work on the extraction paths, not a CLI change, which is why
ADR-068's flag-topology slices leave it alone. Found by Codex review on
PR #1253.
Update (plan Phase 7n): --debug-root, --devel-pkg and
--probe-matrix no longer exist as flags -- each merged into the one input
for its evidence role (--debug-info, -H/--header, --build-info). The
rejections above are unchanged in substance and still keyed on the
generated destinations (devel_pkg2, probe_matrix_*, ...), which is what
made them survive the rename; only the spelling each message names moved.
Writing the test found one the manual audit had missed
(--used-by-manifest), which is the argument for the mechanism in one line.
The whole compute/render split is new in this pass too:
report/no_baseline.py now follows abicheck/report/AGENTS.md's convention
— one compute_no_baseline_document resolving per-finding verdict/category,
evolution counts, coverage and exit contributions, and pure render_* halves
that format and decide nothing — so a new audit report section goes in the
document, never into one renderer. The audit publishes its own schema
(abicheck/schemas/audit_report.schema.json, audit_report_schema_version
starting at 1.0) rather than a version of the compare report's: findings[],
cross_source_evolution, exit_axes and a candidate-only
pattern_preprocessor_scan block are its own, and changes: [] stays, so a
consumer reading changes off any abicheck report still finds it. It briefly
stamped report_schema_version instead, which offered the audit under the
compare report's identity -- see that field's own note in
report/no_baseline_document.py for why that is not merely cosmetic.
The Action's mode: scan still routes several request shapes to the legacy scan CLI¶
Found while closing ADR-068's baseline cross-source authority divergence
(see the ADR's 2026-09-09 amendment,
plans/one-comparison-product.md Phase
4). Update (2026-09-09, Phase 4 commit 2): the maintainer re-scoped this
work — the Action does not keep a compatible interface with every scan
capability, so each remaining condition below is now ruled (a) already
covered, (b) dropped as a documented breaking change, or (c) genuinely
required and implemented — see ADR-068's second 2026-09-09 amendment for the
full per-condition table. Two rows closed for real in this commit:
--budget— closed.comparegained a--budgetoption (frontends/cli/commands/compare.py), enforced viadeadline.deadline_scopearound both the resolve and classify phases (remaining-time-aware, not a fresh full budget on each entry), settingDiffResult.budget_overflow(exit 5) on overflow. Verified live. Typed API:CompareRequest.budget_s. Known, accepted narrowing: bounds two coarser phases (resolve, classify) rather thanscan_engine.py's own finer per-stage checks — still a real, enforced ceiling, not a capability gap.- An explicit
--depth build/--depth sourcewith unreached evidence — closed. This was a real, undocumentedcomparegap distinct from everything else in this entry:compare --depth build old.so new.sowith no--sources/--build-infosilently degraded to symbols-only evidence and reportedNO_CHANGE/exit 0 (verified live) —workflows.artifact. execute.enforce_requested_depthalready implemented this exact floor as a hardValidationError/exit 64, but only for the typed-API resolution path (resolve_compare_request); the native CLI's own resolution (cli_resolve._resolve_compare_snapshots) never called it. Newpolicy/depth_evidence_contract.pyrecomputes the same floor and records it as ADR-064's exit-7 axis instead of raising (mirroringworkflows. abi3_audit.record_abi3_evidence_contract_error's existing pattern), wired into both the native CLI and the typed pipeline. Verified live:compare --depth buildon an evidence-free pair now exits7.
Ruled (b), dropped, documented breaking changes to mode: scan (see the
ADR amendment for the full reasoning each):
--risk-rulesand risk-drivenautodepth selection (no explicit--depth) — omitting--depthoncomparealready deterministically defaults toheaders, so dropping the risk-scoring auto-escalation removes a sometimes-deeper convenience, not a floor; a CI job wanting guaranteed source-level assurance must now pin--depth sourceitself.--crosscheck KEY=errorpromotion syntax — already superseded: all eleven cross-source checks reachcompareas ordinaryChangeKinds, so--policy/.abicheck.yml'spolicy.overridesalready lets a user control any one check's severity; only theKEY=LEVELsyntax itself does not survive.--build-target— stale, corrected below. This entry originally said no config equivalent existed for either command; that was already false by the time it was written (.abicheck.yml'sbuild.targetskey existed anddump --build-targetalready consumed it, CLI-override-wins).scandid have its own--build-targetflag (changelog.d/20260816_113000_noreply_abicheck_scan_build_target.mdrecords it being added), and it was retired first, in ADR-068's 2026-09-09 amendment (thescanretirement) —dump's own--build-targetwas deliberately left in place at that point precisely becausescanstill existed and shared its plumbing, so pullingdump's copy first would have brokenscan's still-live flag.dump's own--build-targetCLI flag was later removed outright too (ADR-068 Phase 6 follow-up, oncescan's full removal cleared the routing hazard that had deferreddump's own removal) —.abicheck.yml'sbuild.targetsis now the only front-end-reachable source for either command, with no CLI override left to take precedence over it.--artifact-set/new-library-set— ADR-065 S3's package component inventories (plan Prerequisite P5) are explicitly "Not started"; routing this ontocompare --no-baseline DIRtoday would silently narrow its member-selection/coverage guarantees rather than reproduce them, which is exactly the false-negative risk the (c) admission bar exists to catch.
Ruled (a), already covered by compare today, no Action-level gap left once
routed unconditionally: the additive-vs-overriding --header/--include
combination (fixed by unioning the shared root into each side in run.sh
itself, no compare change needed); a .json/.gz/.zst/content-sniffed
snapshot baseline's dependency_scope tag matching (service.run_dump's
include_dependencies parameter already provides it); --output-file and a
bare -o/--write via extra-args (compare already has -o and a
repeatable --write); the compile-context-option family reaching through
extra-args (--lang/--ast-frontend/--compiler*/--sysroot/etc. were
already removed from compare/dump's own CLI before this gap was first
written, in favor of .abicheck.yml's compile.* — scan just hadn't
migrated yet, so this was never a compare-side gap); the default (or
--no-pattern-verdicts) pattern-verdict divergence (moot once routed:
compare's modulation has been unconditional, with no off switch, since
D4). A genuinely new (a) finding this commit's investigation also
surfaced, not previously documented: the non-JSON/json --format
restriction and the scan-vs-compare JSON schema shape itself were never a
compare flag gap — compare already accepts every format scan did.
Once mode: scan routes to compare unconditionally, its JSON output
switches from scan_schema_version/nested diff.findings to
report_schema_version/root changes — a documented breaking change to
the JSON shape, not a remaining capability gap: any workflow step parsing
that file by its old shape must update to the canonical ReportDocument
shape.
A separate, newly-confirmed gap this investigation surfaced, not yet
closed: compare --no-baseline (the audit-only replacement for scan
without --against, ADR-068 D2) genuinely does not read --sources/
--build-info/--depth from its own CLI kwargs today
(frontends/cli/commands/compare_no_baseline.py's _run_no_baseline_compare_cmd
only reads headers/includes/lang/public_headers*), does not honor
--dry-run (never checked), and supports only json/markdown formats
(_SUPPORTED_FORMATS) — confirming, not merely repeating, the narrower
audit-only capability set this entry's Action-routing comment already
described. This means the Action's audit-only mode: scan (no baseline)
cannot yet route to compare --no-baseline without narrowing what it
collects, and stays out of scope for this commit's file ownership
(frontends/cli/commands/compare_no_baseline.py is not among the files this
phase owns). Tractable when picked up: thread sources/build_info/
depth/dry_run from _run_no_baseline_compare_cmd's own kwargs into
the audit pipeline the way the two-sided path already does, and extend
_SUPPORTED_FORMATS.
Closed (2026-09-10 ADR-068 amendment). The audit-only gap this
paragraph deferred to closed (see the 2026-09-10 amendment recorded on this
entry's own compare --no-baseline sibling above), and the mechanical
follow-up landed with it: action/run.sh's routing predicate
(_SCAN_AUDIT_ONLY_NEEDS_LEGACY_CLI, the immediate successor to
_SCAN_NEEDS_LEGACY_CLI below) and every MODE == "scan" branch that
existed only to serve its legacy-CLI fallback are deleted -- there is no
CMD+=(scan) site left anywhere in run.sh. Every mode: scan request
(audit-only or baseline) now assembles compare/compare --no-baseline
through one shared branch, with --severity-preset default injected on the
audit-only shape whenever the caller stated no preset of its own (see the
sibling entry's own "composite Action's own translation has since landed
too" note for the full account, and tests/test_action_run_sh_audit_gate.py
for the end-to-end coverage against real fixtures).
Not fixed here (deferred to a follow-up in this same phase, once the audit-
only gap above closes -- historical text, kept for the record; see the
"Closed" note just above): action/run.sh's _SCAN_NEEDS_LEGACY_CLI predicate
itself, and the ~17 MODE == "scan" branches it guards, are not yet deleted
— doing so safely requires the audit-only routing above to actually work
(today's Action still routes every audit-only mode: scan request to the
legacy CLI unconditionally, independent of this predicate), and a
line-for-line removal of a script this size without an Action e2e run to
verify against was judged higher-risk than shipping the two closed (c) items
and this ruling on their own. The routing predicate's decision table is
now complete (every condition ruled); what remains is mechanical: replace
the predicate's body with the two (b) hard-error checks plus the header/
include union fix, delete the legacy scan-CLI assembly branch and the now-
dead _CLI_MODE == "scan" downstream branches, once compare --no-baseline
closes the audit-only gap above.
~~scan's JSON diff.findings[] entries never carry gate_contribution~~ — CLOSED (no longer applies)¶
Closed (2026-09-11, ADR-068 Phase 6). scan — and with it
cli_scan_baseline.py, _baseline_finding_dicts, and every diff.
findings[] JSON shape this gap described — was deleted outright when the
scan command was retired. There is no longer a second JSON envelope for
this field to be missing from: compare's own changes[] entries already
stamp gate_contribution unconditionally (the half of this gap that was
never broken). Kept here, struck through, as the historical record; the
tests/parity/runner.py harness this entry describes (RunOutcome.
gate_contributions, the None-skip in what was assert_full_parity())
was itself deleted in the same PR along with every other scan-vs-compare
comparison it existed to support — see tests/parity/runner.py's own
module docstring for what tests/parity/ is now.
Found in CodeRabbit review round 12 on PR #1172, while building
tests/parity/runner.py's scan-vs-compare parity harness (ADR-068 plan
Phase 4). compare's JSON changes[] always stamps a per-finding
gate_contribution field (ADR-049 D1, unconditionally — 0 included —
via reporter.py's _change_to_dict calling
severity.gate_contribution_for_change). cli_scan_baseline.py's
_baseline_finding_dicts, the equivalent builder for scan --against's
diff.findings[], never calls it and never emits the key at all — not "always
0", genuinely absent from the dict.
Not fixed here: gate_contribution_for_change needs the same
severity_config/policy/kind_sets/policy_file context compare's
renderer already threads through, and _baseline_finding_dicts has five call
sites in cli_scan_baseline.py that would each need that context passed
in — CodeRabbit's own review flagged this as a "Heavy lift" beyond the scope
of a review-response round, and this PR's file ownership is scoped to
checker.py/policy/**/cli_scan_baseline.py/service_scan.py/
action/run.sh/tests/parity/**, not a redesign of that builder's call
graph. What is fixed here, within tests/parity/** ownership: the harness
itself no longer silently defaults a missing scan-side gate_contribution to
0 before comparing it against compare's real (possibly nonzero) value, which
would have let a genuine divergence on this field read as false parity.
RunOutcome.gate_contributions is typed dict[tuple[str, str], int | None] —
None means "the raw finding dict never had the key" (scan's current
across-the-board state), a real int means the field was present and its
value read (compare's state, and scan's future state once this gap closes);
assert_full_parity() skips the comparison for a key where either side reads
None rather than coercing both to 0 first.
Tractable when picked up: thread the same evaluation context (severity
config, policy, kind sets, resolved policy file) cli_scan_baseline.py
already has in scope for the compare-parity verdict recompute into each of
_baseline_finding_dicts's five call sites, and call
severity.gate_contribution_for_change per finding the way reporter.py
does. Once real values are emitted, the parity harness's own None-skip
becomes dead code (no test result changes, since a value that now compares
equal was previously skipped, not marked passing under a false default) — at
that point drop the skip and go back to an unconditional comparison so a
future regression on this field is caught structurally rather than by an
absent key silently reading as "no divergence".
L2 performance bottlenecks found by the full-CLI harness (measured, not fixed)¶
Found while building scripts/check_l2_cli_perf.py. Recorded here rather than
acted on: that work was explicitly measurement-only, and each of these is a
production behaviour change needing its own design and its own evidence that the
change is worth it. Every number below is a real local measurement (gcc 13.3.0 /
castxml 0.7.0 / clang 18.1.3, 4 CPUs, Linux), reproducible with
python scripts/check_l2_cli_perf.py --suite extended.
Interpreter startup dominates a small-input CLI run¶
abicheck --version — interpreter plus import plus Click tree construction,
before any work at all — costs 0.56–0.66 s. A stored-snapshot/stored-snapshot
compare of a small fixture costs ~1.05 s end to end, so roughly 60% of it is
startup. compare --dry-run (startup plus config and input resolution, diff
never run) costs 0.60–0.79 s, which bounds resolution itself at well under
0.2 s.
Consequences worth knowing before optimizing anything else: a change that halves
the actual L2 comparison work would move that run's wall time by under 20%, and
the full-CLI lane's absolute noise floor has to be set around this cost rather
than around the comparison's (hence --regress-min-delta-seconds 0.5 by
default there, where the in-process gates use 0). A warm-cache dump is
not measurably faster than a cold one on a small fixture for the same
reason — see below.
Not investigated: which imports dominate, and whether lazy-importing the heavy ones is feasible without breaking the registration-by-import-side-effect pattern the CLI is built on.
The clang -M include-graph pass is per-header, per-side, and never cached¶
Measured on the header-count axis (one library, N top-level public headers over
one shared detail/ dependency closure, live/live comparison):
| top-level headers | observed include_pass invocations |
wall |
|---|---|---|
| 1 | 2 | 1.10 s |
| 8 | 16 | 2.48 s |
| 32 | 64 | 10.40 s |
Two separate things show up here. The invocation count is exactly
headers × 2 sides — one clang -M subprocess per top-level header per side,
with no batching across headers that share a dependency closure. And the wall
time grows superlinearly in header count (8x the headers costs ~9.5x the
time going 1→32), so this is not merely a constant per-header cost.
The separate cache finding: on a warm AST cache the header extraction count
drops to zero (the AST cache serves it) while the include_pass count stays
unchanged. The include graph is recomputed on every run regardless of cache
state. Combined with the startup cost above, that is why a fully warm small
dump measures ~0.93 s against a cold ~0.88 s — within noise, i.e. the cache
hit buys nothing observable at that scale even though it demonstrably happened
(proved by the counters, not the clock).
A set of libraries pays full cost per library, and a shared header context does not reduce it¶
Five libraries compared as five per-library compare invocations (the only
supported shape — there is no declarative L2 bundle comparison), cost ~1.15–1.50 s
each and 4 header extractions each, for a set total of ~6.3 s. That figure is
the same whether the five libraries share one dependency header or each have
their own:
| arm | resolved dependency headers | extractions (5 libraries) |
|---|---|---|
| shared context | 1 | 20 |
| distinct contexts | 5 | 20 |
The mechanism, which is the actually useful part: the header-frontend invocation count is driven by the top-level headers, not by their dependencies, so sharing a dependency header cannot reduce it. Each library still needs its own parse of its own public header, and that parse pulls the shared dependency in regardless of whether another library already parsed it.
This claim was previously unsupported and is worth flagging as such. The
first version of the fixture gave every library its own byte-identical copy of
detail/core.h under its own libN/include/ path — and the AST cache keys on
each header's resolved path, so the "shared" arm shared nothing. The two arms
were both distinct-path workloads, and comparing them could only ever have
produced "no difference". The fixture now resolves the shared arm through one
physical file at a common include root, header_contexts is counted from the
resolved paths actually built rather than from the flag that requested them, and
the numbers above are from that corrected fixture. The conclusion happens to be
the same; the evidence for it did not exist before.
Recorded as a measurement of current behaviour, deliberately not as a claim that
cross-library sharing should be implemented: whether a cross-library
header-AST cache is worth its invalidation complexity is a real design question,
and scripts/l2_real_profiles.py's oneDAL profile (five header-bearing
libraries across two compile contexts) is the realistic case to judge it
against.
~~compare --format repeated silently keeps only the last format~~ — CLOSED by the export grammar¶
Recorded while building the full-CLI harness against main at f6aa2aae, where
compare --format json --format markdown -o out exited 0 and wrote markdown
only, with no way to get both artifacts from one invocation. ADR-068's slices
7m/7n landed before this branch merged and closed it outright: --format is gone
and -o FORMAT=DESTINATION is repeatable, with every export rendered from the
one completed analysis.
Kept as a closed entry rather than deleted because the measurement survives and
is worth knowing: check_l2_cli_perf.py's compare_two_formats scenario now
verifies that promise rather than trusting it. A single compare exporting both
json= and markdown= over a stored-old/live-new pair performs 2 header
extractions — exactly one side's worth, so the second renderer demonstrably
does not re-run the analysis. (Stored/stored would have been the easier operand
pair and a useless test: the count is zero either way, so it could not
distinguish one analysis from two.)
The clang -M include pass is unaffected by a warm AST cache — confirmed at --repeat 3¶
Re-confirmed after the cache-lifecycle fix below, now that a multi-repetition run
actually works: across three repetitions of the cold/warm sequence the header
extraction count goes 2 → 0 (the AST cache serves it) while include_pass
stays at 1 every time. The include graph is recomputed on every run regardless of
cache state, which is the same finding as the per-header scaling above seen from
the cache side.
A labelled side-scoped --include appeared to suppress unlabelled global include roots¶
Observed once, on a real PVXS comparison, and not yet reduced to a minimal reproduction — recorded so the next person does not lose the observation. Invoking:
compare old.so new.so --include old:public=OLD_INC --include new:public=NEW_INC \
--include EPICS_INC --include EPICS_INC/os/Linux --include EPICS_INC/compiler/gcc ...
failed with fatal error: 'epicsTime.h' file not found, despite EPICS_INC
being passed as an unlabelled (both-sides) include root and genuinely containing
that header. The same roots passed to a bare dump worked. Re-expressing every
root in the per-side labelled form made the comparison succeed.
So either unlabelled roots are dropped once any labelled side-scoped root is
given for that side, or they are ordered such that castxml does not see them.
--include's own help text documents the two forms as composable, so if this
reproduces it is a defect rather than a usage error. Next step: a minimal
two-header fixture mixing one labelled and one unlabelled root.
--crosscheck KEY=LEVEL's engine half outlives its retirement ruling¶
ADR-068's second 2026-09-09 amendment rules --crosscheck KEY=error's
promotion syntax (b) — dropped, superseded: every cross-source check
already reaches compare as an ordinary ChangeKind, so the underlying
capability (controlling one check's severity) exists as
policy.overrides.<CHANGE_KIND>: error in --policy/.abicheck.yml.
Phase 4's typed-API slice (2026-09-09) deleted the half it owned:
ScanRequest.severities/enabled_checks are gone with the request type, and
no typed caller can state either any more. The scan CLI flag and its
engine plumbing are still there, deliberately. Its promotion is not a
scan-local detail — it is ADR-064's crosscheck_promotion_contribution, a
published ExitDecision axis:
policy/exit_decision.pycarries the field andresolve_exit_decision's parameter; it is emitted in every persistedexitblock.policy/exit_decision_precedence.pycarries it through a prior decision.workflows/scan_abort_result.pynames it as a fold participant for a budget/evidence-contract abort.schemas/__init__.py'sREPORT_SCHEMA_VERSION2.42 entry documents it oncompare's side of the report contract, andaction/run.shreadspromoted_crosscheckout ofblocking_categoriesto word its own verdict escalation.scan_engine._crosscheck_severity_exit/_promote_published_gateandcli_scan_baseline'sinfo/warningnon-gating exclusion implement it.
Removing a published exit axis changes compare's report schema as well as
scan's and needs an ADR-064 amendment deciding what a consumer reading
crosscheck_promotion_contribution should see afterwards — a different
change from retiring a request field. It is not done in the typed-API slice,
and the slice does not pretend otherwise.
What closing it looks like: delete --crosscheck from cli_scan.py
(both halves — the KEY=off enable/disable side has no separate ruling, but
it is the same flag and compare runs every check unconditionally per plan
§3 #3), delete _parse_crosschecks/_CROSSCHECK_LEVELS/
_PROMOTABLE_FINDING_KINDS, drop severities/enabled_checks from
run_scan_core/_run_baseline_compare, delete
_crosscheck_severity_exit/_promote_published_gate/
CROSSCHECK_BLOCKING_CATEGORY, retire the ExitDecision field under an
ADR-064 amendment with its own REPORT_SCHEMA_VERSION/SCAN_SCHEMA_VERSION
entries, and update action/run.sh's two promoted_crosscheck readers plus
pr_comment_scan.py's crosscheck_severities promotion read.
Retired CLI spellings still named in runtime messages¶
Status: one instance fixed (PR #1184), the repo-wide sweep deliberately
not attempted. Bug class:
cli_surface.retired_spelling_in_remediation in
tests/regressions/manifest.py —
check there first before restating the invariant.
Codex review on PR #1184 caught
project_snapshot_legacy.materialize_release_variant_artifacts's
multi-variant ambiguity error still telling the caller to "pass an explicit
variant id (--old-variant/--new-variant)" after plan Phase 7j retired
both spellings with no alias. A user who followed the tool's own advice
landed straight in an exit-64 usage error — the CLI actively misdirecting
them. Fixed — twice. The first fix named the live --variant flag but
hard-coded the side to old=, so a caller whose NEW operand was the
ambiguous one was told to run something that fails just as hard (Codex
review, second round). The flag-existence oracle passed on that bug,
which is the lesson: a live flag aimed at the wrong operand is still
broken advice. The invariant is now the stronger one — run the
advice: the seed test takes the message's own (e.g. --variant new=v1)
example, re-invokes compare with it, and asserts the ambiguity is
actually resolved, parametrized over which side is ambiguous. A bare
--variant ID is not a safe fallback either, since it applies to both
sides and the unambiguous side does not declare that id — which is why
the side must reach the message at all.
The layering fix that makes it possible: the engine
(AmbiguousVariantSelectionError) states the fact and carries the
declared ids but names no CLI flag, because only the front end that
resolved both operands knows which side this package is;
cli_compare_release_matrix._resolve_release_package_side appends the
side-correct example. That is the same engine-error/CLI-wrapper split
AmbiguousLibraryMatchError already uses.
What is not closed. An AST sweep of non-docstring string literals under
abicheck/ against every spelling in scripts/retired_surfaces.py's
RETIRED_SURFACES reports 44 hits across 25 modules, and several read
like the same defect already sitting in the tree:
pdb_utils.py:129—"locate_pdb: skipping network path %s (use --pdb-path to override)"(--pdb-pathleftcomparein Phase 7i anddumpin Phase 7c)reporter_markdown.py:297—"Unknown --show-only token: "(--show-onlyfolded into--viewin Phase 5)dumper.py:1527,dumper_elf_fallback.py:145—--dwarf-onlycli_dump_helpers.py:133,frontends/cli/commands/dump.py—--debug-formatcli_audit.py:77—--reconcile-build-context(removed in Phase 7i)
Why this is not a mechanical sweep, and why it was not attempted in the same PR. The hits are not decidable without reading each site against the command that emits it:
- A flag retired from
compare/dumpcan still be live onscan, which kept the whole L2 compile-context and debug-resolution families (7a/7b/7c explicitly leftscanuntouched). A shared engine module's message naming--dwarf-onlymay be correct for the caller that actually reaches it. cli_compare_release.py's unregistered release engine legitimately still defines--old-variant/--new-variant/--dso-only/--support-promise/--include-private-dsoas its own options — those are live option definitions, not stale advice, and account for 9 of the 44 hits on their own.- Historical mentions in
model/change_catalog/descriptions,contract_relevance_types.py, andoptions/rulings.py's own ruling rationales are deliberate records of what a thing used to be called.
So a sweep-turned-gate would need roughly 25 hand-judged allowlist entries —
the shape AGENTS.md's own IMPORT_CYCLE_ALLOWLIST guidance warns is itself
a smell, and precisely the "large unreviewed allowlist shipped as if it
closed the class" outcome the bug-class discipline exists to prevent. It is
also a separate change from PR #1184's CLI option audit.
Tractable when picked up: go site by site, resolving each message
against the command(s) that can actually emit it — the fix for a genuinely
stale one is either naming the live spelling (as PR #1184 did) or scoping
the message per front end. Only once the real hits are down to the
legitimate three categories above is a gate in
scripts/check_docs_contract.py worth adding, extending the existing
RETIRED_SURFACES docs sweep to first-party Python with a small,
reasoned allowlist. Doing the gate first would invert that order and bake
today's 25 unexamined sites into an allowlist nobody revisits.
compare --depth binary still performs a deep DWARF type walk the public evidence-depth contract says that rung skips¶
Reopened 2026-09-11 (Codex review, PR #1220 doc follow-up) after an
earlier pass at that same PR incorrectly marked this entry CLOSED,
reasoning that deleting scan (ADR-068 Phase 6) made the gap moot because
the comparison it originally described needed a scan reading on one side.
That reasoning was wrong: scan's own --depth binary behavior was never
the bug — it was only the oracle this entry originally used to show
compare's behavior was inconsistent with something. Deleting scan
removes that oracle, not the underlying defect in compare itself, and the
entry's own body already said as much before being closed ("that question
is now compare's alone to answer" — see below). Verified live against the
current code before reopening, not taken on Codex's word alone:
$ gcc -shared -fPIC -g old.c -o libold.so # struct Point { int x, y; }
$ gcc -shared -fPIC -g new.c -o libnew.so # struct Point { int x, y, z; }
$ abicheck compare libold.so libnew.so --depth binary -o json=-
reports verdict: BREAKING with a type_size_changed finding ("Size
changed: Point (64 → 96 bits)") and a type_field_added_compatible
finding, from DWARF alone (no headers passed on either side). That directly
contradicts docs/use/evidence-depth.md's own published contract for this
rung (| binary | L0/L1 exported symbols + binary metadata + debug-info
*presence* (no deep DWARF type walk, no L2 AST) + always-on pattern scan |)
— "debug-info presence" promises only "is DWARF present", not "walk every
DWARF type and report on it". policy/depth_projection.py's own module
docstring confirms this is by design, not an oversight: it documents BINARY
as covering both L0 and L1 ("no L2 AST", explicitly not "no debug info"),
and keeps layout/signature facts at the binary rung whenever DWARF
confirms them, "the way a real DWARF-informed binary dump would". The
code's contract and the docs' contract disagree with each other, and
this entry's status must track that disagreement as still open — reworded
below to describe the compare-only shape of the question now that scan
is gone, but not marked CLOSED.
Original gap, kept for context: scan --depth binary extracted exported
symbols only, while compare --depth binary on the same two live binaries
still read DWARF and reported type-level findings that the symbols-only
view could not produce. The two commands were not wrong in the same way:
scan honoured the pin as an extraction floor and ceiling, while
compare's --depth projection (policy/depth_projection.py) drops the
L3-L5 layers but does not restrict the L1 debug parse the resolution
already performed, so the pin acted as a ceiling on collected layers rather
than on read evidence. Which of the two was the intended contract at this
rung was left open as a compare-side question (ADR-063 Phase 8's
--depth ceiling) — that question is now compare's alone to answer,
since there is no scan reading to compare it against any more, but it
remains unanswered: either docs/use/evidence-depth.md's "no deep DWARF
type walk" promise needs fixing to match what compare --depth binary
actually does, or policy/depth_projection.py's BINARY-keeps-L1 behavior
needs to change to match the documented promise. Neither has happened.
ADR-068 Phase 4's typed-API slice had fixed the adjacent headers-rung
divergence before scan's removal — cli_scan_helpers._uses_debug_presence_only
dropped DWARF at the HEADERS/BUILD rungs even when no headers existed to
replace it, so a header-less scan lost every type-level finding while
naming the rung it was asked for — and the parity suite pinned the headers
and unpinned rungs against compare as an oracle
(tests/test_scan_depth_evidence_shortcut.py). That decision function and
its seed tests were deleted with the rest of cli_scan_helpers.py in ADR-068
Phase 6. The evidence.tier_shortcut_without_substitute BugClass this gap
was registered against in tests/regressions/manifest.py was retired in the
same PR rather than retargeted: a repo-wide audit at retirement time found
debug_presence_only (the underlying dumper-level shortcut parameter,
still plumbed through dumper.py/service_dump_cache.py/
service_dump_native.py/dumper_layout_backfill.py/
workflows/input_resolution.py) has no remaining production call site
that ever passes debug_presence_only=True — every live caller forwards it
at its default False. The shortcut mechanism is therefore currently
unreachable from any command, so the bug class it protected has nothing left
to seed-test against. If a future change reintroduces a caller that computes
debug_presence_only from a depth/collect-mode decision (the "none of the
L3/L4/L5 collect-mode decisions has an equivalent... guard yet" gap the
original entry also named, which remains open and un-closed by this
retirement), re-add a BugClass entry for it — retargeted to that new
caller's own seed tests, not restored verbatim, since the invariant text
should describe the caller that actually exists rather than the deleted
scan-specific one.
~~A depth shortfall is a hard per-member ERROR on a directory compare but a soft assurance signal on a single-pair one~~ — CLOSED¶
Closed in PR #1195, in the same PR that surfaced it, after a Codex review round showed the divergence was wider than this entry first described. Kept here because the shape of the mistake is the reusable part.
Measured, the two paths disagreed on every row, not just hard-vs-soft:
| shortfall | scalar compare |
directory compare |
|---|---|---|
live, --depth headers |
0 | 4 (ERROR) |
live, --depth build |
7 | 4 |
live, --depth source |
7 | 4 |
stored snapshot, --depth source |
0 (live-only carve-out) | 4 |
Two mechanisms existed and the fan-out reached the wrong one first.
policy/depth_evidence_contract.py is the one this repo documents as
closing the gap "for every compare caller from one place" — exit-7 axis,
recorded not raised, build/source only, live-extraction only — and
service_compare_pipeline.classify_compare_pair already called it with the
right old_is_live/new_is_live flags. But resolve_compare_request
called enforce_requested_depth unconditionally ~170 lines earlier, which
raised first and pre-empted that recording in exactly the cases it was
written for. The native scalar CLI escaped only because it does not route
through resolve_compare_request at all.
The fix: resolve_compare_request no longer calls enforce_requested_depth,
so the exit-7 axis governs every compare surface; the release fan-out
aggregates each member's evidence_contract_error_contribution with max()
the way it already aggregates the contract-coverage floor, and emits its own
stderr notice (the per-member DiffResult is discarded before the note a
single-pair run renders). All eight rows of the matrix now agree, and a run
without --depth is unchanged.
dump's own floors and workflows.bundle_stored_pair_compare's separate
enforce_requested_depth call were deliberately not touched — each is a
different command with its own tested contract, and
test_cli_compare_bundle_facts_stored_pair.py pins the bundle one
explicitly (a stored pair does hard-fail there on the headers rung).
That is a third behaviour for the same question, still open, and worth
reading before anyone unifies further.
The reusable lesson is the one the bug class
cli_surface.capability_guard_diverged_from_pipeline now records: when two
mechanisms implement the same rule and one is documented as "the one place",
the other one silently winning on a subset of surfaces is not a redundancy,
it is a divergence waiting for a front-end change to expose it.
A directory compare's -H/--header set is applied to every member, so header-derived findings are reported against libraries they do not belong to — steps 1 and the export-obligation half CLOSED (2026-09-17); steps 2/3 open¶
Update (2026-09-17), read this first. The release product model landed:
a directory/package comparison now judges one public contract backed by
several binary providers (docs/learn/products-not-libraries.md § "One
public surface, many providers"). Concretely, against the three steps this
entry proposes below:
- Step 1 ("de-duplicate first") is done. A finding several members report
identically is rendered once, release-scoped, naming every affected library
(
report.release_public_surface.dedupe_shared_member_findings), and each member entry records how many of its findings were folded there (product_level_findings). Counts, verdicts and exit codes are untouched by the fold, so nothing is hidden or downgraded — only the duplicated evidence stops being emitted N times. - The
public_not_exportedhalf is not merely de-duplicated, it is answered correctly. It was never a duplication problem at heart: the obligation is satisfied by any member, so the per-member answer was a Cartesian product. It now runs once, at release level, against the union of the bundle's usable exports (policy.release_contract_reconciliation), with the member pass no longer running it at all (workflows.crosscheck_ownership). Measured on a 12-library / 480-declaration fixture: 5,280 findings -> 0, report 8.7 MiB -> 77 KiB. exported_not_publicstayed per member, deliberately. The example below reads it as the same defect, and it is not: the exporting member is its correct attribution. It is judged against the one shared declaration index and folded only when several members really do export the same symbol.- Steps 2 and 3 remain open exactly as written below (a per-member header
operand; reachability-based attribution where no map is declared). The type
findings in the example above are still not attributed to the member whose
surface reaches
Widget— they are now reported once for the release, naming both members, which is the honest "which member this affects was not established" reading step 1 asks for, not the attribution step 3 would give. Over-reporting attribution, not duplication, is what is left.
Update (2026-09-18). The model is no longer scoped to the live
directory/package fan-out. The other three drivers that compare several
libraries at once — a stored BundleFacts baseline against a live release
(workflows.release_public_surface.stored_old_live_new_reconciliation), two
stored documents (workflows.bundle_stored_pair_compare), and a
multi-library ABICC descriptor (compat.multi_library_run) — each reconcile
one product contract through the same
workflows.release_public_surface.reconcile_member_sets and enter the same
member_pass_scope ownership for their member pass. What remains scoped to
the live path: only that path acquires a surface (the other three read a
recorded or projected one), so the acquisition ledger's instrumentation is
empty on a stored comparison, and only that path performs the
shared_findings fold — the stored documents already emit one product-level
finding rather than repeating it per member. The driver inventory is not
mechanically enforced: nothing fails when a fifth multi-member driver is
added, so tests/test_release_public_surface_drivers.py is a hand-maintained
list (recorded in tests/regressions/manifest_classification.py's
release.cartesian_product_contract known gaps).
Known dormant branch (2026-09-18). storage/bundle_facts_archive.py's
public-surface blob carries the same second-materialization byte charge
manifest_blob does (a blob served from cache still builds a second object
graph, so its bytes are charged again -- the object-count amplification the
aggregate budget bounds). Through the public loader that branch is
currently unreachable: every other slot that could share the surface's
hash decodes it under a different shape first (the instantiation manifest
demands a top-level provides: list, a library slot demands a snapshot),
so a shared hash is refused before the surface slot runs. The guard is kept
because it is cheap and becomes load-bearing the moment slot ordering or a
shape check changes;
tests/test_release_public_surface_persistence.py::TestTheSurfaceBlobIsReadExactlyOnce
pins the property that makes it dormant, so that change cannot happen
silently. It is also why that file's own patch coverage will not reach
100% -- the uncovered lines are this guard, and covering them would mean
reaching past the public entry point, which this repo's own
third-party-boundary rule refuses.
The original entry follows, unchanged.
Reproduced while verifying the --depth/header-graph behaviour above, on a
two-member fixture (libfoo.so built from foo.h, libbar.so from nothing
but its own source):
abicheck compare old/ new/ --header old=inc/foo.h --header new=inc_new/foo.h
libbar.so BREAKING type_size_changed:Widget, type_alignment_changed:Widget,
type_field_type_changed:Widget, type_field_offset_changed:Widget,
exported_not_public:bar_fn, public_not_exported:widget_area, ...
libfoo.so BREAKING type_size_changed:Widget, ... (the same four)
Widget lives in foo.h and is nothing to do with libbar.so, yet the
whole Widget change set is reported once per member, and libbar.so
additionally earns exported_not_public/public_not_exported findings for
the mismatch between its own exports and a header set describing a different
library. A release's finding count therefore scales with member count rather
than with what changed, and --output-dir's per-library reports carry the
duplicates too.
The cause is a missing transport, not a missing model. cli_compare_release.py
resolves one old_h/new_h/old_inc/new_inc set for the whole release and
passes it unchanged to every _compare_one_library call — but that function
already takes its header list per member (cli_compare_release_pairwise.py's
old_h parameter, forwarded straight to _run_compare_pair); only the caller
flattens it. And the per-member mapping already exists upstream:
build-output.json carries per-target public_header_roots/
generated_header_roots, buildsource/baseline_publish.py's
derive_baseline_libraries() turns those into actions/baseline's
libraries[].header, and publish-baseline.yml already dumps each library
with its own headers at a selectable depth. BundleSpec.targets is a list
of target ids and TargetSpec.public_headers exists per target, so the
declaration is in the project schema too.
So an earlier revision of this entry was wrong to frame this as "decide what
the attribution model is" and to propose naming conventions or a new
.abicheck.yml key. The model is declared; compare simply has no operand
shape that carries it. Three things close this, in increasing order of scope:
- De-duplicate first. A header-derived finding that cannot be attributed to one member should be reported once, release-scoped, rather than once per member. That is the honest reading of the evidence ("which member this affects was not established") and it removes most of the pain with no guessing at all. The current N× duplication is the actual defect.
- Carry the map into
compare. A per-member header operand (mirroringactions/baseline's ownlibrariesJSON) so the fan-out resolvesold_h/new_hper member. Small, given the seam already exists. - Attribute by reachability where no map is declared: a header-derived
finding belongs to the member(s) whose export surface reaches the entity —
export_surface.compute_export_surfaceandtype_reachability.pyalready do exactly this per library. Must fall back to (1), never to silence.
Note that this is only half of header-aware package comparison. The other
half is where OLD's headers come from at all:
buildsource/project_targets.py's BUNDLE_CHECK_DEPTHS = {binary} exists
because a bundle baseline stages raw binaries and check-project.yml has one
project-wide header: input, so depth: headers would parse both sides
with the current checkout's headers and make a header-only change silently
invisible — a false negative, strictly worse than this entry's over-reporting.
Staging bundle baselines as per-member snapshots (what single-target mode
already does) is what would let that restriction be relaxed.
Deliberately not attempted as part of the --depth fix: it is a separate
change in a different layer. Over-reporting is the safe direction — no finding
is lost today — which is why this is a gap rather than a blocker on the fix
that surfaced it.
Suppression provenance stops at the display label outside the audit path¶
ADR-067 D3 says a disposition keeps the rule that made it — rule id, source
file, reason, label, expiry. Change.suppression_rule is not that record: it
is SuppressionOutcome.rule_label()'s deliberate label or reason collapse,
so a waiver stating both publishes one and silently drops the other, along
with the file it lives in and when it lapses — exactly what a reviewer needs
to decide whether the waiver still applies.
compare --no-baseline had this in all four of its projections and was fixed
in PR #1188: each suppressed entry now carries the run's own
DispositionLedger.rule_for record, and the regression test is parametrized
over NO_BASELINE_SUPPORTED_FORMATS so a format added later is held to it.
Two-sided compare's JSON was already correct before that
(reporter.py's _suppressed_change_entry emits rule.to_dict()).
Two readers are still on the display label alone. Both were found by grepping
every suppression_rule reader once the audit's own were fixed, and both are
outside the scope PR #1188 was opened for, so they are recorded here rather
than folded into it:
sarif.py(two-sidedcompare) — the suppression'sjustificationinterpolateschange.suppression_ruleonly. Since the same run's JSON already publishes the full record, this is a between-formats split of one report: a SARIF consumer sees strictly less than a JSON consumer of the identical run.cli_scan_baseline.py(scan --against) — each suppressedfindings[]entry setsentry["suppression_rule"]and carries no provenance field at all.
Fixing either is the same shape as the audit's fix: route the run's
DispositionLedger to that entry builder and read rule_for(change), rather
than re-evaluating the rule set (which can name a different rule than the one
that actually fired, since a finding's fields may have been enriched after the
match). Neither needs a new mechanism — only the existing one wired to one
more builder.
Registered as open residuals on the report.finding_entry_builder_parity
bug class in tests/regressions/manifest.py's report sibling
(tests/regressions/manifest_report.py), so the next person to touch that
class sees them without re-deriving the grep.
compare --no-baseline crashes on any real ELF shared library¶
Auditing a real ELF shared library raises an uncaught
NoBaselineInvariantError — a Python traceback, not a diagnostic — from a
bare CLI call:
$ abicheck compare --no-baseline /lib/x86_64-linux-gnu/libm.so.6 -o json=-
abicheck.policy.no_baseline_findings.NoBaselineInvariantError: a snapshot
compared against itself must never produce a comparison finding -- if this
fires, a detector is reading non-identity state. Offending findings:
visibility_leak(<visibility>)
Reproduced on main at f5df70dd as well as on the branch that found it,
so it is not a regression from any in-flight work. It was found only because
a test fixture in tests/test_compare_no_baseline_cli.py was made more
realistic (see that file's _a_live_binary_this_host_can_audit); every
committed G20 fixture is a stored snapshot, so no existing test audits a
real live library at all — which is why a crash on the command's most
obvious input survived.
The invariant's own message misdiagnoses it. Nothing is reading
non-identity state. diff_platform_elf_dynamic._diff_visibility_leak is
deliberately single-sided — its body opens del new # detector is
intentionally old-library-only — and reports internal-looking symbols
exported from one snapshot under elf_only_mode. That is candidate-side
hygiene, exactly the category policy/no_baseline_findings.
is_one_sided_finding exists to recognise. But the detector emits a plain
make_change(...) carrying neither marker that predicate looks for
(cross_source_evolution, candidate_side_enrichment), so
partition_no_baseline_findings files it under identity, and
check_no_baseline_partition raises.
There is an irony worth recording, because it is the actual lesson:
is_one_sided_finding's docstring argues at length against a ChangeKind
allowlist on the grounds that it "would drift out of sync with the detector
registry." The marker-based design it chose instead has drifted the other
way — a genuinely one-sided detector that never got a marker. Neither
mechanism is self-enforcing; the missing piece is a check that a detector
ignoring its new argument emits marked findings.
Why it was not fixed where it was found. The obvious patch — set
candidate_side_enrichment on the finding — is not local. That marker is
read on the ordinary two-sided compare path too, where this detector also
runs and reports leaks in the OLD library, so setting it relabels a finding
in the main command. And _diff_visibility_leak is unlikely to be alone:
any detector that dels or ignores new is in the same position, so the
real fix is an audit of that whole set plus a gate, not a one-line change
to the one instance a traceback happened to name. That is a change with its
own review surface, and it was out of scope for the pull request (#1188)
that found it — which touches neither file.
Fix shape, for whoever takes it:
- Enumerate every detector under
abicheck/diff_*.pywhose body ignores itsnewsnapshot argument (del new, or never referencing it). That set is the population, notvisibility_leakalone. - Decide per detector whether it is candidate-side hygiene (mark it) or a
genuine comparison detector that happens not to need
newyet. - Mark the hygiene ones, and check what that does to two-sided
compare's reports and gating before assuming it is inert there. - Add the missing self-enforcement: a gate — the AI-readiness script is
the natural home, alongside
fact-detector-misuse— asserting that a detector which ignoresnewemits only marked findings. Without it the next single-sided detector reintroduces this exact crash. - Add a test that audits a real live shared library, not a stored
snapshot. Its absence is why this shipped;
tests/test_compare_no_baseline_cli.py's helper documents the platform traps (a soname is not a path; macOS keeps system libraries in the dyld shared cache; a PE executable has no export directory).
Registered as test_fixture.host_artifact_assumed_capability in
tests/regressions/manifest_report.py's sibling
tests/regressions/manifest_tool_surface.py for the fixture half; the
detector half above has no registry entry yet, deliberately — it is a real
open defect, not a closed class.
compare's migrated cross-source checks drop --since's changed-path confidence boost¶
Found during the ADR-068 Phase 6 doc follow-up (a stale mention of the
retired scan --since in catalog/cases/case181_xcheck_public_to_internal_dependency/README.md
prompted checking whether the claim still held).
buildsource/cross_source_checks.py's CrosscheckConfig.changed_paths lets
a check (currently public_to_internal_dependency) report a finding at
higher confidence when the internal declaration it reaches lives in a file
the revision actually changed — "this call reaches a file that changed this
revision" is a stronger signal than "this call reaches something
internal". The retired scan --since wired its changed-path set through to
this automatically.
compare()'s own migrated cross-source-evolution path
(checker.compare() → workflows.cross_source_evolution.
compute_cross_source_evolution(old, new)) does not currently accept or
forward a changed-path set at all — the function's signature is
(old: AbiSnapshot, new: AbiSnapshot), with no changed_paths parameter,
even though compare's own --since/--changed-path flags exist and are
already threaded through to other consumers (cli_compare_helpers.py's
changed_paths parameter, ADR-043 D7 POI scoping — comment-tagged
# ADR-068 Phase 2c: ADR-043 D7 POI scoping). So public_to_internal_dependency
is always reported at the lower "reaches something internal" confidence
under compare today, regardless of --since/--changed-path.
Tractable when picked up, but checker.compare() has no changed_paths
parameter to thread today (verified against its real signature,
abicheck/checker.py's def compare(old, new, suppression=None, *,
policy=..., ...) — no changed_paths anywhere in it) — an earlier version
of this entry wrongly described the value as already existing on
compare() and needing only to be threaded further down. Adding a
changed_paths parameter to checker.compare() (and updating every
caller that already resolves a changed-path set — cli_compare_helpers.py
already has one — to actually pass it through) is itself the first piece
of required wiring work, not a step that can skip straight to modifying
compute_cross_source_evolution/CrosscheckConfig. Needs a regression
test asserting the confidence does change under --since/
--changed-path on a real (not just internal-API) compare invocation,
not only an internal run_crosschecks(...) call — the gap here was
invisible to internal tests precisely because nothing exercises the public
--since flag's effect on this specific check's confidence.
compare --dry-run's cost preview does not reflect --since's changed-path seeding¶
Found during the PR #1220 doc follow-up (Codex review): a two-sided
compare old.so new.so --depth source --since origin/main --dry-run
example claimed the dry run "prints the translation units the seed selects
and the projected per-layer cost without scanning". Verified live against
the current code before fixing the doc: it does not.
build_compare_dry_run_result (frontends/cli/compare_dry_run.py) always
renders "source scope: target on each side (compare has no PR change
seed)" whenever collect_mode is source-target/source-changed/
graph-full, and its "Cost preview" section is built from
workflows.compare_cost_preview.estimate_compare_dry_run_cost, whose
signature accepts no since/changed-path parameter at all — so the TU
counts and per-layer cost it prints are the unseeded, full-target
numbers regardless of --since. The reason is ordering, not a missing
render field: cli_compare_helpers.py's --dry-run branch calls
emit_dry_run(...) (which raises SystemExit, per dry_run.py) before
_enrichment.resolve_compare_enrichment_inputs(since=since, ...) —
the call that actually interprets --since and localizes collect_mode —
ever runs. --since genuinely does narrow the real (non-dry-run) replay;
only the dry-run preview is blind to it.
Tractable when picked up: resolve the changed-path seed (and its resulting
localized collect_mode/TU set) before the --dry-run emit, the same
way --dry-run already reflects other resolved-but-not-yet-executed
decisions (depth, headers, tool discovery) rather than echoing raw CLI
input back. Needs a regression test asserting the dry run's own TU
count/cost preview actually changes between --since origin/main and no
--since on the same real inputs, not only that the flag is accepted.
compare --depth binary ignoring matrix-wide --sources/--build-info is untested¶
Found while retiring tests/scenarios/ci_gating.yaml's
SC-SCAN-BINARY-DEPTH-MATRIX-ARGS scenario and its automated test
(test_sc_scan_binary_depth_matrix_args) in the ADR-068 Phase 6 doc
follow-up: the scenario's premise was that a CI matrix builds one command
template and varies only --depth, leaving --headers/--sources/
--build-info present on every rung, and the binary rung must ignore the
deeper inputs and skip pattern-scan/L3 collection. The retired scan
command exposed this as a checkable coverage array
(layer/status rows) plus a pattern_scan.files_scanned field; verified
live, compare -o json=... emits neither at any --depth. compare
--depth binary does reach the same verdict/exit code on the scenario's
fixture, but nothing currently asserts it skipped the deeper collection
rather than merely projecting an already-collected superset down afterward
-- the two are observably different only through the now-gone coverage
block.
Tractable when picked up: decide what compare's own coverage-reporting
surface should look like for this question (a --depth-scoped rung either
was or wasn't collected), add it, and un-retire the scenario against it.
ADR-061 gap F: thirteen nested buildsource/compat/impact modules still carry no disposition¶
ADR-061's gap F requires
every unclassified first-party module under abicheck/ to carry one of
three recorded dispositions (migrate/retain/accept) — "unclassified for
now" is explicitly not one of them. architecture/dispositions.yaml
already carries this for every root abicheck/*.py module (its own
schema requires a direct root path, so it structurally cannot record a
nested one), and architecture/debt.yaml's files list requires
baseline_lines >= 800 (this repo's production file-size cap), so it
cannot record a small nested leaf module either. A repo-wide re-audit for
this gap (enumerating every .py file under abicheck/ not already
covered by a canonical layer directory, a layer's legacy_paths, or an
existing debt.yaml entry) found 44 such nested modules, all under
abicheck/buildsource/, abicheck/compat/, abicheck/impact/, and
abicheck/schemas/. 31 of them were classified by adding them to the
appropriate layer's legacy_paths in architecture/modules.yaml (the
"migrate through a named responsibility slice" disposition — verified,
not merely asserted, by a full scripts/check_architecture.py run showing
no new dependency-direction/dependency-cycle/unclassified-import
findings after the change; three of the thirty-one — entity_identity.py/
entity_resolver.py/source_graph_query.py — are pure re-export facades
and are additionally registered in architecture/modules.yaml's facades
list so _check_facade's delegation-only rules apply to them too). The
remaining
thirteen could not be classified
the same way without breaking that verification, and are recorded here
instead, following the same "trial classification measured N new
violations, blocked" pattern this ADR's other gaps already use for
comparability.py/build_context.py/etc.
Blocked migrate dispositions, three shapes:
- Caller-forced (the common case below): each module's own outgoing
imports are clean for its named target layer, but a different,
already-classified module imports it directly from a layer that target
does not allow, so classifying it would immediately trip
check_architecture.py'sdependency-directioncheck. Each needs the same fix this ADR's other blocked entries name: route the offending caller through a workflows-owned wrapper (or otherwise decouple it) before the target classification is safe — not attempted here, per this ADR's "not the same-PR fix" bar for migrations that already have caller-side test/behavior surface to preserve. - Self-dependency (
build_evidence.pyandgraph_impact.py, the two exceptions below): the module's own outgoing import, not a caller, is what blocks its target classification. The caller-side remedy above (wrap or decouple the caller) does not apply here — the fix is to move or decouple the blocking dependency inside the module itself. -
Mixed-responsibility (
fact_set.py,impact/use_case_impact.py, andevidence_report.py, the three exceptions below): the module itself bundles two structurally different responsibilities whose real callers are already split across incompatible layers. Neither the caller-side nor the self-dependency remedy applies — no single target layer is even the right answer until the module is split along its own seam. -
abicheck/buildsource/build_evidence.py(target:model— its own docstring is literally "Build-system-neutral build evidence model", andabicheck/buildsource/pack.py(alreadymodel-classified) imports it directly, ruling outextract) — a self-dependency block: it itself imports.comdat_groups(extract-classified), whichmodel's emptymay_importforbids. Unlike every other entry below, no caller needs to change —comdat_groups(or the piece of it this module actually uses) would need to move or be decoupled from this module directly. abicheck/buildsource/graph_impact.py(target:compare— its own docstring: "Structured graph impact/proof-path data attached to findings"; it deliberately enriches an existingChangefinding rather than extracting a fact, the same shape as its already-compare-classifiedgraph_reconcile.pysibling —source_graph_findings.pyis not acompare-classified precedent despite a similar-sounding role:architecture/debt.yamlrecords it with targetextract/build-or-source, and it carries nomodules.yamlclassification of its own today — notextract, which this module's previous classification in this change wrongly assigned it, laundering a real boundary issue instead of recording it) — a self-dependency block, verified empirically by trial classification: it itself imports.call_graph(extract-classified), producing four newcompare -> extractfindings the moment it is classifiedcompare, sincecompare'smay_importismodelonly.call_graph(or the specific pieces this module actually uses from it) would need to move or be decoupled from this module directly.abicheck/buildsource/build_output.py(target:extract, alongside its sibling adapters) — blocked becauseabicheck/cli_project.py(frontends) imports it directly;frontendsmay not importextract.abicheck/buildsource/merge_support.py(target:extract) — blocked becauseabicheck/cli_buildsource_merge.py(frontends) imports it directly.abicheck/buildsource/evidence_policy.py(target:policy, per its own D7 verdict-modulation/require_evidence-gate docstring) — blocked becauseabicheck/cli_buildsource_helpers.py(frontends) imports it directly;frontendsmay not importpolicy.abicheck/buildsource/fact_set.py— not a single-target-layer case at all, unlike every other entry here: this module bundles two structurally different responsibilities (rollup_coverage/rollup_fact_set/fact_set_rollup_is_inconsistent/incomplete_families, a dependency-free per-TU fold — genuinelyextract-shaped, andcheck_fact_set_compatibility/check_fact_compatibility, an old/new pairwise comparison rule —compare/policy-shaped) under one file, and its callers split along exactly that seam:abicheck/buildsource/source_link.py(extract) uses only the rollup half;abicheck/buildsource/inputs_validate.py(extract) uses both;abicheck/buildsource/source_diff.py(compare) andabicheck/analysis_assurance.py(policy) use only the compatibility half. No single classification —extractincluded, this entry's own earlier revision wrongly proposed — can be correct while both halves stay in one file, sincecompare's andpolicy'smay_importnever includeextract. Splitting the file is necessary but not sufficient on its own: the compatibility half's natural owner iscompare(it's an old/new pairwise comparison rule, the same shape assource_diff.py's own responsibility —modelwould mis-own it, sincemodelholds entities/values, not comparison algorithms), butinputs_validate.pyitself isextract-classified and callscheck_fact_set_compatibilitydirectly;extract'smay_importismodel/storageonly, so moving the compatibility half tocomparewould immediately turninputs_validate.py's own call into a new, forbiddenextract -> compareedge. The real fix therefore has two parts, not one: the internal split (rollup functions toextract, the compatibility-check functions tocompare) and moving or interposinginputs_validate.py's own call site (e.g. routing it through aworkflows-owned caller, the same pattern this ADR's caller-forced entries already use) — a combination neither the caller-forced nor the self-dependency shape above covers on its own, and not a single blocked-migrate target either; recorded here as its own third shape.abicheck/impact/use_case_impact.py— the identical mixed-responsibility shape asfact_set.py:build_use_case_impact()is comparison-time orchestration (workflows-shaped — not this module's actualarchitecture/modules.yamlclassification, since it carries none, but the layer its own content fits), but the same file also definesrender_use_case_impact_lines()(text/markdown rendering) andadd_use_case_impact()(mutates a serialized JSON report dict) — both report-shaping, not orchestration. Aworkflowsclassification (as an earlier version of this change assigned it) would bless report construction as workflow logic and let a future workflow-only dependency reach those two functions unchecked. No single classification is correct while all three stay in one file; the real fix is the same shape asfact_set.py's — split the report-shaping functions out to areport-owned sibling.abicheck/buildsource/evidence_report.py— the identical mixed-responsibility shape again:resolve_side_pack()/intrinsic_coverage()/optional_coverage()/detect_coverage_asymmetry()/diff_embedded_build_source()/prepare_embedded_build_source()/attach_evidence_metrics()are pack resolution and diff orchestration (workflows-shaped — again, the layer its content fits, not a real classification this module carries), butcoverage_lines()/compare_side_coverage_lines()/capability_lines()in the same file construct the human-readable evidence-coverage report text —report- shaped. Same fix shape as the two entries above: split the rendering functions out to areport-owned sibling before either half gets a real target.abicheck/compat/descriptor.py(target:extract— ABICC XML descriptor parsing, the same shape as the already-extract-classifiedcompat/abicc_dump_import.py) — blocked becauseabicheck/compat/cli.py(frontends) imports it directly (both the runtimeparse_descriptorcall and aTYPE_CHECKING-onlyCompatDescriptorreference).abicheck/impact/engine.py(target:workflows— itsassess_changebuilder is called fromappcompat.py/appcompat_consumer_impact.py, both alreadyworkflows) — blocked becauseabicheck/post_processing_reachability.py(policy) also imports it directly (lazily);policymay not importworkflows.abicheck/compat/_helpers.py(target:frontends— split directly out ofcompat/cli.pyper its own module docstring, implements ABICC CLI translations, importsclick, and is imported only bycompat/cli.pyitself, alreadyfrontends-classified) — blocked because its own body imports..policy.classificationdirectly, whichfrontends'may_import(model/workflows/reportonly) forbids. Its siblingcompat/_errors.pycarries no such import (onlyclickand the already-model-classifiederrors.py) and was reclassified tofrontendsin this same pass — verified empirically, not merely asserted, the same way everymigratedisposition in this gap was. Classifying_helpers.pyunderworkflowsinstead (as a superficial fix) would not be a correct disposition: it would paper over the real edge —compat/cli.py(frontends) reachingpolicy.classificationthrough this one intermediate file — rather than recording it as the blockedfrontends -> policycase it actually is.
Retain, deliberately unclassified:
abicheck/buildsource/source_graph.py— a pure back-compat re-export facade (its own docstring: "Back-compat facade: source-graph values/ construction/comparison split"), not a real implementation module. Every name it used to define now lives in already-classified siblings (model.graph_facts/model.source_graph/the now-model-classifiedsource_graph_query.pyfor values,extract-classifiedsource_graph_build.py/source_graph_build_source_abi.pyfor construction,compare-classifiedsource_graph_compare.pyfor comparison); this module only re-exports those names (X as X) so a pre-existingfrom .source_graph import ...call site keeps resolving. No internal first-party module actually imports it any more (every real internal caller already imports the split-out siblings directly) — it exists purely for external/legacy callers. Classifying itworkflows(as an earlier version of this change did) would misstate its responsibility: it coordinates nothing, and giving a pure re-export facade a real layer identity is exactly the kind of false ownership this ADR'sfacadesmechanism exists to prevent elsewhere. Unlikedispositions.yaml,architecture/modules.yaml'sfacadeslist is not root-path-limited —check_architecture.pyresolves each entry asfacade.replace(".", "/") + ".py", which reaches a nested module likeabicheck.buildsource.source_graphjust as well (verified: adding it there and runningcheck_architecture.pydoes invoke_check_facadeagainst it). The real reason it isn't registered there is that it doesn't satisfy that check's stricter delegation-only rules: it has no__all__, and it carries a real__getattr__function body (a deliberate, documented lazy import breaking a realsource_graph -> source_graph_findings -> source_graphcycle the AI-readiness gate would otherwise reject) — trial-registering it produces exactly those twofacade-exports/facade-logicfindings. Making it pass would mean removing that cycle-breaking__getattr__or restructuring the module, a real code change out of scope for this docs-only pass. Recorded here as a retained, deliberately-unclassified exception instead, with the concrete blocking findings named rather than asserted.abicheck/impact/model.py— the identical shape gap B already established forchecker_policy.py/contract_gating.py/reclassify.py:abicheck/checker_types.py(model, whosemay_importis empty) importsImpactAssessmentfrom this module directly and statically (from .impact.model import ImpactAssessment), so the module cannot be classified into anything other thanmodelitself without turning that pre-existing edge into a realdependency-directionviolation — but the module's own body imports..policy.evidence_status(Confidence,ReachabilityState), whichmodel's emptymay_importequally forbids. No single ADR-061 layer admits both directions at once, the same conflict gap B's own worked example (DiffResult/checker_policy) describes. Its siblingimpact/engine.pyabove depends on this module too and inherits the same constraint, which is part of why it stays blocked rather than reclassified on its own.
Not fixed here, matching this ADR's own migration-rules bar ("a real,
separate migration slice, not a same-PR fix"). This applies differently
per shape, not as one blanket remedy: each caller-forced entry above
names the specific caller that would need to route through a
workflows-owned wrapper (mirroring gap A's cli_dump_helpers.py ->
header_conditionals.py precedent) before its target classification
becomes safe; each self-dependency entry (build_evidence.py,
graph_impact.py) instead needs its own blocking import moved or
decoupled, with no caller-side fix at all; each mixed-responsibility
entry (fact_set.py, impact/use_case_impact.py, evidence_report.py)
needs an internal split before either half gets a target. The two
retained entries are not
migration targets at all, but for two different reasons, not one:
source_graph.py is a pure facade with no offending caller in the first
place (it exists purely for external/legacy call sites); impact/model.py
is the opposite — it has a real, named offending edge
(checker_types.py -> impact.model -> policy.evidence_status), the same
"no single ADR-061 layer admits both directions" conflict gap B's own
worked example describes, and is forced toward (never actually
classified as) model because that conflict has no caller-side or
self-dependency fix available today — not because there's no caller to
fix. It carries no architecture/modules.yaml entry at all, same as
every other entry in this file. Tractable per-entry, not as one slice —
build_output.py's single frontends caller is independent of any other
entry's fix.
The compare --no-baseline audit report carries no per-finding provider attribution, so provider_assertions is unvalidated¶
Every G20 audit case in catalog/ground_truth.json declares
provider_assertions — which providers must corroborate each finding
(e.g. case151: private_header_leak from both public_header_ast and
source_index; case148: header_build_context_mismatch from
build_config + public_header_ast). Legacy scan published these as
crosscheck.providers, and validation/scripts/run_special_cli_examples.py
checked them. ADR-068 Phase 6 retired the whole-audit orchestrator
(scan_engine.py) that built that block, and no replacement projection
landed in report/no_baseline.py, so the assertion is now unchecked by
anything.
Measured, not assumed: the rich and thin fixtures of case151 —
the case that exists purely to show corroboration growing with evidence —
produce byte-identical public reports today. Both answer
evidence_tiers: ["elf", "header"] and a single private_header_leak
finding. There is no public signal to validate the assertion against, so
the runner records unvalidated_assertions: ["provider_assertions"] and
collect_full_example_matrix.py surfaces it on the row (the same
mechanism as kinds_strict). That makes the gap visible in the matrix
artifact; it does not detect a regression that drops a provider while
still emitting the expected finding kind, and those rows still count as
COVERED (Codex review, PR #1225, raised twice — the second time
correctly pointing out that metadata alone changes no coverage
accounting).
Why it was not closed in PR #1225. The data already exists and the
fix is small and precisely located: CrosscheckResult.providers is
computed and serialized today, and
workflows/cross_source_evolution._run_one_side already reads it
(evaluated = check in result.providers) and discards the list — so
closing this means stamping result.providers[check] onto the emitted
Change and projecting it. What makes it a separate change is not
size: it bumps a published report schema
(AUDIT_REPORT_SCHEMA_VERSION, plus the two-sided compare report, since
compute_cross_source_evolution runs on both paths) and adds a public
Change field. This file's own root contract is explicit that a schema
or public-interface change "still needs its ADR and migration" and is not
something a repair PR folds in as a side effect.
The alternative offered in review — mark rows with a non-empty
unvalidated_assertions as UNRESOLVED — was declined for a stated
reason, not skipped: validation/CLAUDE.md's matrix contract requires one
COVERED row per ground-truth entry and no UNRESOLVED rows, and all
ten audit cases declare provider_assertions, so it turns a currently
green required lane red for a capability removed upstream in PR #1211.
That is a maintainer call about blocking on the follow-up, not a
repair-PR decision.
Visibility.PUBLIC used to be a proxy for "declared in the public API", conflating source presence with dynamic export — fixed¶
Reported against a real comparison, alongside the three defects the "stop inferring public-contract membership from symbol name shape" change fixed. This one is now fixed too — by the model/schema change it always needed, not by a call-site patch. The account below is kept because the shape of the conflation, and the reasoning about what a fix had to do, is the load-bearing part; what changed is the last section.
model/vocabulary.py's Visibility has three states, and its own comments
showed the conflation:
PUBLIC = "public" # default visibility / exported
HIDDEN = "hidden" # __attribute__((visibility("hidden")))
ELF_ONLY = "elf_only" # present in ELF symbol table, not in headers
PUBLIC meant both "declared in a public header" and "dynamically
exported"; ELF_ONLY meant "exported but not declared". There was no state
for the fourth, entirely ordinary combination: declared in a public header
and not dynamically exported — an inline member, a function the optimizer
fully inlined away, or one a -fvisibility=hidden/version-script change
stopped exporting. Detectors then used f.visibility != Visibility.PUBLIC:
continue as if it answered "is this declaration part of the public API",
which it did not.
diff_namespaces.py showed the consequence (_func_index_items,
_collect_public_declared_names, _batch_demangle_public all filtered this
way): the removal event is "absent from the new-side index", and a
declaration that is still present in the header — byte-identical, still
compiling for consumers of both header sets — dropped out of that index
purely because its emission changed. The run then reported a source-API
removal for an API that was not removed. The reported case was a build whose
default and public-only artifacts had byte-identical headers and differed
only in whether one method was dynamically exported; the two sides classified
as PUBLIC and ELF_ONLY respectively, and the surviving overload was
reported as removed.
The fix was not to ignore export changes for inline functions: a disappearing dynamic export can still break an already-linked binary. The two facts are independent and both matter, so the result reads as two findings, not one wrong one:
- source declaration — still present, no removal;
- binary export — changed, evaluated on the binary-contract axis.
How it was fixed¶
model/surface_facts.py owns the split. Function and Variable each
carry three Fact[bool] siblings — declared_in_headers_fact,
in_public_contract_fact, binary_exported_fact (schema v46,
storage/fact_codec.py) — and that module's accessors are the only
supported way to read them, each named for the question it answers
(declared_in_headers, in_public_contract, binary_exported, plus the
consumer-shaped in_public_surface, is_abi_visible,
in_source_declaration_index, declaration_confirmed_absent,
is_export_confirmed_absent). There is deliberately no fourth merged
boolean. Because they are real Fact[...] fields, the
fact-detector-misuse AI-readiness gate covers every reader.
The three points the original entry named, and what each became:
- The model change. Done as above;
AbiSnapshot'sSCHEMA_VERSIONwent to 46. A pre-v46 snapshot has no such keys, andmodel/surface_facts.py's legacy bridge derives all three from the storedvisibilityvalue, markedPARTIALwith aderived-from-legacy-visibilitydiagnostic — so a stored baseline classifies exactly as it did before (pinned bytests/test_surface_fact_split.py::TestLegacyBridgeIsBehaviourPreserving) and the weaker provenance stays visible rather than being laundered into a producer's own claim. No enum member establishes (a) in either direction: not a positive out of "it was exported", and not a negative out ofELF_ONLYeither, since both header backends assignELF_ONLYto a declaration they parsed out of a header whose symbol landed in.symtab. The record's own header provenance is what answers (a) — a positive when it is present, unknown when it is absent — and it is consulted for every member alike. - Producers answer only what they observed
(
extract/surface_fact_producers.py). A header-AST backend asserts (a); it asserts (c) only when a real export table was consulted, and leaves it unknown for a header-only dump. DWARF/BTF/CTF leave (a) unknown — debug info is not header evidence. An export-table-only entry leaves (a) unknown unless headers were actually parsed.provenance.tag_provenanceadds the scope-aware half: a declaration whose defining header is in the supplied public set is positive evidence for (b) — and only ever a positive, since origin-based exclusion is already its own separately controlled scoping decision and asserting the negative here would apply it twice, silently. An evidence-depth projection returns both header-derived facts to unknown (headers_discarded_surface_facts) instead of asserting their negation. - The guards were audited. Every
visibility != Visibility.PUBLICfilter now names its question: public-surface membership (in_public_surface), the binary union the old(PUBLIC, ELF_ONLY)tuples spelled out (is_abi_visible, replacingdiff_symbols._PUBLIC_VISanddiff_time64._ABI_VISIBLE), export-table-only-ness (is_export_table_only_record), the intersection of (b) and (c) that the singleis PUBLICtest used to mean (is_public_export), a confirmed non-export (is_export_confirmed_absent), or — fordiff_namespaces' removal indexes, the site the report reproduced — the source-declaration population (in_source_declaration_index), which is blind to (c) by construction.
compare/export_transition.py is the second finding the entry asked for,
and it is symmetric in both directions: a matched pair whose export went
away while the declaration stayed reports FUNC_VISIBILITY_CHANGED/
VAR_VISIBILITY_CHANGED on the binary axis, and one that gained an export
reports the compatible FUNC_EXPORT_ADDED/VAR_EXPORT_ADDED. Both
directions are gated on confirmed evidence on both sides, so "exported
before, unknown now" (or its mirror) is never rendered as an observed
transition.
Three review findings on the fix are worth recording, because each is the same mistake in a different place — reaching for a predicate that answers a neighbouring question:
- The gain direction was missing at first. Before the split a
promised-but-unexported declaration failed the old
(PUBLIC, ELF_ONLY)filter outright, so the pair never matched and a newly exported declaration was reported asFUNC_ADDED. Once the declaration keeps its place on both sides the pair matches — and with only a loss-side detector the run reported nothing at all. Trading one wrong finding for a silent diff is worse than the bug being fixed, and an addition that vanishes is what "record before disposing" forbids. Hence the two compatible kinds, and one entry point per owner (check_function/check_variable) so a caller cannot enumerate the directions and miss the next one. - A binary-symbol subject needs the intersection, not the union.
Replacing a
visibility is Visibility.PUBLICtest within_public_surfacesilently widens the subject to declarations the artifact never exported.diff_templates' instantiation-survival index is the sharp case: its own docstring says a staleextern templatedeclaration must not count as surviving, and that declaration is exactly a promised-but-unexported entity, so the union admitted it and suppressedINSTANTIATION_MISSING_FROM_BINARY.is_public_exportis the named intersection, and it reproduces the legacy enum test exactly (PUBLICtrue,ELF_ONLY/HIDDENfalse). Visibility.ELF_ONLYcannot establish header absence. The legacy bridge originally read it as a confirmed "not declared in any header". It is not: both header-AST backends assignELF_ONLYto a declaration they parsed out of a header whose symbol landed in.symtabrather than the dynamic table, and a headerless pre-v46 snapshot reaches the same member with no header parse at all. Fact (a) is now answered from the record's own header provenance for every member alike; which member it was is a different question, still answered byis_export_table_only_record, so the ELF-only removal kind and the stub-record consumers are unaffected.
Every finding with one declaration behind it
carries a surface_facts block (report schema 4.4) stating all three as
"true"/"false"/"unknown" — always complete when present, because an
omitted key is what a reader mistakes for a negative.
Bug class: evidence.export_presence_as_declaration_presence
(tests/regressions/manifest.py), whose one registered residual is that the
generalized test builds its snapshots in-process; a live castxml/clang dump
of two builds differing only in a version script is owned by the
integration lane. The sibling name-shape defects stay under
classification.name_shape_as_contract_membership.
A stored-BundleFacts baseline's narrower format set is not pre-checked by the Action¶
actions/' preflight (action/validate-inputs.sh) mirrors every one of
compare's format allowlists so a bad --format fails before the
multi-minute toolchain install rather than after it — except one. A stored
BundleFacts OLD side with a directory/package NEW side renders json or
markdown only (frontends/cli/commands/compare_bundle_facts_rejections.py),
narrower than the release allowlist's json|markdown|junit|oneline that
preflight applies when either operand is release-style. So
format: junit on that shape passes preflight and then fails in the CLI with
a usage error (exit 64, surfaced as VERDICT=ERROR). A late clear failure,
not a wrong result — but late.
It is not fixed here because both available fixes are worse than the gap:
- Classify in shell. Whether a file is a stored
BundleFactsdocument is answered bystorage/bundle_facts_codec.looks_like_bundle_facts_document, a deliberately two-tier classifier (explicitartifact_typemarker, then a v1-only shape fallback with documented, accepted false positives). A shell re-implementation is exactly the duplication_is_release_style_operand's own comment warns about, and that one is cross-checked against the live CLI by a test — this one could not be, since the classifier operates on decoded JSON, not a path. - Read the document at preflight. The operand is attacker-controlled in
the PR-checkout case, which is the whole reason the decode-node ceiling is
config-only and the remedy this PR rewrote says only an explicit
--configmay raise it. Decoding it in a preflight step that runs before those limits exist reopens precisely that surface.
A path-extension heuristic (*.json/*.json.gz/*.json.zst) was considered
and rejected: it would wrongly reject junit for a stored ProjectSnapshot
OLD side, which is the same shape on disk. The honest fix is to make the
narrowing a CLI-reported capability the Action can query cheaply (a
compare --probe-formats style answer, or having the CLI validate formats
before resolving operands), which is a real design slice rather than a guard.
Until then the Action's format input documents the narrowing and says
plainly that it is not pre-checked. Found by Codex review on PR #1237.
ADR-071's release assurance fold: what it deliberately does not do¶
ADR-071 gave assurance.require_complete real semantics for a
directory/package (release) compare and for a stored BundleFacts operand,
and retired the four guards that existed only to stay ahead of the missing
semantics. Three adjacent things were in scope to consider and deliberately
left out, each for a stated reason rather than for effort:
- No per-library
assurance.require_completeoverride. The setting stays project-wide (ADR-071 D4), likegate.fail_on_removed_libraryand therelease.*keys. A per-member override is a real config-surface question (where does it live — arelease.libraries.<key>block? a selector? — and how does it interact with the D7 precedence resolver), not a fold detail, and nothing in the reported gap asked for one. - No per-member
analysis_assuranceblock in the release report. Eachlibraries[]entry carries its ownanalysis_assurance_statusand the fold names the short members and their notes, but the full per-pair block (target/TU/export accounting, the five context statuses, layout-unverified detectors) is not embedded per member. Doing so would multiply a substantial sub-object across every member of every release document — a report-schema sizing decision of its own. A member that needs the full block is reachable today by comparing that library individually, and the fold's notes name which member to compare. -
The three orthogonal
0/1release axes still fold in three places. ADR-049's contract-coverage floor, ADR-065 D6's incomplete-scope floor and ADR-071's assurance floor now have the same shape — resolve a decision per run,maxit across members, carry it to the exit and the report — and each has its own resolver, its own*_termsprojection and its own parameter threaded through_format_release_summary/_finalize_release_output/_exit_compare_release. That is three near- identical threadings, and theno_growthbaseline bumps ADR-071 needed in five files are the visible cost of adding the third. The convergence target is the duplication-and-convergence plan's own P0EffectiveGate/EffectiveEvaluationConfigwork — one object carrying every orthogonal axis's decision, threaded once — not a fourth copy of the pattern. Do not add a fourth0/1release axis by copying this one; land that convergence first. -
The stored-
BundleFactsoperand's JSON carries noexitblock. It folds the axis, exits on it, and — since a Codex P1 on this PR — publishes the canonical top-levelanalysis_assurance_exit_contributionand the fold section in its own report and in every--output-dirfile, so the gate is no longer lost toaggregateor a deferred gate. What it still lacks is anexitblock of its own, unlike the live release document: a consumer readingexit.codefrom this driver's JSON finds nothing, and has to read the verdict and the individual axis keys instead.
An earlier revision of this entry used that missing exit block to excuse
omitting the fold entirely, on the grounds that the section had "nowhere to
hang". That was wrong about the half that mattered: the gate-bearing key is
a plain top-level scalar and needs no exit block at all, which is exactly
why the omission was a real gate bypass rather than a cosmetic gap. Giving
that driver a real exit block remains its own change, and the right one —
it would close several adjacent asymmetries at once.
--depth binaryis not projected onto a stored-BundleFactsOLD side, so that operand is stricter than a live one.compare_bundle_facts. dispatchthreadsdepth=into the stored/stored driver (workflows.bundle_stored_pair_compare.compare_stored_bundle_facts_pair) but not into the stored/live one (bundle_side_input.compare_release_against_bundle_facts, which has no depth parameter at all). At--depth binaryit clears NEW's headers while the stored OLD snapshot keeps whatever L2/L5 facts its capture recorded, so the comparison is asymmetric in a way live-vs-live at the same depth is not.
Measured on a three-library fixture, same pair both ways: live-vs-live at
--depth binary reports analysis_assurance: partial with one note
(scope_resolved is False), while stored-vs-live reports partial with
three (header context asymmetric: the new side carries no header/API-level
evidence, graph completeness unknown, contract_coverage is 'partial').
ADR-071 did not cause the asymmetry — it pre-dates this axis and affects
findings too, not only assurance — but it is what makes the asymmetry
gate, so the divergence is worth stating plainly: under
assurance.require_complete a stored-OLD release at --depth binary can be
floored where the live equivalent is not. The partial is not a false
finding: the evidence really was asymmetric, and AGENTS.md's "weaker
evidence narrows conclusions" says to report that rather than hide it. What
is wrong is only that the user asked for binary depth and the stored side
did not honour it.
The fix is to give that driver a real depth and project both sides to it
before comparing — which moves findings, not just assurance, and so belongs
with the stored/live driver's own evidence handling rather than bolted onto
this axis. Until then, compare a stored OLD side at its captured depth (omit
--depth), or use a live OLD tree when you need --depth binary to be
symmetric. Found by Codex review on PR #1237.
The companion test gap is recorded in tests/regressions/manifest.py under
bug class gate.per_member_axis_fold: the order-independence and monotonicity
properties are stated and generated for the assurance axis only. The coverage
and scope axes have example-level tests but no property suite of their own, so
a regression in their fold would not be caught by it. Generalizing one
property harness over all three axes is the real remaining work there, and it
pairs naturally with the convergence above.
compare --bundle-facts-out stays a compare flag because dump has no release fan-out¶
one-comparison-product.md's own framing, recorded rather than fixed here:
--bundle-facts-out asks a comparison to capture a baseline. Capturing
the OLD side's per-library snapshots into a BundleFacts document is a
dump operation — it reads one release and writes facts about it, and it
does not need a NEW side at all. It lives on compare only because that is
the one command that knows how to turn a directory or package into a set of
per-library snapshots; dump takes exactly one artifact, and a directory
operand there is interpreted as a stored ProjectSnapshot package, not as
a release to fan out over.
The two options, and why neither landed in this pass. Adding the dump
fan-out is the right shape: abicheck dump RELEASE_DIR --format bundle-facts
would make baseline capture a first-class dump, compare would consume the
document it already consumes, and --bundle-facts-out could be retired
rather than reimplemented. What it needs is the whole release input-resolution
chain — package/debug-package/devel-package extraction, library discovery and
canonical-key matching, stored-ProjectSnapshot variant materialization,
--dso-only classification — and every one of those functions is currently
frontends/flat-cli_*-classified (cli_compare_release_matrix.
_prepare_compare_release_inputs and its helpers), with click.UsageError
raises inside them. dump's own execution path is workflows-classified, so
the engine-cli-boundary gate forbids it from reaching any of that, and the
fan-out cannot be written without either duplicating the chain or moving it.
That move is the same work one-comparison-product.md item 4 asks for on
its own terms (relocating frontends/cli/release_compare_request.py's call
chain into workflows with typed errors), which is why the ordering here is
a real dependency rather than a deferral: the dump fan-out is a
straightforward composition once that chain is engine-side, and an
open-coded duplicate of it before then is exactly the second parallel path
this repository's architecture rules forbid.
That dependency is now satisfied (same PR): the chain is
abicheck/workflows/release_inputs.py, it raises the typed
errors.ReleaseOperandError, and abicheck.service.resolve_release_compare
reaches it with no Click context. So the dump fan-out is no longer
blocked -- it is simply not written yet, and writing it is a new command
surface (dump's own operand arity, --format bundle-facts, the ADR-054
root-surface bar for any new spelling, and the retirement path for
--bundle-facts-out) rather than a refactor. Recorded here as the next
slice, with the blocker removed, instead of left reading as unreachable.
Until then, --bundle-facts-out stays where it is, and stays honest about
what it is: a capture ridden on a comparison. It is not reimplemented, not
widened, and not given a second spelling. When the dump fan-out lands, the
flag becomes a deprecated alias for it rather than a separate producer —
abicheck/cli_compare_release_helpers.py's write_bundle_facts_out already
takes the already-resolved per-library snapshots and nothing else, so it is
the reusable half and moves as-is.
A directory/package compare renders only json/markdown/junit; bundles: cannot state scope policy¶
Two halves of one-comparison-product.md's bundle-parity item that this
pass did not close, recorded with what each actually needs. The rest of
that item did land (assurance.require_complete accepted, --write
repeatable, the machine document no longer implicitly truncated) — see the
changelog entry and tests/test_one_comparison_product_parity.py.
1. The format set. compare renders
json/markdown/sarif/html/junit/review/oneline; a directory or
package operand renders json/markdown/junit/oneline and rejects the
rest
(frontends/cli/commands/compare.py's _RELEASE_FORMATS). The rejection is
loud rather than silent, and it is not arbitrary — the missing formats are
the ones whose renderers take a single DiffResult:
sarifneeds one result set with stable per-finding locations; a release has N of them and the fan-out discards each member'sDiffResultbefore rendering (_strip_diff_results_and_adjust_verdict, to bound peak memory across a whole release). Producing a real release SARIF means either keeping every member's findings live or projecting SARIF per member and merging runs — a design choice, not a wiring gap.htmlandrevieware narrative renderings of one comparison (a verdict badge, an OLD→NEW headline, a release recommendation, a root-cause graph).reviewin particular is a PR-comment digest whose aggregate shape ("which of 40 libraries broke, and how badly") is a product question nobody has answered yet.onelinewas the fourth of that list and is closed: it never needed aDiffResultat all (it is a count summary, and the release summary already carries every count), soreport/release_oneline.pyfolds the per-library counts through the sameformat_stat_linea single-paircomparerenders and the release path accepts-o oneline=.../-o oneline=...like any other.
So library count still changes which of sarif/html/review are
available -- a real parity gap, stated here rather than implied by a usage
error -- and no longer changes anything about oneline.
2. Scope policy at bundle level. ADR-065's scope policy is fully
expressible for a directory/package compare — --select,
--select-required and .abicheck.yml's scope.on_incomplete all apply to
a release operand, and --select-required naming an absent member really
does floor the exit code (verified live). What has no expression is the
project-config bundle surface: buildsource/project_targets.py's
BundleSpec accepts only targets: and checks:, so a bundles: entry
cannot state required members beyond its own membership list, nor its own
permitted-incompleteness policy, nor a selection narrower than the whole
bundle. A project that wants "these four of the six must be present, the
other two may be missing" has to say it per invocation rather than in the
config the Action reads. That is a BundleSpec schema addition plus a
check-target input, in the ADR-047 project-targets layer — not a
compare change, which is why it is listed separately from the format set
above.
Release-fan-out CLI tests JSON-parse CliRunner.output, which the memory-clamp note can join¶
Found while verifying an unrelated change, reproduced on a clean tree, not fixed here (it is nobody's feature and touches eight tests across two modules that the change under review did not otherwise touch).
cli_compare_release_pairwise.py echoes one note to stderr when the
per-worker memory budget clamps the release fan-out's parallelism ("Note:
parallel release workers reduced 4 -> 3 to fit available memory ..."). Several
release CLI tests capture with a bare CliRunner() -- which merges stderr
into result.output -- and then json.loads(result.output). When the clamp
fires, the note lands ahead of the document and the parse raises
JSONDecodeError, so the test fails for a reason that has nothing to do with
what it asserts.
It only fires under real memory pressure, which is why it is invisible most
of the time and then shows up as a cluster of unrelated-looking failures on a
loaded machine (e.g. the whole suite under pytest -n 8 on a 16 GiB box).
Reproduced deterministically on a clean tree with
ABICHECK_RELEASE_JOB_MEM_GIB=1000 pytest
tests/test_release_evaluation_config.py::TestReleaseFanOutAcceptsContractUnresolvedPack.
Affected (as of this writing): tests/test_release_evaluation_config.py's
TestReleaseFanOutAcceptsContractUnresolvedPack::test_now_applies_with_contract
and six tests in tests/test_release_package_inventory_cli.py
(TestPackageArchiveInventoryProvesAbsence, TestSupportPromiseFindingsCli).
The fix is per-test and mechanical: render to a file (-o PATH) and parse
that, the way tests/test_one_comparison_product_parity.py already does
where --output-dir's own stdout line would otherwise interfere, or capture
stderr separately. Asserting on the note, or suppressing it, would be the
wrong fix -- it is a real diagnostic a user should see.
Source-read licence: enforced at the one consumer, not repo-wide (2026-09-12)¶
The contract — a path recorded in a snapshot is provenance, not a licence to
re-read the current filesystem for historical facts — is stated in
abicheck/buildsource/source_inputs.py and enforced in the one place that
had violated it: workflows/pattern_preprocessor_scan.py, plus the two
primitives beneath it (buildsource/pattern_facts.py's
find_pattern_facts, which now refuses to stat an unlicensed root, and
buildsource/preprocessor_facts.py, which is simply not invoked for an
unlicensed side since clang -E resolves #includes against the filesystem
it runs on).
Two residuals are deliberately open, both recorded on the
evidence.stored_snapshot_rederivation bug class in
tests/regressions/manifest.py:
-
No mechanical gate against a future violator. Nothing stops a new consumer from reading
AbiSnapshot.source_header(or a compile unit'ssource) and opening it without resolving a licence first — nor a new front end from callingdumper.dumpdirectly and forgetting to grant one. Both halves of that already bit once: the ABICC-compatible CLI was found doing exactly the second (PR #1236's review round), afterservice.run_dumphad already been fixed for the first. The shape of the fix is known — an AST scan inscripts/check_ai_readiness.py, the wayfact-field-readersguardsFact[T]reads, with an allowlist of the reader sites that legitimately hold a licence — but it is a gate of its own, not part of this fix, and today there is exactly one such consumer to guard. -
The licence is object-level, not content-verified. A header-derived live extraction grants it for the whole run; it does not digest each source input and re-check that digest at read time. The consequence is the conservative direction (a stored snapshot declines to re-derive even when the tree on disk genuinely is the one it was dumped from), which is why it is a gap rather than a defect. Closing it properly means persisting per-input content digests, i.e. an
AbiSnapshotschema bump inside the ADR-050 comparability contract plus a codec and migration — the "persist the facts at dump time" half of the original two-option brief. That is the route to take if stored-versus-stored pattern/preprocessor evolution ever needs to be answerable rather than honestly declined; do not instead widen the licence, which would reintroduce exactly the defect above.
Closed, and how (one licence per evidence source). A third residual used
to sit here: the licence required header-derived provenance
(extraction_read_source_inputs), so a dump that collected only L3 build
evidence (--sources with no -H) reported its source-derived facts as not
evaluated even though it genuinely had read its compile units, because nothing
at the grant point separated that from a loaded pack whose paths are as
historical as a stored snapshot's. The same coarseness had a worse, opposite
failure the same review round found: a live header dump merged with a
pre-captured --build-info pack was granted one snapshot-wide licence off the
header AST, and the pattern/preprocessor scans then read the pack's recorded
compile-unit paths from whatever occupies them on this runner — the original
fabrication, reached through the other evidence source (PR #1236, Codex P2).
Both directions are one defect: a side holds up to two source-evidence sources
with independent provenance, and one licence cannot be correct for both. So the
licence is now resolved per source — declared headers via
snapshot_source_licence, an embedded build pack via build_evidence_licence,
which asks the pack itself (BuildSourcePack.live_source_evidence, stamped by
buildsource/embed.py only for an inline collection performed in this run and,
like the snapshot flag, never serialized). A partially-licensed side reports
what it read and keeps the unlicensed roots in the expected-input account as
not_licensed gaps, so no absence is established from the half that was read.
What remains true is that the grant is conditional, which is the point for a
DWARF-only dump whose DW_AT_decl_file paths name the build machine's tree:
that side never opened them, so it gets no header licence.
Note also the shape of the accepted trade-off in the third fix: the
evolution fold now decides each identity from what is established for that
identity, which means persistent is reported from two observations even
when both sides had coverage gaps elsewhere. An earlier revision withheld it
globally. That is not a regression of the earlier fix: an incomplete scan
cannot un-see a hit, so two observations are the strongest premise the fold
has, while introduced/resolved each assert an absence and still require
the relevant side's sufficiency. The lexical scan cannot, however, attribute
an unread file to a particular PatternKind, so a gap in any declared root
leaves every kind's absence unestablished on that side — a per-kind answer
would need per-kind input attribution the scanner does not have, and is
recorded as a gap on coverage.discovery_derived_completeness.
An informational, no-material-change reconciliation outcome gates the build under --severity-preset strict (2026-09-12)¶
Raised as a triage question ("is quality_issues the right bucket for a
COMPATIBLE, purely-informational reconciliation outcome, or does the report
shape need an informational, no action category?"), it has a prior
question that outranks it: do any consumers gate or rank on the bucket?
They do, and that is the real defect.
declaration_coordinates_shifted — and now declaration_identity_unchanged
— are COMPATIBLE but not ADDITION_KINDS, so
report_summary/policy.severity.categorize_changes place them in
quality_issues. That is documented, and the partition invariant holds.
But quality_issues is one of the four SeverityConfig categories, and
PRESET_STRICT sets every category to ERROR. Measured directly:
declaration_coordinates_shifted bucket=quality default rc: 0 strict rc: 1
declaration_identity_unchanged bucket=quality default rc: 0 strict rc: 1
So under --severity-preset strict (or a .abicheck.yml severity: block
setting quality_issues: error) a finding whose whole content is "the
reconciliation proves this declaration did not change" produces a non-zero
exit code. policy.severity.compute_gate_decision additionally names
quality_issues in blocking_categories, and
pr_comment_render.py's _SEVERITY_ICONS renders that category as
"⛔ Quality policy violation" on the PR comment.
This is not a presentation problem and it is not fixed by a dashboard ranking differently. Two candidate fixes, neither taken here:
- Make the bucket honest.
quality_issuesis defined asCOMPATIBLE_KINDS − ADDITION_KINDS, i.e. a residue, and the residue now holds two populations with opposite meanings: real quality problems (stdsymbol leaks) and proofs that nothing happened. A thirdCOMPATIBLEsub-partition (INFORMATIONAL_KINDS, alongsideADDITION_KINDS/QUALITY_KINDS) with noSeverityConfigcategory of its own would fix the gate and the report shape together. It is achecker_policypartition change, areport/compute_*/render_*pair change, and a report-schema bump — andchangekind-partition's AI-readiness gate would need to learn the fourth set. - Exempt these kinds from the gate. Narrower and worse: it leaves the bucket lying about what it holds, and it needs a per-kind exception list in exactly the place ADR-049 D8 works to avoid one.
Not attempted in the PR that found this because it changes a public partition and a report schema — see this file's own precedent that a partition change is an ADR-scoped decision, not a follow-up patch. The measurement above is the evidence; the decision is open.
Declared-header order is still load-bearing because the driver TU is sequential¶
profile_fingerprint's header_sequence field records declared-header
order, and it does so for a real reason: dumper.py generates one
aggregate driver translation unit that #includes the declared headers in
sequence, so a header's macros/pragmas can change how every header parsed
after it resolves.
The comparability gate originally waived only a strict trailing append
(_header_sequence_is_additive_reorder_free), which is the one shape that
proves no existing header's preprocessing context changed. That is correct
for a deliberately declared -H a.h -H b.h order and badly mis-fitted to a
directory-discovered one, where the order is an incidental product of
sorting whatever the sweep found. Real integration evidence: a project
adding one public header named json.h to a set sorting as
data.h, log.h, ... landed it in the middle, the waiver declined, and an
ordinary public-header addition refused the entire comparison —
profile_fingerprint mismatch; differing fields: header_sequence, no
verdict, and no findings on any axis, the binary's own exported-symbol
identity included.
What was fixed, in two rounds. The first round changed the
disposition: an order-preserving insertion of headers the scope
fingerprint independently confirms as new became a non-fatal
ComparabilityMismatch (fatal=False) whose residual risk was recorded as
a bounded assurance reduction on the declaration/layout dimensions. That
removed the refusal but kept pricing the insertion — and the price is real:
AnalysisAssurance.status reads partial, which assurance.require_complete
gates on, so a project running that (correct, unweakened) setting still
failed on an ordinary public-header addition, now with
"extraction contexts were not provably identical" instead of a refusal.
The second round removed the price, because the hazard it named is not one this contract prices anywhere else:
- A declared header's content is not part of
profile_fingerprintat all —comparability_fields._header_identitieskeys a header by its root-relative path, never its bytes. So an existingdata.hthat gains a#defineor a#pragma packbetween two versions changes the parse context of every header after it and is compared at full assurance, by design: the declared surface's content is the subject of the comparison, not evidence about the extraction environment. Adding those same declarations as a new file is the same fact in a different shape. - The added header's position is an incidental product of the project's
discovered, sorted public-header set. Pricing it makes assurance depend
on the new header's spelling —
json.hcosts assurance,zzz.hdoes not, for the same library change. - The scope axis already treats the identical addition as ordinary
evolution at full assurance (
_scope_field_is_additive_superset), and the trailing-append waiver already accepts the added header's own parse.
So the header_sequence carve-out is now stated over
comparability_sequences._header_sequence_is_scope_confirmed_growth — the
union of the append and insertion shapes — and scope-confirmed growth is
waived outright wherever the new header sorts.
comparability_profile._declared_header_insertion_mismatch and
inserted_header_entries were deleted rather than left as a second,
unreachable path. The non-fatal (fatal=False) machinery itself stays:
it expresses a general rule, has no producer today, and
analysis_assurance_comparability.py remains the (still correct) bridge
that stops a bounded run from reporting complete.
What still refuses, unchanged: a reorder of an existing declared
header, growth whose extra entries the declared surface does not confirm as
new (e.g. a header fed to the L2 frontend on the new side only, whose
content the old snapshot never parsed), a duplicate/sentinel/undecodable
sequence, and any other diverging profile field riding alongside the
addition — compiler family or version, target triple, pointer width,
endianness, ABI dialect, language standard, macro ops, pass-through flags,
or an unexplained include_sequence (a changed -I topology, which decides
which dependency header an #include resolves to). The carve-out only ever
removes header_sequence from the working set, so that safety property is
structural rather than a list to keep in sync.
What remains open: the sequential aggregate TU itself. Parsing each
declared header in an independently scoped TU would make declared order
non-load-bearing outright — a reorder would then be comparable too, and
this whole carve-out family could retire — but that is a dumper change
carrying a per-header TU cost, and it was deliberately not attempted
alongside either disposition fix. --diagnostic-comparison is explicitly
not the answer to any of this: it downgrades assurance wholesale
instead of resolving the extraction question.
The compatibility percentage counts findings against symbols¶
report_summary.compatibility_metrics computes
binary_compatibility_pct = (old_symbol_count - breaking_count) / old_symbol_count * 100,
where breaking_count is a count of findings whose effective verdict is
BREAKING and old_symbol_count is a count of the old side's exported
symbols. The two quantities are not the same unit, so the percentage is
not what it reads as. A library with ten exports and two breaking findings
about one symbol reports 80% binary compatibility while nine of its ten
symbols are untouched and one is broken — verified with a synthetic probe.
When old_symbol_count is unknown the numerator is divided by
len(changes) instead — a third unit — and affected_pct degrades to
0.0, which is missing denominator information rather than a claim that
nothing is affected. A consumer must not reconstruct a symbol count from
either percentage: they are lossy in both the rounding and the unit.
Not fixed, deliberately. It is an ABICC-compatible heuristic, it is part
of the JSON report's public data contract, and redesigning it is a change to
the shared semantic owner (report_summary.py) with consistent behaviour
required across JSON, HTML and Markdown — not something a reporting-surface
change may do for one format. What the PR-comment work did do is refuse to
propagate it: the comment states explicit counts (breaking / needs review /
safe, plus the entity-by-operation rollup in report/change_summary.py) and
carries no percentage at all, so it cannot be read as a confidence score or
as "N% of symbols are compatible". The semantics, the limitation and this
prohibition are recorded in CompatibilityMetrics' own docstring, next to
the formula.
A release/bundle report carries no authoritative entity-by-operation counts¶
report/change_summary.py can summarise a single comparison exactly,
because compare's JSON carries the complete changes list. A release
(directory/package fan-out) report does not: the only itemized, kind-level
view of a library's findings is cli_compare_release.py's findings list,
capped at ten per library. pr_comment._release_change_summary therefore
builds the rollup from that sample and marks it exact=False with a stated
reason, and the renderer prints "Not exact totals — …" rather than
presenting a floor as a total.
Closing it needs an authoritative per-entity/per-operation count in the
release JSON schema itself — a cli_compare_release.py change, not a
rendering-only one. It is the same shape of gap pr_comment._from_release's
own docstring already records for the evidence-kind bucket, and it should be
closed in the same pass as that one.
An -I include root makes another library's public headers this component's export obligations (2026-09-16)¶
Partially closed (2026-09-16). An
-Iroot now widens public provenance only where the run's own declared public headers live underneath it (extract/public_root_ownership.py, applied byprovenance.public_dirs_with_owned_rootsand, identically,buildsource/header_graph.py). That is a fourth route none of the three candidates below considered, and it needs no newScopeOriginmember, noFactretightening and noSCHEMA_VERSIONbump: the evidence to separate the two questions was already in hand at classification time, in the declared-H/--public-header-dir/sources.public_headersset the caller passes alongside the-Ilist.A root the rule declines is classified
UNKNOWN, notPRIVATE_HEADER. That distinction is the whole safety argument and was not in the first version of the fix:PUBLIC_HEADERis what creates an export obligation, so a dependency's declarations must not have it; butPRIVATE_HEADERis a confident signal that public-surface scoping acts on to drop findings, so a root the run merely could not place must not have it either, or a library whose own public headers are split across include roots loses real breaking changes from its verdict. Raised as a P1 by Codex's security review on the PR; four attempted repros were each caught by some other mechanism (the undeclared-export removal exemption, the export-table closure, the conservative-unknown fallback), but the safety of a demotion rule must not rest on unrelated mechanisms happening to cover it.What it closes: a dependency's include tree. Intel MKL passes an MPI include directory solely so
mkl_cdft.hcan parse#include <mpi.h>; no MKL public header lives under it, so it no longer widens anything, and the 2,211MPI_*/PMPI_*/QMPIX_*public_not_exportedfindings are gone. Regression tests:tests/test_dependency_include_root_ownership.py, including a property-style statement of the containment predicate.What it does not close, and this is the reported PVXS case above.
-H include/pvxs/iochooks.hwith-I <pvxs>/includeis containment: the declared header lives under that root, so the root keeps its widening power andversion.hbeside it is stillPUBLIC_HEADER— still charging libpvxsIoc for libpvxs's exports. Containment separates "a dependency's tree" from "the library's own tree"; it cannot separate two sibling libraries sharing one include tree, because from one snapshot's point of view those are the same tree. Everything below still applies to that case, and the three candidate fixes are still the options — the narrowest (recording the declared-Hset onAbiSnapshot) remains the most promising, since "which headers this run declared" is exactly the evidence a shared tree makes unrecoverable at classification time.The positive control the entry demands is kept and tested: a symbol declared in a header that is in
-Hand genuinely absent from the binary still reports.
Reported on a real PVXS build (abicheck 3737f9f9, CastXML 0.7.0, EPICS
Base 7.0, gcc 13, -std=c++11), and reproduced by comparing one snapshot
against itself — byte-identical operands, verdict NO_CHANGE:
abicheck dump lib/linux-x86_64/libpvxsIoc.so.1.5 \
-H include/pvxs/iochooks.h \
-I <pvxs>/include -I <epics>/include ... --depth headers
-H names exactly one file. include/pvxs/version.h is reachable only
because iochooks.h #includes it and -I <pvxs>/include makes it
findable; the symbols it declares (pvxs::version_int(),
version_str(), version_abi_int()) are exported by libpvxs, a
different library. The comparison charged libpvxsIoc with three
public_not_exported findings for them.
Root cause, confirmed by reading both halves.
provenance.apply_provenance deliberately folds the -I roots into the
public-directory set once a real -H set has opted classification in
(provenance.py, the include_search_dirs parameter and
_public_dirs_from_include_roots). That fold exists for a real defect it
fixed: without it every transitively-#included header classified
PRIVATE_HEADER, and a genuine breaking layout change reached only through
an umbrella header silently dropped out of the compared surface. So the
declaration's ScopeOrigin becomes PUBLIC_HEADER, and
buildsource/cross_source_checks.py's _has_export_obligation /
_var_has_export_obligation gate on exactly origin == PUBLIC_HEADER.
The two questions are being conflated:
- Is this declaration in the component's compared public surface? — the fold's answer (yes) is defensible: an API change to it matters.
- Does this binary owe an exported symbol for it? — the fold's answer is not implied by (1) at all, and for a shared include tree serving several libraries it is wrong.
Note the reported case used a -H file, so this is not the documented
"a directory entry tags everything under it public" behaviour; a directory
-H root reaches the same place through the same fold.
Not fixed here, and why. The evidence needed to separate (1) from (2) —
which headers the run's own -H set declared, as opposed to which the
-I widening admitted — is not recorded anywhere a check can read it after
serialization. ScopeOrigin collapses both to PUBLIC_HEADER, and
ExtractionContract keeps fingerprints rather than paths. The three
candidate fixes each carry real blast radius:
- A new
ScopeOriginmember (INCLUDE_CONTEXT_HEADER): 52 existingScopeOrigin.PUBLIC_HEADERsites must each decide whether they mean "in the compared surface" or "owned by this component", and the value is persisted vocabulary. - Tighten
in_public_contract_factto the declared set (already persisted, schema v46): butcompare/export_transition.pyalready keys onis_confirmed_false(in_public_contract(...))to decide not to suppress a removal, so a confirmed-false for widened declarations would add findings elsewhere. - Record the declared
-Hfiles/dirs onAbiSnapshotand re-classify in the check: the narrowest of the three, and independently useful (a stored baseline records nothing about the contract it was dumped under), but it is aSCHEMA_VERSIONbump inside ADR-050's comparability contract.
Whichever is taken, the fix must keep the positive control: a symbol declared
in a header that is in -H and genuinely absent from the binary must still
report. A suppression rule or a per-project carve-out is explicitly not the
answer.
The native dump CLI does not stamp excluded_header_patterns on the snapshot it writes (2026-09-16)¶
Measured, not inferred (2026-09-16, while wiring
scope.exclude_headers through dump): a snapshot written by
abicheck dump ... --exclude-header c2.h -o base.json carries
excluded_header_patterns: null and excluded_header_matching: null. The
config spelling behaves the same way, because both reach the same place. A
compare operand, by contrast, is stamped -- cli_resolve.
_resolve_compare_snapshots builds an InputSpec that reaches
workflows.input_resolution.resolve_input, which calls
model.header_exclusion_record.record_header_exclusions. The native dump
CLI executes through service_dump_pipeline.execute_dump_request instead
and never reaches that recorder.
Why it matters. The recorded field is the entire input to two
mechanisms, and both are blind to a dump-produced baseline:
extract.header_exclusions.exclusion_asymmetry_reason, the comparability gate that refuses a pair whose two sides were narrowed by different rules. A baseline dumped under--exclude-header a.hcompared against a candidate narrowed byb.hreads as((), ("b.h"))-- asymmetric in the data, but the gate sees the baseline's()as "excluded nothing", which is exactly the false-symmetry caserecord_header_exclusions's ownextracted_nowparameter was added to prevent in the other direction.confidence.header_exclusion_warnings, so such a run reports full header-aware assurance over a surface nobody looked at -- the promise--exclude-header's help text makes ("reported as reduced evidence") and that this field exists to keep.
Not fixed here. It is a pre-existing gap in dump's own execution path,
orthogonal to the three defects this change addresses, and the fix is a
question about where the recorder belongs rather than a line to add: the
compare path stamps inside resolve_input, which execute_dump_request
deliberately does not go through, so either the recorder moves to a point
both share or execute_dump_request grows its own call and the two can
drift. Both are real choices with their own blast radius (the second
reintroduces exactly the "two places record the same fact" shape the
extracted_now fix had to reason about).
Until it is fixed, consistency between a dump baseline and a compare
candidate rests on both commands resolving the same rules, which is why
scope.exclude_headers is honored by dump as well as compare
(frontends/cli/dump_debug_config.resolve_dump_scope_exclude_headers) --
that wiring is load-bearing, not a convenience on top of a gate that would
otherwise have caught the divergence.
An --exclude-header pattern spelled as a path matches nothing on Windows (2026-09-16)¶
extract/header_exclusions.apply_header_exclusions tries each pattern against
three spellings of a header: the bare name (fftw3.h), the full path
(str(path)), and */<pattern>. Only the first is platform-independent.
str(path) is backslash-separated on Windows, and fnmatch treats / and
\ as ordinary characters, so a forward-slash pattern (include/fftw3.h,
**/fftw/*) matches no header there while matching on POSIX. A Windows user
must either spell the pattern with backslashes or fall back to the bare name.
Not fixed here, and deliberately not papered over in the test that found
it (tests/test_header_exclusion_primitives.py states the bare-name spelling
as an unconditional contract and marks the path-shaped spellings
skipif(win32) pointing at this entry, rather than asserting a POSIX-only
claim as if it were universal). The fix is a real decision with its own blast
radius: normalizing both sides to POSIX separators would change which headers
an existing Windows invocation excludes, and the patterns are hashed into
excluded_header_patterns, the comparability gate
(exclusion_asymmetry_reason) and the effective-configuration digest -- so a
normalization that alters matching also alters run identity, and a baseline
dumped before it would stop being comparable against a candidate dumped
after. That is a migration, not a one-line as_posix().
effective_depth reports source for a --depth headers dump whose only L5 is the header-only graph (2026-09-16)¶
Same PVXS run as the entry above: dump --depth headers produced a snapshot
whose compare reported L5 source graph summary: present on both sides and
effective_depth: source, while L3/L4 correctly read not_collected.
The L5 summary itself is expected and is not the bug.
service_header_graph_attach._attach_header_graph has attached a header-only
(L2) semantic graph to AbiSnapshot.surface_graph on every headers-depth
dump since G29 Phase A (ADR-041 addendum). It is a declaration graph, not
source-tier evidence.
The over-claim is the depth label. evidence_depth.py already carries two
labels and documents this exact trap:
depth_label_forreportssourcewhenever L4 or L5 carries facts;gated_source_labelrefuses that for the header-only case, discriminating onl4_source_abi_was_attempted(pack)— "a non-empty L5 can also come from a header-only (L2) declaration graph that never ran any source-tier replay at all".
buildsource/check_report.derive_effective_depth reads
old_evidence_depth/new_evidence_depth off the compare report, which come
from the honest label — so the strict gate knows the dump only reached
headers while the reported effective_depth says source. A consumer
reading the report (an assurance policy, a CI gate, a reviewer) is told
source-tier evidence was collected when none was.
Fixed (2026-09-17). evidence_depth.reported_depth_label is the one
rule every report now uses -- it is gated_source_label, the gate's own
function, not a parallel copy of its reasoning. Both consumers were
converted together, because a document whose
analysis_assurance.effective_depth and whose
old_evidence_depth/new_evidence_depth disagreed would have been worse
than the original over-claim:
analysis_assurance.compute_analysis_assurance (which calls it directly;
the _effective_depth_label wrapper that used to stand in front of it was
deleted along with two other pure-delegation aliases) and
cli_dump_helpers.evidence_depth_label (the compare report's two side
depths and dump's own provenance stamp) both go through it.
buildsource/check_report.derive_effective_depth needs no change and gains
a correct answer: a
--depth source check over header-only-L5 evidence now reads degraded
instead of complete.
Two values move, as this entry predicted. A run whose only L5 is the
header-only graph reports headers/build where it reported source.
And -- the direction that is easy to miss -- the gate's rule is not
uniformly stricter: a zero-match source-only dump (replay parsed TUs and
linked nothing, because there is no binary to link against) leaves L4 and
L5 both empty, so depth_label_for answers build while the gate
correctly answers source. That case now reports source too. Neither
direction moves a verdict or an exit code; check_requested_depth_satisfied
already gated on this rule, which is the whole reason a report disagreeing
with it was a bug. tests/test_analysis_assurance_implicit_depth.py
states the invariant as equality with the gate over the whole
snapshot/pack cross product, rather than as a one-sided bound a second
quietly-different rule would also satisfy.
Nothing stopped a test fabricating a Change state the types forbid (2026-09-16)¶
An earlier revision of this section claimed, under the heading "Change.symbol
is annotated str but a real detector emits None", that a real
public_surface_shrank finding carries symbol=None. That claim was
wrong, and the way it was reached is the actual gap worth recording.
The None was observed by instrumenting report/review_groups.py during
tests/test_policy_registry_and_summary.py, and read as evidence about
production. It was not: it came from three hand-written fixtures in that
test file (Change(..., symbol=None)). The real producer of that kind,
diff_surface_metrics.py, passes the "<surface>" sentinel — a str, as
the annotation requires. Verified: make_change(symbol: str) in
diff_helpers.py, no # type: ignore on any Change( construction under
abicheck/, and mypy abicheck/ clean. So there is no production/annotation
disagreement, and widening the field to str | None (measured: 84 new mypy
errors across 24 files) would have been a breaking change to a public type in
service of a defect that does not exist.
The real gap. mypy runs over abicheck/ only, so tests/ could
construct a Change in a state the types forbid and nothing anywhere would
say so — and a downstream reader could then be "fixed" against an impossible
input, or, as here, a false gap recorded from one. Typechecking tests/
outright is not available as a fix (mypy --explicit-package-bases tests/
reports 26,780 errors against an entirely unannotated suite).
Closed by a narrow structural gate instead: check_ai_readiness.py's
test-change-symbol-typed rejects Change(..., symbol=None) and
make_change(symbol=None) anywhere under tests/. The three fixtures now
pass "<surface>", matching the producer.
What remains true about review_groups. The display_name fallback to
the finding's kind stays, and still earns its place — the reachable
nameless case is the empty string, which is type-valid and which
qualified_name or demangled_symbol or symbol resolves to unchanged, leaving
a group rendered under an empty heading. tests/unit/report/test_review_groups.py
states that as an order-independence property over every mix of named and
nameless findings, with a vacuity guard asserting the fixture really is
nameless, and constructs it with "" rather than a fabricated None.
Export-loss ownership: declaration existence is not an export obligation¶
func_removed_elf_only's ctor/dtor exemption resolved its owner through an
index keyed by the unqualified record name, and then treated "the owning
class is still declared" as a reason to drop a concrete export loss. Both were
falsified by measurement (a loader running a client built once against OLD),
and the mislabelled -O0 vs -O2 corpus entry the exemption was built to
satisfy was itself a true positive under its own header. Full account, the
runtime controls, the intentional semantic change and the remaining gaps:
export-loss-ownership-root-cause.md.
What is still open there: the inline-definition fact is read from the
declaration's is_inline, which is a fact about declaration linkage, not
proof that a consumer emitted its own copy; and the recovered owner path for
a class-template specialization names only the primary template, since this
repository has no Itanium type decoder.
Snapshot digest/save amplification is in the sectioning layer, and four attempts to reduce it bought nothing (2026-09-17)¶
Measured while fusing snapshot_to_dict()'s two encoding passes into one. The
digest path peaks at roughly 16x the live model and the encoder is not why.
Stage peaks for _uncached_snapshot_content_digest() over a synthetic
20,000-function snapshot whose live model is 36.35 MiB, with the source dict
already allocated and not counted:
| stage | peak |
|---|---|
snapshot_to_dict() (after the fusing change) |
102.2 MiB |
to_sectioned_document(...) |
474.0 MiB |
| whole function | 576.1 MiB |
to_sectioned_document repackages the already-encoded document through
import_legacy_snapshot -> InMemoryObjectStore.put, which builds a section
DTO, a to_dict() of it, a strip_capture_metadata/canonical_form copy, and
a canonical-JSON string for hashing -- several more whole-document copies on
top of the one the encoder just produced. Within one put of the largest
section: canonical_form 120.3 MiB, the JSON string 46.9 MiB, its .encode()
44.2 MiB, copy_of_canonical_form (what store.get pays) 120.3 MiB.
Four reductions were implemented, measured, and three reverted. All are recorded because each looks obviously worthwhile from the source and is not:
iterencodeinto a running sha256 instead ofdumps(...).encode()in_uncached_snapshot_content_digest. Exactly equivalent ("".join(iterencode(o)) == dumps(o)), removes a 318 MiB string and a 396 MiB bytes copy -- and moved the measured peak not at all (576.1 MiB either way), because both allocations happen strictly after sectioning has already peaked above them. ~20% slower. Reverted; the reasoning is in that function's own docstring.- The same, one level down, inside
semantic_digest_of_canonical_form, where the string is built during sectioning rather than after. Also no change (474.0 / 576.1 unchanged), also ~15% slower. Reverted. InMemoryObjectStore.detach--getreturns a defensive deep copy, andto_sectioned_documentbuilds a store, fills it, reads it all back, and discards it, so that copy guards an object about to become garbage. Removing it eliminates a real 120.3 MiB copy. It changed neither the peak (474.0 MiB before and after) nor the time (45.61 s vs 45.69 s). Reverted.hashlib.new(algorithm, domain + payload)-> twoupdate()calls. This one was kept: the concatenation materialises a complete second copy of the payload (44.2 MiB on this fixture, 0.0 MiB after) purely to prepend five bytes, and removing it costs nothing and adds no API. It does not move the end-to-end peak either, and is kept as waste removal rather than as a measured improvement.
A measurement error worth not repeating. Attempt 3 was first reported as
576.1 -> 474.0 MiB, an apparent 18% win. It was an artifact: the "before"
figure came from a script that allocated the source dict after
tracemalloc.start() and the "after" from one that allocated it before, so the
two differed by exactly the 102 MiB dict and not by anything the change did.
Running the same script against the unmodified base revision showed 474.0
both ways. Any before/after here must come from one script run against two
revisions -- a git worktree of the base is the cheap way -- never from two
scripts.
What actually remains. The peak is the DTO -> to_dict() ->
canonical_form chain, each link of which is a whole-document container tree,
and a live-allocation attribution at the end of sectioning confirms the residue
is canonical_form's own rebuilding (storage/canonical.py:198-202). Reducing
it means changing how sections are built and addressed, and the digests
involved are persisted content addresses -- a storage-format decision needing
its own ADR and migration, not a serializer patch. ADR-063 Phase 8's
project-snapshot-dto-no-asdict gate already points at the same DTO files,
which is the natural home for that work.
Note this is the digest and single-file save path specifically. The
multi-library bundle writer does not go through it -- it encodes each member
with snapshot_to_dict() directly, and its own whole-bundle retention was
fixed (see storage/bundle_facts_archive.py). No claim is made here that
large-library OOM is solved.
SurfaceGraph.reached_by was built for every graph and read by nobody (2026-09-17)¶
Recorded as a class, not an incident. reached_by was materialised eagerly at
build_surface_graph() time from ADR-027's original design sketch, at
O(roots x types) cost, and a full-tree audit found its only readers were three
test assertions and the ADR's own code block — no production consumer ever
existed. It has been removed; the relation is still derivable in two lines from
public_roots() and reachable_types().
The general point for the next index added to a shared structure: an eagerly materialised derived relation on a widely-constructed object is paid for by every construction, including the overwhelming majority that never read it, and nothing in the type system or the test suite reports that. When adding a field like this, either give it a consumer in the same change or compute it at the query that needs it.
The release fan-out's future map is not a last owner, so dropping it saves nothing (2026-09-17)¶
Checked while looking for lifetime wins after the encoder/archive work above, and recorded as a negative result so it is not "fixed" speculatively later.
cli_compare_release_pairwise.py's parallel fan-out builds
futures = {executor.submit(...): key for key in matched_keys} and keeps the
whole mapping alive until the with ThreadPoolExecutor(...) block exits. That
looks like per-member results being retained for the length of the run, and it
is a real retention — but not of the results. results_by_key[key] =
future.result() stores the same object the completed Future holds in its
own _result slot, so the dict and the future are two references to one
result. Releasing the future frees the Future wrapper and nothing else; the
per-library result dict stays owned by results_by_key, which the function
returns.
So the available saving here is a few hundred bytes per member, not a member's
worth of findings, and any patch that deletes futures as they complete while
claiming a memory reduction would be measuring its own wrapper objects. A real
reduction on this path has to shrink or stream what results_by_key holds —
the per-library result payloads themselves — which is a change to what the
release fan-out returns to its caller, not a lifetime tweak inside the loop.
Five SVS-scan report findings not addressed by the change-vs-inventory/depth/exclusion fixes (2026-09-17)¶
An external reproduction of a real PR scan (SVS, Linux ELF C++, Clang 20,
two byte-identical runtime artifacts) reported nine defects against
abicheck's report. Four were fixed together, because they are one
invariant -- a report may only count, and claim coverage for, what the
comparison actually observed (tests/regressions/manifest_report.py's
report.unobserved_population_counted_as_observed): persistent hygiene
counted as this release's risk, an implicit depth reported as unanswered,
reduced source-graph evidence reported as source depth, and unmatched
--exclude-header rules reported as lost coverage.
The remaining five are recorded here rather than patched narrowly. Each is a genuinely separate piece of work with its own owner, and the run that reported them is reproducible, so none of them needs re-discovery.
- Header coverage cannot be audited even on a
completerun.analysis_assurancereportedstatus: completewhile requested/resolved header roots were empty,translation_units' selected/parsed/failed/skipped counts were allnull, the L2 elapsed time was zero, and no matched-header count was recorded anywhere. Nothing in the document demonstrates that the intended public headers were found and parsed. Owner:analysis_assurance._translation_units/_target_accounting(which already have the shape, and are simply not populated on the header-AST path), and the accounting must then contribute tostatus-- an unpopulated accounting block currently cannot lower it, which is what letcompletestand. - "Not applicable" detectors are reported beside genuine coverage gaps.
PE, Mach-O, kABI, Python, SYCL and the DWARF family are all listed as
detectors "not evaluated" for a Linux ELF C++ comparison where most of
them could never apply. The vocabulary needs at least four states --
applicable and evaluated, applicable but missing evidence, not
applicable to this artifact/platform, not requested at the selected
depth -- and only the second may reduce confidence or appear
prominently. Owner:
policy/evidence_status.pyplus whatever populates the detector roster; note this is a classification change, so the applicability rule must be derived from the artifact's own container/ language facts rather than from a hand-maintained per-detector list that will drift. - The default Markdown report is ~294 KB for a no-change result.
Mostly 32 persistent findings expanded with full C++ mangled names, plus
suppressed findings. The default CI/PR report should summarize persistent
hygiene by family, collapse or truncate mangled symbols, cap example
counts, and leave the complete inventory to JSON or a downloadable full
report. The 31
std::once_flagguard/thunk symbols are one obvious grouped finding. Owner:report/render_markdown_document.pyplusreport/review_groups.py(the grouping already exists; what is missing is a family-level rollup for hygiene and a default cap). - Performance telemetry misses almost all runtime. Wall time was ~31s;
extractor.duration_secondsreported 0.457s. Header discovery, AST parsing, graph construction, detector execution and report generation are all invisible. Owner: whatever publishesevidence_metrics-- the point is one phase table covering the whole run, not another individual timer. - Byte-identical binaries are categorized as a coverage warning. That
the two artifacts match is useful provenance, not reduced coverage --
especially on a run that still parsed headers, where
confidence.note_if_same_binary_comparedalready words it as "any difference would have to come from the header/build evidence". It belongs in an informational observation channel, which does not exist yet; adding one is the actual work, and inventing it as a side effect of this fix would have been the wrong place for it. (The same run also suggests reusing/caching binary-side extraction when the digests match while still scanning headers -- a separate performance opportunity.)
Three further items from that report are not abicheck defects and are
recorded only so they are not re-filed: the 32 retained
exported_not_public findings are real SVS visibility hygiene (one
svs::datatype_v<unsigned int> instantiation, 31 std::once_flag
guard/thunk symbols from IVF/LeanVec) to be fixed there or classified by
policy; the missing DWARF/layout evidence is an artifact-build limitation;
and the configured header exclusions match nothing and should be removed
from the SVS integration.
The release public surface is acquired once, but each member snapshot still stores its own copy of the header evidence¶
Recorded with the release product model (2026-09-17), measured rather than assumed. The output cardinality is closed: the contract is reconciled once, a shared finding is rendered once, and a release's report size now scales as O(public surface + members + real findings) — 8.7 MiB -> 77 KiB on a 12-library / 480-declaration fixture. Two costs are not closed:
- Storage. Every entry in
BundleFacts.per_library_snapshotsstill carries its own copy of the header evidence its dump parsed.BundleFacts.public_surface(schema 4) gives the product contract one canonical home and records the acquisition identity it was acquired under, which is what lets a stored baseline be reconciled against the union of its members' exports — but it does not deduplicate the per-member header evidence beside it. Doing that is a change to snapshot persistence itself (a shared-surface reference plus a migration for every existing baseline), not to the release layer, which is why it was recorded rather than half-done here. -
One extra header parse per side. The release-level surface is acquired through
header_only_dump.build_header_only_snapshot, whose acquisition key legitimately differs from the member dumps' own (a header-only parse resolves its language mode without a binary's export evidence), so it does not hit the AST cache entry the member dumps share. Measured on the two-library fixture: 2 castxml cache entries before, 4 after — i.e. two parses per side rather than one, and O(1) per side either way, never O(members). The requirement it does meet is the one that mattered: castxml is not invoked once per library for an identical acquisition request, and the release acquisition ledger reports exactly one acquisition per side (public_surface_reconciliation.acquisition). Closing it means letting the release surface reuse a member dump's already-parsed header AST under the same acquisition key, which is a change to howdumper's language-mode resolution keys a header-only parse — worth doing, and not a correctness issue in the meantime. -
The
ProjectSnapshotimport adapter refuses a schema-4 document.storage/import_bundle_facts.pyhas no composition section forpublic_surface, so it keeps its own_BUNDLE_FACTS_SCHEMA_VERSION = 3and its existing "newer than this build knows how to interpret" gate refuses such a document outright. That is deliberate, and it is the fail-closed half of "no silent reinterpretation": carrying the version forward without the block would import members whose recorded contract had been discarded, leaving them reconciled against nothing. It is still a gap. The adapter has no in-tree production caller today (it is a public storage API exercised by tests), which is why teaching it apublic_surfacecomposition section -- anotherstorage/dto.pysection version -- was recorded rather than done here.tests/test_release_public_surface.py::TestBundleFactsSchema::test_the_project_snapshot_importer_refuses_a_v4_documentstates the refusal so it cannot silently become a drop.
Also open, and smaller: the JUnit projection of a release carries per-member
test cases only, so the release-level public-surface section appears in the
JSON and Markdown renders but not there. The gate is unaffected — a
release-level contract finding folds into worst_verdict and the exit code
before any format renders — so this is a traceability gap in one format, not
a missed finding.