ci: add a small per-file slack to the type-check gate

Allow each file to drift PER_FILE_SLACK (5) errors past its recorded count
before failing, so a basedpyright inference ripple in an unrelated file
doesn't break the build over a couple of errors. Budgets still record exact
counts; the tolerance is applied at check time.
This commit is contained in:
mateo-berri 2026-06-14 11:10:06 +00:00
parent d533ed9e26
commit 8adb982f36
No known key found for this signature in database
2 changed files with 24 additions and 7 deletions

View file

@ -24,6 +24,11 @@ PATTERNS: Mapping[str, re.Pattern[str]] = {
"basedpyright": re.compile(r"^\s*(?P<file>.+?):\d+:\d+ - error:"),
}
# Headroom per file on top of its recorded count, so an inference ripple in an
# unrelated file (basedpyright especially) does not fail the build over a couple
# of errors. Small on purpose: a file still fails once it drifts past this.
PER_FILE_SLACK = 5
class Breach(NamedTuple):
file: str
@ -52,11 +57,13 @@ def count_errors(lines: Iterable[str], pattern: re.Pattern[str]) -> dict[str, in
return dict(counts)
def evaluate(counts: Mapping[str, int], budget: Mapping[str, int]) -> list[Breach]:
def evaluate(
counts: Mapping[str, int], budget: Mapping[str, int], slack: int
) -> list[Breach]:
return sorted(
Breach(file, count, budget.get(file, 0))
Breach(file, count, budget.get(file, 0) + slack)
for file, count in counts.items()
if count > budget.get(file, 0)
if count > budget.get(file, 0) + slack
)
@ -74,7 +81,7 @@ def cmd_update(tool: str, counts: Mapping[str, int]) -> None:
def cmd_check(tool: str, counts: Mapping[str, int]) -> None:
budget = json.loads(budget_path(tool).read_text())
breaches = evaluate(counts, budget)
breaches = evaluate(counts, budget, PER_FILE_SLACK)
if not breaches:
print(
f"OK: every file is within its {tool} ceiling ({sum(counts.values())} errors total)"

View file

@ -41,14 +41,24 @@ def test_paths_outside_repo_are_skipped():
def test_at_or_under_ceiling_passes():
assert gate.evaluate({"a.py": 5, "b.py": 3}, {"a.py": 5, "b.py": 10}) == []
assert gate.evaluate({"a.py": 5, "b.py": 3}, {"a.py": 5, "b.py": 10}, 0) == []
def test_one_more_error_than_ceiling_fails():
breaches = gate.evaluate({"a.py": 6}, {"a.py": 5})
breaches = gate.evaluate({"a.py": 6}, {"a.py": 5}, 0)
assert breaches == [gate.Breach("a.py", 6, 5)]
def test_file_absent_from_budget_has_zero_ceiling():
breaches = gate.evaluate({"new.py": 1}, {})
breaches = gate.evaluate({"new.py": 1}, {}, 0)
assert breaches == [gate.Breach("new.py", 1, 0)]
def test_slack_absorbs_small_increase_then_fails_past_it():
assert gate.evaluate({"a.py": 10}, {"a.py": 5}, 5) == []
assert gate.evaluate({"a.py": 11}, {"a.py": 5}, 5) == [gate.Breach("a.py", 11, 10)]
def test_slack_applies_to_new_files_too():
assert gate.evaluate({"new.py": 5}, {}, 5) == []
assert gate.evaluate({"new.py": 6}, {}, 5) == [gate.Breach("new.py", 6, 5)]