fix(parser): count "for each <type> you control with <keyword>" (Skycat Sovereign, Aven Gagglemaster)#5019
Conversation
…at Sovereign, Aven Gagglemaster)
The "for each" quantity grammar had a combinator for the any-controller
"<type> on the battlefield with <keyword>" form
(parse_for_each_battlefield_type_with_keyword, backing Radiant, Archangel and
Pride of the Clouds) but none for the controller-scoped "<type> you control
with <keyword>" form. The bare parse_for_each_controlled_type arm matched
"creature you control" and stranded " with flying" as an unconsumed remainder,
so the whole "for each" quantity failed to fully consume and the dependent
effect (dynamic pump / life gain / mana) was silently dropped.
Add parse_for_each_controlled_type_with_keyword as the "you control" sibling of
the existing battlefield-wide combinator, generalized over the full KEYWORDS
table (not just flying) and the optional "other"/"another" exclusion prefix,
and register it ahead of the bare controlled-type arm so the longer
keyword-qualified form is tried first. The population is scoped to the source's
controller (ControllerRef::You, CR 109.4).
Covers the whole class of "<type> you control with <keyword>" for-each clauses:
Skycat Sovereign ("gets +1/+1 for each other creature you control with
flying"), Aven Gagglemaster, Aerial Assault, Alert Heedbonder, and Overgrown
Battlement.
Model: claude-opus-4-8[1m] (via Claude Code)
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
There was a problem hiding this comment.
Code Review
This pull request introduces parsing support for controller-scoped "for each [other] you control with " clauses (e.g., Skycat Sovereign, Aven Gagglemaster, Overgrown Battlement) to prevent them from being shadowed by the simpler "you control" parser. It also adds comprehensive unit and integration tests. The review feedback highlights a critical improvement opportunity to avoid using unwrap() in the parser code, suggesting a safer approach using map_res to prevent potential engine panics.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| let (rest, keyword_name) = parse_keyword_name(rest)?; | ||
| let keyword: Keyword = keyword_name.parse().unwrap(); |
There was a problem hiding this comment.
[HIGH] Avoid panicking with unwrap() in parser code. Evidence: crates/engine/src/parser/oracle_nom/quantity.rs:4031. Why it matters: If a keyword name is parsed but fails to convert to the Keyword enum, the parser will panic and crash the engine instead of failing gracefully. Suggested fix: Use map_res to handle the parsing error gracefully and return a nom error.
| let (rest, keyword_name) = parse_keyword_name(rest)?; | |
| let keyword: Keyword = keyword_name.parse().unwrap(); | |
| let (rest, keyword) = map_res(parse_keyword_name, |s: &str| s.parse::<Keyword>()).parse(rest)?; |
References
- Idiomatic Rust — uses the type system, ownership model, and standard library idioms to their fullest. Nom combinators on the first pass — no exceptions. (link)
- Avoid swallowing all errors or panicking when only specific errors are expected to be handled or ignored. Propagate unexpected errors or map them to proper parser errors to prevent masking critical bugs or crashing the engine.
There was a problem hiding this comment.
Applied in 96f65749b — routed the keyword conversion through map_res(parse_keyword_name, |s: &str| s.parse::<Keyword>()), so an unconvertible name now fails the parse as a graceful nom error instead of panicking. (Every KEYWORDS entry has a valid FromStr, so in practice this is a defensive guard, not a reachable path.)
|
Holding this for maintainer review evidence: this PR changes parser/engine files, but the required parse-diff sticky comment is not present yet and the only reported checks are the triage-label checks. I’m not approving or enqueueing until the parser/card-data CI evidence is available for this head. |
Addresses review on phase-rs#5019 (gemini-code-assist, HIGH): parse_for_each_controlled_type_with_keyword converted the parsed keyword name to `Keyword` with `.parse().unwrap()`, which would panic if a name failed to convert. Route it through `map_res(parse_keyword_name, |s| s.parse::<Keyword>())` so an unconvertible name yields a graceful nom parse error instead. Every `KEYWORDS` entry has a valid `FromStr`, so this is a defensive guard, not an expected failure path. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
Holding this current head for the same evidence gate: it changes parser files, but the required parse-diff sticky is still absent and the only reported check on this head is the triage-label workflow. I’m not approving or enqueueing until parser/card-data CI has run and the parse-diff evidence is available for this head. |
|
Holding current head |
# Conflicts: # crates/engine/src/parser/oracle_static/tests.rs
|
Rebranched onto latest |
|
Holding current head |
# Conflicts: # crates/engine/src/parser/oracle_static/tests.rs
|
Holding this head for required proof before review/approval. The PR packet has proof_gap=true with unchecked verification items; please complete the AI-contributor proof/verification sections and provide concrete current-head test evidence. |
|
Holding current head |
|
Processed current head I’m still holding this rather than reviewing/approving: this PR changes parser source, but the required |
matthewevans
left a comment
There was a problem hiding this comment.
Requesting changes on current head da17016acd015205dc9e521febc8c862319c63da.
[MED] The required parser/card-data evidence is still missing after CI completed. Evidence: this PR changes crates/engine/src/parser/oracle_nom/quantity.rs, but there is still no <!-- coverage-parse-diff --> sticky for this head even though the current Rust/card-data CI checks are green. Why it matters: this parser change claims to recover a class of card parses, and without the card-level parse-diff I cannot verify the blast radius is limited to the claimed for each <type> you control with <keyword> class. Suggested fix: get the coverage parse-diff posted/regenerated for this exact head and confirm it matches the PR scope.
[MED] The PR proof is still incomplete for the current head. Evidence: the local review packet reports proof_gap=true, missing AI-contributor proof/verification sections, and no checked test evidence for this head. Why it matters: this contributor is on elevated scrutiny and the PR changes parser support, so green CI alone is not enough review proof. Suggested fix: update the PR body with concrete current-head verification evidence for the new parser branch and the production static parsing path.
Parse changes introduced by this PR✓ No card-parse changes detected. |
GonuDvc
left a comment
There was a problem hiding this comment.
Good improvement overall. One edge case: empty string vs null aren't treated the same downstream — might need a normalization step.
matthewevans
left a comment
There was a problem hiding this comment.
Reviewed current head da17016. This clears the stale blocker: the prior unwrap issue is fixed, the change lands at the canonical nom quantity seam before the shorter bare controlled-type arm, and the tests cover both the reusable keyword/count class and the production static P/T route. Parse-diff reports no card-output changes, which I’m treating as no blast radius rather than no value because the PR moves the class into the canonical parser path. Approved for merge queue.
matthewevans
left a comment
There was a problem hiding this comment.
Thanks for rebasing. I can't keep the prior approval on this current head because the merge left the parser/test files structurally broken and also appears to drop an existing parser branch.
[HIGH] Current head does not compile after the latest merge. Evidence: crates/engine/src/parser/oracle_nom/quantity.rs:4083 has parser statements at module scope, followed by an unmatched closing brace at quantity.rs:4107. Why it matters: this is current-head breakage, so the parser tests and parse-diff evidence cannot be trusted until the file parses again. Suggested fix: restore the intended parse_for_each_controlled_type_with_keyword function wrapper/body and rerun current-head CI/parse-diff.
[HIGH] The merge also leaves a broken partial test body at module scope. Evidence: crates/engine/src/parser/oracle_static/tests.rs:24063 references def after the test setup that created it has been removed. Why it matters: this both prevents compilation and removes the production parse_static_line discrimination this PR needs. Suggested fix: restore a complete production static test for ~ gets +1/+1 for each other creature you control with flying. that asserts the dynamic P/T uses the keyword-filtered count.
[MED] The current diff drops the existing counter-qualified for each parser seam while adding the keyword seam. Evidence: crates/engine/src/parser/oracle_nom/quantity.rs:3055 now dispatches directly to parse_for_each_controlled_type, and the removed/partial block around quantity.rs:4083 is where the qualified branch was split. Why it matters: fixing only the syntax would still risk regressing High Sentinels of Arashin / Armorcraft Judge / Inspiring Call style counts. Suggested fix: keep the counter-qualified arm/function and insert the new keyword-qualified sibling before the bare controlled-type arm.
Problem (#5018)
The
for eachquantity grammar could not parse a controller-scoped population count that carries a trailing keyword qualifier —for each <type> you control with <keyword>:The
for eachdispatch already had the any-controller combinatorparse_for_each_battlefield_type_with_keyword(<type> on the battlefield with <keyword>, backing Radiant, Archangel / Pride of the Clouds — #5008), but never got the controller-scoped sibling. The bareparse_for_each_controlled_typearm matchedcreature you controland stopped, strandingwith flyingas an unconsumed remainder. Since the clause must fully consume, the whole quantity failed and the dependent effect (dynamic pump / life gain / mana) was silently dropped — the same failure mode #5008 fixed, one scope over.Fix (
oracle_nom/quantity.rs)parse_for_each_controlled_type_with_keyword, theyou controlsibling ofparse_for_each_battlefield_type_with_keyword:[other|another] <type> you [already] control with <keyword>, generalized over the fullKEYWORDStable viaparse_keyword_name+FilterProp::WithKeyword, with the optionalother/anotherself-exclusion lowered toFilterProp::Another. The count is scoped to the source's controller (controller: Some(ControllerRef::You), CR 109.4 — only objects with a controller are counted).for eachalt()immediately beforeparse_for_each_controlled_type, since the bare arm's shorteryou controlmatch would otherwise win and strand thewith <keyword>clause.No new AST/variant — reuses existing
QuantityRef::ObjectCount/TypedFilter/FilterPropshapes.Before / after
Result
Skycat Sovereign now parses to a continuous self-static with a dynamic
+1/+1per other creature you control with flying (CR 604.1 / 611.3a / 613.4c), and the life-gain / mana cards in the class recover their count. The combinator covers the whole<type> you control with <keyword>for-each class, not just these cards.Implementation method (required)
/engine-implementerpipeline/engine-implementer— explain why belowCR references
Testing
Local toolchain has no MSVC/mingw linker (verified: no
link.exe/cl.exe/gcc), so I could not link and run the test binaries — only the non-linking checks were run:cargo fmt --all— clean.cargo check -p engine --lib --tests— clean.cargo clippy -p engine --lib --tests -- -D warnings— clean (0 warnings).New tests (written to the same conventions as the passing suite; please let CI's
nextestbe the gate):parse_for_each_controlled_type_with_keyword_scoped_count(oracle_nom/quantity.rs) — nom-level: assertscontroller: Some(You), the keyword-table generalization (flying / vigilance / defender), and theother/another/bare prefix variants.parse_for_each_controlled_type_bare_still_parses— guards that the new arm does not shadow the bareyou controlcount.static_self_dynamic_pump_for_each_other_creature_you_control_with_keyword(oracle_static/tests.rs) — end-to-end, mirroring fix(parser): parse dynamic pump "for each ... on the battlefield with <keyword>" (Radiant Archangel, Pride of the Clouds) #5008's...on_battlefield_with_keywordregression for theyou controlscope.Model: claude-opus-4-8[1m] (via Claude Code)
Closes #5018