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
1016test_backlog_digest.py), but **mypy is the real one**: shimming it would
1319claims. The git shim names a changed file for the same reason; with an empty
1420file list the gate takes its "no changed Python files" path and never calls
1521mypy 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
1828import os
3545# rather than assert against a gate that cannot run.
3646pytest .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.
4252GIT_RESOLVES = """
4353case "$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 *) ;;
4860esac
4961exit 0
5365# space-joined file list silently splits into two arguments.
5466GIT_RESOLVES_SPACED_PATH = """
5567case "$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 *) ;;
6074esac
6175exit 0
6276"""
6377
6478GIT_CANNOT_RESOLVE = """
6579case "$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 *) ;;
7086esac
7187exit 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+
74103WELL_TYPED = """
75104def f(x: int) -> int:
76105 return x
@@ -95,9 +124,17 @@ def _write_shim(bin_dir: Path, name: str, body: str) -> None:
95124
96125
97126def _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
149215def 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
162227def 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
172255def 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
0 commit comments