Research-2092: Pre-push type-check baseline evaluates branch checker configuration¶
Scope and problem statement¶
State item T-CI-MYPY-PREPUSH-BASELINE-USES-BASE-CONFIG-2026-09-21 identified a self-blocking defect in the pre-push type-check delta gate (scripts/git-hooks/pre-push-mypy.py, introduced in ADR-1261):
The pre-push type-check delta gate is self-blocking for any change to its own configuration.
scripts/git-hooks/pre-push-mypy.py re-checks the branch's files at the merge base in a
disposable worktree, and that worktree carries the merge base's pyproject.toml. When a
branch edits [tool.mypy], the two sides of the comparison are therefore evaluated under
different settings, and every finding the new setting makes visible is attributed to
the branch.
Measured on fix/bug-mypy-pyver, which changed python_version from 3.10 to 3.14 (ADR-1282): the hook reported 103 introduced findings across inherited Python files on the branch against 0 when the merge base was evaluated under the same 3.14 setting.
Root cause analysis¶
The delta gate operates by comparing mypy findings on the branch head against findings on the merge base:
selected_paths()determined which files to analyze. Previously, it only checkedgit diff --name-only base...headfor files ending in.py. If a branch edited only configuration (e.g.pyproject.toml),selected_paths()returned an empty set, bypassing validation entirely.- For branches touching both source and configuration, the disposable baseline worktree (
git worktree add --detach <tmp> <base>) checked out the repository atbase. The baseline execution ran usingbase's checker configuration files (pyproject.toml,mypy.ini, etc.). - When
headintroduced stricter type checking, new flags, or higher language versions, existing debt in the baseline files went unflagged in the baseline run, but was reported onhead. The delta calculationnew_fingerprints = head_fingerprints - base_fingerprintsattributed every newly unmasked finding to the branch as an introduced finding, blocking push.
Self-block reproduction¶
The self-block was reproduced in scripts/git-hooks/test-pre-push-mypy.py via test_branch_only_mypy_config_reproduces_self_block_without_copied_config. When a branch adds a stricter configuration rule (e.g. strict = true in [tool.mypy]), an unchanged source file containing pre-existing debt triggers a finding under the new setting. Without copying branch configuration into the baseline worktree:
- Baseline worktree (running under
baseconfig) reports 0 findings. - Branch worktree (running under
headconfig) reports 1 finding. - The hook computes
introduced = 1and exits with status 1 (blocked).
When branch configuration is synchronized to the baseline worktree:
- Baseline worktree reports 1 inherited finding.
- Branch worktree reports 1 finding.
- The hook computes
introduced = 0and permits the push.
Module-identity blocker exposed by the widened scope¶
The first implementation widened a checker-configuration change to every tracked Python file under ai/ and scripts/, then failed before it could compare findings:
ai/src/vmaf_train/__init__.py: error: Source file found twice under different
module names: "vmaf_train" and "ai.src.vmaf_train"
ai/src is the configured mypy_path and therefore owns the canonical vmaf_train.* identity. Four compatibility call sites still import the same tree through the non-runtime ai.src.vmaf_train.* alias. A full-scope run saw both import graphs at once. exclude = ["ai/src/"] only affects recursive file discovery, and ignore_errors = true is applied after discovery, so neither can prevent mypy from rejecting the duplicate module identity.
The ai.src.* per-module override now uses follow_imports = "skip". Mypy does not traverse that compatibility alias, while the canonical vmaf_train.* tree continues to be checked by the dedicated ai/src invocation with --explicit-package-bases. This resolves each source file once without editing the overlapping AI-helper restoration branch.
Implementation details¶
The fix in scripts/git-hooks/pre-push-mypy.py implements the following mechanisms:
-
Configuration file tracking & extraction: Monitors canonical configuration locations:
pyproject.toml,mypy.ini,.mypy.ini,setup.cfg. Extracts[tool.mypy]viatomlliband[mypy*]sections viaconfigparser. Changes to unrelated sections inpyproject.toml(e.g.[tool.black]) are ignored. -
Scope widening on configuration changes: When
mypy_config_changed(root, base, head)detects an altered checker configuration,selected_paths()expands selection to all tracked.pyfiles under the supported check roots (ai/andscripts/). If configuration is unchanged, diff-only file selection is preserved. -
Baseline configuration synchronization: Before executing the baseline checker in the temporary worktree:
- Any configuration file present in the branch is copied into the baseline worktree.
- Any configuration file deleted in the branch is removed (
unlink) from the baseline worktree. -
All source code files at the merge base remain untouched.
-
Preservation of merge-base source files: Verified by
test_branch_mypy_config_change_preserves_merge_base_source_files. If a branch modifies configuration and also introduces a syntax or type error in a source file, the baseline worktree evaluates the original merge-base source under the new configuration, correctly identifying the source change as an introduced finding. -
Fail-closed semantics:
- Syntax errors in configuration files (e.g. invalid TOML) raise errors and terminate execution with exit code 2.
-
Mypy exit 0 is success and exit 1 is the ordinary findings status. Any other status is a blocking analysis error and terminates the hook with exit 2, even if mypy printed partial parseable findings before aborting. Exit 1 without an attributable finding is also rejected.
-
Canonical module identity: The
pyproject.tomloverride for the legacyai.src.*namespace skips following that alias. Canonicalvmaf_train.*modules remain checked in theai/srcexplicit-package-base pass.
Alternatives considered¶
| Approach | Outcome | Reason |
|---|---|---|
| Evaluate merge-base source under the branch's checker configuration | Chosen | It compares the same source under the same rules and attributes only source deltas to the branch. |
| Compare head and base under their own differing configurations | Rejected | A stricter branch configuration reports inherited debt as newly introduced and blocks its own rollout. |
| Treat configuration-only changes as a no-op or bypass the delta gate | Rejected | It would let a checker-policy change land without proving that the new policy is executable and fail-closed. |
| Add broad ignores or suppressions for newly exposed findings | Rejected | It hides inherited debt and checker failures instead of separating baseline findings from branch findings. |
Verification evidence¶
The regression suite in scripts/git-hooks/test-pre-push-mypy.py verifies all aspects:
test_branch_only_mypy_config_change_evaluates_baseline_under_branch_config: PASStest_branch_only_mypy_config_reproduces_self_block_without_copied_config: PASS (proves red-to-green transition)test_branch_mypy_config_change_preserves_merge_base_source_files: PASStest_unrelated_pyproject_change_does_not_trigger_full_check: PASStest_mypy_config_fails_closed_on_invalid_toml: PASStest_baseline_checker_failure_fails_closed: PASStest_checker_blocker_with_a_finding_fails_closed: PASStest_baseline_blocker_with_a_finding_fails_closed: PASStest_earlier_exit_1_does_not_mask_later_blocker_across_mypy_groups: PASStest_earlier_blocker_does_not_get_masked_by_later_exit_1_across_mypy_groups: PASStest_baseline_earlier_exit_1_does_not_mask_later_blocker_across_mypy_groups: PASStest_branch_deleted_mypy_config_unlinks_baseline_config: PASStest_config_change_resolves_ai_source_root_once: PASS (real mypy; reproduces the exact duplicate-module abort without the alias traversal guard)
Full test run:
Formatting and lint checks (black --check, ruff check) pass cleanly.