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`."
+ )