Skip to content

ci(sports): gate that display() returns a bool on every path - #264

Merged
ChuckBuilds merged 3 commits into
mainfrom
ci/sports-display-contract-gate
Aug 10, 2026
Merged

ci(sports): gate that display() returns a bool on every path#264
ChuckBuilds merged 3 commits into
mainfrom
ci/sports-display-contract-gate

Conversation

@ChuckBuilds

@ChuckBuilds ChuckBuilds commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Follow-up to #263 (merged). That fixed the hockey blank-panel bug; this stops it coming back, and closes the biggest of the reasons nothing caught it for six months.

Why a source-level gate

Nothing caught the original for structural reasons, not bad luck:

  • The safety harness calls display() and discards the result, so a wrong return type is invisible to it by construction.
  • Its fixtures deliberately seed games — hockey's harness.json says so explicitly ("one final, one in-progress, and one scheduled game so recent/upcoming render real game cards") — so the empty path the bug lives on is never rendered.
  • Hockey's fixture disables live mode outright, because live can't be fed from mock data. One of the two reported modes was never exercised at all.
  • CI never runs a plugin's own test_*.py — only manifest checks and the harness — so the regression test added in fix(sports): stop an empty mode holding a blank panel for its duration #263 wouldn't have gated anything either.

A source-level check sidesteps every one of those: it needs no data, no display and no core. Run against the migration commit that introduced the drift, it fails — so it would have caught this in February rather than August.

What it checks

Every display() on SportsCore / SportsUpcoming / SportsRecent / SportsLive in a bundled sports.py must return a bool on every path — including the fall-off-the-end path, which is what the original bug's worst case actually was and which a checker that only inspects return nodes would miss entirely.

return super().display(...) is allowed: four plugins delegate their live mode that way and the parent is itself checked. Truthy non-bools are not — the dispatcher branches on result is True / result is False, so return 1 lands in the same "assume success" arm that caused this.

Why it's scoped to sports

25 of 43 plugins return None from display(), and are right to. A clock always has content, so "nothing to show" never arises and the controller's default is correct for them. Only the sports managers have a real no-content state and a dispatcher that reads the return value. Enforcing bools fleet-wide would fail more than half the repo for no benefit.

Verification

  • Against merged main: OK: 9 plugin(s) with a sports.py, all returning a bool from every display() path.
  • Against main before fix(sports): stop an empty mode holding a blank panel for its duration #263: correctly fails hockey and lacrosse and passes the other seven. That run also surfaced a false positive on four plugins' super().display() delegation, which is why that's allowed.
  • scripts/test_check_sports_display_contract.py pins 12 cases, following the test_check_scroll_adoption.py precedent: valid shapes (both-paths return, try/except, super() delegation, raise, unpoliced classes), and every broken shape (bare return, fall-through, no return at all, truthy non-bool, returned variable, if without else, handler that falls through), plus that an unparseable file fails rather than silently passing.
  • Wired into the existing Plugin Structure workflow beside the collision and scroll-adoption gates, with if: always() so one PR surfaces every structural problem at once.

The second commit adds the two new scripts to that workflow's paths filter. Without it a PR editing only the gate would skip the very check it was changing — the workflow already carries that reasoning in a comment about itself.

Still open

The remaining gap is that CI doesn't run plugin test_*.py at all. That can't just be switched on: test_lacrosse_plugin.py currently fails against live ESPN for an out-of-season date range, and several tests skip without a core on PYTHONPATH. Worth doing, but it needs triage first.

There's a companion core-side change in LEDMatrix#447 that makes the harness warn when a mode draws nothing while claiming content. Independent of this one.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

Summary by CodeRabbit

  • Bug Fixes

    • Added validation to detect invalid or incomplete sports display results before release.
    • Improved reporting for syntax errors and contract violations across bundled sports plugins.
  • Tests

    • Added regression coverage for valid and invalid display behaviors, including exception handling, fallback paths, loops, and pattern matching.
    • Automated checks now run when the relevant validation logic or tests change.
    • Validation failures now produce a failing check status for clearer release feedback.

claude added 2 commits August 9, 2026 17:30
Nothing caught the blank-panel bug this PR fixes, and the reasons are
structural rather than bad luck. The safety harness calls display() and
discards the result, so a wrong return type is invisible to it by
construction; its fixtures deliberately seed games, so the empty path
the bug lives on is never rendered; and hockey's fixture disables live
mode outright because it cannot be fed from mock data. CI runs the
harness and manifest checks only -- it never runs a plugin's own
test_*.py -- so the regression test added here would not have gated
anything either.

Add a source-level gate, in the mould of the module-collision and
scroll-adoption checks it now runs beside. It needs no data, no display
and no core, so it sidesteps every one of those limitations, and it
would have failed on the February migration commit that introduced the
drift.

Scoped to the bundled sports.py copies on purpose. 25 of 43 plugins
return None from display() and are right to: a clock always has content,
so "nothing to show" never arises and the controller's default suits
them. Only the sports managers have a real no-content state and a
dispatcher that reads the return value.

`return super().display(...)` is allowed -- four plugins delegate their
live mode that way, and the parent is itself checked. Bare returns,
falling off the end, and truthy non-bools are not: the dispatcher
branches on `result is True`/`is False`, so `return 1` lands in the same
"assume success" arm that caused this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
The paths filter names each checker explicitly, so the two scripts added
in the previous commit would not have triggered it — a PR that edited
only the gate or its tests would skip the very check it was changing.
The workflow file already carries that reasoning in a comment about
itself; this extends it to the new scripts.

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

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0f0491a6-127e-43ea-871e-d60f4e36a391

📥 Commits

Reviewing files that changed from the base of the PR and between fd14aed and 8230d06.

📒 Files selected for processing (2)
  • scripts/check_sports_display_contract.py
  • scripts/test_check_sports_display_contract.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • scripts/check_sports_display_contract.py

📝 Walkthrough

Walkthrough

Adds an AST-based checker for sports plugin display() return contracts, regression tests for valid and invalid control-flow paths, and always-run workflow steps that execute both scripts.

Changes

Sports display contract validation

Layer / File(s) Summary
Display contract analysis
scripts/check_sports_display_contract.py
Defines valid literal boolean returns and approved super().display(...) delegation. Detects invalid returns and implicit None paths across supported control-flow constructs.
Plugin scanning and command-line execution
scripts/check_sports_display_contract.py
Scans selected or all bundled sports plugins, reports parse and read errors, and returns success or failure status.
Regression coverage and workflow integration
scripts/test_check_sports_display_contract.py, .github/workflows/module-collisions.yml
Tests valid, invalid, ignored, and syntax-error cases. The workflow watches the checker and test files and runs both checks even when earlier steps fail.

Estimated code review effort: 4 (Complex) | ~40 minutes

Sequence Diagram(s)

sequenceDiagram
  participant GitHubActions
  participant ContractChecker
  participant SportsPlugin
  participant RegressionTest
  GitHubActions->>ContractChecker: Run display contract check
  ContractChecker->>SportsPlugin: Parse and inspect display()
  SportsPlugin-->>ContractChecker: Return contract violations or clean result
  GitHubActions->>RegressionTest: Run regression suite
  RegressionTest->>ContractChecker: Exercise valid and invalid cases
  ContractChecker-->>RegressionTest: Return diagnostics
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main CI change: enforcing boolean returns from sports plugin display() methods on every path.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ci/sports-display-contract-gate

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codacy-production

codacy-production Bot commented Aug 9, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 86 complexity

Metric Results
Complexity 86

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/check_sports_display_contract.py`:
- Around line 137-149: The return analysis in _check_function() and termination
analysis in _terminates() must be scope-aware: stop traversal at nested
functions and classes, and count only ast.Break nodes whose target is the
specific last loop rather than breaks from inner loops. Update both affected
sites in scripts/check_sports_display_contract.py (lines 137-149 and 117-122)
using a visitor or equivalent parent/loop tracking, and add tests covering a
nested helper with a non-boolean return and an inner-loop break inside while
True.
- Around line 124-128: Update _terminates() so a final ast.Match is considered
terminating only when it contains an unguarded irrefutable ast.MatchAs wildcard
case and every match_case body terminates; otherwise preserve the current
fall-through result. Add a regression case covering a valid display()
implementation with an exhaustive case _ whose body terminates.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0b3dd62d-96be-4a9e-82fe-12c76aec189c

📥 Commits

Reviewing files that changed from the base of the PR and between 3e94ba0 and fd14aed.

📒 Files selected for processing (3)
  • .github/workflows/module-collisions.yml
  • scripts/check_sports_display_contract.py
  • scripts/test_check_sports_display_contract.py

Comment thread scripts/check_sports_display_contract.py Outdated
Comment thread scripts/check_sports_display_contract.py Outdated
Three false positives, all of which would have failed a correct
display() and so invited the gate's removal.

`ast.walk` is a flat traversal of every descendant, so skipping the
nested FunctionDef node when it came round did nothing -- its children
were already queued. A nested helper's `return` was therefore reported
as the outer function's, and an inner loop's `break` counted as breaking
an enclosing `while True`. The comment claiming otherwise was simply
wrong. Recurse manually and refuse to enter nested scopes, and match a
`break` only against the loop it actually binds to.

A trailing `match` was treated as always falling through, on the
reasoning that exhaustiveness could not be told cheaply. It can: an
unguarded `case _` or bare capture is an ast.MatchAs with no
sub-pattern, and if such a case exists and every case body terminates,
the statement cannot fall through. A guard makes even a wildcard
refutable, so that still falls through.

Verified against the real plugins (still clean) and against
hockey-scoreboard's pre-fix sports.py (still 8 violations), so the
loosening has not blunted the check. The gate's own suite grows from 24
checks to 36, covering each new shape in both directions.

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

Copy link
Copy Markdown
Owner Author

Both findings were valid — and both would have failed a correct display(), which is the worst failure mode for a gate. Fixed in 2b9f0a1.

Scope-aware traversal. You're right, and the guard I'd written was worse than nothing: it looked like it handled nested scopes while doing nothing at all.

for node in ast.walk(fn):
    if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node is not fn:
        continue          # <- no-op: walk already queued this node's children

ast.walk is a flat BFS, so skipping the FunctionDef when it comes round doesn't stop the traversal descending into its body. Confirmed against real shapes before touching anything:

nested helper returning None                 -> FALSE POSITIVE
break in an inner loop inside while True     -> FALSE POSITIVE
exhaustive match                             -> FALSE POSITIVE

Now _walk_scope() recurses manually and refuses to enter FunctionDef / AsyncFunctionDef / Lambda / ClassDef, and _breaks_out_of() reuses it with loops as an additional boundary so a break is only matched against the loop it actually binds to.

Match exhaustiveness. My comment said this "cannot be told cheaply", which was just wrong — your MatchAs observation makes it a two-line check. Implemented as you suggested: an irrefutable case is an unguarded ast.MatchAs with no sub-pattern (covering both case _: and a bare capture case other:), and the statement terminates only if such a case exists and every case body terminates. A guard keeps even a wildcard refutable.

Checked the loosening didn't blunt the gate, since three of these changes make it accept more:

  • real plugins: OK: 9 plugin(s) with a sports.py, all returning a bool from every display() path
  • hockey-scoreboard's pre-fix sports.py (the bug this gate exists for): still 8 violations

The suite goes from 24 checks to 36, covering each new shape in both directions — the valid ones now pass, and break that really does escape while True, a match with no irrefutable case, a guarded wildcard, a wildcard whose case falls through, and a nested helper that doesn't excuse a fall-through outer function all still fail.

@ChuckBuilds
ChuckBuilds merged commit 0e93907 into main Aug 10, 2026
3 checks passed
@ChuckBuilds
ChuckBuilds deleted the ci/sports-display-contract-gate branch August 10, 2026 12:59
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