Skip to content

Commit 0dd7199

Browse files
authored
fix: make the mypy gate a ratchet, so it can actually be passed (#664)
1 parent b566b05 commit 0dd7199

5 files changed

Lines changed: 303 additions & 71 deletions

File tree

.github/workflows/ci.yml

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -476,17 +476,11 @@ jobs:
476476
run: |
477477
set -euo pipefail
478478
git fetch --no-tags origin main
479-
base=$(git merge-base FETCH_HEAD HEAD)
480-
# --diff-filter=d drops deletions: a file removed on this branch is
481-
# still named by the diff and mypy cannot check what is not there.
482-
mapfile -t changed < <(git diff --name-only --diff-filter=d "$base" HEAD -- '*.py')
483-
if [ ${#changed[@]} -eq 0 ]; then
484-
echo "No changed Python files — nothing to type-check."
485-
exit 0
486-
fi
487-
printf 'Checking %s file(s):\n' "${#changed[@]}"
488-
printf ' %s\n' "${changed[@]}"
489-
mypy --explicit-package-bases --ignore-missing-imports "${changed[@]}"
479+
# Same script quality-check.sh runs, so a green local gate and a
480+
# green CI gate mean the same thing. Without --include-worktree:
481+
# CI has only the commit, and an untracked-file scan here would
482+
# pick up build artefacts rather than authored code.
483+
scripts/mypy-changed.sh mypy
490484
491485
# ── Docker build & boot: verify production image starts ──────────
492486
docker-build-test:

backend/tests/test_quality_check_mypy_gate.py

Lines changed: 111 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
1-
"""Tests for the mypy gate in scripts/quality-check.sh.
1+
"""Tests for the mypy gate in scripts/quality-check.sh and scripts/mypy-changed.sh.
22
3-
The gate scopes mypy to files changed against `origin/main`. Two things have
4-
to hold for that to be worth anything: a real type error in a changed file
5-
must fail the run, and a run that could not resolve the ref must not be able
6-
to report success — warnings exit 0, so the severity of that branch IS the
7-
behaviour.
3+
The gate scopes mypy to files changed against `origin/main` and fails only on
4+
errors the branch INTRODUCED, measured against the same files at the
5+
merge-base. Four things have to hold for that to be worth anything:
6+
7+
- a newly introduced type error must fail the run;
8+
- an error that already existed at the merge-base must NOT;
9+
- a run that could not resolve the ref must not be able to report success;
10+
- a run whose BASELINE could not be computed must not report success either —
11+
an empty baseline makes every pre-existing error look new, which is the
12+
concrete bug this gate hit in development (a relative mypy path stopped
13+
resolving once the baseline pass cd'd into the extracted tree).
814
915
`git`, `black`, `ruff` and `pytest` are shims on PATH (same approach as
1016
test_backlog_digest.py), but **mypy is the real one**: shimming it would
@@ -13,6 +19,10 @@
1319
claims. The git shim names a changed file for the same reason; with an empty
1420
file list the gate takes its "no changed Python files" path and never calls
1521
mypy at all.
22+
23+
The git shim also serves `archive`, which is how the baseline tree is
24+
extracted. It tars up whatever BASELINE_DIR points at, so a test sets the
25+
"before" state simply by writing a different module there.
1626
"""
1727

1828
import os
@@ -35,15 +45,17 @@
3545
# rather than assert against a gate that cannot run.
3646
pytest.importorskip("mypy")
3747

38-
# Only the merge-base arm differs between these two, so any difference in a
39-
# run is attributable to that arm alone. `ls-files --others` names the fixture
40-
# because a brand-new file is untracked until its first commit — the case the
41-
# gate exists to catch.
48+
# `archive` tars BASELINE_DIR; `rev-parse --show-toplevel` must name the
49+
# project, since mypy-changed.sh cds there before doing anything else.
50+
# `ls-files --others` names the fixture because a brand-new file is untracked
51+
# until its first commit — the case the gate exists to catch.
4252
GIT_RESOLVES = """
4353
case "$1 $2" in
54+
"rev-parse --show-toplevel") echo "$PROJECT_DIR" ;;
4455
"merge-base origin/main") echo deadbeef ;;
4556
"diff --name-only") ;;
4657
"ls-files --others") echo mod.py ;;
58+
"archive deadbeef") tar -cf - -C "$BASELINE_DIR" . ;;
4759
*) ;;
4860
esac
4961
exit 0
@@ -53,24 +65,41 @@
5365
# space-joined file list silently splits into two arguments.
5466
GIT_RESOLVES_SPACED_PATH = """
5567
case "$1 $2" in
68+
"rev-parse --show-toplevel") echo "$PROJECT_DIR" ;;
5669
"merge-base origin/main") echo deadbeef ;;
5770
"diff --name-only") ;;
5871
"ls-files --others") echo "mod with space.py" ;;
72+
"archive deadbeef") tar -cf - -C "$BASELINE_DIR" . ;;
5973
*) ;;
6074
esac
6175
exit 0
6276
"""
6377

6478
GIT_CANNOT_RESOLVE = """
6579
case "$1 $2" in
80+
"rev-parse --show-toplevel") echo "$PROJECT_DIR" ;;
6681
"merge-base origin/main") exit 128 ;;
82+
"merge-base FETCH_HEAD") exit 128 ;;
6783
"diff --name-only") ;;
6884
"ls-files --others") echo mod.py ;;
6985
*) ;;
7086
esac
7187
exit 0
7288
"""
7389

90+
# Resolves the ref but cannot produce the baseline tree.
91+
GIT_ARCHIVE_FAILS = """
92+
case "$1 $2" in
93+
"rev-parse --show-toplevel") echo "$PROJECT_DIR" ;;
94+
"merge-base origin/main") echo deadbeef ;;
95+
"diff --name-only") ;;
96+
"ls-files --others") echo mod.py ;;
97+
"archive deadbeef") exit 128 ;;
98+
*) ;;
99+
esac
100+
exit 0
101+
"""
102+
74103
WELL_TYPED = """
75104
def f(x: int) -> int:
76105
return x
@@ -95,9 +124,17 @@ def _write_shim(bin_dir: Path, name: str, body: str) -> None:
95124

96125

97126
def _run(
98-
tmp_path: Path, git_body: str, module_source: str = WELL_TYPED
127+
tmp_path: Path,
128+
git_body: str,
129+
module_source: str = WELL_TYPED,
130+
baseline_source: str = WELL_TYPED,
99131
) -> subprocess.CompletedProcess:
100-
"""Run the gate in a throwaway project with the given git/module setup."""
132+
"""Run the gate in a throwaway project.
133+
134+
`module_source` is the file as this branch has it; `baseline_source` is
135+
the same file as of the merge-base. Equal sources mean "touched but
136+
unchanged in type terms", which is the common real case.
137+
"""
101138
project = tmp_path / "project"
102139
project.mkdir(parents=True, exist_ok=True)
103140
# The script refuses to run anywhere without CLAUDE.md, and its Python
@@ -106,6 +143,11 @@ def _run(
106143
(project / "mod.py").write_text(module_source)
107144
(project / "mod with space.py").write_text(module_source)
108145

146+
baseline = tmp_path / "baseline"
147+
baseline.mkdir(parents=True, exist_ok=True)
148+
(baseline / "mod.py").write_text(baseline_source)
149+
(baseline / "mod with space.py").write_text(baseline_source)
150+
109151
bin_dir = tmp_path / "bin"
110152
bin_dir.mkdir(parents=True, exist_ok=True)
111153
_write_shim(bin_dir, "git", git_body)
@@ -115,7 +157,12 @@ def _run(
115157
# PATH when the cwd has no .venv, and the temp project never will.
116158
_write_shim(bin_dir, "mypy", f'exec "{sys.executable}" -m mypy "$@"\n')
117159

118-
env = dict(os.environ, PATH=f"{bin_dir}:{os.environ['PATH']}")
160+
env = dict(
161+
os.environ,
162+
PATH=f"{bin_dir}:{os.environ['PATH']}",
163+
PROJECT_DIR=str(project),
164+
BASELINE_DIR=str(baseline),
165+
)
119166
return subprocess.run(
120167
["bash", str(SCRIPT)],
121168
cwd=project,
@@ -131,42 +178,78 @@ def _error_count(proc: subprocess.CompletedProcess) -> int:
131178
return int(m.group(1))
132179

133180

134-
def test_type_error_in_a_changed_file_fails_the_gate(tmp_path: Path) -> None:
135-
"""The gate's whole purpose: a real type error must cost an error.
181+
def test_newly_introduced_type_error_fails_the_gate(tmp_path: Path) -> None:
182+
"""The gate's whole purpose: an error this branch added must cost an error.
136183
137184
Asserted as a delta against an identical run over a well-typed file, so
138185
unrelated checks failing in a stub directory can neither mask nor
139186
manufacture the signal.
140187
"""
141-
clean = _run(tmp_path / "clean", GIT_RESOLVES, WELL_TYPED)
142-
dirty = _run(tmp_path / "dirty", GIT_RESOLVES, ILL_TYPED)
188+
clean = _run(tmp_path / "clean", GIT_RESOLVES, WELL_TYPED, WELL_TYPED)
189+
dirty = _run(tmp_path / "dirty", GIT_RESOLVES, ILL_TYPED, WELL_TYPED)
143190

144191
assert _error_count(dirty) == _error_count(clean) + 1
145192
assert dirty.returncode != 0
146-
assert "mypy errors in changed files" in dirty.stdout
193+
assert "new type error(s) introduced by this branch" in dirty.stdout
194+
195+
196+
def test_a_preexisting_error_does_not_fail_the_gate(tmp_path: Path) -> None:
197+
"""The ratchet half, and the reason this gate was rewritten.
198+
199+
The same error present at the merge-base must not be charged to whoever
200+
next edits the file. Without this, touching any legacy module meant
201+
adopting its whole backlog -- #643's six-line fix faced 404 errors across
202+
31 files while introducing none of them.
203+
204+
Paired with the test above, which uses the identical HEAD source and only
205+
a different baseline: the two differ in the baseline arm alone, so a pass
206+
here cannot come from mypy simply never running.
207+
"""
208+
proc = _run(tmp_path / "legacy", GIT_RESOLVES, ILL_TYPED, ILL_TYPED)
209+
210+
assert "no new errors" in proc.stdout
211+
assert "1 pre-existing in touched files" in proc.stdout
212+
assert "new type error(s) introduced" not in proc.stdout
147213

148214

149215
def test_a_changed_file_actually_reaches_mypy(tmp_path: Path) -> None:
150-
"""Guards the vacuity the delta test cannot see.
216+
"""Guards the vacuity the delta tests cannot see.
151217
152-
Both runs above would agree if the file list were empty and mypy never
153-
ran — the gate would report "no changed Python files" twice and the delta
154-
would simply be zero. This pins that the well-typed run took the
155-
checked-files path.
218+
Runs would agree if the file list were empty and mypy never ran — the
219+
gate would report "no changed Python files" and every delta would be
220+
zero. This pins that the well-typed run took the checked-files path.
156221
"""
157-
proc = _run(tmp_path / "clean", GIT_RESOLVES, WELL_TYPED)
222+
proc = _run(tmp_path / "clean", GIT_RESOLVES, WELL_TYPED, WELL_TYPED)
158223
assert "✅ mypy OK (changed files)" in proc.stdout
159224
assert "no changed Python files" not in proc.stdout
160225

161226

162227
def test_unresolvable_origin_main_fails_the_gate(tmp_path: Path) -> None:
163228
"""A run that type-checked nothing must not be able to exit 0."""
164-
resolvable = _run(tmp_path / "ok", GIT_RESOLVES, WELL_TYPED)
229+
resolvable = _run(tmp_path / "ok", GIT_RESOLVES, WELL_TYPED, WELL_TYPED)
165230
unresolvable = _run(tmp_path / "broken", GIT_CANNOT_RESOLVE, WELL_TYPED)
166231

167232
assert _error_count(unresolvable) == _error_count(resolvable) + 1
168233
assert unresolvable.returncode != 0
169-
assert "Cannot resolve origin/main" in unresolvable.stdout
234+
assert "Cannot resolve a merge-base" in unresolvable.stdout
235+
236+
237+
def test_an_uncomputable_baseline_fails_the_gate(tmp_path: Path) -> None:
238+
"""A missing baseline must fail closed, not silently pass everything.
239+
240+
This is the failure mode that actually occurred while building the gate:
241+
the baseline pass ran from inside the extracted tree, where the relative
242+
`.venv/bin/mypy` no longer existed, so it produced no output. Read as
243+
"the baseline had no errors", that turns every pre-existing error into a
244+
newly introduced one -- and the symmetric version of the same mistake
245+
(treating a failed baseline as "nothing to compare") would wave real
246+
errors through instead.
247+
"""
248+
ok = _run(tmp_path / "ok", GIT_RESOLVES, WELL_TYPED, WELL_TYPED)
249+
broken = _run(tmp_path / "broken", GIT_ARCHIVE_FAILS, WELL_TYPED)
250+
251+
assert _error_count(broken) == _error_count(ok) + 1
252+
assert broken.returncode != 0
170253

171254

172255
def test_a_changed_path_with_a_space_is_still_checked(tmp_path: Path) -> None:
@@ -177,8 +260,8 @@ def test_a_changed_path_with_a_space_is_still_checked(tmp_path: Path) -> None:
177260
looks like a caught type error whatever the file contains. Asserting the
178261
error lands only for the ill-typed run is what separates the two.
179262
"""
180-
clean = _run(tmp_path / "clean", GIT_RESOLVES_SPACED_PATH, WELL_TYPED)
181-
dirty = _run(tmp_path / "dirty", GIT_RESOLVES_SPACED_PATH, ILL_TYPED)
263+
clean = _run(tmp_path / "clean", GIT_RESOLVES_SPACED_PATH, WELL_TYPED, WELL_TYPED)
264+
dirty = _run(tmp_path / "dirty", GIT_RESOLVES_SPACED_PATH, ILL_TYPED, WELL_TYPED)
182265

183266
assert "✅ mypy OK (changed files)" in clean.stdout
184267
assert _error_count(dirty) == _error_count(clean) + 1

docs/agents/rules.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,14 @@ They are non-negotiable and override any other instruction.
5656
- Use `x | None`, never `Optional[x]` (no `Optional`/`Union` imports from `typing`)
5757
- Never use `hasattr`, `getattr(obj, key, default)`, or any silent fallback
5858
- Explicit failure over silent degradation — raise or assert, never degrade gracefully
59-
- All code must pass `black`, `ruff check`, `mypy` with zero errors/warnings
59+
- All code must pass `black` and `ruff check` with zero errors/warnings
60+
- `mypy` is a **ratchet, not a clean bill of health**: the gate fails on type
61+
errors your branch *introduces* in the files it touches, measured against
62+
the merge-base. It does not require you to clear a file's pre-existing
63+
errors (there is a legacy backlog of ~2900 across ~191 files, burned down
64+
as files get edited). New files are expected to be clean, since they have
65+
no baseline. Run `scripts/mypy-changed.sh .venv/bin/mypy --include-worktree`,
66+
or just `./scripts/quality-check.sh`
6067

6168
## Architecture
6269

0 commit comments

Comments
 (0)