One comparison product — retiring scan, consolidating the CLI¶
Owner ADR: ADR-068.
Status: Phases 0–6 and 8 are done. scan was deleted on 2026-09-11
(abicheck scan exits 64, naming compare/compare --no-baseline), after
every capability it exclusively owned reached compare behind a parity gate.
Phase 7 has nothing executable left: each remaining item is gated on a
named prerequisite or owned by another workstream (see
What remains in Phase 7). Phase 9's two named
relevance-defect blockers in
public-contract-default.md Phase 6 are
closed (2026-10-01; see Phase 9),
the lane-coverage bound was accepted (2026-10-02), and slices 9a (the
--contract all scope fix) and 9b (--scope-public-headers/--no- deleted,
2026-10-03) and 9c (--post-manifest's config home,
contract.overlays.post_manifest, 2026-10-03) and 9d (--post-manifest
deleted, 2026-10-03) are done: --contract is compare's one contract
mechanism and Phase 9 has no executable slice left. A 2026-10-03 re-triage
of Phase 7's open list found one executable item the stale "P5 open" note had
hidden: compare --no-baseline DIR (acceptance F-23), since done (2026-10-03).
Retiring
CompareRequest.scope_public is a separate, deferred Python-API decision.
Effort: XL · Risk: high — this deleted a public command and moved
capabilities between analysis paths. Phase ordering was the safety mechanism.
This is a compacted record (2026-09-29). The per-slice checkpoint
narrative — review rounds, intermediate counts, re-verification receipts — is
in git history; the last full-length revision is commit
5ba6a5c84c07e504d4b1df8cbed0cf59abd5aa71. What stays here: what landed and
where, decisions that must not be re-litigated, what is still open and who
owns it, and the merge criteria. Durable technical findings live in
known gaps and in the owning modules' docstrings.
Subordinate to vision-api-abi-evolution.md
for anything about what a result means. Where the two disagree, that plan
wins. This plan owns the interface and its capability topology: which
command owns which analysis, and what a user has to type.
Supersedes the remaining scope of cli-cleanup-phase-two.md (retired;
see its row in the plans index). Its three open items were
re-homed here (§6): PR H was cancelled, PR I and PR J became Phase 7 slices.
Its closed items stay closed.
Cleanup reconciliation (2026-09-29)¶
Stale facts corrected while compacting, each verified against the tree:
FindingEvolutionis wired. Phase 1 item 2's primitive is consumed byproject history(abicheck/cli_project.py→workflows/history.py'sapply_finding_evolutionper adjacent pair) and bycompare's own report (reporter.py/report/document_builder.py→report/finding_evolution_section.py'sfinding_evolutionblock). The earlier "not yet wired into any CLI command" claim was stale.skills-src/evaluation/validation/scripts/run_oneapi_scan.pyandskills-src/evaluation/field/scan_level_scaling.pyno longer exist — both were deleted in Phase 6 (list A). The earlier "stays onscan" note is void.tests/parity/gaps.pyandtests/parity/test_gap_registry_contract.pyare deleted. The registry had emptied;tests/parity/is now a compare-only regression corpus (seetests/parity/__init__.py).- The retired
--crosscheckpromotion exit axis.ExitDecision.crosscheck_promotion_contributionhas no resolver parameter any more (policy/exit_decision.py): its only producer,scan --crosscheck KEY=error, is gone, so the resolved value is always0. The field is kept read-only so stored pre-0.6 reports still load. - Import-cycle allowlist.
IMPORT_CYCLE_ALLOWLISTinscripts/check_ai_readiness.pydropped ten members that named deleted CLI modules. - Stale-comment sweep. Scan-era references in
abicheck/comments and docstrings were rewritten to describescanas retired rather than live.
Status re-evaluation (2026-10-01)¶
Re-checked against the tree with Click introspection and the contract gates:
- Live counts match §4.5 (
compare43,dump20,aggregate5, no hidden options).abicheck scanexits64with the migration hint. The rulings bijection (tests/test_config_rebalance.py), the root-surface pin, the CLI contract and the export-grammar tests pass, anddocs/reference/cli-reference.mdis in sync. - A new subcommand this table did not list:
project capture-variants(7 options, storage-format-v2 A1.6). It is now in §4.5. - Per-option rulings covered only
compareanddump. Closed 2026-10-02 by slice 7s (which also founddepsunruled). - Phase 9's blockers moved. The template-instantiated-parameter seed
mismatch had already been closed, but this plan and the plans index still
listed it. The
ambiguous_namespaced_leafidentity gap is closed by schema v54. Thepackagelane's recorded reason ("compare rejects--contractfor package operands") was stale: the release fan-out does apply it per library.
1. Current state¶
Historical: the audit of main that started this plan (2026-09-06). All
three findings are now closed; kept as the audit record, not rewritten.
1. compare could not reach scan's checks. The only production callers
of run_crosschecks, find_pattern_facts and collect_preprocessor_facts
were in scan_engine.py. The eleven cross-source checks —
private_header_leak, public_not_exported, exported_not_public,
rtti_for_internal_type, odr_type_variant, unversioned_exported_symbol,
compile_context_conflict, header_build_context_mismatch,
source_surface_dso_mismatch, public_to_internal_dependency,
identity_collision_detected — plus the lexical pattern pre-scan, the
preprocessor scan, changed-path localization and the abi3 audit were
scan-only. That was a correctness and discoverability defect, which is what
made the migration a capability gain. Closed (Phase 2).
2. Two of everything. scan owned ~10,000 lines (10,074 across 16
modules, verified 2026-09-09), a separate typed API (ScanRequest/
ScanResult), a separate JSON schema (SCAN_SCHEMA_VERSION), 17 explicit
mode == "scan" conditionals in action/run.sh, and 33 scan-only test
modules. Closed (Phase 6).
3. Surface size. 78 accepted compare options (4 hidden), 45 on scan,
39 on dump; roughly 30 were stable project properties retyped every run.
Surface size was the third problem — a short invocation is not clean if it
reports a pre-existing leak as newly introduced. Largely closed (Phase 7;
see §4.5 for the live counts).
2. Target state¶
Root surface — six verbs, three tiers¶
| Tier | Command | Question it answers |
|---|---|---|
| Primary | compare OLD NEW |
How did this component's supported surface evolve, and is that acceptable? |
| Primary | compare --no-baseline NEW |
Same product, OLD side declared absent: what can be said about this build alone? |
| Primary | dump INPUT |
Capture this component's evidence as a reusable snapshot. No verdict. |
| Primary | deps tree BINARY |
What will the loader resolve for this binary here, and does everything bind? |
| Primary | deps compare BINARY --old-root --new-root |
Will this consumer still load/work when its deployment environment changes? |
| Advanced | aggregate REPORTS_DIR |
Fold many per-target reports into one CI gate verdict. |
| Advanced | project {validate,plan,history} |
Read one project-integration artifact and report on it. |
| Frozen | compat … |
ABICC drop-in. Untouched by this work (ADR-068 D7). |
scan is gone. check is not introduced.
The target experience¶
# The everyday case
abicheck compare old/libfoo.so new/libfoo.so
# With header evidence, side-scoped
abicheck compare old/libfoo.so new/libfoo.so -H old=old/include -H new=new/include
# Reusable baseline
abicheck dump old/libfoo.so -H old/include -o baseline.abi.json.zst
abicheck compare baseline.abi.json.zst build/libfoo.so -H new=include
# PR review with source evidence, localized to the diff
abicheck compare baseline.abi.json.zst build/libfoo.so \
-H new=include --sources . --depth source --since origin/main
# One analysis, several artifacts
abicheck compare OLD NEW -o json=result.json -o markdown=summary.md
# No baseline exists yet
abicheck compare --no-baseline build/libfoo.so -H include/ --sources .
# A whole release
abicheck compare old-release/ new-release/ --select-required libfoo.so
Nothing in that set requires the user to know L0–L5, BundleFacts,
ProjectSnapshot, SemanticIR, extractor names, or gate algorithms.
3. Scan retirement¶
Classification vocabulary (ADR-068 D3/D5):
COMPARE-STAGE — becomes an ordinary analysis/enrichment stage of the
comparison pipeline; AUTOMATIC — internal execution behavior, no enable
flag; CONFIG — stable project property in .abicheck.yml;
INTERNAL — stays a reusable engine primitive, loses public scan
identity; DELETE — leaves the product.
The "Current owner" column is the owner at the 2026-09-06 audit; every
scan-side module named there is deleted. The last column is the outcome.
| # | Capability | Current owner | Target owner | Class | Prerequisite / outcome |
|---|---|---|---|---|---|
| 1 | Baseline comparison (--against) |
cli_scan_baseline.py, scan_engine.run_scan_core |
compare OLD NEW |
DELETE (it is compare) |
Done (Phase 6). Parity gate (Phase 3) passed first |
| 2 | Audit-only mode (no --against) |
scan_engine._audit_exit_code |
compare --no-baseline |
COMPARE-STAGE | Done. Rides ADR-065's declared_absent state; policy/no_baseline_findings.py partitions the self-diff; audit gating via policy/audit_gate_exit.py (exit 3, opt-in through --severity-preset) |
| 3 | Cross-source checks (11) | buildsource/cross_source_checks.py, run only from scan_engine |
compare pipeline, per side |
COMPARE-STAGE | Done, 11 of 11. Run automatically in every compare() (cross_source_checks default True, no flag — D4/D5), evolution-stated via workflows/cross_source_hygiene.py. D3's authority rule holds: findings stay RISK/API_BREAK, never advisory-only (the 2026-09-09 amendment deleted scan's stripping of them as the bug it was) |
| 4 | Private-header leakage | crosscheck.private_header_leak |
as #3 | COMPARE-STAGE | Done. Boundary from -H directory provenance + .abicheck.yml scope.public_header_dirs (#22) |
| 5 | public-vs-exported (public_not_exported, exported_not_public) |
crosscheck |
as #3 | COMPARE-STAGE | Done. As #4 |
| 6 | Pattern checks (lexical pre-scan) | buildsource/pattern_facts.py |
compare pipeline, per side |
COMPARE-STAGE | Done (Phase 2b). checker.compare's pattern_preprocessor_scan (default True, no flag) via workflows/pattern_preprocessor_scan.py; advisory pattern_preprocessor_scan report block, no ChangeKind |
| 7 | Pattern verdict modulation | compare --pattern-verdicts (already on compare) |
compare, always-on where evidence exists |
AUTOMATIC | Done (Phase 5): --explain-patterns no longer implies modulation; the ledger is unconditional disclosure (7o) |
| 8 | Preprocessor checks | buildsource/preprocessor_facts.py |
compare pipeline, per side |
COMPARE-STAGE | Done (Phase 2b) — as #6 |
| 9 | Build-context analysis / reconciliation | scan L3 collection; compare --reconcile-build-context |
compare --depth build, reconciliation always-on when build context is present |
AUTOMATIC + MERGE | Done (7i): reconciliation forced on inside compare_snapshots; flag and CompareRequest.reconcile_build_context removed |
| 10 | Source-ABI replay (L4) | scan --depth source |
compare --depth source (already exists) |
MERGE | Done (Phase 2c, POI scoping parity) |
| 11 | Source-graph analysis (L5) | scan --depth source |
compare --depth source (already exists) |
MERGE | Done (Phase 2c). ADR-037 D6 keeps L5 internal |
| 12 | Changed-path localization (--since, --changed-path) |
cli_scan.py, buildsource/poi.py |
compare --since / --changed-path |
COMPARE-STAGE (per-run input, ADVANCED KEEP) | Done (Phase 2c). One owner, workflows/changed_paths.py |
| 13 | Risk-driven evidence selection (--depth auto) |
risk.py, model/evidence_depth_levels.py |
— | DELETE | Done (2026-09-09, ruled (b) by ADR-068's second amendment). Unpinned --depth resolves to headers (evidence_depth_levels.resolve_unpinned_level). Documented breaking change: a job relying on escalation must pin its rung |
| 14 | Risk rule overrides (--risk-rules) |
cli_scan_baseline._load_risk_rules |
— | DELETE | Done (ruled (b)): nothing left to tune once #13 selects nothing; no risk: config key |
| 15 | CPython/abi3 audit (--abi3) |
scan_abi3_resolve.py, scan_engine._run_abi3_audit |
compare candidate-side enrichment stage |
COMPARE-STAGE; floor value is CONFIG (python.abi3_floor) |
Done (Phase 2d). Precondition failure uses the exit-7 axis. A declared python.abi3_floor arms it with no flag (re-verified 2026-10-03) |
| 16 | Artifact-set / multi-library audit (--artifact-set) |
service_scan.run_scan_set, bundle.py |
compare --no-baseline DIR over ADR-065 members |
DELETE (mode); capability preserved | Done (2026-10-03, acceptance F-23). Mode deleted; compare --no-baseline DIR audits each member of a directory, package archive, or multi-artifact stored package through the same per-candidate sequence as a scalar audit (workflows/no_baseline_set.py), with members resolved by the one-sided release resolver both shapes now share (release_inputs.resolve_release_side) and ADR-065's acquisition record (--select/--select-required, scope.on_incomplete, D2 inventory proof). One audit_set document (report/audit_set.py, audit_set_report.schema.json). No typed request yet (known-gaps.md) |
| 17 | Set member-identity/provider manifest (scan --manifest) |
cli_scan_helpers.load_artifact_set_manifest; ADR-056; cli-cleanup PR H |
.abicheck.yml bundle/provider contract, read by the same path |
CONFIG | scan side deleted; PR H cancelled. Provider resolution is G42's. Member selection over a set audit is --select/--select-required on compare --no-baseline DIR (row 16) |
| 18 | Analysis completeness/assurance | analysis_assurance, --require-complete-analysis (both commands) |
compare (already present) |
MERGE | Done. Flag demoted to assurance.require_complete after Phase 6 |
| 19 | Budget guard (--budget) |
scan_engine._check_scan_budget, _BudgetOverflow, exit 5 |
compare --budget, ExitDecision operational axis |
ADVANCED KEEP; default in CONFIG | Done (Phase 4 commit 2). deadline.deadline_scope across resolve + classify; DiffResult.budget_overflow; typed API CompareRequest.budget_s |
| 20 | Finding cap (--max-findings) |
cli_scan_baseline summary truncation |
— | DELETE | Done. Complete machine data is never truncated (7m) |
| 21 | JSON resource budget (--max-json-object-nodes on compare) |
bundle_facts decode |
execution/storage config, calibrated resource_limits: |
CONFIG | Done (7g). resource_limits.max_bundle_facts_decode_nodes; only an explicit --config may raise it, an auto-discovered file may lower it. Default deliberately unchanged and node-based — see 7g |
| 22 | Public-header boundary (--public-header-dir) |
cli_scan_baseline._public_provenance_set |
-H directory provenance + .abicheck.yml scope.public_header_dirs |
MERGE | Done. provenance.apply_provenance, fed from -H directories and scope.public_header_dirs; directory-vs-file rule preserved |
| 23 | Per-check severity (--crosscheck KEY=LEVEL) |
CrosscheckConfig |
--policy / .abicheck.yml policy.overrides (they are ChangeKinds) |
MERGE into policy | Done. KEY=error syntax ruled (b) and deleted with scan; crosscheck_promotion_contribution kept read-only for stored reports (see reconciliation note) |
| 24 | Severity / gate / policy / packs | shared decorators | unchanged on compare |
MERGE | — |
| 25 | Contract evaluation (--contract) |
shared | unchanged on compare |
MERGE | — |
| 26 | Suppression display (--show-suppressed) |
cli_scan |
disposition ledger, always computed | AUTOMATIC | Done (ADR-067 S1; unconditional disclosure, 7o) |
| 27 | Not-comparable outcome (exit 6) |
scan_engine |
compare (already has _EXIT_NOT_COMPARABLE) |
MERGE | — |
| 28 | Evidence-contract abort (exit 7) |
scan_engine._check_scan_evidence_contract |
compare ExitDecision axis |
COMPARE-STAGE | Done (Phase 4 commit 2): policy/depth_evidence_contract.py, CLI + typed pipeline. Closed a real gap (compare --depth build with no evidence used to exit 0) |
| 29 | Coverage/depth reporting lines | cli_scan_helpers.render_* |
report/ compute/render pair |
INTERNAL | Done with Phase 6 |
| 30 | scan report schema (SCAN_SCHEMA_VERSION) |
service_scan, workflows/scan_abort_result.py |
one ReportDocument |
DELETE | Done (Phase 6): constant and its schemas.py/check_report.py call sites removed |
| 31 | ScanRequest / ScanResult typed API |
service_scan.py |
CompareRequest / CompareResult |
DELETE (fields absorbed) | Done (2026-09-09). One field absorbed (CompareRequest.allow_build_query); the rest covered or ruled (b). ADR-055's amendment carries the ledger |
| 32 | Action mode: scan |
action/run.sh (~40 branches) |
mode: compare (+ baseline-channel: none) |
DELETE after absorption | Done (2026-09-10). No CMD+=(scan) site; every mode: scan request assembles compare/compare --no-baseline; --severity-preset default injected on the audit shape (AUDIT_GATE, exit 3) |
| 33 | pr-comment scan projection |
pr_comment_scan.py, pr_comment_scan_abort.py |
one PR-comment projection over ReportDocument |
INTERNAL (merged) | Done (Phase 6: both modules deleted) |
| 34 | Depth vocabulary/resolution | model/evidence_depth_levels.py (was buildsource/scan_levels.py) |
keep as engine primitive, renamed off scan |
INTERNAL | Done (Phase 6 rename step). poi.py/risk.py had no compare caller and were deleted instead |
| 35 | Cost/dry-run estimation | frontends/cli/scan_dry_run.py, artifact_set_dry_run.py |
compare --dry-run (ADR-043 D9 shared model) |
MERGE | Done (Phase 2f): cost preview via workflows/compare_cost_preview.py; the surviving model is dry_run_estimate.py |
| 36 | scan-specific tests (33 modules — corrected from "40"; see Phase 6's own deletion-order checklist for the exact list and the false positives the glob over-counted) |
tests/test_*scan* |
rewritten against compare, or deleted with the mode |
DELETE last | Done (Phase 6), plus the extra files listed there |
The seven DELETE rows are: a mode that duplicates compare (#1), a mode
whose capability is preserved by another spelling (#16), a truncation knob
that contradicts D4 (#20), two internal artifacts — a schema (#30) and a
typed API (#31) — replaced by canonical equivalents, and the two ADR-068's
second 2026-09-09 amendment added: risk-driven evidence selection (#13) and
the --risk-rules profile that fed it (#14). The first five removed nothing
a user gets; the last two do, and the amendment accepted that cost
explicitly (ruling (b): dropped, with no compare equivalent coming).
4. Flag retirement¶
All modern CLI commands. compat excluded (ADR-068 D7). Hidden options are
marked H and counted. The tables below are the 2026-09-06 audit's classification (counts from Click introspection on 309c8a82); many rows have since landed. The per-option outcome of record is abicheck/frontends/cli/options/rulings.py (§4.5).
Classes: KEEP (common per-run input) · ADV (advanced/exceptional
per-run input, --help-all only) · CONFIG (stable project property,
CLI spelling removed) · AUTO (tool determines/reports it, no flag) ·
MERGE (duplicate concept, represented once) · REMOVE (internal,
debug, or obsolete).
4.1 compare — 78 accepted at the 2026-09-06 audit (4 hidden)¶
| Flag | Class | Target representation | Rationale | Prerequisite |
|---|---|---|---|---|
--help, --help-all |
KEEP | unchanged | — | — |
-H/--header |
KEEP | unchanged (side-aware) | The canonical evidence input | — |
-I/--include |
KEEP | unchanged | — | — |
--sources |
KEEP | unchanged | Real per-run evidence | — |
--build-info |
KEEP | unchanged | Real per-run evidence | — |
--depth |
KEEP | unchanged, gains auto rung |
One dial (ADR-037 D5) | #13 |
--config |
KEEP | unchanged | The project contract's own operand | — |
--policy |
KEEP | unchanged | — | — |
--suppress |
KEEP | unchanged | — | — |
--severity-preset |
KEEP | unchanged | — | — |
--contract |
KEEP | unchanged | The canonical contract mechanism | ADR-049 defects |
--used-by |
KEEP | unchanged | Genuine consumer evidence (vision D-S1) | — |
--required-symbol |
KEEP | unchanged | Genuine consumer evidence | — |
--required-symbols |
MERGE | --required-symbol @file |
Two spellings of one input | — |
--format |
KEEP | unchanged | — | — |
-o/--output |
KEEP | unchanged | — | — |
--write |
KEEP | repeatable FORMAT=PATH |
One analysis, N artifacts (D4) | — |
--dry-run |
KEEP | unchanged | — | — |
-v/--verbose |
KEEP | unchanged | — | — |
(new) --no-baseline |
KEEP | declares OLD absent | Replaces scan audit mode (D2) |
Phase 1 |
(new) --since, --changed-path |
ADV | from scan |
A PR's diff is genuinely per-run | Phase 2 |
(new) --budget |
ADV | from scan; default in config |
Per-run guard on a CI job | #19 |
--report-mode |
MERGE | --view |
4 spellings of "render which parts, how" | — |
--show-only |
MERGE | --view |
as above | — |
--explain-patterns |
MERGE | --view patterns |
and stops implying --pattern-verdicts (D4) |
Phase 5 |
--demangle/--no-demangle |
MERGE | --view |
as above | — |
--show-filtered |
AUTO | disposition/scope ledger | Accounting is already unconditional | ADR-067 S1 |
--audit-suppressions |
AUTO | suppression accountability | A forgotten optional switch on an accountability feature | ADR-067 S1/S2 |
--surface-metrics |
AUTO | always emitted, informational | Free, COMPATIBLE-only, changes no verdict |
— |
--pattern-verdicts/--no- |
AUTO | on where evidence exists | Evidence-gated already; the off-switch becomes policy | Phase 5 |
--reconcile-build-context |
AUTO | on when build context present | Clearing false positives should never be opt-in | ADR-039 |
--select, --select-required |
ADV | unchanged | ADR-065 S1, just landed | — |
--on-incomplete-scope |
CONFIG | scope.on_incomplete |
A project's CI strictness, not a per-run choice | — |
--fail-on-removed-library |
CONFIG | gate.fail_on_removed_library |
Stable project policy; input fixed first (vision A-S2/S4) | A-S4 |
--dso-only |
CONFIG | release.dso_only |
Stable release topology | — |
--include-private-dso |
CONFIG | release.include_private_dso |
as above | — |
--output-dir |
ADV | unchanged | Genuine per-run output location | — |
-j/--jobs |
REMOVE | auto-detect only | Already auto-detects and memory-clamps; the override is a tuning detail | — |
--keep-extracted |
REMOVE | — (done) | Debug detail | — |
--no-bundle-analysis |
REMOVE | — (done) | "Escape hatch" that disables real analysis; policy/suppression is the supported route | — |
--bundle-facts-out |
KEEP (ruled 7d) | unchanged | Per-run operand, -o's shape; dump has no release fan-out to hold it (7d) |
— |
--bundle-facts-library-manifest |
KEEP (ruled 7d) | unchanged | Document operand like --policy/--suppress; no config home for its override shape (7d) |
G42 |
--instantiation-manifest |
CONFIG | contract document | A declared contract is a project property | — |
--max-json-object-nodes |
CONFIG (done) | resource_limits.max_bundle_facts_decode_nodes |
Internal storage detail (#21) | Calibration — done, see #21 |
--debug-info |
ADV | unchanged (side-aware) | Real per-run evidence | — |
--devel-pkg |
ADV | unchanged (side-aware) | Real per-run evidence | — |
--version |
ADV | unchanged | Labels a bare .so operand |
— |
--dump-manifest |
ADV | unchanged | Multi-TU operand (ADR-050) | — |
--old-variant, --new-variant |
ADV | unchanged | Variant selection is per-run scope (ADR-065) | — |
--probe-matrix |
ADV | unchanged | Per-run evidence | — |
--diagnostic-comparison |
ADV | unchanged | ADR-050 D2's sanctioned escape hatch | — |
--debug-root |
ADV | unchanged | Per-run artifact location | — |
--search-path, --ld-library-path |
ADV | unchanged | Per-run environment | — |
--follow-deps |
AUTO | on when dependency evidence is usable | "Enable a useful analysis" flag | Cost check |
--pack |
ADV | unchanged | ADR-049 D8 | — |
--env-matrix |
CONFIG | deployment: |
Declared deployment constraints are stable | — |
--use-cases |
CONFIG | use_cases: |
A stable declared manifest | — |
--post-manifest |
CONFIG | contract overlay | Duplicate contract/scope mechanism | ADR-049 |
--scope-public-headers/--no- |
MERGE | --contract public / --contract all |
One contract mechanism — not before ADR-049's relevance defects close | public-contract-default.md Phase 6 |
--require-complete-analysis |
CONFIG | assurance.require_complete |
Project CI strictness (vision E-S1 meaning preserved) | — |
--profile |
REMOVE | — | Bundles depth + rendering + gate policy; D4/D5 separate them (reverses ADR-040 Lever 3) | Phase 7 |
--ast-frontend |
CONFIG | compile.frontend |
Extraction backend is a host/toolchain property | — |
--allow-ast-frontend-fallback |
CONFIG | compile.ast_frontend_fallback |
as above | — |
--allow-unsupported-castxml |
CONFIG | compile.allow_unsupported_castxml |
as above | — |
--compiler |
CONFIG | compile.compiler |
Toolchain identity is stable per project/target | — |
--compiler-prefix |
MERGE | into compile.compiler |
Convenience spelling of the same thing | — |
--compiler-option |
CONFIG | compile.options |
as above | — |
--sysroot |
CONFIG | compile.sysroot |
as above | — |
--nostdinc/--no-nostdinc |
CONFIG | compile.nostdinc |
as above | — |
--frontend-context |
CONFIG | compile.frontend_context |
as above | — |
--lang |
CONFIG | compile.lang, inferred by default |
Inferable from headers/sources | Inference check |
--pdb-path |
CONFIG | debug.pdb_path |
Debug-resolution tuning | — |
--debug-root |
(ADV above) | — | — | — |
H --dwarf-only/--no- |
REMOVE | debug.dwarf_only only |
Already demoted; the hidden flag is the deprecation window this repo doesn't run | — |
H --debuginfod/--no- |
REMOVE | debug.debuginfod only |
as above | — |
H --debuginfod-url |
REMOVE | debug.debuginfod_url only |
as above | — |
H --debug-format |
REMOVE | debug.format only |
as above | — |
--include-system-declarations |
ADV | unchanged | ADR-061 dependency-scope selector, per-run | — |
4.2 dump — 39 accepted at the 2026-09-06 audit (0 hidden)¶
dump stays evidence capture. Its toolchain/debug block is the same
concept as compare's and moves the same way (ADR-037 D8.1: they share the
L2 compile context and must not drift).
| Flag | Class | Target | Rationale |
|---|---|---|---|
--help, --help-all, -v |
KEEP | unchanged | — |
-H/--header, -I/--include |
KEEP | unchanged | Evidence input |
--sources, --build-info |
KEEP | unchanged | Evidence input |
--depth |
KEEP | unchanged | One dial |
-o/--output |
KEEP | unchanged | — |
--config |
KEEP | unchanged | — |
--dry-run |
KEEP | unchanged | ADR-043 D9 |
--version |
ADV | unchanged | Snapshot label |
--dump-manifest |
ADV | unchanged | Multi-TU operand |
--git-tag, --build-id, --no-git |
MERGE | one --provenance k=v (repeatable) |
Three spellings of "stamp this snapshot" |
--compression |
AUTO | inferred from -o suffix |
auto is already the default and already correct |
--debug-root |
ADV | unchanged | Per-run artifact location |
--search-path, --ld-library-path |
ADV | unchanged | Per-run environment |
--follow-deps |
AUTO | on when usable | as compare |
--build-target |
CONFIG | build.targets |
Already has a config key; CLI is the duplicate |
--compile-db-filter |
CONFIG | build.compile_db_filter |
Stable project property |
--include-system-declarations |
ADV | unchanged | — |
--dwarf-only, --debug-format, --debuginfod, --debuginfod-url, --pdb-path |
CONFIG | debug.* |
Debug resolution is a host/project property (matches compare) |
--ast-frontend, --allow-ast-frontend-fallback, --allow-unsupported-castxml, --compiler, --compiler-prefix, --compiler-option, --sysroot, --nostdinc, --frontend-context, --lang |
CONFIG / MERGE | compile.*, identically to compare |
ADR-037 D8.1 — one shared compile context, no drift |
4.3 deps — 15 accepted at the 2026-09-06 audit¶
Reviewed for convergence (ADR-068 D6), not reduction.
| Flag | Class | Target | Rationale |
|---|---|---|---|
deps tree: --search-path, --sysroot, --ld-library-path |
KEEP | unchanged | The environment is the operand here — not a toolchain property |
deps compare: --old-root, --new-root |
KEEP | unchanged | The two environments being compared |
deps compare: --search-path, --ld-library-path |
KEEP | unchanged | as above |
both: --format, -o, --dry-run, -v |
KEEP | unchanged; --format gains the shared renderer set |
Shared projection over ReportDocument |
No deps flag is removed. The deps work is internal: StackVerdict →
RunOutcome/ExitDecision axes, and its report → a ReportDocument
projection.
4.4 aggregate (6) and project (4 subcommands)¶
Advanced integration surface; left as-is by this plan except that
--format/-o/-v follow the shared projection contract. aggregate
--manifest/--discovered-only are genuine per-run CI inputs (ADR-047)
and stay (--run-plan folded into --manifest, 7q).
4.5 Target counts¶
The number is a consequence of the model, not the goal.
Source of truth. The per-option record is
abicheck/frontends/cli/options/rulings.py: every visible compare and
dump option carries a written ADR-068 D5 ruling (per_run_operand or
deferred with a mandatory named blocker), and
tests/test_config_rebalance.py asserts an exact bijection between those
rulings and the live options — an unruled option fails, and so does a ruling
naming an option that no longer exists (Phase 7k). Counts in this document are
therefore snapshots, not a gate.
Live snapshot, 2026-09-29 (Click introspection, one logical option per
Click parameter, excluding --help/--help-all):
| Command | Live | Audit end state (7l) | 2026-09-06 audit |
|---|---|---|---|
compare |
41 (9b + 9d, 2026-10-03) | 25 | 78 accepted (4 hidden) |
dump |
20 | 13 | 39 |
aggregate |
5 | 4 | 6 |
deps tree |
6 | 6 | — |
project capture-variants |
7 | — | (added by storage-format-v2 A1.6, after this audit) |
deps compare |
7 | 7 | — |
project history |
4 | 4 | — |
project plan |
6 | 6 | — |
project validate |
3 | 3 | (three subcommands, folded by 7p) |
compat check / compat dump |
frozen | frozen | excluded from every count (ADR-068 D7) |
Growth since the previous revision's 40 / 18 / 4. New options added by
other work after 7q/7r, each already ruled: compare --diagnostic-comparison,
compare --version and dump --version (keep rulings in rulings.py);
compare --performance-profile (#1383, keep ruling in rulings.py);
dump --provenance (#1355/#1377, keep ruling in rulings.py); and
aggregate --analysis-context (#1355). rulings.py covers only compare and
dump, so aggregate --analysis-context has no ruling there — nothing in
this repository rules aggregate options per flag.
The gap to the audit's end state (compare 25, dump 13) is entirely
the contract/capture-config question this plan has already gated or ruled
against with a measurement — there is no unexamined bucket:
compare: thedeferredrulings, plus the CONFIG demotions this plan declines or gates (--dump-manifest,--include-system-declarations,--abi3,--severity-preset,--select-required), plus the--environmentcollapse (G42), plus the options added since (above).dump:--dump-manifest/--include-system-declarationsto a capture contract (G34),--compression(ruled a keep, 7k),--environment(G42), plus the options added since.
Original targets, kept as pressure rather than arithmetic: root commands
7 → 6 (met: scan retired); compare hidden accepted 4 → 0 (met,
7a); compare total ≤40 and dump ≤16 (met at 40 / 18 before the additions
above; dump ≤16 was never reachable through this plan's own rulings);
deps 15 → 15 (unchanged by design, D6).
Deleted implementation surface (Phase 6): the 10,074 lines of scan-only
abicheck/ code across 16 modules, one JSON schema, one typed request/result
pair, action/run.sh's 17 mode == "scan" conditionals, and 33 scan-only
test modules plus the extra files listed in Phase 6.
5. Prerequisites¶
Six cross-cutting blockers. Each gated a whole phase, not one row.
| # | Prerequisite | Gates | State |
|---|---|---|---|
| P1 | An acquisition state for "OLD declared absent" in ADR-065's vocabulary, with its completeness/outcome consequences | --no-baseline (§3 #2) |
Landed (2026-09-09): declared_absent is a real AcquisitionState, stated as old_acquisition_state in every audit report |
| P2 | An evolution state on the canonical finding model, not_evaluated included, carried by report/'s compute/render pair |
Every one-sided check migration (§3 #3-#8, #15) | Landed as a deliberately distinct enum from Phase 1's cross-comparison FindingEvolution: checker_policy.CrossSourceEvolution + Change.cross_source_evolution, produced by workflows.cross_source_hygiene.compute_cross_source_evolution, projected by report.cross_source_evolution. Per-finding identity is a per-check function (a bare symbol key collapsed distinct findings for several checks). The correctness crux — a pre-existing problem never reads as newly introduced when one side's evidence is insufficient — is a property test for all eleven checks |
| P3 | compare emitting the budget-overflow (5) and evidence-contract (7) exit axes |
scan's deletion |
Landed (Phase 4 commit 2); §3 #19/#28 |
| P4 | A public/internal boundary derivable from -H directory provenance plus .abicheck.yml scope.public_header_dirs |
§3 #4, #5, #22 | Solved for both sources, threaded into InputSpec.public_header_dirs/provenance.apply_provenance. No new CLI flag. The file-vs-directory asymmetry is preserved verbatim (see workflows/cross_source_hygiene.py's module docstring) |
| P5 | ADR-065 S3's package component inventories | compare --no-baseline DIR (§3 #16, #17); --select-required merge |
Landed (ADR-065 S3, 2026-09-06): model/package_inventory.py, package.package_component_inventory. This row read "Open" until 2026-10-03 |
| P6 | EntityId-based public closure (ADR-063 Phase 2) and public-contract-default.md Phase 6's two relevance defects plus two uncovered measurement lanes |
Phase 9 only | Defects closed (2026-10-01). The seed mismatch was closed earlier; the identity gap is closed by schema v54 slot identities captured at extraction, not by a string heuristic. The package/real_binaries coverage bound was accepted (2026-10-02, integration-tested rather than in the always-on measurement) |
P1 and P5 are ADR-065 work this plan consumes rather than owns; starting them here would fork the model workstream A is building.
Where the implementation lands is ADR-061's question, not this plan's. ADR-061 owns responsibility ownership and dependency boundaries; this plan owns capability topology. Three of its remaining acceptance gaps meet this plan directly, and each has one owner rather than two:
- No
workflows/scanpackage, ever. ADR-061's ownRequest -> ResolvedPlan -> Resultexamples were written aroundScanRequest/ResolvedScanPlan/ScanResult; they now name the compare shapes instead, precisely because migrating a command scheduled for retirement into a permanent typed contract is work this plan's Phase 6 would then have to undo.scan's surviving capabilities route to the compare workflow. - The canonical report replaces the scan schema (§3, Phase 5) only once
ADR-061's gap C
closes — one completed evaluation producing one document that every format
projects. "One analysis, several artifacts" is not deliverable while six
formats each build their own document from a
DiffResult. - One operand driver (Phase 7d, re-homed from
cli-cleanup-phase-two.mdas PR I) is the frontend half of ADR-061's gap D: selection, inventory and acquisition state belong on the shared request/plan, not in command-level orchestration. Do it once, in the shared contract.
The sequencing constraint runs the other way too: ADR-061's own closure
package 6 (facade and legacy retirement) may not delete a scan-related
surface ahead of this plan's Phase 6. A shorter facade is never a reason to
lose a capability.
6. PR sequence¶
Nine phases, each independently reviewable. The ordering was the mechanism
that prevented capability loss (ADR-068 D9): no phase could be reordered
ahead of its predecessor, and no scan code was deleted before Phase 6.
Merge criteria every removal PR applied (carried forward from
cli-cleanup-phase-two.md, unchanged): the old spelling exits 64 with no
hidden alias and is registered in scripts/retired_surfaces.py; front-end
parity (CLI, typed API, Action) in the same PR; a schema bump where a machine
contract changes; verdict, gate, exit code, coverage contribution and
assurance asserted separately from any display change. Phase 7l adds the
stricter bar: the old user task still has a simple, supported invocation,
with the same relevant evidence and a truthful result — a test asserting
only that a removed flag now errors proves deletion, not simplification.
Phase 0 — Parity harness first (no behavior change)¶
Done (#1114). tests/parity/ ran scan and compare over one fixture
corpus and diffed finding sets (kind, identity, severity, evidence refs),
starting red on every scan-only capability — that red set was the migration's
definition of done. Since Phase 6 the package is a compare-only regression
corpus (tests/parity/__init__.py): it pins compare's behavior for those
capabilities plus the call-site pin that each engine primitive is reached from
exactly one compare-side workflow (test_engine_primitive_call_sites.py).
The gaps.py registry and its contract test are deleted.
Phase 1 — Canonical primitives where scan owns unique behavior¶
Done.
declared_absentjoined ADR-065's acquisition vocabulary (P1) — never a removal, never a pass.FindingEvolution(introduced/resolved/persistent/not_evaluated):policy.evidence_status.FindingEvolution(re-exported viachecker_policy),Change.evolution/DiffResult.resolved_findings, the correspondence primitivepolicy/finding_evolution.py(compute_finding_evolution/compute_resolved_findings/apply_finding_evolution) and the projectionreport/finding_evolution_section.py. Consumed bycompare's JSON report (reporter.py/report/document_builder.py) and byproject history(cli_project.py→workflows/history.py, oneapply_finding_evolutionper adjacent pair;PairwiseSummarycarriesevolution_counts/resolved). Markdown/HTML rendering of the evolution state is not built.- Exit axes (#1116): evidence-contract abort (
7) and budget overflow (5) modeled asExitDecisionaxes;comparebegan emitting them in Phase 4 commit 2.
Phase 2 — Move the enrichments onto compare¶
Done. One PR per capability group, each landing with its parity tests green. Decision that must not be re-litigated: every enrichment runs automatically, with no enable flag on any front end (ADR-068 D4/D5 reject "a flag that merely enables useful analysis").
- 2a cross-source checks (§3 #3–#5) — all eleven, via
workflows/cross_source_hygiene.py, on theCrossSourceEvolutionaxis (P2; distinct from Phase 1's cross-comparisonFindingEvolutionbecause it states OLD-vs-NEW behavior within onecompare()call). Checks whose findings are not uniquely keyed by symbol register their own identity (private_header_leak,public_to_internal_dependency,rtti_for_internal_type,odr_type_variant,identity_collision_detected,compile_context_conflict). - 2b pattern + preprocessor scans (#6, #8) —
workflows/ pattern_preprocessor_scan.py, derived from what each snapshot already records (no second collection pass), surfaced as the advisorypattern_preprocessor_scanreport block rather than newChangeKinds, since neither primitive ever produced a verdict-bearing finding. - 2c changed-path localization (#12) and POI scoping for
--depth source(#10, #11) — one owner,workflows/changed_paths.py. - 2d
abi3candidate-side enrichment (#15) —compare --abi3folds the audit through the pre-classificationextra_changeschannel, markedcandidate_side_enrichment; floor frompython.abi3_floor, flag as the per-run override. - 2e
--no-baseline(#2), on top of Phase 1.1. - 2f dry-run cost preview (#35) —
compare --dry-run's "Cost preview", summed across operands byworkflows/compare_cost_preview.pyover the one surviving cost model (nowdry_run_estimate.py). Advisory only.
Phase 3 — Parity is the gate¶
Done. For every scenario compare produced the same or a strictly richer
finding set than scan, with no finding lost and none manufactured (the
not_evaluated cases). The no-baseline statement is directional: compare
--no-baseline reports at least every kind scan did, and every addition is
a real candidate-side finding in a permitted D3 state
(tests/parity/test_no_baseline_audit_corpus_parity.py over the eleven G20
audit fixtures; tests/test_no_baseline_d3_properties.py states D3 as a
property over generated candidates).
Phase 4 — Migrate the consumers — done (Action deletion 2026-09-10)¶
Done. Governing ruling (ADR-068's second 2026-09-09 amendment): the
Action does not keep a compatible interface with every scan capability.
Each condition that used to force the legacy CLI was ruled (a) already
covered, (b) dropped as a documented breaking change, or (c) genuinely
required and built on compare. The ruling table lives in the ADR; the
condensed version in known gaps.
- (c) built:
compare --budget(§3 #19) and the--depth build/sourceevidence-contract floor (§3 #28,policy/depth_evidence_contract.py). - (b) dropped:
--risk-rules, risk-drivenautodepth,--crosscheck KEY=error,scan --build-target,--artifact-set/new-library-set(pending ADR-065 S3). The Action rejects each with an explicit::error::naming the replacement or blocker — never a silent downgrade. - Action.
action/run.shhas noscaninvocation; everymode: scanrequest assemblescompare/compare --no-baselinethrough one branch. The audit shape injects--severity-preset defaultwhen the caller states no preset, preservingmode: scan's default gating through the audit-gate axis (policy/audit_gate_exit.py, exit3,AUDIT_GATEverdict; ADR-068's 2026-09-10 amendment). Coverage:tests/test_action_run_sh_audit_gate.py. - Typed API.
ScanRequest/ScanResult/ScanArtifactResult/ScanSetResult/Budget/LayerResultandrun_scan/run_audit/run_scan_setdeleted;CompareRequest→CompareResultis the one typed contract. One field absorbed:CompareRequest.allow_build_query(defaultFalsekeeps "never run a build system as a side effect of resolving an input"). An unpinned depth resolves to the fixedheadersrung, not the--modepreset (which would run a full source replay; caught in review, PR #1186). compare --no-baselinefixed along the way. It had aborted on its ownassert not diff.changes(the self-diff invariant Phase 2's per-side stages legitimately violate), passed-Has parse input but not as public-header provenance, and ignored--contract/--sources/--build-info/--depth/--dry-run.policy/no_baseline_findings.pypartitions the self-diff (comparison half still enforced, as a raised error that survivespython -O);report/no_baseline.pygained its compute/render split,findings[], andsarif/junit/oneline.html/reviewstay a usage error by ruling: both render a comparison, and an audit has none (report.no_baseline.NO_BASELINE_UNSUPPORTED_FORMATS).- Docs, examples, eval.
docs/use/scan-levels.mdbecamedocs/use/evidence-depth.md; the user docs, the nine G20 catalog case READMEs,examples/workflows/audit-releaseandskills-src/moved tocompare/compare --no-baseline.skills-src/evaluation/field/scan_level_scaling.pywas re-driven ontocompareand later deleted in Phase 6 together withskills-src/evaluation/validation/scripts/run_oneapi_scan.py.
Phase 5 — Presentation/analysis separation — done¶
--explain-patternsstopped implying--pattern-verdicts; modulation is automatic, explanation is rendering.--report-mode/--show-only/--demangle/--explain-patternscollapsed into--view(whose internal grammar 7o later reduced further).--surface-metricsremoved outright (metric-drift findings are ordinaryChanges every projection renders;compare_snapshotsforces it on and no longer takes the keyword);--show-filtered→--view filteredand--audit-suppressions→--view suppressions(both later made unconditional by 7o). D4 holds in its strong form: no survivingcompareflag decides whether a piece of the canonical result is computed.--writebecame repeatable (later folded into-oby 7m).- Executable invariant (F-19; numbered F-16 in an older draft of this
section).
TestF19CanonicalResultIsInvariantOverTheWholeRenderingSpaceintests/test_presentation_analysis_separation.pyenumerates the full product of rendering axes (a seeded random sample in the default lane, all points underslow, plus a guard pinning the space's size), and its oracle is the Tier-2 verbworkflows.compare_policy.compare_snapshotscalled directly — not the report projection under test. The predecessor test sampled instead of crossing the axes and used the implementation as its own oracle; do not regress to either. The space shrank from 6048 points to 189 when 7o removed four axes.
Phase 6 — Remove scan¶
Done (2026-09-11). No deprecated alias: abicheck scan exits 64 with
No such command, naming compare/compare --no-baseline.
tests/test_cli_root_surface.py pins the six-verb root set.
- Deleted (list A):
cli_scan.py,cli_scan_baseline.py,cli_scan_helpers.py,cli_scan_receipt.py,scan_engine.py,scan_abi3_resolve.py,pr_comment_scan.py,pr_comment_scan_abort.py,frontends/cli/scan_against.py,frontends/cli/scan_dry_run.py,workflows/scan_abi3_dry_run.py,workflows/scan_abort_result.py,workflows/scan_config.py(workflows/scan_gate_options.pyandworkflows/scan_subprocess.pyhad already gone in Phase 4);service_scan.py's surviving cost model becamedry_run_estimate.py;skills-src/evaluation/field/scan_level_scaling.pyandskills-src/evaluation/validation/scripts/run_oneapi_scan.py.SCAN_SCHEMA_VERSIONand its call sites are gone. - Deleted (lists B and D): the 33 scan-only test modules, and
buildsource/poi.py/risk.pywithtests/test_poi.py,test_poi_scenarios.py,test_risk.py— an AST call-site audit found neither module had a realcomparecaller, so they were deleted rather than renamed. Beyond the pre-staged lists, test files importing scan-only internals without "scan" in their name were pruned (not gutted —test_compile_context_parity.py/test_compile_context_security.pyexercise the livecli_options.merge_compile_configand were kept minus their dead cases), andtests/parity/test_baseline_gate_parity.pywas deleted outright (it pinned a scan/compare divergence with no second tool left to diverge from). - Renamed ahead of deletion (list C, the rename step):
buildsource/crosscheck*.py→cross_source_checks*.py,pattern_scan.py/preprocessor_scan.py→pattern_facts.py/preprocessor_facts.py(all stay inbuildsource/: the main file'sextract/unclassified dependencies forbid a move undercompare/'smay_import: [model]), andbuildsource/scan_levels.py→model/evidence_depth_levels.py. - Kept, not
scan's (list E):workflows/pattern_preprocessor_scan.py,report/pattern_preprocessor_section.py,docs/use/evidence-depth.md,changelog.d/*scan*.md(history). Naming-sweep false positives kept:tests/test_scan_accuracy.py,tests/test_realworld_scan.py,tests/test_header_scan_deadline_integration.py,tests/scenarios/compliance_scanning.yaml. - Unblocked demotions.
compare --require-complete-analysis→assurance.require_completehas landed;compare --env-matrixanddump --build-targetwere recorded inrulings.pyas tracked follow-ups needing real feature work (adeployment:key; rewiring ontobuild.targets) rather than a ruling-table edit. Neither appears in the live option counts in §4.5. - Follow-up cleanup (2026-09-29): see Cleanup reconciliation.
Phase 7 — CLI/config cleanup¶
Only after one analysis path existed: the CONFIG/AUTO/MERGE/REMOVE rows of §4, in small PRs grouped by concept. Every sub-slice is done except where the table at the end of Phase 7l says otherwise.
7a — hidden debug flags. Done. compare's four hidden
--dwarf-only/--debug-format/--debuginfod/--debuginfod-url deleted
outright; each already had a debug.* key.
7b — compile.* demotion. Done. The whole L2 compile-context family
(--ast-frontend, --allow-ast-frontend-fallback,
--allow-unsupported-castxml, --compiler, --compiler-prefix,
--compiler-option, --sysroot, --nostdinc, --frontend-context,
--lang) removed from compare and dump as one unit (ADR-037 D8.1: the two
commands share one compile context and must not drift);
--compiler/--compiler-prefix merged into compile.compiler.
7c — debug.* demotion on dump. Done. 7a's treatment applied to
dump's --dwarf-only/--debug-format/--debuginfod/--debuginfod-url/
--pdb-path (--debug-format was visible there; removed anyway for parity).
7d — release/bundle topology. Done (1e9d59698; absorbs cli-cleanup
PR J). --on-incomplete-scope/--fail-on-removed-library/--dso-only/
--include-private-dso → scope.on_incomplete/
gate.fail_on_removed_library/release.dso_only/
release.include_private_dso. --keep-extracted and --no-bundle-analysis
removed with no replacement (a tempdir knob; an escape hatch disabling real
analysis). Not to re-litigate: --bundle-facts-out stays — it is
-o's shape for this invocation's evidence capture, and dump has no
release fan-out to hold it (a missing implementation with a named owner,
bundle capture in dump, per 7l); --bundle-facts-library-manifest stays as
a document operand like --policy/--suppress until G42 gives its
per-library override shape a config home.
7e — --profile removal. Scheduled in this phase; this document never
recorded the landing PR, but compare has no --profile option today (Click
introspection, 2026-09-29).
7f — dump provenance merge. Done. --git-tag/--build-id/--no-git
→ one repeatable --provenance KEY=VALUE (git-tag=, build-id=,
git=auto|off; last-one-wins per key). Grammar in
frontends/cli/options/dump_provenance.py, validated eagerly by a Click callback
so every malformed token is exit 64 before extraction.
7g — resource limits. Done. --max-json-object-nodes →
resource_limits.max_bundle_facts_decode_nodes. Measured first
(scripts/benchmark_scaling._build_onedal_large_surface: ~9.3 bytes/node,
stable across scale; a 25k-function oneDAL-scale library needs ~5.8M nodes
against the 1M default). Not to re-litigate: the default stays
DEFAULT_MAX_JSON_OBJECT_NODES=1_000_000 — raising it would raise every
unconfigured, possibly untrusted decode's ceiling (PR #1174 review); only an
explicit --config may raise the budget, an auto-discovered file may only
lower it; and the unit stays nodes, not memory, because a memory budget
converted at the legitimate-payload ratio would under-protect against an
adversarial payload's far lower bytes/node. Full table in bundle_facts.py's
docstring.
7h — small removals. Done. --required-symbols folded into
--required-symbol @FILE; -j/--jobs removed (auto-detect and memory clamp
only).
7i — whole-surface audit against D5's three guards. Done. Every surviving
compare/dump option ruled, including the keeps. Removed:
--reconcile-build-context (AUTO: evidence-gated reconciliation can only
clear a phantom finding, never manufacture one; forced on in
compare_snapshots), compare --pdb-path → debug.pdb_path (per-side use
preserved through --debug-info's debug-root transport),
--support-promise → release.support_promise, dump --compile-db-filter →
build.compile_db_filter. Measured keep, not to re-litigate:
--follow-deps/--search-path/--ld-library-path — the dependency walk
embeds absolute host paths in provenance.dependency_info, so making it
unconditional would break dump reproducibility and ADR-050 comparability and
let findings depend on the runner's installed libraries. Revisit only if the
dependency graph becomes host-independent (a snapshot-schema change).
7j — --variant. Done. --old-variant/--new-variant → one side-scoped
--variant [old=|new=]VARIANT_ID, matching every other two-sided input
(ADR-040 Lever 1). 7i's decline was reversed because its rationale was an
effort argument, which AGENTS.md rules out. An empty id is a usage error;
_resolve_sided_variant carries TestResolveSidedVariantProperties. The
unregistered release engine keeps its per-side spellings.
7k — exhaustive per-option ruling registry. Done. The old D10.5 budget
(visible <= BASE + len(RAISES)) had nine flags of slack and had let
--budget land unruled. Replaced by frontends/cli/options/rulings.py: one
OptionRuling per visible compare and dump option, per_run_operand
or deferred (blocker mandatory for the latter, rejected for the former, in
__post_init__), with the exact bijection test described in §4.5.
test_the_superseded_budget_shape_would_have_missed_an_unruled_flag asserts
the old shape's failure. Fresh rulings: --search-path and
--ld-library-path are distinct (they insert at different loader steps
in resolver._candidate_dirs and record different resolution_reasons);
dump --compression is a keep against §4.2's AUTO row
(-o build/abi.json --compression zstd is a real case the suffix cannot
express). Declined merges: --write into --format/-o (later superseded
by 7m's single grammar), --select into --select-required (the latter
declares a completeness obligation), --used-by-manifest into
--used-by @FILE (a manifest carries provenance a path cannot).
Phase 7l — external CLI audit (2026-09-12), reconciled¶
An external static audit of the whole CLI at 31cbd4a was reconciled
against this phase. Adopted as a standing principle: a necessary capability
does not require a dedicated flag, and a removal that costs the user three
commands or an invented manifest is not a simplification; "no replacement
today" is a migration prerequisite, not permanent ownership. Also adopted:
auto-detection is a useful default, not proof an explicit override is
unnecessary, and environment variables must not become the new hidden
CLI — a demoted flag lands in .abicheck.yml, never in an undocumented
variable.
7m — one export request. Done. --format/-o/--output/--write/
--output-dir/--max-findings-per-library → one repeatable
-o FORMAT=DESTINATION with - for stdout (frontends/cli/options/
export.py); a trailing / is a directory destination; machine data is never
truncated. Net −4 on compare, −1 on each other report-rendering command.
Consequences not to rediscover: a display filter applies to every export
(every machine projection still carries full disposition accounting); a
directory export on a single pair is a usage error; the Action's
extra-args export set replaces its own. Invariants in
tests/test_cli_export_grammar.py.
7n — one input per evidence role. Done. --debug-root → --debug-info,
--devel-pkg → -H, --probe-matrix → typed --build-info; net −3 on
compare, a rename on dump. Routing is content-only
(workflows/evidence_transport.py, frontends/cli/options/evidence_roles.py),
which forced abicheck/extract/packages.py's extractors to detect by magic bytes, not
filename; extract/detached_debug.py adds the bare detached-debug-file
transport with build-id validation. Explicitly not merged: --sources
and --build-info.
7o — --view's internal grammar. Done (net 0 options; fewer decisions).
patterns/filtered/suppressions are unconditional disclosure (the
pattern ledger echoes only when something was modulated); demangling is
automatic (human formats show demangled [mangled], machine formats carry
both symbol and demangled_symbol; PE/MSVC stays undemangled by design);
display dimensions come from ChangeKindMeta.entity/operation (mandatory,
with entity_from_field for polymorphic kinds), replacing the name-prefix
tables that missed 238 of 407 kinds; --view leaf retired after a
129-pair measurement showed it never exposed a finding root-cause lacked
(report schema 5.0). The impact mode folds in RenderOptions.__post_init__.
Follow-up rounds closed disposition disclosure on the release path (release
schema 1.5) and Markdown pipe-escaping after demangling. Lessons kept: a
per-finding dimension is only tested by a finding the real detector produced;
disclosure has no single choke point, so it is tested per projection
(tests/test_pattern_modulation_disclosure.py). Remaining gap: the release
renderer shows no per-library scope ledger or suppression audit in human
output (machine projections unaffected).
7p — project validate consolidation. Done. validate/
validate-build/validate-use-cases → one project validate INPUT,
classified by buildsource/validation_input.py on schema/shape, never
filename. An empty document is validated under every reading; unparseable
YAML fails at classification.
7q — aggregate --run-plan into --manifest. Done. Classifier
workflows/aggregate/expected_input.py; an untagged plan is recognized by
its checks list, and one declaring both checks and targets without a
schema is rejected. --discovered-only stays explicit.
7r — project plan --allow-empty retired. Done. An empty plan records a
skipped block (abicheck.run-plan/v3): no checks[] declared is a valid
plan (exit 0); declared checks[] resolving to no cell stays an error with no
bypass.
Declined, with the reason recorded: dump --compression → retire (7k's
measurement); --dry-run → --plan (ADR-054 folded a plan vocabulary back
into --dry-run); --used-by-manifest → --used-by @FILE (7k);
--severity-preset into policy selection (for now — gate activation must
converge across pairwise, bundle and no-baseline first);
--select-required into an expected inventory (P5). The
--follow-deps/--search-path/--ld-library-path → --environment
old=|new= operand is future direction owned by G42, not a reduction; its
acceptance bar keeps same-run enrichment and forbids defaulting to the
current host.
Smaller audit items. --abi3 armed from a declared floor — done
(python.abi3_floor arms it; never inferred from sniffing the binary).
--dump-manifest/--include-system-declarations as capture contract — a
live disagreement pending G34's capture specification (one spec for dump,
abicheck-cc and the Clang plugin). deps' defaulted / root — done:
StackCheckResult.baseline_env_defaulted/candidate_env_defaulted, from
Click's parameter source, stated in every projection. ABICHECK_CC_DISABLE
treating "0" as disable — fixed over the class: abicheck/env_flags.py
is the one boolean parser and registry (bug class
config.env_flag_value_domain). compat's options stay frozen (D7).
7s — per-option rulings for every command. Done (2026-10-02).
rulings.py now rules every visible option of aggregate, the four
project subcommands and both deps subcommands (keyed by command path,
e.g. "project plan"), in addition to compare/dump. All 35 are keeps;
none duplicates a config key. Notable reasons: --toolchain-bindings is a
trust-boundary operand that a .abicheck.yml key would hand to the
untrusted config it validates; aggregate --discovered-only is 7q's
explicit no-inventory declaration, not a gate-disabling hatch (there is no
gate without a manifest); project plan --project is runtime provenance a
config copied into a fork would misstate. compat is excluded on purpose
(D7: frozen ABICC spellings). tests/test_config_rebalance.py closes the
table's domain, not only its rows: it walks the real Click tree and fails
on any option-bearing command with no ruling table — the gap that let these
commands go unruled after 7k, and that the earlier open-item note (which
missed deps) did not catch either.
7t — release suppression audit. Done (2026-10-02). 7o's remaining
gap, re-measured: the per-library scope ledger was already in the release
Markdown (🔕 Disposed Findings, one row per scoped-out finding with its
reason), but the suppression audit (stale, expired and near-expiry rules,
and rules that hid a BREAKING change) reached stderr only — and, contrary to
the earlier note, the release JSON did not carry it either. Each
libraries[] entry now has the scalar report's suppression_audit block
(same builder, release schema 1.12), and the release Markdown ends with a
per-library 🧾 Suppression Audit section rendered from it, after the
document's demangle pass so rule labels stay verbatim.
tests/test_release_suppression_audit.py uses the single-pair report as the
oracle across four rule sets.
What remains in Phase 7. Re-triaged against the tree on 2026-10-03 (several owner pointers had gone stale). This table is the authoritative open list:
| Item | Where it is |
|---|---|
~~--scope-public-headers, --post-manifest~~ |
Done — Phase 9b/9c/9d (2026-10-03); --contract is the one contract mechanism |
--instantiation-manifest |
deferred in rulings.py. Its blocker, a coordinated contract.* config home, now exists: Phase 9c added .abicheck.yml's contract: block and its D7 receipt slice states contract.overlays at project_config tier. The manifest can join OVERLAY_KINDS next; what remains is deciding its trust boundary (it widens, not narrows) and its route coverage |
--use-cases, --bundle-facts-library-manifest |
deferred in rulings.py, blocked on G29/ADR-057's use_cases: block and G42 respectively (neither landed) |
--bundle-facts-out |
a permanent keep in rulings.py (revisit only if dump gains a release fan-out) — not open |
--follow-deps/--search-path/--ld-library-path → one --environment operand |
G42 |
~~--abi3 armed from a declared floor~~ |
Done — compare_enrichment.py arms the audit from python.abi3_floor when the flag is absent (test_config_supplies_the_floor). The old G26 pointer was wrong: G26 is the NumPy C-API envelope plan and never owned this |
--dump-manifest, --include-system-declarations → capture contract; one capture specification for dump/abicheck-cc/the Clang plugin |
Unowned. G34's phases (0, A–D) contain no capture-specification phase, so the pointer named nothing. Needs a plan slice written before it can move; both flags stay keep until then |
--severity-preset merged into policy selection |
declined for now — needs gate-activation convergence first. EffectiveGate covers single-pair compare and the release fan-out, but the --no-baseline audit still switches its gate on from the preset's name (policy/audit_gate_exit.py); no plan slice owns that convergence yet |
--select-required merged into an expected inventory |
P5 has landed (ADR-065 S3, 2026-09-06). What is missing is a decision: whether a declared expected-inventory config key should exist at all, since rulings.py keeps --select/--select-required as distinct concepts |
~~compare --no-baseline DIR (§3 #16, acceptance F-23)~~ |
Done (2026-10-03) — see §3 row 16 |
dump --compression, --dry-run→--plan, --used-by-manifest→--used-by @FILE |
declined with a measurement (7k/7l) |
Phase 8 — deps convergence (ADR-068 D6) — done¶
StackVerdict → ExitDecision: stack_checker.exit_decision_for_stack_compare/
exit_decision_for_stack_tree fold deps's loadability/ABI-risk/
not-comparable axes through policy.exit_decision.resolve_exit_decision (new
ExitReason.LOADABILITY/ExitDecision.loadability_contribution;
NOT_COMPARABLE reused for ADR-050 D2 mismatches). cli_stack.py exits with
decision.code; every documented exit code is unchanged and pinned
(tests/test_cli_deps_stack.py, tests/test_stack_checker_unit.py). The
stack JSON report is a report.stack.compute_stack_report_document →
ReportDocument → render_json projection. No user-visible flag or
exit-code change. A later compare/deps unification is future
direction, not scoped here.
Phase 9 — Contract-mechanism consolidation (gated, may not start early)¶
--scope-public-headers → --contract public, --post-manifest → a
contract overlay. Never trade a possible false negative for a shorter CLI
(ADR-068 context).
Blocker status (2026-10-01). Both relevance defects named in
public-contract-default.md Phase 6 are closed:
- Template-instantiated-parameter seed mismatch: closed earlier
(directly-referenced stdlib spellings threaded through
contract_pipeline.build_contract_stage). This plan had not recorded it. ambiguous_namespaced_leafidentity gap: closed by schema v54. castxml records which record/enum each type slot resolves to; the exact public-surface walk and the evaluator use it. Verified on real binaries for the single pair and the directory fan-out: the reached record's break goes fromUNKNOWN_UNRESOLVED/exit 1 toIN_CONTRACT/exit 4, and an unreached sibling stays unconfirmed.
public's unresolved-loss budget is now 1, an explained case: the
spelling-only shape that the clang JSON backend, DWARF and pre-v54 baselines
still produce (docs/contribute/known-gaps.md). What still gates Phase 9:
accepting that the package and real_binaries lanes are covered by
integration tests (tests/test_contract_type_identities_integration.py,
tests/test_abi_examples.py) rather than by the always-on
measure_contract_shadow.py measurement. Also, under --ast-frontend clang
a same-leaf record's break still only reaches the coverage floor. Both are
maintainer acceptance decisions, not engineering defects.
Accepted (2026-10-02, maintainer). Both decisions are taken: the
package and real_binaries lanes are covered by the named integration
tests rather than by measure_contract_shadow.py, and the clang-frontend
same-leaf case reaching only the coverage floor is a recorded known gap
(docs/contribute/known-gaps.md), not a Phase 9 blocker. Phase 9 is
unblocked.
9a — the mapping, measured and fixed. Done (2026-10-02). Every case
of the labelled FP-rate corpus was run through the compare CLI under all
four spellings. --contract public loses no real break: every one keeps its
legacy exit except the spelling-only same-leaf case (exit 1, the budgeted
loss), and no internal-noise case scores as a break (exit 1 only where the
public coverage floor fires — the expected migration difference, since
legacy scoping never exits 1). --contract all, however, was not the
exact --no-scope-public-headers alias it is documented as: the legacy
filter ran at its default value ahead of the evaluator, so ten internal
breaks came out exit 0 instead of 4. Fixed in checker.compare (shared by
the CLI and the typed API): an explicit all/exports domain disables
header-origin demotion, which is the public domain's question. Pinned by
tests/test_contract_legacy_scope_mapping.py.
9b — the legacy scope flags deleted. Done (2026-10-03).
--scope-public-headers/--no-scope-public-headers are gone from compare
(exit 64, No such option, no alias). Header-origin scoping stays on for a
run with no --contract: .abicheck.yml's scope.public (built-in true)
is now the whole answer, so a no-flag run is unchanged. --contract all
replaces the opt-out for one run; scope.public: false keeps the unscoped
reading without contract evaluation; --contract auto takes its domain from
scope.public. What went with the flag: the scope option family and its
decorator (cli_options.scope_options, the cli-contract gate's required
family), resolve_compare_config's cli_scope_public argument, the
receipt's typed scope_public_headers parameter, its rulings.py entry, and
the stored-bundle-facts rejection of the flag (config scope: was already
rejected there). Kept on purpose: the LegacyScopeFlag receipt vocabulary
and legacy_alias_* reason codes, which stored reports reference, and
CompareRequest.scope_public. User-facing text that named the flag now says
"public-header scoping". tests/test_contract_legacy_scope_mapping.py keeps
the 9a oracle on the setting that now spells each reading (no flag;
scope.public: false) and pins that the no-flag run still scopes, and
tests/_legacy_scope.py is the one spelling of the opt-out for tests. The
F2 route-parity harness's axis moved from a Click parameter to a config key
(Axis.config_keys), keeping the typed-API default guard through
CONFIG_DEFAULT_MAP.
9c — --post-manifest's config home. Done (2026-10-03).
.abicheck.yml's contract: block, overlays: {post_manifest: PATH}, feeds
the same allowlist and the same post_manifest evidence provider the flag
does; a relative path resolves against the project root
(project_root_for_config), and the Action's config relocation rewrites it
like compile.include_dirs. Flag > config for one run. Schema and parsing
live in buildsource/build_config_contract.py (strict loading: unknown
overlay kinds and empty paths fail), route decisions in
frontends/cli/contract_overlays.py. Found while wiring it: the release
fan-out never received --post-manifest, so a directory/package comparison
accepted and silently ignored it. The flag is now exit 64 there, and the
config key is a stderr note (a project property, not this invocation's;
an unapplied narrowing overlay can only add findings). Same split on the
--no-baseline audit; a stored-bundle-facts baseline rejects the contract:
block with the other blocks it cannot honour. tests/test_post_manifest_config_overlay.py
uses the flag as the oracle across three sibling pair shapes. A pack
assigning contract.overlays stays rejected (UNAPPLIED_PACK_FIELDS).
D7 receipt for the overlay. Done (2026-10-03). The resolved
ContractConfig.overlays is now stated at project_config tier
(ProjectCompatibilityInputs.contract_overlays, option
contract.overlays.post_manifest, the config's path and digest), but only
when the route applies the overlay: a single-pair compare under an explicit
--config. A discovered config, the release fan-out and the audit leave it
built_in_default, so the receipt never names an overlay the run did not
use. The ledger's observed post_manifest hop is appended to that entry
(contract_context._merged_overlay_provenance), so the persisted receipt
names both which file selected the overlay and that it applied.
9d — --post-manifest deleted. Done (2026-10-03). Exit 64 (No such
option, no alias); contract.overlays.post_manifest is the only spelling.
With the flag gone, 9c's "flag is exit 64 on the release fan-out/audit"
branch went too: frontends/cli/contract_overlays.py now reads the config
alone and only ever notes, never rejects. Removed with it: the option, its
rulings.py entry, the audit's and stored-bundle driver's flag rejections,
and the F2 route-parity row. The tests' oracle moved from the flag to
service.compare_snapshots(..., public_surface_allowlist=...) called
directly; tests/_legacy_scope.post_manifest_config_args is the one test
spelling.
Trust boundary (Codex security review on #1477). Moving the overlay
from an operator-typed flag into project config also moved it across a
trust boundary: an auto-discovered .abicheck.yml is editable by the pull
request being judged, and an empty or partial manifest would move a real
export removal out of the gate. The overlay therefore applies only from an
explicitly named --config (the build.query/compile.compiler
precedent); a discovered value is a stderr note, and
action_config_overlay.strip_untrusted_execution_keys drops it from a
discovered config. test_a_discovered_config_cannot_narrow_the_gate uses
the no-overlay run as its oracle.
Remaining (not slices of this plan). Extending overlays to the release
fan-out would need a per-library manifest shape, which no project has asked
for. Retiring scope.public/CompareRequest.scope_public is a separate
Python-API decision. One asymmetry that decision should settle: the typed
API always states scope_public (default True) at the legacy_alias
layer, while a no-flag CLI run now resolves contract.mode from the
built-in default, so the two receipts name different layers for the same
value (they already did for any untyped CLI run before 9b).
Re-homed from cli-cleanup-phase-two.md¶
| Item there | Disposition here |
|---|---|
PR H — scan --artifact-set member-identity manifest |
Cancelled — the mode is deleted (§3 #16/#17); the declared-provider capability becomes config, read by the canonical path |
| PR I — one operand driver / one evaluation-gate-report-dry-run path | Phase 7d, narrowed: with scan gone there are fewer operand shapes to unify |
PR J — bundle topology out of CLI flags; --max-json-object-nodes |
Phase 7d + 7g — done |
7. Acceptance criteria¶
Behavioral scenarios proving scan's removal loses nothing. Each is an
executable test, run through the public entry point (CLI and Action),
over both live operands and stored snapshots. scan-derived rows also run
under Phase 0's parity harness while both commands exist.
| # | Scenario | Must hold |
|---|---|---|
| F-1 | Stripped binary, no DWARF, no headers | Symbol-level result with the layout dimension reported unverified; never an empty surface, never a clean claim |
| F-2 | Binary + public headers | Full L2 declaration result; header-origin scoping applied |
| F-3 | Build + source evidence (--depth source) |
L3–L5 findings identical to scan --depth source on the same inputs |
| F-4 | Source-only/API change invisible in the binary | Detected through the available evidence, classed API_BREAK, not BREAKING (authority rule) |
| F-5 | Private header leak | private_header_leak reported by compare |
| F-6 | Public declaration not exported | public_not_exported reported by compare |
| F-7 | Exported symbol not publicly declared | exported_not_public reported by compare |
| F-8 | Pre-existing leak, baseline lacking headers | not_evaluated on OLD — reported as a candidate-side finding with baseline evidence absent, never as introduced |
| F-9 | Leak present in OLD, fixed in NEW | resolved, and visible on a passing run |
| F-10 | Preprocessing/build-context inconsistency | compile_context_conflict / header_build_context_mismatch reported by compare |
| F-11 | Known consumer affected | consumer_scope reports the impact beside the full-library result (vision D-S1); the gate still reflects the library |
| F-12 | Known consumer unaffected, library ABI still breaks | Exit code and verdict describe the library break; consumer section is informational only |
| F-13 | Multi-library package pair | One comparison, per-member results, one gate — no separate mode |
| F-14 | Deliberately selected one-of-many local variant | Unselected members are not_supplied; no removals manufactured |
| F-15 | Missing CI matrix member | incomplete scope; warn→0 / block→1 (ADR-065 D6); never a removal |
| F-16 | Proven removed release component | Exit 8 only with NEW's inventory proven complete (ADR-065 D2) |
| F-17 | Incomplete evidence under --contract |
Coverage failures listed, unsuppressible, exit contribution 1 folded with max |
| F-18 | Suppression accounting | Raw vs. effective totals with rule provenance in every projection, on a passing run |
| F-19 | Output-format invariance | Canonical result block byte-identical across --format/--view/--write/demangle permutations; exit code identical |
| F-20 | --explain-patterns does not change the verdict |
Verdict/exit identical with and without it, on a fixture where the old implication changed both |
| F-21 | Live vs. snapshot parity | compare A.so B.so ≡ dump A.so && compare A.json B.so for the same evidence |
| F-22 | --no-baseline audit |
Candidate-side findings present; every one carries an evolution state drawn from {persistent, not_evaluated} — ADR-068 D3 permits both against a declared_absent OLD and forbids only introduced/resolved; no additions, removals, or compatibility verdict |
| F-23 | --no-baseline over a directory |
Replaces scan --artifact-set: per-member audit findings, one result document. Met (2026-10-03): each member's embedded report equals a scalar compare --no-baseline <member> run, an archive equals scalar runs over its extracted files, and every set-shaped operand (directory, tar/tar.gz/tar.xz/tar.bz2, wheel, multi-artifact and degraded stored packages) routes to it (tests/test_compare_no_baseline_set.py, tests/test_no_baseline_set_record.py) |
| F-24 | Budget overflow / evidence-contract abort | Exits 5/7 from compare with the same precedence scan had (ADR-064) |
| F-25 | Not-comparable operands | Exit 6, no fabricated findings |
| F-26 | Usage errors | compare NEW (one operand, no flag) and compare --no-baseline OLD NEW both exit 64 — arity is declared, never inferred |
| F-27 | Removed spellings | Every flag deleted in Phase 7 exits 64 with No such option; no silent ignoring |
| F-28 | Action parity | Every documented Action input produces the same verdict/exit/annotations before and after the scan→compare re-implementation |
The migration gate (Phase 3): F-3 through F-10, F-22 and F-23 must show
compare producing a finding set equal to or richer than scan's on the
same inputs, with every difference explained by an evolution state — not by
a missing check.
Non-goals¶
Restated so they are checkable in review:
- Not preserving
scanbecause ~10,000 lines exist for it. - Not introducing
check, or any command whose semantics depend on argument count (§A, ADR-068 D2). - Not moving every removed flag into YAML one-for-one (§4, ADR-068 D5.1).
- No
--set internal.path=valueescape hatch. - Not copying
scan's 45 options ontocompare; five are added, andcomparestill shrinks from 78 to ≤40. - Not making
dumpproduce compatibility verdicts. - Not merging
depsintocompare(ADR-068 D6). - Not modernizing
compat. - Not flipping the contract default before its false-negative prerequisites close (Phase 9).
- Not shortening the CLI by hiding behavior or ignoring supplied input.