diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af64923..c3a862c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,5 +91,20 @@ jobs: python -m pip install --upgrade pip pip install pre-commit - - name: Run pre-commit (black + ruff + mypy) + - name: Run pre-commit (black + ruff) run: pre-commit run --all-files + + # mypy is deliberately NOT run through the pre-commit step above. + # M-TYPECHECK TYPE.2: the mypy hook is pinned `stages: [pre-push]` (kept + # off every commit — it's slow) and `pre-commit run --all-files` only + # runs default-stage hooks, so it silently skipped mypy here for months + # while this job's name and the branch-protection check both claimed + # type checking was happening. A `--hook-stage pre-push` flag would fix + # today's instance but not the class of bug: the next hook to acquire an + # unusual `stages:` pin would vanish from CI the same way, silently. An + # explicit step that runs mypy directly has no stage-filtering machinery + # to hide behind. + - name: Type check (mypy) + run: | + pip install "mypy~=1.10.0" numpy types-requests + mypy --ignore-missing-imports quantui/ diff --git a/pyproject.toml b/pyproject.toml index 9177dc4..aace8ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -175,7 +175,11 @@ dev = [ "pytest-cov>=4.0.0", "pytest-mock>=3.10.0", "pytest-xdist>=3.0.0", # parallel test execution (-n=auto in addopts) - "mypy>=1.0.0", + "mypy~=1.10.0", # pinned, not an open floor — same reasoning as + # black/ruff below: it must agree with .pre-commit-config.yaml's rev, + # which is what CI enforces (M-TYPECHECK TYPE.2). A newer mypy silently + # disagrees: 2.x drops support for python_version = "3.9" (this repo's + # floor) and reports a different error set than the pinned check. "types-requests>=2.28.0", # Formatter/linter versions are pinned to a compatible range, NOT an open # floor: they must agree with the revs in .pre-commit-config.yaml, which is diff --git a/quantui/analytics.py b/quantui/analytics.py index 9c18b8c..c9bae43 100644 --- a/quantui/analytics.py +++ b/quantui/analytics.py @@ -34,7 +34,7 @@ from collections import defaultdict from datetime import datetime, timezone from pathlib import Path -from typing import Optional +from typing import Optional, cast from quantui.calc_log import _log_dir, get_perf_history, get_prediction_history @@ -280,11 +280,17 @@ def _bar_chart_html( margin=dict(l=40, r=20, t=10, b=40), plot_bgcolor="#ffffff", ) - return pio.to_html( - fig, - include_plotlyjs="inline" if include_plotlyjs else False, - full_html=False, - config={"displayModeBar": False}, + # plotly has no bundled type stubs; --ignore-missing-imports leaves + # pio.to_html untyped. It genuinely returns str (verified: this + # function's other returns are explicit None for the no-data case). + return cast( + str, + pio.to_html( + fig, + include_plotlyjs="inline" if include_plotlyjs else False, + full_html=False, + config={"displayModeBar": False}, + ), ) @@ -344,11 +350,14 @@ def _timeline_html(records: list[dict], *, include_plotlyjs: bool) -> Optional[s plot_bgcolor="#ffffff", legend=dict(orientation="h", x=0, y=1.05), ) - return pio.to_html( - fig, - include_plotlyjs="inline" if include_plotlyjs else False, - full_html=False, - config={"displayModeBar": False}, + return cast( + str, + pio.to_html( + fig, + include_plotlyjs="inline" if include_plotlyjs else False, + full_html=False, + config={"displayModeBar": False}, + ), ) @@ -452,11 +461,14 @@ def _prediction_scatter_html( plot_bgcolor="#ffffff", legend=dict(orientation="h", x=0, y=1.05), ) - return pio.to_html( - fig, - include_plotlyjs="inline" if include_plotlyjs else False, - full_html=False, - config={"displayModeBar": False}, + return cast( + str, + pio.to_html( + fig, + include_plotlyjs="inline" if include_plotlyjs else False, + full_html=False, + config={"displayModeBar": False}, + ), ) diff --git a/quantui/app.py b/quantui/app.py index 9cda920..043a21a 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -22,7 +22,7 @@ import uuid as _uuid from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, ClassVar, List, Literal, Optional +from typing import TYPE_CHECKING, Any, Callable, ClassVar, List, Literal, Optional, cast import ipywidgets as widgets from IPython import get_ipython @@ -1025,6 +1025,8 @@ class QuantUIApp: _clear_log_cache_confirm_btn: Any _exit_btn: Any _exit_output: Any + _exit_cancel_btn: Any + _exit_warn_html: Any _help_btn: Any _issue_btn: Any _issue_cancel_btn: Any @@ -1039,6 +1041,7 @@ class QuantUIApp: _cal_run_btn: Any _cal_step_label: Any _cal_stop_btn: Any + _cal_skip_btn: Any _log_clear_btn: Any _log_output_html: Any _log_source_lbl: Any @@ -1062,6 +1065,7 @@ class QuantUIApp: _status_tab_panel: Any _theme_style: Any _welcome_html: Any + _welcome_header: Any _activity_btn: Any advanced_accordion: Any calc_setup_panel: Any @@ -1102,6 +1106,8 @@ class QuantUIApp: history_basis_dd: Any history_date_from: Any history_date_to: Any + _history_calc_chips: Any + _history_status_chips: Any lib_category_dd: Any lib_search_txt: Any lib_results_dd: Any @@ -1115,6 +1121,7 @@ class QuantUIApp: results_path_lbl: Any run_btn: Any cancel_btn: Any + basis_fix_btn: Any run_output: Any run_panel: Any run_status: Any @@ -1145,6 +1152,7 @@ class QuantUIApp: _freq_seed_dd: Any _freq_seed_note: Any _freq_seed_refresh_btn: Any + _tddft_seed_dd: Any _go_analysis_btn: Any _go_results_btn: Any _ir_export_btn: Any @@ -1154,8 +1162,15 @@ class QuantUIApp: _ir_fwhm_slider: Any _ir_mode_toggle: Any _ir_accordion: Any + _ir_copy_data_btn: Any _iso_accordion: Any _iso_generate_btn: Any + _iso_cancel_btn: Any + _iso_colors_dd: Any + _iso_export_cube_btn: Any + _iso_isovalue_slider: Any + _iso_opacity_slider: Any + _iso_resolution_dd: Any _last_result_dir: Any _nmr_accordion: Any _nmr_output: Any @@ -1165,17 +1180,25 @@ class QuantUIApp: _orb_export_btn: Any _orb_export_fmt_dd: Any _orb_export_status: Any + _orb_copy_data_btn: Any _orb_iso_controls: Any _orb_iso_output: Any _orb_n_orb_input: Any + _orb_index_input: Any + _orb_png_inbox: Any _orb_toggle: Any _orb_ymax_input: Any _orb_ymin_input: Any _pes_export_btn: Any _pes_export_fmt_dd: Any _pes_export_status: Any + _pes_copy_data_btn: Any _pes_plot_html: Any _pes_scan_accordion: Any + _reorg_view_toggle: Any + _reorg_overlay_pair: Any + _reorg_exaggerate: Any + _reorg_mode_dd: Any _result_dir_label: Any _result_log_accordion: Any _result_log_output: Any @@ -1194,6 +1217,7 @@ class QuantUIApp: _uv_export_btn: Any _uv_export_fmt_dd: Any _uv_export_status: Any + _uv_copy_data_btn: Any _uv_fwhm_slider: Any _uv_mode_toggle: Any _to_analysis_btn: Any @@ -1222,6 +1246,7 @@ class QuantUIApp: export_pdb_btn: Any export_status: Any export_xyz_btn: Any + _export_bundle_btn: Any fmax_fi: Any log_clear_btn: Any max_steps_si: Any @@ -1234,6 +1259,13 @@ class QuantUIApp: _basis_card_html: Any _descriptor_cards_box: Any _open_shell_hint: Any + spin_metal_dd: Any + spin_ox_si: Any + spin_geom_dd: Any + spin_suggest_btn: Any + spin_helper_output: Any + spin_apply_btns: Any + spin_helper_box: Any nstates_si: Any perf_estimate_html: Any post_calc_panel: Any @@ -1252,6 +1284,11 @@ class QuantUIApp: vib_accordion: Any vib_mode_dd: Any vib_output: Any + vib_prev_btn: Any + vib_next_btn: Any + _vib_export_btn: Any + _vib_export_status: Any + _last_vib_molecule: Any def __init__(self) -> None: # ── Instance state ──────────────────────────────────────────────── @@ -2610,15 +2647,15 @@ def _preview_file_path(self, path: Path) -> None: if suffix in {".html", ".htm"}: try: - raw = path.read_text(encoding="utf-8", errors="replace") - if len(raw) <= 1_000_000: + html_text = path.read_text(encoding="utf-8", errors="replace") + if len(html_text) <= 1_000_000: # Sandboxed iframe via srcdoc — embedded JS can't # reach the parent app. iframe_html = ( '' + f'srcdoc="{_html.escape(html_text, quote=True)}">' ) with self._files_preview_output: display(HTML(iframe_html)) @@ -3090,7 +3127,11 @@ def _rerender_3d_views(self) -> None: # toggled backends on the Analysis tab. html = _render_molecule_html( self._analysis_displayed_molecule, - backend=str(chosen), + # VizBackend is a StrEnum whose only members are + # "py3dmol"/"plotlymol", a subset of render_molecule_html's + # accepted Literal — cast documents that, str() alone widens + # to plain str for mypy. + backend=cast(Literal["auto", "py3dmol", "plotlymol"], str(chosen)), style=self._viz_style, lighting=self._viz_lighting, bgcolor=self._plotly_theme_colors()["scene_bgcolor"], @@ -3151,7 +3192,10 @@ def _on_gpu_enabled_changed(self, change) -> None: try: from quantui.gpu_offload import is_gpu_available, probe_gpu - is_gpu_available.cache_clear() + # cache_clear is forwarded from _probe_gpu's lru_cache onto this + # function at definition time (gpu_offload.py); mypy can't see a + # monkey-patched attribute across the module boundary. + is_gpu_available.cache_clear() # type: ignore[attr-defined] state = probe_gpu() except Exception: # noqa: BLE001 — a probe failure must not break the UI state = (False, None, "") diff --git a/quantui/app_builders.py b/quantui/app_builders.py index a073100..5759d8c 100644 --- a/quantui/app_builders.py +++ b/quantui/app_builders.py @@ -881,7 +881,7 @@ def build_shared_widgets( ) for _ in range(2) ) - app._spin_suggested_mults: list = [] + app._spin_suggested_mults = [] app.spin_helper_box = widgets.Accordion( children=[ widgets.VBox( diff --git a/quantui/app_runflow.py b/quantui/app_runflow.py index 4db230b..440e5b7 100644 --- a/quantui/app_runflow.py +++ b/quantui/app_runflow.py @@ -4,7 +4,7 @@ import threading import time -from typing import Any, Optional +from typing import Any, Dict, Optional import ipywidgets as widgets from IPython.display import HTML, Javascript, display @@ -1563,7 +1563,7 @@ def _update_open_shell_hint(app: Any) -> None: #: Calculate-tab dropdown label → the key used in perf records, saved #: results, and checkpoint identities. One mapping, so the estimator and the #: checkpoint layer can never disagree about what calculation is configured. -_CALC_TYPE_KEYS: dict = { +_CALC_TYPE_KEYS: Dict[str, str] = { "Single Point": "single_point", "Geometry Opt": "geometry_opt", "Frequency": "frequency", @@ -1708,7 +1708,7 @@ def _hide() -> None: #: Inverse of ``_CALC_TYPE_KEYS`` — a stored calc-type key back to the label #: the Calculate-tab dropdown actually uses. Derived rather than written out #: twice, so the two can never drift apart. -_CALC_TYPE_LABELS: dict = {v: k for k, v in _CALC_TYPE_KEYS.items()} +_CALC_TYPE_LABELS: Dict[str, str] = {v: k for k, v in _CALC_TYPE_KEYS.items()} def _age_phrase(updated_at: Any) -> str: diff --git a/quantui/app_visualization.py b/quantui/app_visualization.py index 020e5c9..046d18b 100644 --- a/quantui/app_visualization.py +++ b/quantui/app_visualization.py @@ -7,7 +7,7 @@ import time from contextlib import contextmanager from pathlib import Path -from typing import Any, List +from typing import Any, List, cast import ipywidgets as widgets from IPython.display import HTML, display @@ -2541,7 +2541,9 @@ def _xyz(g: dict) -> str: view.setBackgroundColor(bgcolor) view.zoomTo() - view_html = view._make_html() + # py3Dmol has no type stubs (ignore_missing_imports); _make_html() + # genuinely returns str. + view_html = cast(str, view._make_html()) if re.search(r"3dmolviewer_(\w+)", view_html) is None: return view_html diff --git a/quantui/benchmarks.py b/quantui/benchmarks.py index 1814393..7f02b1f 100644 --- a/quantui/benchmarks.py +++ b/quantui/benchmarks.py @@ -56,7 +56,7 @@ from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path -from typing import Callable, List, Optional +from typing import IO, Any, Callable, List, Optional, cast # --------------------------------------------------------------------------- # Benchmark suite definition @@ -1022,12 +1022,24 @@ def _calibration_worker( f"\n========= {_dt.utcnow().isoformat()} :: {label} =========\n" ) per_calc_buf = _io.StringIO() - stream = _TeeStream(log_fh, per_calc_buf) + # _TeeStream implements only .write() — the one method any of + # optimize_geometry / run_freq_calc / run_in_session's + # progress_stream actually calls in these code paths (no + # .flush(), confirmed). The cast documents that narrower-than- + # declared usage rather than widening _TeeStream into a full + # IO[str] implementation it doesn't need. + stream = cast(IO[str], _TeeStream(log_fh, per_calc_buf)) from quantui.molecule import Molecule as _Molecule mol = _Molecule(atoms, coords, charge=charge, multiplicity=mult) + # `res` genuinely holds one of three unrelated result types + # depending on `calc_type` — each branch below reads only its own + # type's attributes, and the shared use after the if/elif + # (_save_calibration_step) takes it untyped. Annotate Any so mypy + # doesn't pin the variable to whichever branch it sees first. + res: Any if calc_type == "geometry_opt": from quantui.optimizer import optimize_geometry as _opt @@ -1348,12 +1360,20 @@ def _emit_progress(*args, live_message=None, step=None) -> None: # accept. Modern callers (do_calibration) take both; tests pass # ``lambda *a: ...``. try: - progress_cb(*args, live_message=live_message, step=step) + # ProgressCallback declares only the 5 positional params — these + # richer, newer kwargs are a deliberate, untyped progressive + # enhancement: try the richest signature a modern callback might + # accept, and let TypeError (a callback that doesn't) fall + # through to a plainer call. There's no static type for + # "optionally accepts extra kwargs, probe at runtime". + progress_cb( # type: ignore[call-arg] + *args, live_message=live_message, step=step + ) return except TypeError: pass try: - progress_cb(*args, live_message=live_message) + progress_cb(*args, live_message=live_message) # type: ignore[call-arg] return except TypeError: pass diff --git a/quantui/cli.py b/quantui/cli.py index f2f13e1..8c80726 100644 --- a/quantui/cli.py +++ b/quantui/cli.py @@ -35,7 +35,7 @@ import json import sys from pathlib import Path -from typing import Optional, Sequence +from typing import Optional, Sequence, cast from quantui.calc_log import _event_path, get_recent_events @@ -94,7 +94,10 @@ def _cmd_gpu_check(args: argparse.Namespace) -> int: # The detection probe is cached; clear so each CLI invocation is # fresh (the user may have just installed gpu4pyscf and wants to # confirm without restarting their shell). - is_gpu_available.cache_clear() + # cache_clear is forwarded from _probe_gpu's lru_cache onto this function + # at definition time (gpu_offload.py); mypy can't see a monkey-patched + # attribute across the module boundary. + is_gpu_available.cache_clear() # type: ignore[attr-defined] available, name, reason = probe_gpu() if available: print(f"GPU offload available: {name}") @@ -281,7 +284,10 @@ def _build_parser() -> argparse.ArgumentParser: def main(argv: Optional[Sequence[str]] = None) -> int: parser = _build_parser() args = parser.parse_args(argv) - return args.func(args) + # args.func is set per-subcommand via set_defaults(func=_cmd_xxx) + # (argparse.Namespace is untyped, so mypy sees Any here); every _cmd_* + # handler returns an int exit code by convention. + return cast(int, args.func(args)) if __name__ == "__main__": diff --git a/quantui/estimator_eval.py b/quantui/estimator_eval.py index 440587e..a7c0bbd 100644 --- a/quantui/estimator_eval.py +++ b/quantui/estimator_eval.py @@ -120,7 +120,9 @@ def _is_scoreable(record: dict) -> bool: if not record.get("converged"): return False try: - elapsed = float(record.get("elapsed_s")) + # record is untyped JSON; float() may reject a missing/non-numeric + # value at runtime, which the except below already handles. + elapsed = float(record.get("elapsed_s")) # type: ignore[arg-type] except (TypeError, ValueError): return False # A zero/negative elapsed can't produce a meaningful error percentage. diff --git a/quantui/freq_calc.py b/quantui/freq_calc.py index e8475ad..4b48f8b 100644 --- a/quantui/freq_calc.py +++ b/quantui/freq_calc.py @@ -30,7 +30,7 @@ import os import sys from dataclasses import dataclass, field -from typing import IO, Any, List, Optional +from typing import IO, Any, List, Optional, cast from .molecule import Molecule from .session_calc import HARTREE_TO_EV @@ -457,7 +457,14 @@ def _displaced_scf_dipole() -> _np_ir.ndarray: # failure, so this is safe to call unconditionally. _mf_d, _used_gpu, _gpu_name = _try_to_gpu_inner(_mf_d, "RHF") _mf_d.kernel(dm0=_dm0) - return _np_ir.array(_mf_d.dip_moment(verbose=0)) + # pyscf has no type stubs (ignore_missing_imports), so + # dip_moment()'s Any return defeats asarray's overload + # resolution too; dip_moment() genuinely returns an + # array-like of floats. + return cast( + _np_ir.ndarray, + _np_ir.asarray(_mf_d.dip_moment(verbose=0), dtype=float), + ) # Opt-in parallel path (Pass B). When (a) the user has # set ``QUANTUI_FREQ_PARALLEL=1``, (b) no GPU is available, diff --git a/quantui/orbital_visualization.py b/quantui/orbital_visualization.py index 70f5454..3b60faa 100644 --- a/quantui/orbital_visualization.py +++ b/quantui/orbital_visualization.py @@ -534,7 +534,9 @@ def orbital_summary_html(info: OrbitalInfo) -> str: # ============================================================================ -def infer_charge_and_spin(mol_atom: list, mo_occ: np.ndarray | list) -> Tuple[int, int]: +def infer_charge_and_spin( + mol_atom: Optional[list], mo_occ: Optional[np.ndarray | list] +) -> Tuple[int, int]: """Infer ``(charge, spin)`` for a ``gto.Mole`` from atoms + MO occupations. Cube/isosurface generation from saved MO data does not have direct access @@ -925,9 +927,12 @@ def _build_molecule_overlay_data(atoms: list[tuple[int, float, float, float]]) - atom_sizes.append(max(6.0, 15.0 * _COVALENT_RADII_ANGSTROM.get(z_num, 0.75))) atom_labels.append(_ATOMIC_SYMBOLS.get(z_num, str(z_num))) - bond_x: List[float] = [] - bond_y: List[float] = [] - bond_z: List[float] = [] + # None entries deliberately break a Plotly line trace between bond + # segments (x=[x1,x2,None,x3,x4,None,...]) so consecutive bonds don't + # visually connect. + bond_x: List[Optional[float]] = [] + bond_y: List[Optional[float]] = [] + bond_z: List[Optional[float]] = [] for i, (zi, xi, yi, zi_pos) in enumerate(atoms): for zj, xj, yj, zj_pos in atoms[i + 1 :]: ri = _COVALENT_RADII_ANGSTROM.get(zi, 0.75) diff --git a/quantui/progress.py b/quantui/progress.py index 97f2f99..1504cb4 100644 --- a/quantui/progress.py +++ b/quantui/progress.py @@ -99,8 +99,9 @@ def _render(self) -> None: f'font-weight:{weight}; color:{color};">' f"{icon} Step {i + 1}: {html.escape(label)}" ) - if self._messages[i]: - line += f" — {html.escape(self._messages[i])}" + message = self._messages[i] + if message: + line += f" — {html.escape(message)}" line += "" lines.append(line) diff --git a/quantui/results_storage.py b/quantui/results_storage.py index be5e1b5..be75c7c 100644 --- a/quantui/results_storage.py +++ b/quantui/results_storage.py @@ -79,7 +79,7 @@ def _opt_int(x: object) -> Optional[int]: if x is None: return None try: - return int(x) # type: ignore[arg-type] + return int(x) # type: ignore[arg-type, no-any-return, call-overload] except (TypeError, ValueError): return None @@ -89,7 +89,7 @@ def _opt_float_list(x: object) -> Optional[list]: if x is None: return None try: - return [float(v) for v in x] # type: ignore[union-attr] + return [float(v) for v in x] # type: ignore[union-attr, attr-defined] except (TypeError, ValueError): return None @@ -99,7 +99,7 @@ def _opt_str_list(x: object) -> Optional[list]: if x is None: return None try: - return [str(v) for v in x] # type: ignore[union-attr] + return [str(v) for v in x] # type: ignore[union-attr, attr-defined] except TypeError: return None @@ -454,6 +454,10 @@ def save_molden( if has_vib: try: + # has_vib = bool(frequencies_cm1) and bool(normal_modes) (above), + # so both are truthy here — the assert gives mypy that narrowing + # (it can't see it through the intermediate has_vib flag). + assert frequencies_cm1 is not None _append_molden_vibrations( dest, frequencies_cm1=frequencies_cm1, diff --git a/quantui/session_calc.py b/quantui/session_calc.py index db7c488..8f7e5cd 100644 --- a/quantui/session_calc.py +++ b/quantui/session_calc.py @@ -23,7 +23,7 @@ import logging import sys from dataclasses import dataclass -from typing import IO, Any, List, Optional +from typing import IO, Any, Dict, List, Optional from .molecule import Molecule @@ -140,7 +140,7 @@ def summary(self) -> str: # externally — same pattern as PBE-D3 below. This is D3, not the # original Chai 2008 D2; the empirical dispersion energies differ by # a few percent for most systems but the functional family is the same. -_XC_ALIAS: dict = { +_XC_ALIAS: Dict[str, str] = { "M06-L": "m06l", "wB97X-D": "wb97x", # bare functional; D3 applied via _NEEDS_D3 "CAM-B3LYP": "camb3lyp", diff --git a/quantui/vib_cache.py b/quantui/vib_cache.py index 57a249c..2af574a 100644 --- a/quantui/vib_cache.py +++ b/quantui/vib_cache.py @@ -52,6 +52,7 @@ import logging import os from pathlib import Path +from typing import cast _SCHEMA_VERSION = 1 _LOG = logging.getLogger(__name__) @@ -137,7 +138,10 @@ def get_cached_html( entry = idx["modes"][str(mode_number)] html_path = cache_dir(result_dir) / entry["file"] try: - return html_path.read_text(encoding="utf-8") + # html_path is Any: load_index()'s bare `dict` return (untyped JSON) + # propagates through entry["file"] and the / operator. read_text() + # genuinely returns str; the cast documents that, not a runtime check. + return cast(str, html_path.read_text(encoding="utf-8")) except OSError as exc: _LOG.warning( "Failed to read cached html at %s (%s)", @@ -242,6 +246,9 @@ def _amplitude_matches(saved: object, requested: float) -> bool: if saved is None: return False try: - return abs(float(saved) - requested) < _AMPLITUDE_TOL + # saved is deliberately object (untyped JSON) — float() may reject + # it at runtime for any non-numeric value, which the except below + # already handles; that's the real type check, not this cast. + return abs(float(saved) - requested) < _AMPLITUDE_TOL # type: ignore[arg-type] except (TypeError, ValueError): return False diff --git a/tests/test_ci_hook_reachability.py b/tests/test_ci_hook_reachability.py new file mode 100644 index 0000000..b2e6296 --- /dev/null +++ b/tests/test_ci_hook_reachability.py @@ -0,0 +1,154 @@ +"""M-TYPECHECK TYPE.5 — guard the CLASS of bug, not just today's instance. + +The mypy pre-commit hook was pinned ``stages: [pre-push]`` (kept off every +commit — it's slow), and CI's "Lint & type check" job ran +``pre-commit run --all-files``, which only executes **default-stage** hooks. +So mypy silently never ran: not on commit, not in CI, despite the job name, +the step name, and the branch-protection check all claiming type checking was +happening. Nobody would find this by looking at CI results — a missing job is +visible; a green one that skipped its work is not (see roadmap +42-m-typecheck-restore-type-checking-in-ci). + +Fixed by giving mypy its own explicit CI step (.github/workflows/ci.yml) that +invokes it directly, independent of pre-commit's stage filtering. This test +is the regression guard: it walks every hook in ``.pre-commit-config.yaml`` +and asserts that any hook restricted away from the default stage is +independently invoked somewhere in the CI workflow — so the *next* hook to +acquire an unusual ``stages:`` pin fails a test instead of vanishing from CI +silently, the way mypy did. + +Deliberately dependency-free (no PyYAML import): both config files are simple +enough that line-based parsing is robust and keeps this test from depending +on a package that isn't a direct project dependency. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import List, Optional, Tuple + +_REPO_ROOT = Path(__file__).resolve().parent.parent +_PRECOMMIT_CONFIG = _REPO_ROOT / ".pre-commit-config.yaml" +_CI_WORKFLOW = _REPO_ROOT / ".github" / "workflows" / "ci.yml" + +# Modern pre-commit's default stage is named "pre-commit" (the legacy alias +# "commit" is still accepted). A hook with no `stages:` key — or whose +# `stages:` list includes either spelling — runs under a bare +# `pre-commit run --all-files`, with no separate CI step required. +_DEFAULT_STAGE_NAMES = {"pre-commit", "commit"} + +_HOOK_ID_RE = re.compile(r"^\s*-\s*id:\s*(\S+)") +_NEXT_BLOCK_RE = re.compile(r"^\s*-\s*(id|repo):") +_STAGES_RE = re.compile(r"^\s*stages:\s*\[(.*?)\]") +_RUN_BLOCK_RE = re.compile(r"^(\s*)run:\s*[|>]?\s*(.*)$") + + +def _iter_hooks(config_text: str) -> List[Tuple[str, Optional[List[str]]]]: + """Yield ``(hook_id, stages)`` for each hook block in the pre-commit config. + + ``stages`` is ``None`` when the hook has no ``stages:`` key (i.e. it runs + at the default stage). Line-based, not a full YAML parse — see the module + docstring for why. + """ + lines = config_text.splitlines() + hooks: List[Tuple[str, Optional[List[str]]]] = [] + i = 0 + while i < len(lines): + m = _HOOK_ID_RE.match(lines[i]) + if not m: + i += 1 + continue + hook_id = m.group(1) + stages: Optional[List[str]] = None + j = i + 1 + while j < len(lines) and not _NEXT_BLOCK_RE.match(lines[j]): + sm = _STAGES_RE.match(lines[j]) + if sm: + stages = [s.strip().strip("\"'") for s in sm.group(1).split(",")] + break + j += 1 + hooks.append((hook_id, stages)) + i = j + return hooks + + +def _extract_run_commands(workflow_text: str) -> str: + """Return just the shell command text from every ``run:`` step body. + + Excludes step ``name:`` labels and comments — a step *named* + "Type check (mypy)" with an empty or unrelated ``run:`` body must NOT + count as "mypy is reachable"; only an actually-invoked command should. + Handles both ``run: `` and block-style ``run: |`` / + ``run: >`` followed by more-indented lines. + """ + lines = workflow_text.splitlines() + commands: List[str] = [] + i = 0 + while i < len(lines): + m = _RUN_BLOCK_RE.match(lines[i]) + if not m: + i += 1 + continue + indent, inline = m.group(1), m.group(2) + if inline: + commands.append(inline) + i += 1 + # Block-style body: collect lines indented further than `run:` itself. + while i < len(lines) and ( + lines[i].strip() == "" + or len(lines[i]) - len(lines[i].lstrip()) > len(indent) + ): + stripped = lines[i].strip() + if stripped and not stripped.startswith("#"): + commands.append(stripped) + i += 1 + return "\n".join(commands) + + +def test_config_files_exist_and_parse(): + """Sanity check for the two files the reachability test below depends on.""" + assert _PRECOMMIT_CONFIG.exists() + assert _CI_WORKFLOW.exists() + hooks = _iter_hooks(_PRECOMMIT_CONFIG.read_text(encoding="utf-8")) + assert hooks, "no hooks found — the line-based parser may be broken" + # mypy must currently be present and pinned to a non-default stage — this + # guards the parser itself against silently matching nothing. + mypy_stages = dict(hooks).get("mypy") + assert mypy_stages == ["pre-push"], ( + "expected the mypy hook to still be stages: [pre-push]; if this " + "changed, review whether the explicit mypy CI step below is still " + "needed and update this assertion" + ) + + +def test_every_non_default_stage_hook_is_reachable_from_ci(): + """A hook pinned away from the default stage must be independently + invoked in CI — never left to rely on the stage-filtered + ``pre-commit run --all-files`` alone (exactly the bug that hid mypy from + CI for months).""" + config_text = _PRECOMMIT_CONFIG.read_text(encoding="utf-8") + run_commands = _extract_run_commands(_CI_WORKFLOW.read_text(encoding="utf-8")) + + unreachable = [] + for hook_id, stages in _iter_hooks(config_text): + if stages is None or any(s in _DEFAULT_STAGE_NAMES for s in stages): + continue # reached by the default `pre-commit run --all-files` + # Restricted to a non-default stage: require the tool to appear as an + # actually-invoked command in CI (not a step `name:` label, not a + # comment, not merely inside the stage-filtered + # `pre-commit run --all-files` line, which is what silently skipped + # mypy for months). + pattern = re.compile(rf"(?m)^(?!.*pre-commit run).*\b{re.escape(hook_id)}\b") + if not pattern.search(run_commands): + unreachable.append((hook_id, stages)) + + assert not unreachable, ( + "Hook(s) pinned to a non-default stage but never independently " + f"invoked in .github/workflows/ci.yml: {unreachable}. " + "`pre-commit run --all-files` silently skips stage-filtered hooks — " + "this is exactly what hid mypy from CI (M-TYPECHECK). Add an " + "explicit CI step that runs the tool directly, or fold it into an " + "explicit `--hook-stage ` invocation that isn't just the bare " + "`pre-commit run --all-files`." + )