Skip to content

fix: small independent contracts + pyright canary (#433, 6C) - #439

Merged
pbean merged 4 commits into
mainfrom
port/small-contracts
Aug 2, 2026
Merged

fix: small independent contracts + pyright canary (#433, 6C)#439
pbean merged 4 commits into
mainfrom
port/small-contracts

Conversation

@pbean

@pbean pbean commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Forward-port of the 0.9.1 hotfix to main, sub-phase 6C. Part of #433.

Three unrelated one-file robustness fixes, landed together because they are independent of the
rest of the program and clear noise from the sub-phases that follow. The other reason they go
first is the pyright canary — see below.

What lands

1. An undecodable policy.toml / config.yaml is reported, not a crash. UnicodeDecodeError
is a ValueError, not an OSError, so a config saved in UTF-16 or latin-1 escaped every
except (PolicyError, OSError) / except BmadConfigError handler in the codebase — and those
handlers exist precisely to degrade to defaults rather than take the process down. It reached
cli._configure_mux, which runs before argument dispatch on every command, and
DashboardScreen.__init__, which runs before the TUI can draw anything: the app died at
construction, not at a keypress. policy.load and bmadconfig.load_paths convert it to their own
typed errors, fixing every handler at once instead of asking each to name a second exception type
it has no other reason to know about.

2. _stories_defaults drops the unreachable ParseError leg. policy.py parses with
tomllib; tomlkit's ParseError has never been reachable from that call. With the conversion
above, (PolicyError, OSError) is total there. The import stays — action_settings catches a
genuine tomlkit ParseError from PolicyDoc.load.

3. diagnose aliases the spec name, and gives one spec one alias. A journal record's spec
field carries the customer's feature name. A bare basename is identifier-shaped, so scrub_json
waved it through verbatim, and the egress backstop could not rescue it — it only repairs
values already in the legend, and an unrouted value never enters one.

spec gets a namespace of its own, not story: the epic lookup is keyed on ns == "story",
so a filename aliased there always misses and renders as an epic-less story-<hex>,
indistinguishable from a story key whose epic could not be resolved.

Routing alone would have been half a fix, and on the path shape strictly worse than today. The
producers disagree on shape — engine.py journals str(spec_path) (absolute), stories_engine.py
journals task.spec_file, which StoryTask persists worktree-relative — so one spec drew two
aliases in a single dump, defeating the correlation these fields are aliased rather than dropped to
preserve, and parked an absolute home path in the local --legend file. _alias_input reduces the
value to its basename first, splitting on both separators, because a journal written on Windows
is routinely diagnosed on POSIX, where PurePath treats a backslash as an ordinary character and
the normalization would silently no-op. The or value fallback covers a trailing separator, whose
empty tail alias() passes through unaliased — the event would render a blank spec and lose its
only reference, with no <redacted:str> marker to show anything was removed.

The pyright canary — the reason 6C is early

policy.py is in pyright's strict list, and none of this code had ever been typechecked:
pyright is configured and gated on main, and was not configured at all on release/0.9.x.
Landing the 8-line strict change here means any 0.9.x→main strict surprise surfaces on 8 lines
rather than confounded with model.py's ~110 in 6I.

Baseline extracted from origin/main before any edit: 0 errors, 0 warnings, 0 informations.
After: 0 / 0 / 0 — zero delta, not zero-compared-to-nothing. The gate was proven to still bite
by appending def _gate_probe(x: int) -> str: return x to policy.py, confirming it reported
(reportUnusedFunction + reportReturnType), and reverting. pyright==1.1.411 is untouched —
its pin comment declares it the single pin keeping CI and local identical, and a forward-port is
not the change that renegotiates a gate.

Ablation matrix — whole suite, both lanes

origin/main baseline: 3929 collected (3901 passed / 24 skipped / 4 pre-existing
test_module_skills_sync failures from local dev-box skill drift, identical on a clean checkout).
This branch: 3938 collected (+9 tests). Every row below was run against the whole suite,
__pycache__ cleared between flips, and the source digest verified byte-identical after each
revert. The 3.14 lane is a separate checkout with its own --all-extras venv, import sentinel
confirmed, and 3938 collected there too.

ablation new reds (3.13) new reds (3.14) which tests
A policy.load decode conversion reverted 4 4 policy unit, validate --json, both TUI
B bmadconfig.load_paths reverted 2 2 bmadconfig unit, validate --json
C "spec": "spec" routing deleted 8 8 3 new + no_canary_leaks + routing_gap_repaired + 3 in test_cli.py
D spec aliased under story 3 3 the namespace fullmatch and both correlation legs
E _alias_input call deleted 2 2 correlation + Windows witness
F [\\/]/ 1 1 Windows witness alone
G or value fallback dropped 1 1 Windows witness alone (the totality assert)
H _stories_defaults narrowed to except OSError 1 1 modal prefill alone
I ParseError restored to the tuple 0 0

Row I is reported, not hidden. Restoring the removed leg reddens nothing on either lane,
because a wider-but-unreachable except tuple is behaviorally identical. Naming which kind of
finding that is: redundant code, not an unpinned claim — no test can distinguish the two
tuples, and one that appeared to would be reading a coincidence. (A single red did appear in row I
on the first 3.13 run, test_decision_modal_scrolls_when_content_long; re-running the whole row
gave 0. It is the known modal-scroll flake, not an effect of the ablation.)

Two rows worth reading because they did not move: test_no_repairs_on_fully_routed_run stays
green under row C — the backstop cannot repair a value that was never routed, which is exactly why
the three new tests exist and why the backstop could not substitute for them. And row C's red in
test_routing_gap_is_repaired_end_to_end was not predicted: it fails with
LEAK after repair: 'AcmeVaultRotation', a second independent canary witness of the same leak.

Test notes

The command-level decode tests go through machine_json, not an rc assertion — cli.main's bare
except Exception backstop already returns 1, so an rc-only test is green with the conversion
reverted.

SPEC_NAME deliberately does not embed STORY_KEY: a name like 1.2-Acme….md would be
rescued by the story key already in the legend, and the fixture could not express the failure.

The Windows witness asserts on strings only, never the filesystem, so it runs unguarded on the
POSIX lanes — that is the only reason the separator divergence is covered at all.

Both TUI tests carry a precondition asserting the same policy text, when decodable, would have
produced a different result — so the degraded-default assertions cannot pass on an inert fixture.

Port adaptations

_findings_by_check does not exist on main; the tests use main's inline
next(f for f in doc["findings"] if …) idiom rather than importing a helper that isn't there.

0.9.x seeds the fixture with journal kind spec-deferrals-harvested, which does not exist on
main
— it arrives with the harvest slice in a later sub-phase. The fixture uses kinds main
actually emits (checkpoint-pause, spec-status-reconciled). The routing is keyed on the field
name, not the kind, so nothing is weakened; the comment naming the producers was re-provenanced to
main accordingly.

Gates

pytest 3910 passed / 24 skipped · pyright 0 errors / 0 warnings / 0 informations
(baseline delta zero) · trunk fmt and full trunk check --no-fix clean · full ablation matrix on
the 3.13 and 3.14 lanes.

The 4 tests/test_module_skills_sync.py failures are pre-existing local dev-box skill drift,
present identically on a clean origin/main, and are not reproduced in CI.

Summary by CodeRabbit

  • Bug Fixes
    • Invalid UTF-8 in policy and configuration files now produces clear validation errors instead of crashes.
    • The TUI falls back to safe default settings when policy files cannot be decoded.
    • Diagnostic output now consistently pseudonymizes specification names across path formats while preserving alias correlation.
  • Tests
    • Added regression coverage for invalid file encoding, TUI fallbacks, and cross-platform diagnostic pseudonymization.

t added 4 commits August 2, 2026 16:27
`UnicodeDecodeError` is a `ValueError`, not an `OSError`, so a `policy.toml`
or `_bmad/bmm/config.yaml` saved in UTF-16 or latin-1 escaped every
`except (PolicyError, OSError)` / `except BmadConfigError` handler in the
codebase — and those handlers exist precisely to degrade to defaults rather
than take the process down. It reached `cli._configure_mux`, which runs
before argument dispatch on every command, and `DashboardScreen.__init__`,
which runs before the TUI can draw anything.

`policy.load` and `bmadconfig.load_paths` convert it to their own typed
errors, which fixes every handler at once instead of asking each to name a
second exception type it has no other reason to know about.

The command-level tests go through `machine_json`, not an rc assertion:
`cli.main`'s bare `except Exception` backstop already returns 1, so an
rc-only test is green with the conversion reverted.

Forward-ported from the 0.9.1 hotfix (`dac373b`).
…433)

`_stories_defaults` caught tomlkit's `ParseError` around a `policy.load`
call, but `policy.py` parses with `tomllib` — the branch has been dead
since it was written. `policy.load`'s new decode conversion makes the
remaining `(PolicyError, OSError)` pair total for that call.

Honest note: no test can distinguish the two tuples, so this is a
redundant-code deletion rather than an unpinned claim — restoring
`ParseError` reddens nothing (measured against the whole suite on both
the 3.13 and 3.14 lanes). The import stays; `action_settings` catches a
genuine tomlkit `ParseError` from `PolicyDoc.load`.
A journal record's `spec` field carries the customer's feature name. A bare
basename is identifier-shaped, so `scrub_json` waved it through verbatim,
and the egress backstop could not rescue it — it only repairs values already
in the legend, and an unrouted value never enters one.

`spec` is routed to an alias namespace of its OWN, not `story`: the epic
lookup is keyed on `ns == "story"`, so a filename aliased there always
misses and renders as an epic-less `story-<hex>` — indistinguishable from a
story key whose epic could not be resolved.

Routing alone would have been half a fix. The producers disagree on shape —
`engine.py` journals `str(spec_path)` (absolute), `stories_engine.py`
journals `task.spec_file`, which `StoryTask` persists worktree-relative — so
one spec drew two aliases in a single dump, defeating the correlation these
fields are aliased rather than dropped to preserve, and parked an absolute
home path in the local `--legend` file. `_alias_input` reduces the value to
its basename first, splitting on BOTH separators because a journal written
on Windows is routinely diagnosed on POSIX, where `PurePath` treats a
backslash as an ordinary character. The `or value` fallback covers a
trailing separator, whose empty tail `alias()` would pass through unaliased.

Forward-ported from the 0.9.1 hotfix (`e3dba25`, `2edd753`).

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

pbean has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The change adds typed handling for undecodable policy and BMAD configuration files. It also adds dedicated, basename-normalized pseudonyms for diagnostic spec values across POSIX and Windows path formats.

Changes

Invalid UTF-8 handling

Layer / File(s) Summary
Typed decoding errors and fallback coverage
src/bmad_loop/bmadconfig.py, src/bmad_loop/policy.py, src/bmad_loop/tui/..., tests/test_bmadconfig.py, tests/test_policy.py, tests/test_cli.py, tests/test_tui_app.py, CHANGELOG.md
Loaders now raise typed errors for invalid UTF-8. Validation reports structured findings, and TUI startup uses fallback defaults.

Diagnostic spec pseudonymization

Layer / File(s) Summary
Normalized spec aliases
src/bmad_loop/diagnostics.py, tests/test_diagnostics.py, CHANGELOG.md
Spec values use a separate alias namespace. Paths normalize to basenames across separator styles, with consistent aliases for equivalent representations.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

Suggested reviewers: dracic

Poem

A bunny reads bytes in the moonlit glow,
Bad UTF-8 gets a typed hello.
Specs shed their paths, keep names neat,
One alias follows each basename’s beat.
The TUI defaults, calm and bright.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 65.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title is related to the changes but uses vague wording and does not identify the configuration decoding or diagnostic aliasing fixes. Replace the vague wording with a concise summary of the main fixes, such as configuration decoding errors and diagnostic spec aliasing.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
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 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch port/small-contracts

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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 `@tests/test_policy.py`:
- Around line 326-337: Update test_non_utf8_file_raises_policy_error to use the
project sandbox fixture instead of tmp_path, and create policy.toml beneath
project.project while preserving the existing invalid-UTF-8 bytes and
PolicyError assertion.
🪄 Autofix (Beta)

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: f18a3ef3-932c-47f2-90c5-4266aa600eb3

📥 Commits

Reviewing files that changed from the base of the PR and between 2753dd5 and d33b399.

📒 Files selected for processing (11)
  • CHANGELOG.md
  • src/bmad_loop/bmadconfig.py
  • src/bmad_loop/diagnostics.py
  • src/bmad_loop/policy.py
  • src/bmad_loop/tui/app.py
  • src/bmad_loop/tui/screens/dashboard.py
  • tests/test_bmadconfig.py
  • tests/test_cli.py
  • tests/test_diagnostics.py
  • tests/test_policy.py
  • tests/test_tui_app.py

Comment thread tests/test_policy.py
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.

1 participant