Skip to content

fix: make the mypy gate a ratchet, so it can actually be passed - #664

Merged
johanzander merged 2 commits into
mainfrom
fix/mypy-gate-ratchet
Aug 21, 2026
Merged

fix: make the mypy gate a ratchet, so it can actually be passed#664
johanzander merged 2 commits into
mainfrom
fix/mypy-gate-ratchet

Conversation

@johanzander

@johanzander johanzander commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Scope assessment

Local, not structural. The gate already had one owner — the mypy step — invoked from two places with duplicated inline logic. This consolidates that logic into a single script both callers delegate to, rather than giving it a new owner.

The diff adds no parameter, flag, default-fallback, second construction site, or extra trigger whose job is to route around an ordering/timing/dependency problem. Two alternatives were considered and rejected with reasons recorded in the script header: a committed baseline file (drifts, needs regenerating) and git worktree add for baseline extraction (side effects on a repo shared with live agent sessions).

Every failure path fails closed — unresolvable merge-base, unextractable baseline, and a crashed baseline mypy run all exit 2 rather than reporting a pass.

Summary

  • --follow-imports=silent so mypy errors are attributed to the files under test, not to everything they import
  • A merge-base baseline, so only errors this branch introduces fail the gate
  • CI and quality-check.sh now share one script, so a green local gate and a green CI gate mean the same thing

Root cause

#614 scoped mypy to changed files, intending that "the legacy backlog burns down as files get edited". As written the gate could not be satisfied at all: mypy reports errors in the modules a checked file imports, not only in the file named on the command line. Handing it one core/bess file pulls in ha_api_controller.py and 30 others — so the gate demanded exactly the repo-wide cleanup its own comment ruled out ("Repo-wide is not an option (2914 errors across 191 files)").

Measured on #643's six-line fix: 404 errors across 31 files, none introduced by that branch. No PR touching core/bess could pass. It went unnoticed because #614 merged Aug 19, after the last core/bess PRs (#618, #619) had already landed — #643 was simply the first to hit it.

Fix

scripts/mypy-changed.sh owns the logic; ci.yml and quality-check.sh both call it.

The baseline is computed, not committed — the merge-base tree is extracted with git archive and type-checked on the fly. No generated file to drift or regenerate, and deliberately not git worktree add: this repo is shared with other checkouts and live agent sessions, and a lint gate should not register and remove worktrees as a side effect.

Error signatures drop line numbers, so inserting a line above an existing error does not read as a new one. A file added on this branch has no baseline entry, so new files are still expected to be clean.

Two bugs found while building it, both now pinned by tests:

  • The baseline pass runs from inside the extracted tree, where a relative .venv/bin/mypy no longer resolves. It silently produced an empty baseline, turning all 61 pre-existing errors in one file into "newly introduced". Fixed by resolving mypy to an absolute path, and by checking the baseline run's exit status rather than discarding it.
  • quality-check.sh invoked the script by a cwd-relative path — correct from the repo root, but it meant the gate silently never ran from anywhere else. Now resolved against quality-check.sh's own location.

Test plan

  • ./scripts/quality-check.sh — fast suite 2190 passed / 50 skipped, frontend 125 passed, mypy gate green on its own diff
  • Slow suite: 554 passed, 8 skipped
  • Run against the real Runtime error: Check grid charge state #643 diff: ✅ mypy OK (no new errors; 85 pre-existing in touched files), exit 0 — where the old gate failed it on 404 errors
  • A genuinely new error still fails with exactly one finding
  • Inserting lines at the top of a legacy file (shifting every error's line number) does not manufacture new errors
  • A brand-new untracked file carrying an error fails
  • shellcheck not run — not installed locally

Evidence the test discriminates

  • Reverted: the baseline arm, by pointing the git archive shim at a well-typed source while HEAD keeps the ill-typed one
  • Result: test_a_preexisting_error_does_not_fail_the_gate and test_newly_introduced_type_error_fails_the_gate use the identical HEAD source and differ only in the baseline — so a pass cannot come from mypy never running
  • Separately: the empty-baseline bug was caught by a real run reporting 61 new errors for a comment-only edit, before any test existed for it; test_an_uncomputable_baseline_fails_the_gate now pins the fail-closed behaviour
  • Restored: tree clean

Outcome-level coverage

backend/tests/test_quality_check_mypy_gate.py, rewritten rather than extended — its cases pinned the old "changed files must be clean" semantics. Every guard it had is kept (vacuity, unresolvable ref, spaced paths); the two new ones are the halves that matter now: a pre-existing error must pass, and an uncomputable baseline must fail closed. 6/6.

Documentation

docs/agents/rules.md claimed all code must pass mypy "with zero errors/warnings". That was never true — there is a ~2900-error backlog — and it is now explicitly not the rule. It documents the ratchet instead.

No CHANGELOG.md entry: internal tooling with no user-visible effect, consistent with #656/#654/#651.

Note

This PR proves itself — CI runs the new gate on the diff that changes it. It also unblocks fix/issue-643-grid-charge-read, pushed and waiting on this to land.

🤖 Generated with Claude Code

https://claude.ai/code/session_015EswRHZDV575RZYPBq7L7o

johanzander and others added 2 commits August 21, 2026 06:47
#614 scoped mypy to changed files with the intent that "the legacy
backlog burns down as files get edited". As written it could not be
satisfied: mypy reports errors in the modules a checked file imports,
not only in the file named on the command line. Handing it one
core/bess file pulled in ha_api_controller.py and 30 others, so the
gate demanded exactly the repo-wide cleanup its own comment ruled out.

Measured on #643's six-line fix: 404 errors across 31 files, none of
them introduced by that branch. No PR touching core/bess could pass.
It went unnoticed because #614 merged on Aug 19, after the last
core/bess PRs (#618, #619) had already landed.

Two changes make the gate mean what it intended:

- --follow-imports=silent, so errors are attributed to the files under
  test rather than to whatever they import.
- a baseline comparison against the merge-base, so only NEW error
  signatures fail. Signatures drop line numbers, so inserting a line
  above an existing error is not a new one.

The baseline is computed rather than committed — the merge-base tree is
extracted with `git archive` and type-checked on the fly. No generated
file to drift, and no `git worktree add`: this repo is shared with other
checkouts and live sessions, and a lint gate should not register and
remove worktrees as a side effect.

CI and quality-check.sh now run the same script, so a green local gate
and a green CI gate mean the same thing.

Verified against the real #643 diff: "no new errors; 85 pre-existing in
touched files", exit 0, where the old gate failed it on 404.

The gate's tests are rewritten rather than extended — they pinned the
old "changed files must be clean" semantics. Every guard they had is
kept (vacuity, unresolvable ref, spaced paths) plus the two halves that
matter now: a pre-existing error must pass, and an uncomputable
baseline must fail closed.

rules.md claimed all code must pass mypy with zero errors, which was
never true and is now explicitly not the rule; it documents the ratchet
instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015EswRHZDV575RZYPBq7L7o
@bess-agent

Copy link
Copy Markdown
Collaborator

@claude-bot review

@bess-manager-claude-bot bess-manager-claude-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: fix/mypy-gate-ratchet

Root cause matches evidence. Verified independently: running the old invocation style (mypy --explicit-package-bases --ignore-missing-imports core/bess/ha_api_controller.py, no --follow-imports=silent) against current main produces 305 errors across 28 files from checking a single file — confirming the PR's claim that #614's gate demanded repo-wide cleanup by construction, not by accident.

Verified locally (fresh venv, not just trusting the PR description):

  • .venv/bin/pytest backend/tests/test_quality_check_mypy_gate.py -v → 6/6 pass
  • .venv/bin/pytest -m "not slow" → 2190 passed, 50 skipped
  • black --check . and ruff check . → clean
  • shellcheck scripts/mypy-changed.sh → zero warnings (the one shellcheck hit in quality-check.sh is pre-existing, unrelated line 685, not part of this diff)
  • Ran scripts/mypy-changed.sh .venv/bin/mypy --include-worktree against this branch's own diff → ✅ mypy OK (changed files), confirming the "this PR proves itself" claim in the description

Traced the script logic line-by-line (scripts/mypy-changed.sh):

  • MYPY is resolved to an absolute path before the later cd "$repo_root", so the fix for the "relative .venv/bin/mypy breaks inside the extracted baseline tree" bug is real and correctly ordered.
  • signatures()'s sed (s/^([^:]+):[0-9]+:([0-9]+:)? /\1: /) correctly strips both file:line: and file:line:col: mypy output shapes, verified against both formats.
  • comm -13 diffing is valid because both head_errors and base_errors flow through the same signatures() function, which sorts — a real precondition for comm, satisfied.
  • Baseline exit-status handling (base_status -gt 1 → fail closed with exit 2) correctly distinguishes mypy's "errors found" (1) from a crash/misconfiguration (>1), matching the documented fail-closed intent.
  • New-vs-deleted-file handling is consistent: --diff-filter=d keeps deletions out of changed[] (so mypy is never asked to check a file that no longer exists), and a file with no baseline entry (new on this branch) has all its errors counted as new — matches "new files are expected to be clean."

Architecture/rules compliance: no Optional[x], no hasattr/getattr fallbacks, no new classes, no sensor/API-layer touch, no exception-string matching — none of these apply to this diff (shell + test + docs only), and none are violated.

Tests check behavior, not implementation: the test suite drives the gate through shims (git, mypy) and asserts on the script's observable contract (exit code, stdout messages, error-count deltas) rather than internal script structure — consistent with docs/agents/testing.md. The delta-based pairing (test_newly_introduced_type_error_fails_the_gate vs test_a_preexisting_error_does_not_fail_the_gate, identical HEAD source, only the baseline arm differs) is a sound way to rule out "gate never ran" as an explanation for a pass.

Minimal, no scope creep: touches exactly what's needed — one new script that both ci.yml and quality-check.sh delegate to (eliminating the duplicated inline logic that existed before), the test file rewritten to match the new ratchet semantics, and a one-paragraph doc correction in rules.md that was simply false before this PR (claimed "zero errors" when a ~2900-error backlog already existed).

One process nit, not a blocker: docs/agents/rules.md's Debugging Protocol (step 9) asks a fix's scope assessment (local vs. structural vs. needs-second-opinion) to be stated explicitly in the PR description. This PR doesn't use that literal framing, but the "Fix" section effectively documents the same judgment — consolidating logic into one script both callers delegate to, with explicit rationale for rejecting alternatives (git worktree add, a committed baseline file). Given the reasoning is present, just not labeled, this doesn't rise to a blocking finding.

No rule violations, no correctness bugs found. APPROVE.

@johanzander
johanzander marked this pull request as ready for review August 21, 2026 05:03
@johanzander
johanzander merged commit 0dd7199 into main Aug 21, 2026
8 checks passed
@johanzander
johanzander deleted the fix/mypy-gate-ratchet branch August 21, 2026 10:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants