refactor(source-control): make the babysit JSON predicates TypeGuards, deleting 15 casts - #3892
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Claude finished @kyle-sexton's task in 2m 22s —— View job CI code review
Scope: Findings: none. I traced every Everything in the "Verification" section of the PR body (pyright counts, mutation One thing double-checked because it looked like a possible version-floor risk but No inline comments posted — no findings cleared the high-signal bar for this lane. |
|
Claude finished @kyle-sexton's task in 1m 9s —— View job Security review complete
No security issues found. This PR is a pure typing refactor of
Every replaced No findings to report. |
|
Last security-reviewed head: |
|
Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
93a6ef9 to
0870f1f
Compare
`is_json_object` and `is_json_array` returned plain booleans, so a true result told the type checker nothing and every caller that acted on it had to restate the fact with a cast. Return `TypeGuard` instead and drop the casts the narrowing makes redundant. Each guard asserts only what its runtime check establishes: `isinstance(value, dict)` earns `dict[Any, Any]`, not `dict[str, Any]` (the check never inspects a key), and `isinstance(value, list)` earns `list[Any]` (no element is inspected). That is strictly weaker than the `cast(dict[str, Any], ...)` it replaces, which asserted string keys with nothing behind it. No runtime behavior changes: both predicates test the same conditions and return the same values. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
`babysit_util.py` mapped to no test suite, which scripts/affected-tests.sh reports as an error rather than an empty selection. The predicates are now TypeGuards, so their runtime side is what the compiler trusts; pin it. Mutation proof, each mutant caught by this module: is_json_object also accepts a list -> 3 cases fail is_json_array rejects an empty list -> 1 case fails is_json_object rejects an empty dict -> 1 case fails dig passes a non-dict level through -> 1 case fails Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
0870f1f to
71fc898
Compare
Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
Closes #3448
Summary
babysit_util.is_json_objectandis_json_arrayreturned a plainbool. A trueresult therefore told the type checker nothing, so every caller that acted on one had
to restate the fact with
cast(dict[str, Any], ...)orcast(list[Any], ...), or withan inline
isinstancetest paired with the same cast. Both predicates now return atyping.TypeGuard, and the casts the narrowing makes redundant are gone.Count correction. The issue estimated "~12 casts in merge/gh"; triage counted 17
cast sites in the merge module. The real number deleted is 15: 14 in
babysit_merge.pyand 1 inbabysit_util.dig.babysit_gh.pycontains nocastatall, so nothing was deleted there, which matches the triage comment's own correction.
Three casts remain in
babysit_merge.py(lines 208, 968, 1088); none of them restatesa predicate result, so none is in scope. Ten inline
isinstancetests that existedonly to pair with a cast now call the shared predicate instead.
Fix
What each guard's runtime check validates, versus what it asserts. This is the
whole risk of the change: once a predicate is a
TypeGuard, the compiler trusts it andstops warning about the thing the cast made visible. Neither guard asserts more than
its own check earns.
is_json_objectisinstance(value, dict)TypeGuard[dict[Any, Any]]dict[str, Any].is_json_arrayisinstance(value, list)TypeGuard[list[Any]]Anyelement type asserts.Both guards are weaker than the casts they replace only in what they assert: every
deleted
cast(dict[str, Any], ...)asserted string keys with nothing behind it, and theguard that replaces it asserts only dict-ness. That is not a type-system barrier, though,
because
dict[Any, Any]is gradually assignable todict[str, Any]. Concretely, atbabysit_merge.py:552, therefthatis_json_objectnarrows flows straight into_ref_repo(ref: dict[str, Any])and pyright raises no complaint. That is harmless here,since
refwasAnybefore this change, but it means the weaker assertion does notblock a stronger downstream use the way an actual type-system boundary would. No call
site needed
dict[str, Any]for its own purposes: they all index with string literals,which
dict[Any, Any]already permits.TypeGuard(notTypeIs) is used on purpose,so the negative branch is left unnarrowed.
The stronger argument for safety is runtime, not the type checker:
castreturns itsargument unchanged, it is a runtime identity with no effect of its own, and the 10
inline
isinstancetests this PR swapped for the shared predicate run the same checkthe predicate now runs. So the runtime behavior at every deleted-cast site is provably
byte-identical to before the change, regardless of what pyright or any other type
checker reports.
Each deletion was checked for guard dominance at the site rather than pattern-matched on
the cast's presence: the guarded-
if-then-return/continuesites (repository_default_branch,head_committed_at, thebranch_rulesloop,dig) dominate their uses by early exit; theconditional-expression sites (
unresolved_threads,pr/data/params/required_context_list) narrow inside the true arm only; and the one comprehension sitenarrows the element expression from its own
ifclause.One collateral fix: at
babysit_merge.py:557the sharper narrowing made pyright seeint(number)receiveAny | None. Theexcept (TypeError, ValueError)around that callwas already the real validation, so
numberis annotatedAnyto say the shape isdecided there. No behavior change.
Test coverage added.
babysit_util.pymapped to no test suite, whichscripts/affected-tests.shtreats as an error rather than an empty selection. A newtests/test_babysit_util.py(12 cases) pins the runtime side of the two guards and ofdig.Version chain:
source-control0.55.61 -> 0.55.62 plus the CHANGELOG entry. 0.55.62 wasconfirmed free against current
main(1b68186, itself 0.55.61) and against every openPR that bumps this plugin (#3873 claims 0.55.61, #3871 0.55.60, #3774 0.55.58, #3740
0.55.54).
Verification
Run in a dedicated worktree at
93a6ef96, branched fromorigin/main1b681862.pyright1.1.408 over the babysit script tree:130 errors -> 92, with zero new diagnostics (
commagainst a baseline takenfrom a clean
git archive origin/mainexport). Over the three files the issue names,babysit_util.py/babysit_merge.py/babysit_gh.py: 12 errors -> 0. The 38errors that disappear are
reportOptionalMemberAccesson.get(...)calls thepredicates already guarded at runtime. Note: the repo has no
pyproject.tomland nochecked-in pyright config, so this is pyright's default (
basic) mode and is not aCI gate here; it is the bundled python ecosystem's
check-cmdtool.bool, errors mustreturn at the deleted cast sites):
is_json_array->bool: 2 errors return atbabysit_merge.py:980(
reportOptionalIterable,reportGeneralTypeIssues), which is exactly the use ofrequired_context_list, whosecast(list[Any], ...)this PR deletes.is_json_object->bool: 8 errors return inbabysit_merge.py. One of them,line 202 (
author_object.get("login")), is directly downstream of a deleted cast inunresolved_threads. Honest qualifier: the other 7 are the pre-existing errorsthis PR fixes, not deleted-cast sites, and several deleted casts (the
gh_json-returns-Anysites at 228/759-766) do not produce a compiler error whenthe guard is reverted, because
Anyswallows it. Those casts were ceremony ratherthan compiler-required; removing them is safe but is not proven by this mutation.
is_json_objectalso accepts alist-> 3 cases fail;is_json_arrayrejects an emptylist -> 1 fails;
is_json_objectrejects an empty dict -> 1 fails;digpasses anon-dict level through instead of returning
None-> 1 fails.plugins/source-control/skills/babysit-prs/scripts/engine.test.sh:662 tests, OK (650 before this PR's 12 new cases), plus its ruff pass and all 11
guarded-wrapper behavior checks PASS.
scripts/test_check_contract_clause_coverage.py:24 tests, OK.
bash scripts/affected-tests.sh --run: exit 3 (success: 3 shell suites PASS, 10Python suites SELECTED as
NOT RUNfor a runner it will not guess; those are theengine.test.shtree, run above). No unmapped file remains: addingtests/test_babysit_util.pyclosed the oneUNMAPPEDreport the first run produced.scripts/run-ruff.sh checkon the babysit scripts tree: all checkspassed.
scripts/run-ruff.sh format --checkon all three changed Python files: alreadyformatted.
markdownlint-cli2on the CHANGELOG: 0 issues.93a6ef96:--check,--check-order,--check-bump origin/main,--check-preserved origin/main(237 headings compared).Unmet / out of scope. Nothing in the acceptance criteria is unmet, with one wording
correction recorded above: the criteria say "the repository's configured Python type
checker", and this repo configures none, so pyright-default is reported as the evidence
it is. Sibling issue #3449 (the triplicated guarded-mutation preamble in the babysit
scripts) is untouched. Predicates were not introduced anywhere else in the plugin, and no
merge-module logic changed.
Related
/code-tidying:batch-simplifycloseout (Phase 8)🤖 Generated with Claude Code
https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
Generated by Claude Code