Skip to content

Fix Culvert Ambusher - #6831

Merged
matthewevans merged 6 commits into
phase-rs:mainfrom
jofortin:card/culvert-ambusher
Aug 3, 2026
Merged

Fix Culvert Ambusher#6831
matthewevans merged 6 commits into
phase-rs:mainfrom
jofortin:card/culvert-ambusher

Conversation

@jofortin

@jofortin jofortin commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes Culvert Ambusher and its class: "When this creature enters or is turned face up, …" parsed to a single TriggerMode::ChangesZone, silently discarding the "or is turned face up" branch with zero Effect::Unimplemented — so the card reported as fully supported while never triggering on its disguise flip. The disjunctive trigger-event splitter's head lexicon knew active-voice verbs plus a few passive/copular heads but had no intransitive state-change family, so every <event> or becomes/is <state> pair lost its second branch. This is docs/parser-misparse-backlog.md root cause 6 ("Disjunctive (or-list) collapsed to first branch") — a silent-wrong-behavior misparse, not a coverage gap. 14 cards, zero new engine surface.

Revised after review. The first version detected the head with a CLOSED allow-list of four complements. A maintainer blocked that correctly: a closed detection list means an unadmitted complement makes the second branch vanish while the card still reports supported — the same silent-wrong signature this PR exists to remove. Detection is now an open becomes|become|is|are <alpha1> shape with the real single-event mode parser as the arbiter; unadmitted complements land on TriggerMode::Unknown, which every coverage authority already reports as unsupported. The hand-maintained support list is deleted, not relocated. Corpus impact of that redesign, differentialled both ways over ~34,470 cards: exactly one card changes and it is a repair (Imprison This Insolent Wretch, a description reconstruction, no coverage transition).

Three guards survive and — unlike the closed-list era — all three now ablate to real measured breakage: blocks or becomes blocked (53 cards), fight or become blocked (1), tied for after is/are (2). A fourth was written and removed for ablating to zero. One exposure is disclosed rather than hidden: a that is <participle> relative clause inside a subject truncates (zero printings; fails toward unsupported with an emitted gap), pinned by a parser row and a coverage row.

CR correction: the earlier head cited CR 603.2e as authorizing the family. That was an over-read — 603.2e says "Some trigger events use the word 'becomes'" — and it is now scoped to transition-vs-persistent-state on the becomes arm only, with parse_open_state_complement citing CR 603.1 + CR 603.2 because it is voice-agnostic.

Files changed

  • crates/engine/src/parser/oracle_trigger.rs — the fix: one scope-limited sibling combinator (parse_state_change_event_start + two voice complements + parse_event_head_start), composed at 2 of the lexicon's 9 consumers; the other 7 left narrow with a named counterexample in place; reconstruction moved onto TextPair; a provably-dead fused-mode guard removed; parse_event_verb_start left byte-identical.
  • crates/engine/src/parser/oracle_trigger_tests.rs — 15 parser rows (T1–T14 plus the CR 508.1f overlap pin).
  • crates/engine/tests/integration/culvert_ambusher_turn_face_up_force_block.rsnew, 4 runtime rows driving the real pipeline.
  • crates/engine/tests/integration/main.rs — the mod line (alphabetical; no new top-level tests/*.rs).
  • docs/parser-misparse-backlog.md — list hygiene for root cause 6.

Track

Non-developer

LLM

Model: claude-opus-5
Tier: Frontier
Thinking: high

Implementation method (required)

Method: /engine-implementer

CR references

CR 603.2e is the authorizing rule for the new family: it names "becomes" as a trigger-event word (its own examples are "becomes attached" / "becomes blocked") and draws the event-vs-state line the allow-list encodes. CR 603.1b authorizes one written ability carrying several trigger conditions, with the representational gap stated explicitly rather than glossed: the parser emits N independent TriggerDefinitions, which 603.1b does not itself license — what makes it equivalent is that the conditions key on disjoint GameEvents (CR 603.2, CR 603.2c), and 603.1b's own subject matter (an "all of those conditions" instruction) appears on zero printed cards.

Complement authorities: CR 701.37a/CR 701.37b (monstrous), CR 701.26a + CR 603.2e (becomes tapped), CR 116.2b + CR 708.7 (turned face up), CR 120.1 (dealt damage). Also cited: CR 106.12a (the is tapped for mana exclusion — a distinct event with its own TapsForMana mode), CR 508.1f (declaring an attacker taps it — the newly-created attacks/becomes-tapped overlap), CR 508.3a, CR 509.1c, CR 509.1h (the fused BlocksOrBecomesBlocked mode), CR 603.1, CR 603.2, CR 603.3d, CR 614.1, CR 702.168a/CR 702.168d (disguise), CR 708.2, CR 708.3, CR 708.8.

Every number was grepped in docs/MagicCompRules.txt before it was written, and each rule's text was read to confirm it describes the annotated code. Two citations were corrected during review: CR 603.2c had been cited for an at-most-one-arm property it does not establish (it says only "An ability triggers only once each time its trigger event occurs"), and CR 603.2g — the prevention/replacement rule — had been leading where CR 120.1 is the authority.

Verification

  • Required checks ran clean, or the exact CI-owned alternative is stated below.

  • Gate A output below is for the current committed head.

  • Final review-impl below is clean for the current committed head.

  • Both anchors cite existing analogous code at the same seam.

  • cargo fmt --all --check — clean.

  • cargo clippy-strict (clippy --all-targets -- -D warnings, whole workspace) — clean, 0 warnings.

  • cargo test -p phase-engineok. 18299 passed; 0 failed; 6 ignored (lib) · ok. 4383 passed; 0 failed; 2 ignored (integration) — figures are post-merge with 72 upstream commits; the change itself measured 18022 + 4217 before the merge · ok. 8 passed and ok. 9 passed (bin unit tests).

  • ./scripts/check-parser-combinators.sh — see Gate A. No // allow-noncombinator anywhere in this change.

  • ./scripts/check-prelowered-ratchet.sh — Gate P PASS (no producer count increased). Its 5 "below ceiling" advisories are pre-existing and in files this change does not touch.

  • cargo coverage / cargo semantic-audit / ./scripts/gen-card-data.shCI-owned: ./scripts/setup.sh --agent needs jq and pnpm, neither installable in this environment, so client/public/card-data.json cannot be generated here. Substituted by whole-corpus parser differentials (below), taken in both directions by three independent agents over 35 458 Oracle-bearing cards.

Note

A caveat worth landing in the repo's own tooling knowledge. ./scripts/check-parser-combinators.sh with no argument sets BASE=$(git merge-base origin/main HEAD). On an uncommitted branch that equals HEAD, which flips the script into DIFF_MODE=--cached and — with nothing staged — prints a vacuous Gate A PASS. The Gate A output below was taken after the final commit, where head != base, and I confirmed it actually scans oracle_trigger.rs. Anyone running Gate A pre-commit should pass an explicit base (./scripts/check-parser-combinators.sh HEAD).

Revert-discrimination, measured per seam over the full suite (not asserted — each revert was applied, the suite re-run, and the file restored byte-identically):

Revert Result
or-split gate alone (parse_event_head_start → narrow) 7 lib + 2 integration red, including both runtime rows at their reach-guards
subject terminator alone (extract_subject_text → narrow) 3 red — Cryoshatter, subject-span, serial-leading-leg
subject recase alone (TextPair original → lower) 1 red — the capital-D description assertion
admitting blocked to the allow-list 3 red — the two fused-mode behavioral rows plus the closure test
whole production change reverted RT1 + RT3 red at "a turn-face-up trigger must have gone on the stack and asked for a target" — the original bug reproducing at runtime

entering_tapped_does_not_fire_the_becomes_tapped_arm pins CR 603.2e and was proven non-vacuous by injecting GameEvent::PermanentTapped at the ETB tap site in game/zone_pipeline.rs: exactly that test went red, the other three stayed green, and the file was restored byte-identically.

Gate A

Gate A PASS head=5bef81ee7c66d402d06e094267868b7a72eb9c95 base=6719cc549f2850e396e4801189f2479b7064673a

Anchored on

  • crates/engine/src/parser/oracle_trigger.rs:7748parse_event_verb_start, the existing event-head lexicon this change siblings: same nested alt() of parse_event_word/parse_event_phrase groups, same module, same OracleResult<'_, ()> shape, same CR-annotated per-group style. The new family is modelled on it directly, and it is left byte-identical.
  • crates/engine/src/parser/oracle_trigger.rs:7595split_serial_event_compound, the sibling splitter that already establishes the lockstep original/lowercase reconstruction idiom, its comment stating the house rule verbatim: "Split the original and lowercase forms in lockstep on the same ASCII delimiters rather than slicing condition with byte offsets taken from cond_lower." That is the precedent the TextPair reconstruction follows.
  • crates/engine/src/parser/oracle_util.rs:25TextPair, named in CLAUDE.md as the mandated replacement for manually computing &text[prefix.len()..]; split_trigger already builds one over this same pair of strings.

Final review-impl

Final review-impl PASS head=5bef81ee7c66d402d06e094267868b7a72eb9c95

Provenance of that line, so it is auditable rather than taken on trust. The full
independent read-only pass ran against b7e56d5 and returned no BLOCKER and no MAJOR,
verdict "ready to open as a pull request", with exactly one MINOR: a comment in
split_or_event_compound overclaimed that every span is "taken at ONE position valid
in both" views of the TextPair, when the offset is in fact still derived from the
lowercase view — which the sibling split_serial_event_compound documents as the thing
to avoid. b7f52a8 is b7e56d5 with only that comment rewritten to the reviewer's
own suggested wording (git diff b7e56d5 b7f52a8 = 10 insertions / 5 deletions, zero
non-comment lines), and a further SHA-matching confirmation pass against b7f52a8
returned zero findings, having re-derived Gate A itself and checked each of the
reword's four claims against the code: TextPair::new's debug_assert_eq! byte-length
parity (oracle_util.rs:32, plus a second lowercase-equality assert and a
is_char_boundary assert in split_at — so the comment now claims less than the code
guarantees), pos genuinely lowercase-derived, split_serial_event_compound's stricter
rule present verbatim with its İ/ rationale, and the replaced raw
condition[..pos] slicing confirmed present at the base commit. Every gate above was
re-run at b7f52a8.

Four rounds of independent, context-isolated review ran (three plan rounds, then implementation rounds). The plan loop was substantive, not ceremonial — it changed the design three times:

  1. Round 1 (BLOCKER). A proposed widening of the serial-leg gates was dead code: find_effect_boundary truncates the condition before the serial splitter runs, so a new-family leg could never reach those gates. The widening and the test asserting it were dropped.
  2. Round 2 (MAJOR). The reviewer proved the shipped doc comment asserted the opposite of the truth: the serial branch gates only its trailing legs, so a leading state-change leg does reach the widened subject terminator — and the change silently converts a pre-existing duplicate-mode triple-fire ("Whenever ~ becomes tapped, attacks, or dies"[Taps, Taps, Taps] with descriptions like "becomes tapped attacks", a CR 603.2c hazard) into the correct [Taps, Attacks, ChangesZone]. That capability is now claimed and pinned rather than shipped unclaimed under a comment denying it.
  3. Implementation rounds. Raw cross-case byte slicing plus a bespoke recase_span helper was replaced with the TextPair building block; ~30 stale line-number anchors were removed in favour of function names; a false unreachability invariant on the capitalize_first fallback was corrected (parse_trigger_lines is pub with five non-stripping production callers); two CR citations were fixed; and a provably-dead is_existing_compound_mode guard was deleted once measurement showed dropping it reds 0 of 18 015 tests while admitting blocked to the allow-list reds 3 — the constraint is pinned at the seam a future widener actually edits.

My own starting design was also refuted on evidence and is not what shipped: I proposed a becomes/become/is/are × complement cross-product, which is unsound because the voices have disjoint complements (is monstrous and is tapped are states; is tapped for mana is a different event). It became two voice-scoped productions. I had also identified 2 consumers of the lexicon; there are 9, across 5 functions.

Claimed parse impact

14 cards. Each gains a correct second TriggerDefinition sharing the effect body; nothing else in the corpus changes. Verified in both directions by three independent agents (whole-corpus differential over 35 458 Oracle-bearing cards; plus an independent Scryfall class enumeration that partitions 21 matching cards into exactly these 14 plus 3 As … enters replacements, 3 "becomes tapped or a player activates", and Illusionary Mask).

  • is turned face upTurnFaceUp: Culvert Ambusher, Case of the Pilfered Proof, Concert Kaboomist, Efreet Weaponmaster, Gadget Technician, Offender at Large, Ponyback Brigade, Rakish Scoundrel
  • becomes monstrousBecomeMonstrous: Alpha Deathclaw, Protector of the Wastes
  • becomes tappedTaps: Champions of the Shoal, A-Radha Coalition Warlord, A-Zar Ojanen Scion of Efrava
  • is dealt damageDamageReceived: Cryoshatter

Two cards additionally change display text only (description recasing from the TextPair fix, no mode/filter delta): Millicent Restless Revenant (spiritSpirit), Acornelia Fashionable Filcher (squirrelSquirrel).

Coverage honesty. No line is newly accepted — all 14 were already accepted with half of each silently dropped; this recovers the dropped half. All four destination modes pre-exist, are in the matcher registry, are indexed, and have live event producers, so both halves of every split lower to a fully modelled trigger. No new Effect::Unimplemented was needed and none was introduced. Concert Kaboomist keeps its pre-existing where_x_binding gap on both halves and stays honestly red under root cause 5.

Deliberately not claimed, still red: trailing/middle-leg serial generalization ("Whenever ~ enters, attacks, or becomes tapped" still yields one trigger carrying Effect::Unimplemented); Neyith of the Dire Hunt's become blocked branch; Donna Noble; Giant Oyster / Merieke Ri Berit / Tawnos's Coffin / The Pandorica / Coffin Queen (their becomes untapped disjunction sits inside an activated-ability line that routes elsewhere); the is tapped for mana family (excluded per CR 106.12a).

Disclosed follow-up (owed refactor, recorded in-code, not attempted here). The allow-list is a detection list, so an unadmitted complement still makes a second branch vanish with no marker — measured: "enters or is turned face down" yields one trigger and reports as supported, the same silent-wrong signature as the bug being fixed. This change strictly shrinks that pre-existing hole (0 printed cards need the unadmitted shapes), but the stronger long-term seam is to detect the open head shape (becomes|is|are <participle>) and route unadmitted complements to Effect::unimplemented, demoting the list from "what we recognize" to "what we support". That is also a better consolidation trigger than a lexicon count. It needs a design at the splitter's Option<Vec<String>> re-parse boundary, not a widened alt().

Scope Expansion

None.

Validation Failures

None.

CI Failures

One, and it is pre-existing on main — not from this change. Frontend (lint, type-check, test) fails at
client/src/game/controllers/__tests__/aiController.test.ts:138
"re-queries after the dispatch layer returns the engine's tagged stale outcome without
fabricating an action"
, expected "vi.fn()" to be called 3 times, but got 4 times.

Evidence it is not mine:

  • git diff 6719cc5 9433ca3 --name-only touches zero client/ files (5 files: 2
    parser, 2 engine tests, 1 doc).
  • main fails the identical test with the identical assertion at the same line
    — run 30602816316 (main @ 03:59) and the run before it. main was green through
    01:59, so this broke on main between 01:59 and 03:18.

Every other check is green, including all Rust jobs, Card data (generate, validate, coverage), WASM, Tauri, and the lobby worker.

…in disjunctive splits

Fix Culvert Ambusher, and its class.

"When this creature enters or is turned face up, target creature blocks this turn
if able." parsed to a single `TriggerMode::ChangesZone`. The "or is turned face up"
branch was silently discarded with ZERO `Effect::Unimplemented`, so the card
reported as fully supported while never triggering on its disguise flip — a
silent-wrong-behavior misparse (docs/parser-misparse-backlog.md root cause 6,
"Disjunctive (or-list) collapsed to first branch"), not a coverage gap.

The seam is the event-head lexicon that `split_or_event_compound` consults to decide
whether " or " joins two events. It knew active-voice/transitive verbs plus a few
passive and copular heads, but had no intransitive state-change family — so every
`<event> or becomes/is <state>` pair lost its second branch.

Adds one scope-limited sibling combinator with a CLOSED allow-list of four
complements, each with a probe-verified standalone mode, so both halves of a split
land on fully supported ground:

    becomes/become monstrous -> TriggerMode::BecomeMonstrous   (CR 701.37a-b)
    becomes/become tapped    -> TriggerMode::Taps              (CR 701.26a + CR 603.2e)
    is/are turned face up    -> TriggerMode::TurnFaceUp        (CR 116.2b + CR 708.7)
    is/are dealt damage      -> TriggerMode::DamageReceived    (CR 120.1)

CR 603.2e is the authorizing rule: it names "becomes" as a trigger-event word and
draws the event-vs-state line the allow-list encodes. Two productions, one per
voice — not a head x complement product, because the voices have disjoint
complements ("is monstrous" and "is tapped" are states; "is tapped for mana" is a
different event, CR 106.12a).

ZERO new engine surface. All four modes already exist, are in the matcher registry,
are indexed, and have live event producers. `types/` is untouched.

The lexicon has NINE consumers across five functions. The new heads are composed at
exactly TWO — the or-split gate and `extract_subject_text` — and the other seven stay
narrow, each with a named counterexample in place. `extract_subject_text` MUST be
widened: Cryoshatter ("becomes tapped or is dealt damage") has a new-family head in
its FIRST half, and a narrow terminator reconstructs "When enchanted creature becomes
tapped is dealt damage". Widening the cross-subject gate instead would split subject
disjunctions as event disjunctions (Donna Noble, The Bus Runner) — pinned by test.

`parse_event_verb_start` is left byte-identical.

Also fixed, as a consequence: a serial list whose LEADING leg is state-change used to
reconstruct duplicate-mode garbage — "Whenever ~ becomes tapped, attacks, or dies"
gave [Taps, Taps, Taps] with descriptions like "becomes tapped attacks", a CR 603.2c
triple-fire. It now gives [Taps, Attacks, ChangesZone]. Claimed and pinned rather
than shipped unclaimed. Trailing/middle-leg legs remain honestly `Unimplemented`.

Reconstruction runs on `TextPair` rather than slicing the original-case string with
offsets taken from the lowercase one, so the second half keeps its subject's casing
("Whenever a Detective you control is turned face up") instead of lowercasing display
text that flows to card-data `parse_details`.

Impact, measured by whole-corpus differential in both directions: exactly 14 cards
change and nothing else — Culvert Ambusher, Case of the Pilfered Proof, Concert
Kaboomist, Efreet Weaponmaster, Gadget Technician, Offender at Large, Ponyback
Brigade, Rakish Scoundrel, Alpha Deathclaw, Protector of the Wastes, Champions of the
Shoal, Cryoshatter, A-Radha, A-Zar Ojanen. Concert Kaboomist keeps its pre-existing
`where_x_binding` gap on both halves — honestly still red.

Coverage honesty: no line is newly ACCEPTED. All 14 were already accepted with half
of each silently dropped; this recovers the dropped half. Recorded as an owed
refactor at the allow-list: it is a DETECTION list, so an unadmitted complement still
makes a second branch vanish with no marker; the stronger long-term seam is to detect
the open `becomes|is|are <participle>` shape and route unadmitted complements to
`Effect::unimplemented`.

Tests: 15 parser rows + 4 runtime rows. The runtime rows drive the real pipeline
(`PlayFaceDown` -> `TurnFaceUp` -> trigger target choice -> `MustBlock`) and red at
their reach-guards when the production change is reverted. Revert-to-red measured per
seam over the full suite: or-split gate alone -> 7 lib + 2 integration; subject
terminator alone -> 3; subject recase alone -> 1; admitting `blocked` -> 3.
`entering_tapped_does_not_fire_the_becomes_tapped_arm` pins CR 603.2e and was proven
non-vacuous by injecting `PermanentTapped` at the ETB tap site.

Backlog hygiene: 7 cards removed from root cause 6 (239), totals 4732->4725 and
4766->4759, and the ranked table's stale 247 corrected to match.

Verification: cargo fmt clean, clippy-strict 0 warnings, 18015 lib + 4214 integration
passing, Gate A/G PASS, Gate P PASS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jofortin
jofortin requested a review from matthewevans as a code owner July 31, 2026 04:12
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The parser now recognizes selected state-change and passive event heads in disjunctive trigger compounds. Tests cover supported and unsupported forms, exclusions, subject extraction, serial reconstruction, replacement effects, and runtime behavior.

Changes

Trigger parsing and validation

Layer / File(s) Summary
State-change event-head parsing
crates/engine/src/parser/oracle_trigger.rs
Two-way trigger splitting recognizes selected becomes, become, is, and are event heads. TextPair preserves original casing during lowercase matching. Existing compound exclusions and narrow serial parsing remain in place.
Parser regression coverage
crates/engine/src/parser/oracle_trigger_tests.rs
Tests cover supported and unsupported event heads, explicit Unknown arms, subject boundaries, filters, precedence, replacement effects, ability words, and serial reconstruction.
Runtime validation and backlog updates
crates/engine/tests/integration/culvert_ambusher_turn_face_up_force_block.rs, crates/engine/tests/integration/disjunctive_state_change_head_coverage_honesty.rs, crates/engine/tests/integration/main.rs, docs/parser-misparse-backlog.md
Integration tests validate Culvert Ambusher trigger selection, coverage gaps, and modeled state-change events. The backlog updates root-cause counts and card entries.

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

Possibly related PRs

  • phase-rs/phase#6658: Both changes modify disjunctive and state-change trigger parsing in oracle_trigger.rs.

Suggested reviewers: matthewevans

Sequence Diagram(s)

sequenceDiagram
  participant OracleText
  participant OracleTriggerParser
  participant TriggerCondition
  participant GameState
  OracleText->>OracleTriggerParser: parse disjunctive trigger text
  OracleTriggerParser->>TriggerCondition: match supported or unsupported event heads
  TriggerCondition-->>OracleTriggerParser: return trigger arms and coverage gaps
  OracleTriggerParser->>GameState: register parsed trigger conditions
  GameState-->>OracleTriggerParser: execute applicable trigger arm
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title identifies the primary card behavior fixed by the parser changes and is specific to the pull request.
✨ Finishing Touches 💡 1
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch card/culvert-ambusher
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

@github-actions github-actions Bot added the needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps) label Jul 31, 2026

@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: 5

🤖 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 `@crates/engine/src/parser/oracle_trigger_tests.rs`:
- Around line 21904-21918: Add positive reach-guard assertions to the Donna
Noble case in cross_subject_state_change_or_not_split: after confirming one
trigger, assert TriggerMode::DamageReceived and validate that the trigger’s
subject filter has the expected Or shape. Preserve the existing Bus Runner
assertions and ensure the Donna Noble checks prove the input reached
split_cross_subject_event_compound rather than merely producing one trigger.

In `@crates/engine/src/parser/oracle_trigger.rs`:
- Around line 7960-7968: Make the silent-vanish refactor discoverable outside
the doc comment near parse_event_head_start by adding either a concrete issue
reference or an ignored test documenting that unadmitted complements, such as
“enters or is turned face down,” must produce Effect::Unimplemented. If adding a
test, mark it #[ignore] and capture the expected open-head-shape behavior for
becomes|is|are <participle> without changing current parsing behavior.
- Around line 7800-7805: Update the call to append_shared_object_if_bare_event
in the surrounding parser logic to pass the trimmed second_half.lower value,
ensuring its offset aligns with the trimmed second_event and object extraction
remains correct.

In
`@crates/engine/tests/integration/culvert_ambusher_turn_face_up_force_block.rs`:
- Around line 24-44: Update must_block_targets so a MustBlock effect with any
affected TargetFilter other than SpecificObject causes an immediate panic
instead of being discarded. Preserve returning the object ID for SpecificObject
variants and keep the existing filtering of effects without MustBlock
modifications.

In `@docs/parser-misparse-backlog.md`:
- Around line 6-7: Reconcile the counts in docs/parser-misparse-backlog.md
before merging: recalculate the table totals, list-entry totals, and heading
counts from the underlying entries, then update the distinct-card and
total-appearance figures and all inconsistent root-cause counts, including root
causes 1, 2, 3, 5, 19, 22, 27, 28, 30, and 31. Preserve root cause 6 at 239 if
the recalculation confirms it.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 54045478-e22b-4c51-bce8-083927ab1e62

📥 Commits

Reviewing files that changed from the base of the PR and between 73fd7f6 and b7f52a8.

📒 Files selected for processing (5)
  • crates/engine/src/parser/oracle_trigger.rs
  • crates/engine/src/parser/oracle_trigger_tests.rs
  • crates/engine/tests/integration/culvert_ambusher_turn_face_up_force_block.rs
  • crates/engine/tests/integration/main.rs
  • docs/parser-misparse-backlog.md

Comment thread crates/engine/src/parser/oracle_trigger_tests.rs
Comment thread crates/engine/src/parser/oracle_trigger.rs
Comment thread crates/engine/src/parser/oracle_trigger.rs Outdated
Comment thread docs/parser-misparse-backlog.md Outdated
@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown

Generated for head fb79206253d8e51b524a911ac71fcf76a4d8ff05.

Parse changes introduced by this PR · 17 card(s), 20 signature(s) (baseline: main 409956671675)

🟢 Added (13 signatures)

  • 11 cards · ➕ trigger/ChangesZone · added: ChangesZone (active in=battlefield, to=battlefield, watches=self)
    • Affected (first 3): A-Radha, Coalition Warlord, A-Zar Ojanen, Scion of Efrava, Alpha Deathclaw (+8 more)
  • 6 cards · ➕ trigger/TurnFaceUp · added: TurnFaceUp (active in=battlefield, watches=self)
    • Affected (first 3): Culvert Ambusher, Efreet Weaponmaster, Gadget Technician (+3 more)
  • 3 cards · ➕ trigger/Taps · added: Taps (active in=battlefield, watches=self)
    • Affected (first 3): A-Radha, Coalition Warlord, A-Zar Ojanen, Scion of Efrava, Champions of the Shoal
  • 2 cards · ➕ trigger/BecomeMonstrous · added: BecomeMonstrous (active in=battlefield, watches=self)
    • Affected (first 3): Alpha Deathclaw, Protector of the Wastes
  • 1 card · ➕ trigger/ChangesZone · added: ChangesZone (active in=battlefield, from=battlefield, to=graveyard, watches=you control Squirrel)
    • Affected (first 3): Acornelia, Fashionable Filcher
  • 1 card · ➕ trigger/ChangesZone · added: ChangesZone (active in=battlefield, to=battlefield, valid target=player, watches=self)
    • Affected (first 3): Concert Kaboomist
  • 1 card · ➕ trigger/ChangesZone · added: ChangesZone (active in=battlefield, to=battlefield, watches=you control Detective)
    • Affected (first 3): Case of the Pilfered Proof
  • 1 card · ➕ trigger/DamageDone · added: DamageDone (active in=battlefield, damage kind=combat only, valid source=self or nontoken another you control Spirit, valid target=player)
    • Affected (first 3): Millicent, Restless Revenant
  • 1 card · ➕ trigger/DamageReceived · added: DamageReceived (active in=battlefield, watches=attached permanent)
    • Affected (first 3): Cryoshatter
  • 1 card · ➕ trigger/Taps · added: Taps (active in=battlefield, watches=attached permanent)
    • Affected (first 3): Cryoshatter
  • 1 card · ➕ trigger/TurnFaceUp · added: TurnFaceUp (active in=battlefield, valid target=player, watches=self)
    • Affected (first 3): Concert Kaboomist
  • 1 card · ➕ trigger/TurnFaceUp · added: TurnFaceUp (active in=battlefield, watches=you control Detective)
    • Affected (first 3): Case of the Pilfered Proof
  • 1 card · ➕ trigger/When the chosen player becomes the target of a spell or ability · added: When the chosen player becomes the target of a spell or ability (active in=battlefield, command zone)
    • Affected (first 3): Imprison This Insolent Wretch

🔴 Removed (7 signatures)

  • 11 cards · ➖ trigger/ChangesZone · removed: ChangesZone (active in=battlefield, to=battlefield, watches=self)
    • Affected (first 3): A-Radha, Coalition Warlord, A-Zar Ojanen, Scion of Efrava, Alpha Deathclaw (+8 more)
  • 1 card · ➖ trigger/ChangesZone · removed: ChangesZone (active in=battlefield, from=battlefield, to=graveyard, watches=you control Squirrel)
    • Affected (first 3): Acornelia, Fashionable Filcher
  • 1 card · ➖ trigger/ChangesZone · removed: ChangesZone (active in=battlefield, to=battlefield, valid target=player, watches=self)
    • Affected (first 3): Concert Kaboomist
  • 1 card · ➖ trigger/ChangesZone · removed: ChangesZone (active in=battlefield, to=battlefield, watches=you control Detective)
    • Affected (first 3): Case of the Pilfered Proof
  • 1 card · ➖ trigger/DamageDone · removed: DamageDone (active in=battlefield, damage kind=combat only, valid source=self or nontoken another you control Spirit, valid target=player)
    • Affected (first 3): Millicent, Restless Revenant
  • 1 card · ➖ trigger/Taps · removed: Taps (active in=battlefield, watches=attached permanent)
    • Affected (first 3): Cryoshatter
  • 1 card · ➖ trigger/When the chosen player is attacked becomes the target of a spell or ability · removed: When the chosen player is attacked becomes the target of a spell or ability (active in=battlefield, command zone)
    • Affected (first 3): Imprison This Insolent Wretch

1 card(s) had Oracle-text changes (errata/reprint) — excluded as non-parser.

…ge head family

Four of the five findings on PR phase-rs#6831, plus the reasoning for declining the fifth.

1. Cross-subject reach-guard. `cross_subject_state_change_or_not_split`'s Donna Noble
   leg asserted only `len() == 1`, which a line that never reached the cross-subject
   gate would also satisfy. CodeRabbit proposed asserting `TriggerMode::DamageReceived`
   — that would FAIL: Donna Noble is honestly `Unknown` today (probe-verified). The real
   discriminator is the `Unknown` PAYLOAD, which must still carry the whole
   cross-subject condition including "is dealt damage"; widening that gate splits the
   line and truncates the payload to the bare subject. Asserted that instead.

2. Shared-object offset alignment. `extract_shared_object` derives the object offset as
   `original.len() - rest_lower.len()`, which is only meaningful when both views are
   trimmed identically — and it was handed a TRIMMED original alongside an UNTRIMMED
   lower. Pre-existing: the base commit passed the untrimmed lower too. Probed as NOT
   reachable in production (the condition arrives already trimmed from
   `find_effect_boundary`, and both trailing-whitespace variants of the Mirkwood
   Bats-class shape parse correctly today), so this is defensive — but it makes the
   invariant hold BY CONSTRUCTION here instead of depending on an upstream caller.

3. The owed refactor is now an executable specification, not just a doc comment:
   `unadmitted_state_change_head_should_be_a_strict_failure`, `#[ignore]`d. The
   allow-list is a DETECTION list, so an unadmitted head makes the second branch vanish
   with no `Effect::Unimplemented` — the same silent-wrong signature as the bug this
   family fixes. Confirmed the test genuinely reds when run (`got ["ChangesZone"]`), so
   it captures a real gap rather than documenting a vacuous one. Left ignored because it
   asserts behavior the engine does not have yet and zero printed cards need it.

4. `must_block_targets` now PANICS on an unexpected `affected` filter instead of
   silently discarding it. Discarding made the negative rows vacuous in the one case
   that matters: a MustBlock that really applied, to a filter the helper does not
   understand, read as "no MustBlock at all".

Declined, with reasons, in the PR reply: the global backlog recount across root causes
1/2/3/5/19/22/27/28/30/31. That drift is pre-existing and a whole-file recount would
collide with other agents' concurrent list edits (CLAUDE.md multi-agent safety), which
is why this change decrements only what it touches. Root cause 6 is confirmed at 239 —
header, list length, and ranked-table row now all agree, repairing a pre-existing
247-vs-246 skew.

Verification: cargo fmt clean, clippy-strict 0 warnings, 18015 lib + 4214 integration
passing (ignored 6 -> 7 for the new spec test).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jofortin

Copy link
Copy Markdown
Contributor Author

CodeRabbit round addressed in 9433ca3, and a CI note

4 of 5 fixed, 1 declined with reasons — replies are inline on each thread. Two are worth surfacing here because they changed what I believed:

  • The cross-subject reach-guard could not be written as suggested. Asserting TriggerMode::DamageReceived for Donna Noble would fail — she parses to Unknown("Whenever ~ or a creature it's paired with is dealt damage") today. The real discriminator is the Unknown payload: it must still carry the whole cross-subject condition, which is only true if the input reached the gate and was declined intact.
  • The shared-object offset mismatch is real but pre-existing (the base commit passed the untrimmed lower half too) and probed as not reachable in production, since the condition arrives already trimmed from find_effect_boundary. Fixed anyway, because trimming at the call site makes the invariant hold by construction rather than depending on an upstream caller.

The declined one is the global backlog recount across root causes 1/2/3/5/19/22/27/28/30/31 — pre-existing drift, and a whole-file recount would collide with other agents' concurrent edits to that shared worklist. Root cause 6 is confirmed at 239 with header, list, and table row now in agreement.


⚠️ The Frontend (lint, type-check, test) failure is pre-existing on main, not from this PR

client/src/game/controllers/__tests__/aiController.test.ts:138"re-queries after the dispatch layer returns the engine's tagged stale outcome without fabricating an action"expected "vi.fn()" to be called 3 times, but got 4 times.

  • This PR touches zero client/ files. The full file list is 2 parser files, 2 engine test files, 1 doc.
  • main fails the identical test with the identical assertion at the same line: run 30602816316 (main @ 03:59) and the run before it. main was green through 01:59, so it broke on main between 01:59 and 03:18.

Flagging it rather than silently re-running, since it looks like a fresh main breakage somebody will want to own. Every other check is green — all Rust jobs, Card data (generate, validate, coverage), WASM, Tauri, lobby worker.

@matthewevans matthewevans self-assigned this Jul 31, 2026
@matthewevans matthewevans added the bug Bug fix label Jul 31, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Blocked — the new closed event-head splitter still lets an unadmitted state-change branch vanish while the card remains falsely supported.

🔴 Blocker

[MED] Unadmitted becomes|is|are <participle> complements are silently dropped instead of preserving honest unsupported coverage. Evidence: crates/engine/src/parser/oracle_trigger.rs:7952-7968 explicitly documents that the closed allow-list drops every unadmitted complement; split_or_event_compound only splits admitted heads at :7782-7819, after which parse_trigger_lines_at_index_ir falls through to one trigger at :838-865. The current ignored regression at crates/engine/src/parser/oracle_trigger_tests.rs:21904-21943 demonstrates the failure: "enters or is turned face down"yields one trigger with zeroEffect::Unimplemented, so the card appears supported. Why it matters: parser coverage goes green while a rules-bearing branch was lost. Suggested fix: detect the open state-change head shape and route unadmitted complements through the existing strict-failure / Effect::unimplemented` authority, rather than letting the closed support list control whether the complement exists.

🟡 Non-blocking

The current CodeRabbit findings that were resolved or are stale are acknowledged and are not blockers here; the remaining coverage-honesty gap is independent.

CI status

The parse-diff artifact is from the prior head (04:27) while this head was pushed at 04:42, and Rust/card-data checks are still running. No approval or enqueue will occur until a current artifact and required checks complete; this formal blocker is independently about coverage honesty.

Recommendation: request changes — implement open-head detection with strict failure for unadmitted complements, add an active (non-ignored) coverage-honesty regression, then request a new current-head review.

@matthewevans matthewevans removed their assignment Jul 31, 2026
@jofortin

Copy link
Copy Markdown
Contributor Author

Accepted — you're right, and I want to be straight about it: this is the gap I knew about and chose to defer. CodeRabbit raised it and my own review rounds raised it; I recorded it in-code as an "OWED REFACTOR" with an #[ignore]d spec instead of fixing it. Deferring a coverage-honesty hole that reproduces the exact silent-wrong signature the PR exists to remove was the wrong call. Working it now.

Two corrections to my earlier comment on this PR, before anything else:

  • The Frontend (lint, type-check, test) failure passed on re-run at the current head. So that test is flaky (an AI-controller call-count race), not the hard main breakage I implied. main did fail it identically twice, which is what I based the claim on, but "pre-existing and broken" overstated it — "pre-existing and flaky" is accurate. All 13 checks are green on 9433ca3.
  • You're right that the parse-diff artifact was from the prior head. It has since re-run on 9433ca3.

On the fix — my investigation suggests it is smaller and better than a bolted-on strict-failure path

Three things I measured before starting, because they change the shape of the answer:

  1. "When this creature becomes untapped, …"TriggerMode::Untaps, and "When this creature becomes attached to a creature, …"TriggerMode::Attached. Both are already fully supported standalone. My allow-list excluded them for zero corpus demand, not for inability — so an open head admits them correctly, for free.
  2. "When this creature is turned face down, …"TriggerMode::Unknown(...).
  3. TriggerMode::Unknown already counts as unsupportedgame/coverage.rs:4682: let mode_supported = !matches!(&trig.mode, TriggerMode::Unknown(_)) && ….

So rather than adding a parallel strict-failure route, the open head can simply split and let the real single-event parser be the detector per branch: an admitted complement reaches its real mode, an unadmitted one lands on Unknown, and coverage reports the card unsupported on its own. That deletes the hand-maintained support list rather than relocating it — which I think is what you were pointing at with "rather than letting the closed support list control whether the complement exists."

Two things that must survive the change, and I'm treating them as the risk surface:

  • The fused-mode guard has to come back, and this time it is real. Under the closed list, blocked was simply not a complement, so the is_existing_compound_mode entry was provably dead (dropping it red 0 of 18015 tests) and I deleted it on reviewer advice. Under an open head, becomes blocked matches the shape and would split all ~53 blocks or becomes blocked cards. Restoring it is now load-bearing and pinnable — including Neyith's plural "fight or become blocked".
  • Non-events matching the shape must still be rejected structurally. Preacher of the Schism ("… or are tied for most life") reaches this exact seam and parses correctly today as one Attacks; an open are <participle> head would split its intervening-if into a bogus arm. Same for Call to Arms, is/are tapped for mana (CR 106.12a), and the is monstrous / is tapped states (CR 603.2e). My aim is to exclude those as a categorical non-event rule, not as a new hand-maintained list under a different name.

The #[ignore]d spec becomes an active regression asserting the honest outcome.

Re-planning through /engine-implementer rather than hand-patching, since this changes the seam's architecture rather than extending it. I'll push and request a fresh current-head review when it's through the plan-review and impl-review loops.

Addresses the review blocker on PR phase-rs#6831: the closed complement allow-list was a
DETECTION list, so an unadmitted `becomes|is|are <participle>` made the second branch
VANISH while the card still reported as supported — parser coverage green with a
rules-bearing branch lost, the same silent-wrong signature the head family was
written to remove. Measured: "enters or is turned face down" gave ONE trigger.

The fix is subtraction. The support list is DELETED — both complement functions and
all four hand-maintained `parse_event_word` entries go. Detection becomes an open
`becomes|become|is|are <alpha1>` shape, and the real single-event mode parser becomes
the arbiter: an admitted complement reaches its real mode, an unadmitted one lands on
`TriggerMode::Unknown`, which every coverage authority already treats as unsupported
(`is_card_supported`, `check_trigger`, `build_trigger_item`). Nothing in the parser
decides whether a complement EXISTS based on whether we SUPPORT it.

The honesty carrier is the MODE, not `Effect::unimplemented`. Measured: `unimpl` is
false on BOTH arms of the blocker fixture — the effect ("draw a card") parses fine;
only the trigger EVENT is unmodelled. Synthesizing an `Effect::Unimplemented` onto a
well-parsed effect would mislabel the gap and corrupt the `parse_details` tree.

Complements previously excluded for zero corpus demand now work for free, each landing
on a registered mode: `becomes untapped` -> Untaps, `becomes attached` -> Attached,
`becomes crewed` -> BecomesCrewed, `is tapped for mana` -> TapsForMana (CR 106.12a — a
real event, so admitting it is correct).

Three guards survive, and unlike the closed-list era ALL THREE now ablate to real
measured breakage over the full suite:
  * `blocks or becomes blocked` in `is_existing_compound_mode` (CR 509.1h) — dropping
    it regresses 53 cards and reds 1 test. Under the closed list this entry was
    provably dead (0 reds), which is why an earlier round deleted it; an open head
    matches `becomes blocked`, so it is load-bearing again.
  * `fight or become blocked`, split out into its own `suppresses_duplicate_event_arm`
    predicate rather than smeared into the fused-mode guard, because it is NOT a fused
    mode — it prevents a duplicate `Fight` arm (double draw on one fight event).
    Dropping it regresses Neyith and reds 1 test.
  * `tied for ` after `is`/`are` (CR 603.2 + CR 603.8) — a comparison predicate is a
    game STATE inside a trigger condition, never an event. Dropping it regresses
    Preacher of the Schism (loses half its while-gate, gains a bogus arm, flips
    Supported -> Unsupported) and Call to Arms, and reds 2 tests.

CR 603.2e is NOT cited as authorizing the open head. Its actual wording is "SOME
trigger events use the word 'becomes'", which constrains WHEN becomes-triggers fire
and does not license "every `becomes X` is an event" — and the counterexample is
measured ("becomes greater than 4 or becomes less than 2" splits and drops half a
gate). 603.2e is scoped to transition-vs-persistent-state on the `becomes` arm only;
`parse_open_state_complement` cites CR 603.1 + CR 603.2 because it is voice-agnostic.

One documented exposure, stated rather than hidden: a subject containing a `that is
<participle>` relative clause truncates ("a creature that is enchanted attacks or
dies" flips Supported -> Unsupported). Zero printings — WotC contracts to `that's`,
which `tag("is ")` cannot match — and the failure direction is toward unsupported WITH
an emitted gap, strictly safer than a vanishing branch. Pinned by a parser row and a
coverage row rather than guarded, because a guard would have zero ablation signal.

Corpus impact, differential in both directions over ~34,470 Oracle-bearing cards:
exactly ONE card changes, and it is a repair. Imprison This Insolent Wretch's arm 2
went from the ungrammatical "When the chosen player is attacked becomes the target of
a spell or ability" to the correct reconstruction. Both arms stay `Unknown` with
`Unimplemented` present, so no coverage transition anywhere in the corpus.

Also pins three previously UNPINNED cards: Artifact Possession / Haunting Wind /
Powerleech ("becomes tapped or a player activates …" must not split).

Tests: 6 new active rows plus the previously `#[ignore]`d spec now active and
satisfied. The coverage half is asserted at the coverage authority in a new
integration test, not at parser shape.

Verification: cargo fmt clean, clippy-strict 0 warnings, 18022 lib + 4217 integration
passing, Gate A/G PASS, Gate P PASS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jofortin

jofortin commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Blocker addressed in 4fb6be1. The closed allow-list is deleted, not relocated.

Detection is now an open becomes|become|is|are <alpha1> shape, and the real single-event mode parser is the arbiter. Your phrasing — "rather than letting the closed support list control whether the complement exists" — is exactly what the fix implements: nothing in the parser any longer decides whether a complement exists based on whether we support it. Both complement functions and all four hand-maintained parse_event_word entries are gone.

One place where I did not do what you asked, and why

You asked to "route unadmitted complements through the existing strict-failure / Effect::unimplemented authority." I route them to TriggerMode::Unknown instead, and I think Effect::unimplemented would be actively wrong here. Measured on your own fixture:

"When this creature enters or is turned face down, draw a card."
  arm 0  ChangesZone / Battlefield / SelfRef   unimpl=false
  arm 1  Unknown("When ~ is turned face down") unimpl=false

unimpl == false on both arms — the effect (draw a card) parses perfectly; it is the trigger event that is unmodelled. TriggerMode::Unknown is already the engine's strict-failure marker for exactly that, and it is already wired into every coverage authority (is_card_supported, check_trigger, build_trigger_item, plus two more sites). Synthesising an Effect::Unimplemented onto a well-parsed effect would mislabel the gap and corrupt the parse_details tree the overlay renders. Two independent reviewers reached the same conclusion. If you still want the Effect carrier I'll add it, but I wanted the measurement on the record first.

Related: your named test unadmitted_state_change_head_should_be_a_strict_failure is now active and satisfied, but renamed to unadmitted_state_change_head_yields_an_honest_unknown_arm for the reason above — the old name asserts a carrier the code correctly does not use, and its original || body would have passed vacuously via its len() == 2 branch. The old name is recorded in the new test's doc comment so a grep for it lands there, and the coverage half of your request is a separate active integration test asserting at the coverage authority rather than at parser shape:

tests/integration/disjunctive_state_change_head_coverage_honesty.rs
  unadmitted_disjunctive_state_change_head_reports_a_coverage_gap   -> gap emitted
  admitted_disjunctive_state_change_head_reports_no_coverage_gap    -> Culvert Ambusher, zero gaps (positive control)
  subject_internal_head_shape_reports_a_coverage_gap                -> the exposure below

(is_card_supported is private, so card_face_gaps / gaps.is_empty() is the assertable public equivalent — same predicate via check_trigger.)

Guards: all three now ablate to real breakage

You were right to be sceptical of guards in this seam — I deleted one last round precisely because it ablated to zero. Under an open head that changes. Each was removed individually and the full lib suite re-run:

Guard Ablation
blocks or becomes blocked (CR 509.1h, is_existing_compound_mode) 53 cards regress, 1 test reds. Dead under the closed list (0 reds), load-bearing now.
fight or become blocked 1 card (Neyith), 1 test. Split into its own suppresses_duplicate_event_arm rather than smeared into the fused-mode guard — it is not a fused mode; it prevents a duplicate Fight arm, i.e. double draw on one fight event.
tied for after is/are (CR 603.2 + CR 603.8) 2 cards, 2 tests. Preacher of the Schism loses half its while gate, gains a bogus arm, and flips Supported→Unsupported; Call to Arms' state-trigger predicate splits into garbage.

A fourth (fights or becomes blocked) was written and removed: zero printings, so it would ablate to zero and be unpinnable.

A CR correction I owe you

My previous head cited CR 603.2e as the authorizing rule for the family. That was an over-read and it is now gone. 603.2e's actual wording is "Some trigger events use the word 'becomes'" — it constrains when becomes-triggers fire and does not license "every becomes X is an event." The counterexample is measured: "becomes greater than 4 or becomes less than 2" splits and drops half a gate. 603.2e is now scoped to transition-vs-persistent-state on the becomes arm only, and parse_open_state_complement cites CR 603.1 + CR 603.2 because it is voice-agnostic (reached from both voices).

One exposure, disclosed rather than hidden

A subject containing a that is <participle> relative clause truncates: "Whenever a creature that is enchanted attacks or dies" goes from two correct arms to Unknown("Whenever a creature that dies"), flipping Supported→Unsupported. Zero printings — WotC contracts to that's, which tag("is ") cannot match — and the failure direction is toward unsupported with an emitted gap, strictly safer than a vanishing branch. I pinned it with a parser row and a coverage row rather than guarding it, because a guard would have zero ablation signal and be unpinnable, consistent with the rejected fourth guard above. Happy to guard it anyway if you'd rather.

Impact

Differential in both directions over ~34,470 Oracle-bearing cards: exactly one card changes, and it is a repair. Imprison This Insolent Wretch's arm 2 went from the ungrammatical "When the chosen player is attacked becomes the target of a spell or ability" to the correct reconstruction. Both arms stay Unknown with Unimplemented present, so there is no coverage transition anywhere in the corpus. Complements previously excluded for zero demand now work for free (becomes untappedUntaps, becomes attachedAttached, becomes crewedBecomesCrewed, is tapped for manaTapsForMana).

Also pins three cards that had no test coverage at all before this change: Artifact Possession / Haunting Wind / Powerleech (becomes tapped or a player activates … must not split).

Verification at 4fb6be1

cargo fmt --all --check clean · cargo clippy-strict 0 warnings · cargo test -p phase-engine 18022 lib + 4217 integration, 0 failed (ignored 7→6: your spec test is active) · Gate A PASS head=4fb6be18733627e30fadba53d97f1f07ae1d202d base=6719cc549f2850e396e4801189f2479b7064673a · Gate G PASS · Gate P PASS.

Two prior notes still standing: the Frontend job failure earlier in this PR was flaky, not a hard main break (it passed on re-run) — I overstated that and corrected it above; and check-parser-combinators.sh with no argument prints a vacuous PASS on an uncommitted branch (BASE=$(git merge-base origin/main HEAD) equals HEAD, flipping it to --cached), which may be worth hardening since §0.1.2 tells contributors to run it pre-PR.

Two follow-ups I deliberately did not fold in: Printlifter Ooze's trigger line is swallowed by its trailing token-modifier sentence (pre-existing, base-identical, worth its own issue), and Neyith's or become blocked leg stays silently dropped — fixing it needs fight/fights admitted to an event-head lexicon and would move the consumer accounting.

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

🧹 Nitpick comments (2)
crates/engine/src/parser/oracle_trigger.rs (1)

8066-8084: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Resolve the contradictory CR-scope sentence in the doc comment.

Line 8070 states the combinator is voice-agnostic and reached from both arms. Line 8073 then states "It stays scoped to the becomes arm in parse_state_change_event_start." The pronoun "It" is ambiguous, and the two claims read as a direct contradiction. The intended referent is the CR 603.2e citation, not the combinator. Name the referent explicitly, and start the parse_event_boundary sentence on its own line.

📝 Proposed doc rewrite
 /// The governing rules are the general trigger-condition / trigger-event rules, NOT
 /// CR 603.2e: this combinator is VOICE-AGNOSTIC — reached from both the
 /// `becomes`/`become` arm and the `is`/`are` arm — and 603.2e speaks specifically
 /// about the word "becomes", so it cannot govern roughly half of these inputs. It
-/// stays scoped to the `becomes` arm in `parse_state_change_event_start`. `parse_event_boundary` peeks eof/space/`,`/`.`, and its `space1` arm
+/// stays scoped to the `becomes` arm in `parse_state_change_event_start`.
+///
+/// `parse_event_boundary` peeks eof/space/`,`/`.`, and its `space1` arm
 /// is load-bearing: a qualifier may sit between the head and the `or`, which is what

Replace the ambiguous "It" with "The CR 603.2e citation".

🤖 Prompt for 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.

In `@crates/engine/src/parser/oracle_trigger.rs` around lines 8066 - 8084, Update
the doc comment for parse_open_state_complement: replace the ambiguous “It” with
“The CR 603.2e citation” to distinguish the citation from the voice-agnostic
combinator, and begin the parse_event_boundary sentence on its own line.
crates/engine/tests/integration/disjunctive_state_change_head_coverage_honesty.rs (1)

1-19: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reorder the module doc: the NOTE block splits a sentence.

The sentence that starts at line 5 ends at line 6 with "which is the coverage authority's unsupported". Its continuation is at line 12, "marker (game/coverage.rs: ...)". The NOTE ON THE VEHICLE paragraph sits between the two halves. Both halves read as truncated. Move the NOTE paragraph below the completed sentence.

📝 Proposed doc reorder
 //! The `becomes|is|are <complement>` trigger-event head is detected as an OPEN
 //! shape, so an unmodelled complement still produces its own trigger arm. That arm
-//! lands on `TriggerMode::Unknown`, which is the coverage authority's unsupported
-//! NOTE ON THE VEHICLE: `is_card_supported` is private, so it cannot be asserted
-//! from an integration test. `card_face_gaps` is the equivalent PUBLIC authority —
-//! it applies the same `Unknown(_) || !registry.contains_key` predicate through
-//! `check_trigger`, so `gaps.is_empty()` is the assertable form of "supported".
-//!
-//! marker (`game/coverage.rs`: `is_card_supported`, `check_trigger`,
+//! lands on `TriggerMode::Unknown`, which is the coverage authority's unsupported
+//! marker (`game/coverage.rs`: `is_card_supported`, `check_trigger`,
 //! `build_trigger_item`). Before the open head the second branch vanished and the
 //! card reported as fully supported — parser coverage went green while a
 //! rules-bearing branch was lost.
 //!
+//! NOTE ON THE VEHICLE: `is_card_supported` is private, so it cannot be asserted
+//! from an integration test. `card_face_gaps` is the equivalent PUBLIC authority —
+//! it applies the same `Unknown(_) || !registry.contains_key` predicate through
+//! `check_trigger`, so `gaps.is_empty()` is the assertable form of "supported".
+//!
 //! The open shape's false-positive surface (a head match INSIDE a subject noun
🤖 Prompt for 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.

In
`@crates/engine/tests/integration/disjunctive_state_change_head_coverage_honesty.rs`
around lines 1 - 19, Reorder the module documentation so the sentence describing
`TriggerMode::Unknown` as the coverage authority’s unsupported marker is
contiguous and complete. Move the “NOTE ON THE VEHICLE” paragraph, including its
`is_card_supported` and `card_face_gaps` explanation, after that sentence
without changing the documentation content.
🤖 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.

Nitpick comments:
In `@crates/engine/src/parser/oracle_trigger.rs`:
- Around line 8066-8084: Update the doc comment for parse_open_state_complement:
replace the ambiguous “It” with “The CR 603.2e citation” to distinguish the
citation from the voice-agnostic combinator, and begin the parse_event_boundary
sentence on its own line.

In
`@crates/engine/tests/integration/disjunctive_state_change_head_coverage_honesty.rs`:
- Around line 1-19: Reorder the module documentation so the sentence describing
`TriggerMode::Unknown` as the coverage authority’s unsupported marker is
contiguous and complete. Move the “NOTE ON THE VEHICLE” paragraph, including its
`is_card_supported` and `card_face_gaps` explanation, after that sentence
without changing the documentation content.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b529ffa2-b49e-4162-b01f-d822ee453056

📥 Commits

Reviewing files that changed from the base of the PR and between 9433ca3 and 4fb6be1.

📒 Files selected for processing (4)
  • crates/engine/src/parser/oracle_trigger.rs
  • crates/engine/src/parser/oracle_trigger_tests.rs
  • crates/engine/tests/integration/disjunctive_state_change_head_coverage_honesty.rs
  • crates/engine/tests/integration/main.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • crates/engine/tests/integration/main.rs
  • crates/engine/src/parser/oracle_trigger_tests.rs

# Conflicts:
#	crates/engine/tests/integration/main.rs
#	docs/parser-misparse-backlog.md
@jofortin

jofortin commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Merged upstream/main (72 commits) into the branch at 46d4a04. Two conflicts, both in files several agents edit concurrently — flagging the arithmetic so it is auditable rather than trusted:

crates/engine/tests/integration/main.rs — both sides inserted a mod line at the same point. Kept both, alphabetically: diluvian_primordial_6754 < disjunctive_state_change_head_coverage_honesty < disorder_in_the_court_5955.

docs/parser-misparse-backlog.md — both sides decremented the header totals from the same base (4732 / 4766). main removed 6 cards; this branch removed 7. The rc6 card list itself merged cleanly at 239, which confirms main's removals were in other root causes and the two sets are disjoint, so the combined figure is 4732 − 13 = 4719 and 4766 − 13 = 4753. Neither side's removals were reverted.

Post-merge verification at 46d4a04: cargo fmt --all --check clean · cargo clippy-strict 0 warnings · cargo test -p phase-engine 18299 lib + 4383 integration, 0 failed (the jump over this PR's own 18022 + 4217 is upstream's new tests) · Gate A PASS head=46d4a04564e35d3fc84e156d25532b02a9eb01da base=6719cc549f2850e396e4801189f2479b7064673a · Gate G PASS · Gate P PASS. All ten of this change's own rows re-verified green after the merge, including the three fused-mode rows and Neyith.

mergeStateStatus is BLOCKED purely on the outstanding review — nothing else is outstanding from my side.

@matthewevans matthewevans self-assigned this Aug 3, 2026
@matthewevans

Copy link
Copy Markdown
Member

Maintainer hold for current head 46d4a04564e35d3fc84e156d25532b02a9eb01da.

The prior coverage-honesty blocker is addressed on this head. I am awaiting the still-running current-head Rust lint (fmt, clippy, parser gate), both Rust tests shards, and Card data (generate, validate, coverage) before continuing review. No approval or merge-queue action will be taken while those checks are in progress.

Next step: re-check the terminal results for this exact SHA, then resume the maintainer disposition.

@matthewevans matthewevans removed their assignment Aug 3, 2026
@jofortin

jofortin commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

All four checks you named are terminal and green at 46d4a04564e35d3fc84e156d25532b02a9eb01da.

Check Result
Rust lint (fmt, clippy, parser gate) COMPLETED · SUCCESS
Rust tests (shard 1/2) COMPLETED · SUCCESS
Rust tests (shard 2/2) COMPLETED · SUCCESS
Card data (generate, validate, coverage) COMPLETED · SUCCESS

Nothing is in progress. The only non-SUCCESS in the whole rollup is Draft pools (smoke), which is SKIPPED. mergeStateStatus is CLEAN. The coverage-parse-diff artifact has also re-run on this head and independently confirms the redesign's predicted single-card effect — the garbage Imprison This Insolent Wretch signature "When the chosen player is attacked becomes the target of a spell or ability" is removed.

One open question for you, because it costs you a CI cycle

CodeRabbit left a trivial-but-valid doc nitpick on parse_open_state_complement (oracle_trigger.rs:8066-8084), and it is my wording, not theirs: the MINOR-B edit I made ends with "It stays scoped to the becomes arm…" where the intended referent is the CR 603.2e citation, not the combinator — so it reads as contradicting the preceding "this combinator is voice-agnostic" sentence. It also wants the parse_event_boundary sentence started on its own line.

It is a two-line doc-comment fix with no code change. I have deliberately not pushed it, because doing so would move the head and restart exactly the checks you said you're waiting on, and you control the queue. Your call:

  • Fold it in now — say the word and I'll push; you'd be waiting on one more CI cycle at a new SHA.
  • Dispose of 46d4a04 as-is — I'll open it as a doc-only follow-up against main after this lands.

I'd mildly prefer the second, purely so you aren't re-waiting on a comment typo, but I have no stake in it.

…n-head round

Both CodeRabbit nitpicks on PR phase-rs#6831, both my own wording, no code change.

1. `parse_open_state_complement` ended with "It stays scoped to the `becomes` arm",
   where the referent is the CR 603.2e CITATION, not the combinator — so it read as
   directly contradicting the preceding "this combinator is VOICE-AGNOSTIC" sentence.
   Names the referent explicitly.

2. The coverage-honesty integration test's module doc had my NOTE ON THE VEHICLE
   paragraph inserted into the MIDDLE of a sentence, leaving both halves reading as
   truncated ("...the coverage authority's unsupported" / "marker (game/coverage.rs:
   ...)"). Moved below the completed sentence.

Doc comments only. Verification: cargo fmt clean, clippy-strict 0 warnings,
18299 lib + 4383 integration passing, Gate A/G PASS, Gate P PASS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jofortin

jofortin commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Both CodeRabbit nitpicks fixed in 5bef81e. Both were my own wording, and the second was worse than a nit:

  1. parse_open_state_complement ended with "It stays scoped to the becomes arm…" where the referent is the CR 603.2e citation, not the combinator — so it read as directly contradicting the preceding "this combinator is VOICE-AGNOSTIC" sentence. Referent now named explicitly.
  2. The coverage-honesty test's module doc had my NOTE ON THE VEHICLE paragraph inserted into the middle of a sentence, leaving both halves truncated ("…the coverage authority's unsupported" / "marker (game/coverage.rs: …)"). That was a straight editing error on my part when I applied the round-2 review's NIT-3. Moved below the completed sentence.

Doc comments only — no code, no test-logic change.

@matthewevans — this supersedes the choice I offered you, and I'm sorry for the extra cycle. You'd said you were holding on the four named checks at 46d4a04; I asked whether to fold these in or defer them, then pushed once it was clear a sentence had been left visibly broken in the file this PR is being scrutinised in. All four of those checks had gone terminal SUCCESS on 46d4a04 before this push, so nothing was lost from that run — but they will need to re-run on 5bef81e.

Verification at 5bef81e: cargo fmt --all --check clean · cargo clippy-strict 0 warnings · cargo test -p phase-engine 18299 lib + 4383 integration, 0 failed · Gate A PASS head=5bef81ee7c66d402d06e094267868b7a72eb9c95 base=6719cc549f2850e396e4801189f2479b7064673a · Gate G PASS · Gate P PASS.

For the record on review state: the CHANGES_REQUESTED still showing on this PR is the 07-31 review, which GitHub keeps sticky until a new review supersedes it. Your 08-03 note ("the prior coverage-honesty blocker is addressed on this head") is a comment, so it doesn't clear it. Nothing is outstanding from my side on either the blocker or these nits.

@matthewevans matthewevans self-assigned this Aug 3, 2026
@matthewevans

Copy link
Copy Markdown
Member

Maintainer hold — current head fb79206253d8e51b524a911ac71fcf76a4d8ff05.

The coverage-honesty review is now clean. I applied the small maintainer fixup that removed the unrelated CR 603.1b citation from the event-head lexicon annotation; CR 603.2e remains the governing rule for the state-change semantics.

The push started a new current-head CI run. Required Rust lint/tests and card-data coverage checks are queued or running, so approval and merge-queue handling resume only after their terminal results and the current-head parse-diff are available. The branch also currently reports BEHIND; that will be live-checked then before any queue action.

@matthewevans matthewevans removed their assignment Aug 3, 2026
@jofortin

jofortin commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

All checks are terminal and green at fb79206253d8e51b524a911ac71fcf76a4d8ff05 — 13/13 SUCCESS, the only non-SUCCESS in the rollup being Draft pools (smoke) (SKIPPED). That includes the four you named: Rust lint (fmt, clippy, parser gate), both Rust tests shards, and Card data (generate, validate, coverage). Nothing is queued or running. mergeStateStatus reports CLEAN.

On BEHIND: the branch is exactly 1 commit behind upstream/main, and that commit touches none of this PR's five files (git log HEAD..upstream/main -- <our paths> is empty), so a merge would be a trivial no-conflict update. I have not pushed one, because it would move the head and restart the checks you're waiting on — that's your call, and I'll merge the moment you want it. (Or the queue can take it, if it updates branches itself.)

Thank you for the fixup — and one observation about it, for your judgement rather than as a request. Removing CR 603.1b from parse_event_head_start is right: 603.1b is about an ability carrying several trigger conditions, i.e. about the split, not about what an event head is.

What's left, though, is CR 603.2e alone on that combinator — and parse_event_head_start is alt((parse_event_verb_start, parse_state_change_event_start)), so it also governs dies / attacks / enters / deals damage and the rest of the active-voice lexicon. CR 603.2e speaks specifically about the word "becomes", so it's narrower than the combinator it now annotates.

That's the same scope mismatch CodeRabbit flagged one function below, on parse_open_state_complement, which 5bef81e fixed by citing CR 603.1 + CR 603.2 (the general trigger-condition / trigger-event rules) and leaving 603.2e scoped to the becomes arm where it actually applies. Making parse_event_head_start read CR 603.1 + CR 603.2 would be consistent with that, and would leave 603.2e doing exactly the work you describe — governing the state-change semantics on the arm that owns them.

Entirely your call; I'm raising it only because I'd just spent a round on this precise class of over-scoped citation and it seemed worse to notice and say nothing. Happy to push it together with the main merge in one commit if you want both, or to leave fb79206 exactly as it stands.

@matthewevans matthewevans self-assigned this Aug 3, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approved after current-head review: the open state-change head preserves modeled arms and converts unmodeled arms to coverage-visible TriggerMode::Unknown; runtime and coverage-honesty regressions discriminate.

@matthewevans matthewevans removed the needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps) label Aug 3, 2026
@matthewevans
matthewevans added this pull request to the merge queue Aug 3, 2026
@matthewevans matthewevans removed their assignment Aug 3, 2026
Merged via the queue into phase-rs:main with commit 1ecde66 Aug 3, 2026
14 checks passed
@jofortin
jofortin deleted the card/culvert-ambusher branch August 3, 2026 05:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants