ADR-1261: The local type-check hook fails on findings a branch introduces, not on ones it inherits¶
- Status: Proposed
- Date: 2026-09-19
- Deciders: Lusoris
- Tags: ci, hooks, python, tooling, fork-local
Context¶
mypy runs in two places, with two different contracts.
In CI, the Python Lint job runs mypy ai/ scripts/ || echo "mypy advisory only on first run". The || is deliberate and the job comment gives the reason: stub coverage for numpy, pandas and torch is uneven. The job is therefore advisory and has never blocked a merge.
Locally, scripts/git-hooks/pre-push-mypy.py runs the same checker over the Python files a branch changed, and its exit status blocks the push. That made the local hook stricter than the gate it mirrors, and the difference was not a matter of degree:
- Nothing under
ai/src/could be pushed at all.ai/srcis on mypy'smypy_path, so a file under it has two possible module names, and mypy refuses outright with "Source file found twice under different module names" when such a path is named on the command line. Theexcludeentry inpyproject.tomlonly prevents discovery while crawling. Measured on 2026-09-19: a branch that editedai/src/aiutils/run_manifest.pycould not be pushed, and neither couldmasteritself have been, had it been a branch. - Inherited findings blocked unrelated work. Whether a finding appears depends on which of numpy, pandas and torch the checkout has installed, so a developer environment richer than CI's reports more. Measured on the same day, in a checkout with those packages present: the 23 files a dependency-restoration branch touched produced 14 findings, and
master's own copies of the same files produced the same 14. None belonged to the branch.
Raising python_version from its stale 3.10 to the 3.14 the project actually requires unmasks 175 further findings on master's AI tree. That is real debt worth paying down, but it is not something a branch can be asked to carry as the price of pushing.
Decision¶
The hook stays blocking, and it measures a delta.
- Files under
ai/src/are checked in their own invocation with--explicit-package-bases, which names them from themypy_pathbase alone, matching the module they have at runtime. Files outside it keep the plain invocation, so their module names, and the per-module overrides keyed on them, do not change. - Blocking runs use
--no-site-packagesand disable the resultingimport-not-founddiagnostics. This makes the gate independent of whichever third-party PEP 561 packages happen to be installed while retaining checks over the selected repository source, resolved repository imports, and the standard library. The dependency-rich CI run remains advisory. - The same file set is checked again at the branch's merge base, in a disposable worktree, and only findings absent there are reported. Line numbers are not part of a finding's identity, so an edit above a finding does not make it look new.
- A non-zero exit with no attributable finding fails the push. That is mypy breaking rather than a clean run, and it must not pass silently.
python_version and the 175 findings behind it are left to their own change.
Alternatives considered¶
| Option | Why not |
|---|---|
| Delta gate (chosen) | Keeps the hook meaningful: a branch may add no type error. Costs one extra checker run and a disposable worktree per push. |
| Make the hook advisory, like CI | Matches the CI contract exactly, and gives up the only place where mypy actually gates anything. A gate that cannot fail is documentation. |
| Fix the 175 inherited findings first, keep the hook absolute | The right end state, and far too large to sit in front of every unrelated branch. It is also not stable: the finding set moves with the local environment's installed packages. |
Exclude ai/src/ from the hook's file selection | Removes the hard error without checking those files at all, which is a coverage hole, not a fix: ai/src holds the shared AI helpers. |
| Use the active environment's third-party stubs | Preserves dependency type detail, but makes a blocking gate depend on packages outside the repository contract. A NumPy stub using Python 3.12 syntax made mypy exit before it could check this repository's Python 3.10 target. The hosted advisory run still provides that signal. |
Raise python_version to 3.14 in the same change | Measured: 175 findings on master's AI tree, and a second class of error from compat/python-vmaf not being an importable package name. A separate change with its own risk. |
Consequences¶
- A branch that edits a file under
ai/src/can be pushed. - A branch that adds a type error is still rejected, and the message names the finding and says that inherited ones are not listed.
- The blocking result no longer changes with locally installed PEP 561 packages. Missing-import diagnostics and third-party stub detail remain the responsibility of the advisory hosted run until the dependency surface has a pinned type-check environment.
- Each push runs the checker twice and creates one disposable worktree under the cache directory, removed even when the run fails, so the ADR-0332 worktree-drift guard sees nothing.
- The inherited-finding count is printed, so the debt stays visible instead of silently accepted.
- Paying the debt down needs no change here: as findings disappear from the merge base, they disappear from the baseline.
References¶
.github/workflows/lint-and-format.yml, thePython Lintjob:mypy ai/ scripts/ || echo "mypy advisory only on first run".scripts/git-hooks/pre-push-mypy.pyand its regression suitescripts/git-hooks/test-pre-push-mypy.py.- ADR-0922 — the same delta shape for coverage, which this follows.
- ADR-0332 — why the baseline worktree must always be removed.
req(paraphrased, standing instruction): a pre-existing defect is not a reason to leave a gate broken; fix it.