Skip to content

fix(coverage): expose Mana effect grants in the parse-diff signature (Closes #5507) - #5511

Merged
matthewevans merged 1 commit into
phase-rs:mainfrom
minion1227:minion_5507
Jul 10, 2026
Merged

fix(coverage): expose Mana effect grants in the parse-diff signature (Closes #5507)#5511
matthewevans merged 1 commit into
phase-rs:mainfrom
minion1227:minion_5507

Conversation

@minion1227

Copy link
Copy Markdown
Contributor

Closes #5507.

Summary

The mana effect's parse-diff signature in effect_details rendered only produced, swallowing restrictions / grants / expiry / target with ... So a parser change that attaches a ManaSpellGrant to produced mana — e.g. Hall of the Bandit Lord's creature-spell haste rider (#5502) — showed only removals in the coverage-parse-diff sticky with no compensating addition: the signature of a regression, when it was actually a half-rendered effect.

This is the third instance of the family (#5492 PreventDamage#5493; #5495 ChangeZone#5501). Following #5501's approach, Effect::Mana is now fully destructured (no ..), so a newly added field becomes a compile error rather than another silent omission, and each field is emitted only when set so unqualified mana signatures stay byte-identical.

Fields now surfaced: restrictions, grants (the named blind spot — ManaSpellGrant), expiry, target.

Anchored on

CR

None. Per CLAUDE.md, effect_details is a signature renderer (review plumbing), not rule-implementing code, so no CR annotations are added — consistent with the maintainer note on #5500 that these annotations are not required here (and it avoids a wrong-citation risk).

Gate A

./scripts/check-parser-combinators.shexit 0 (no violations). No parser-dispatch code — this extends a signature renderer with typed field reads.

Verification

  • cargo test -p engine --lib coverage::tests → green, including the new mana_signature_exposes_grants (grants appears when a ManaSpellGrant is set, absent when empty; reverting the emission fails it).
  • cargo fmt -p engine clean.
  • Full destructure (no ..) compiles, so any future Effect::Mana field is a compile error until it's considered here.

Scope / risk

Single file, review-signature tooling only (effect_details feeds the coverage-parse-diff sticky). No game-logic, parser, or serialization change — it does not alter how any card parses or resolves, only what the diff sticky reports. Zero runtime impact.

Model: claude-opus-4-8
Tier: Frontier
Thinking: High

…n the parse-diff signature (phase-rs#5507)

`effect_details` rendered only `produced` for `Effect::Mana`, swallowing
`restrictions`/`grants`/`expiry`/`target` with `..`. So attaching a
`ManaSpellGrant` to produced mana (Hall of the Bandit Lord's creature-spell
haste rider, phase-rs#5502) showed only removals in the coverage-parse-diff sticky
with no compensating addition — reading as a regression when it was a
half-rendered effect.

Third instance of the family (phase-rs#5492/phase-rs#5493 PreventDamage, phase-rs#5495/phase-rs#5501
ChangeZone). Following phase-rs#5501, fully destructure `Effect::Mana` (no `..`) so a
new field is a compile error not another silent omission, emitting each only
when set so unqualified mana signatures stay byte-identical. Adds
mana_signature_exposes_grants. Closes phase-rs#5507.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@minion1227
minion1227 requested a review from matthewevans as a code owner July 10, 2026 15:22

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request updates the Effect::Mana destructuring in effect_details to fully destructure and render all of its fields (produced, restrictions, grants, expiry, and target) rather than swallowing them with ... This prevents silent omissions of fields in the mana signature. A corresponding test was added to verify that the grants field is correctly exposed. The review feedback suggests expanding this test to cover all the newly exposed fields (restrictions, expiry, and target) to ensure comprehensive test coverage of the entire building block.

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.

Comment on lines +10464 to +10504
#[test]
fn mana_signature_exposes_grants() {
use crate::types::ability::ManaContribution;
use crate::types::mana::ManaSpellGrant;

// #5507: a `ManaSpellGrant` attached to produced mana (e.g. Hall of the
// Bandit Lord's creature-spell haste rider, #5502) is parser-alterable but
// was swallowed by `..`. It must appear in the mana signature when set and
// be absent when the grants list is empty, so unqualified mana signatures
// stay byte-identical. (Mirrors #5493/#5501.)
let signature_keys = |grants: Vec<ManaSpellGrant>| -> Vec<String> {
effect_details(&Effect::Mana {
produced: ManaProduction::AnyOneColor {
count: QuantityExpr::Fixed { value: 1 },
color_options: vec![ManaColor::White, ManaColor::Blue],
contribution: ManaContribution::Base,
},
restrictions: vec![],
grants,
expiry: None,
target: None,
})
.into_iter()
.map(|(k, _)| k)
.collect()
};
assert!(
signature_keys(vec![ManaSpellGrant::AddKeywordUntilEndOfTurn {
keyword: Keyword::Haste,
restriction: None,
duration: Box::new(Duration::UntilEndOfTurn),
}])
.iter()
.any(|k| k == "grants"),
"a set ManaSpellGrant must appear in the mana parse-diff signature",
);
assert!(
!signature_keys(vec![]).iter().any(|k| k == "grants"),
"an empty grants list must not appear (unqualified mana signature unchanged)",
);
}

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.

medium

The new test mana_signature_exposes_grants correctly verifies that the grants field is exposed in the Effect::Mana signature. However, the associated change in effect_details also exposes restrictions, expiry, and target fields, which are not covered by this test.

To make the test suite more robust and prevent future regressions for these other fields, I suggest expanding this test to cover all newly exposed fields. This aligns with the testing philosophy of asserting the properties of the entire building block, not just one input.

Here's a more comprehensive test that verifies each field individually:

    #[test]
    fn mana_signature_exposes_all_fields() {
        use crate::types::ability::{ManaContribution, ManaSpendRestriction};
        use crate::types::mana::{ManaExpiry, ManaSpellGrant};

        // #5507: The 'Effect::Mana' signature must expose all its fields when set,
        // and omit them when empty/default, to keep parse-diffs accurate.
        let base_effect = Effect::Mana {
            produced: ManaProduction::AnyOneColor {
                count: QuantityExpr::Fixed { value: 1 },
                color_options: vec![ManaColor::White, ManaColor::Blue],
                contribution: ManaContribution::Base,
            },
            restrictions: vec![],
            grants: vec![],
            expiry: None,
            target: None,
        };

        // Base case: only 'produced' is set.
        let keys = effect_details(&base_effect).into_iter().map(|(k, _)| k).collect::<Vec<_>>();
        assert_eq!(keys, vec!["mana"], "base effect should only have 'mana' key");

        // Test 'restrictions'.
        let with_restrictions = Effect::Mana {
            restrictions: vec![ManaSpendRestriction::SpellOnly],
            ..base_effect.clone()
        };
        let keys = effect_details(&with_restrictions).into_iter().map(|(k, _)| k).collect::<Vec<_>>();
        assert!(keys.contains(&"restrictions".to_string()), "must expose restrictions when set");
        assert_eq!(keys.len(), 2, "should have mana and restrictions keys");

        // Test 'grants'.
        let with_grants = Effect::Mana {
            grants: vec![ManaSpellGrant::AddKeywordUntilEndOfTurn {
                keyword: Keyword::Haste,
                restriction: None,
                duration: Box::new(Duration::UntilEndOfTurn),
            }],
            ..base_effect.clone()
        };
        let keys = effect_details(&with_grants).into_iter().map(|(k, _)| k).collect::<Vec<_>>();
        assert!(keys.contains(&"grants".to_string()), "must expose grants when set");
        assert_eq!(keys.len(), 2, "should have mana and grants keys");

        // Test 'expiry'.
        let with_expiry = Effect::Mana {
            expiry: Some(ManaExpiry::EndOfTurn),
            ..base_effect.clone()
        };
        let keys = effect_details(&with_expiry).into_iter().map(|(k, _)| k).collect::<Vec<_>>();
        assert!(keys.contains(&"expiry".to_string()), "must expose expiry when set");
        assert_eq!(keys.len(), 2, "should have mana and expiry keys");

        // Test 'target'.
        let with_target = Effect::Mana {
            target: Some(TargetFilter::Controller),
            ..base_effect.clone()
        };
        let keys = effect_details(&with_target).into_iter().map(|(k, _)| k).collect::<Vec<_>>();
        assert!(keys.contains(&"target".to_string()), "must expose target when set");
        assert_eq!(keys.len(), 2, "should have mana and target keys");
    }

@github-actions

Copy link
Copy Markdown

Parse changes introduced by this PR · 241 card(s), 87 signature(s) (baseline: main c92263ab6cd7)

22 card(s) · ability/Mana · field expiry: EndOfTurn

Examples: Birgi, God of Storytelling, Branch of Vitu-Ghazi, Brazen Collector (+19 more)

20 card(s) · ability/Mana · field target: parent target's controller

Examples: Blighted Burgeoning, Buried in the Garden, Dawn's Reflection (+17 more)

19 card(s) · ability/Mana · field restrictions: [SpellType("Creature")]

Examples: Abundant Countryside, Adherent's Heirloom, Ancient Ziggurat (+16 more)

12 card(s) · ability/Mana · field restrictions: [ActivateOnly]

Examples: Boommobile, Cryptic Trilobite, Eumidian Lifeseed (+9 more)

12 card(s) · ability/Mana · field target: triggering player

Examples: Barbflare Gremlin, Dictate of Karametra, Eloren Wilds (+9 more)

11 card(s) · ability/Mana · field restrictions: [SpellType("Instant or Sorcery")]

Examples: Curious Homunculus, Galazeth Prismari, Great Hall of the Biblioplex (+8 more)

11 card(s) · ability/Mana · field restrictions: [SpellTypeOrAbilityActivation { spell_type: "Artifact", ability: OfSpellType }]

Examples: Cargo Ship, Dalakos, Crafter of Wonders, Fabrication Foundry (+8 more)

8 card(s) · ability/Mana · field restrictions: [ChosenCreatureType]

Examples: Cavern of Souls, Echoing Cavern, Eclipsed Realms (+5 more)

7 card(s) · ability/Mana · field target: scoped player

Examples: Belbe, Corrupted Observer, Blinkmoth Urn, Cheering Crowd (+4 more)

6 card(s) · ability/Mana · field restrictions: [SpellType("Noncreature")]

Examples: Chandra, Chill of Compliance, Happy Dead Squirrel, Immortus, Master of Eternity (+3 more)

6 card(s) · ability/Mana · field restrictions: [SpellTypeOrAbilityActivation { spell_type: "Artifact", ability: Any }]

Examples: Battery Bearer, Guidelight Optimizer, Hydraulic Helper (+3 more)

5 card(s) · ability/Mana · field restrictions: [SpellType("Instant and Sorcery")]

Examples: Abstract Paintmage, Daxiver, Izzet Electromancer, Paradigm Shifter (+2 more)

5 card(s) · ability/Mana · field target: player

Examples: Bigger on the Inside, Color Pie, Mad Science Fair Project (+2 more)

4 card(s) · ability/Mana · field restrictions: [SpellType("Legendary")]

Examples: Delighted Halfling, Great Hall of the Citadel, Plaza of Heroes (+1 more)

4 card(s) · ability/Mana · field restrictions: [XCostOnly]

Examples: Elementalist's Palette, Nexos, Rosheen Meanderer (+1 more)

4 card(s) · ability/Mana · field target: opponent

Examples: Carpet of Flowers, Jeska's Will, Orcish Squatters Avatar (+1 more)

3 card(s) · ability/Mana · field restrictions: [SpellType("Artifact")]

Examples: Castle Doom, Mishra's Workshop, Steelswarm Operator

3 card(s) · ability/Mana · field restrictions: [SpellTypeOrAbilityActivation { spell_type: "Creature", ability: OfSpellType }]

Examples: Castle Garenbrig, Gwenna, Eyes of Gaea, Lukka, Bound to Ruin

3 card(s) · ability/Mana · field restrictions: [SpellTypeOrAbilityActivation { spell_type: "Elemental", ability: OfSpellType }]

Examples: Flamebraider, Primal Beyond, Smokebraider

3 card(s) · ability/Mana · field target: chosen player

Examples: Spectral Searchlight, Stadium Vendors, Valleymaker

3 card(s) · ability/Mana · field target: controller

Examples: Organ Harvest, Priest of Forgotten Gods, Red Death, Shipwrecker

2 card(s) · ability/Mana · field grants: [AddKeywordUntilEndOfTurn { keyword: Haste, restriction: Some(OnlyForCreatureType("Dragon")), duration: UntilEndOfTurn …

Examples: A-Carnelian Orb of Dragonkind, Carnelian Orb of Dragonkind

2 card(s) · ability/Mana · field grants: [AddKeywordUntilEndOfTurn { keyword: Haste, restriction: Some(OnlyForSpellType("Creature")), duration: UntilEndOfTurn }]

Examples: Arena of Glory, Generator Servant

2 card(s) · ability/Mana · field grants: [CantBeCountered]

Examples: Cavern of Souls, Delighted Halfling

2 card(s) · ability/Mana · field grants: [TriggerOnSpend { restriction: Some(OnlyForCreatureType("Dragon")), ability: {"kind":"Activated","effect":{"type":"Scry…

Examples: A-Lapis Orb of Dragonkind, Lapis Orb of Dragonkind

… 62 more signature(s) (70 card-changes) — see parse-diff.json
  • 2 card(s) · ability/Mana · field restrictions: [Any([SpellType("Equipment"), ActivateTagged(Equip)])]
  • 2 card(s) · ability/Mana · field restrictions: [SpellFromZone(ZoneSpend { zone: Graveyard, polarity: From })]
  • 2 card(s) · ability/Mana · field restrictions: [SpellOnly]
  • 2 card(s) · ability/Mana · field restrictions: [SpellType("Dragon Creature")]
  • 2 card(s) · ability/Mana · field restrictions: [SpellType("Kicked")]
  • 2 card(s) · ability/Mana · field restrictions: [SpellType("Multicolored")]
  • 2 card(s) · ability/Mana · field restrictions: [SpellTypeOrAbilityActivation { spell_type: "Cleric, Rogue, Warrior, or Wizard", ability: OfSpellType }]
  • 2 card(s) · ability/Mana · field restrictions: [SpellTypeOrAbilityActivation { spell_type: "Dragon", ability: OfSpellType }]
  • 1 card(s) · ability/Mana · field grants: [AddKeywordUntilEndOfTurn { keyword: Haste, restriction: Some(OnlyForSpellType("Creature")), duration: Permanent }]
  • 1 card(s) · ability/Mana · field grants: [AddKeywordUntilEndOfTurn { keyword: Riot, restriction: Some(OnlyForSpellType("Creature")), duration: Permanent }]
  • 1 card(s) · ability/Mana · field grants: [TriggerOnSpend { restriction: Some(OnlyForCreatureType("Dragon")), ability: {"kind":"Activated","effect":{"type":"Gain…
  • 1 card(s) · ability/Mana · field grants: [TriggerOnSpend { restriction: Some(OnlyForSpellWithManaValue { comparator: GE, value: 6 }), ability: {"kind":"Activate…
  • 1 card(s) · ability/Mana · field grants: [TriggerOnSpend { restriction: Some(SharesCreatureTypeWithCommander), ability: {"kind":"Activated","effect":{"type":"Sc…
  • 1 card(s) · ability/Mana · field restrictions: [ActivateTagged(PowerUp)]
  • 1 card(s) · ability/Mana · field restrictions: [Any([ActivateOnly, SpellType("Artifact")])]
  • 1 card(s) · ability/Mana · field restrictions: [Any([FaceDownSpell, TurnPermanentFaceUp])]
  • 1 card(s) · ability/Mana · field restrictions: [Any([SpellType("Assassin"), SpellWithKeywordKind(Freerunning), SpellTypeOrAbilityActivation { spell_type: "Assassin", …
  • 1 card(s) · ability/Mana · field restrictions: [Any([SpellType("Dragon"), SpellType("Omen")])]
  • 1 card(s) · ability/Mana · field restrictions: [Any([SpellType("Elemental"), SpellType("Chandra Planeswalker")])]
  • 1 card(s) · ability/Mana · field restrictions: [Any([SpellType("Enchantment"), UnlockDoor, TurnPermanentFaceUp])]
  • 1 card(s) · ability/Mana · field restrictions: [Any([SpellType("Room"), UnlockDoor])]
  • 1 card(s) · ability/Mana · field restrictions: [SpellFromZone(ZoneSpend { zone: Exile, polarity: From })]
  • 1 card(s) · ability/Mana · field restrictions: [SpellFromZone(ZoneSpend { zone: Hand, polarity: NotFrom })]
  • 1 card(s) · ability/Mana · field restrictions: [SpellMatchingCostCriteria { spell_type: None, criteria: [ManaValue { comparator: GE, value: 5 }, HasXInCost] }]
  • 1 card(s) · ability/Mana · field restrictions: [SpellMatchingCostCriteria { spell_type: Some("Creature"), criteria: [ManaValue { comparator: GE, value: 4 }, HasXInCos…
  • 1 card(s) · ability/Mana · field restrictions: [SpellType("Ally")]
  • 1 card(s) · ability/Mana · field restrictions: [SpellType("Angel")]
  • 1 card(s) · ability/Mana · field restrictions: [SpellType("Artifact or Creature")]
  • 1 card(s) · ability/Mana · field restrictions: [SpellType("Aura And/or Equipment")]
  • 1 card(s) · ability/Mana · field restrictions: [SpellType("Blue Creature")]
  • 1 card(s) · ability/Mana · field restrictions: [SpellType("Colorless")]
  • 1 card(s) · ability/Mana · field restrictions: [SpellType("Creature or Enchantment")]
  • 1 card(s) · ability/Mana · field restrictions: [SpellType("Dragon")]
  • 1 card(s) · ability/Mana · field restrictions: [SpellType("Elf Creature")]
  • 1 card(s) · ability/Mana · field restrictions: [SpellType("Instant And/or Sorcery")]
  • 1 card(s) · ability/Mana · field restrictions: [SpellType("Instant, Sorcery, Demon, and Spirit")]
  • 1 card(s) · ability/Mana · field restrictions: [SpellType("Knight or Equipment")]
  • 1 card(s) · ability/Mana · field restrictions: [SpellType("Lesson or Shrine")]
  • 1 card(s) · ability/Mana · field restrictions: [SpellType("Lesson")]
  • 1 card(s) · ability/Mana · field restrictions: [SpellType("Mount or Vehicle")]
  • 1 card(s) · ability/Mana · field restrictions: [SpellType("Mount")]
  • 1 card(s) · ability/Mana · field restrictions: [SpellType("Ninja or Turtle")]
  • 1 card(s) · ability/Mana · field restrictions: [SpellType("Omen")]
  • 1 card(s) · ability/Mana · field restrictions: [SpellType("Phyrexian Creature")]
  • 1 card(s) · ability/Mana · field restrictions: [SpellType("Pilot or Vehicle")]
  • 1 card(s) · ability/Mana · field restrictions: [SpellType("Planeswalker")]
  • 1 card(s) · ability/Mana · field restrictions: [SpellType("Sliver")]
  • 1 card(s) · ability/Mana · field restrictions: [SpellType("Vampire")]
  • 1 card(s) · ability/Mana · field restrictions: [SpellType("Vampire, Cleric, And/or Demon")]
  • 1 card(s) · ability/Mana · field restrictions: [SpellTypeOrAbilityActivation { spell_type: "Ally", ability: OfSpellType }]
  • 1 card(s) · ability/Mana · field restrictions: [SpellTypeOrAbilityActivation { spell_type: "Colorless Eldrazi", ability: OfSpellType }]
  • 1 card(s) · ability/Mana · field restrictions: [SpellTypeOrAbilityActivation { spell_type: "Colorless", ability: Any }]
  • 1 card(s) · ability/Mana · field restrictions: [SpellTypeOrAbilityActivation { spell_type: "Dinosaur", ability: OfSpellType }]
  • 1 card(s) · ability/Mana · field restrictions: [SpellTypeOrAbilityActivation { spell_type: "Hero", ability: OfSpellType }]
  • 1 card(s) · ability/Mana · field restrictions: [SpellTypeOrAbilityActivation { spell_type: "Myr", ability: OfSpellType }]
  • 1 card(s) · ability/Mana · field restrictions: [SpellTypeOrAbilityActivation { spell_type: "Outlaw", ability: OfSpellType }]
  • 1 card(s) · ability/Mana · field restrictions: [SpellTypeOrAbilityActivation { spell_type: "Time Lord or Alien", ability: OfSpellType }]
  • 1 card(s) · ability/Mana · field restrictions: [SpellTypeOrAbilityActivation { spell_type: "Villain", ability: OfSpellType }]
  • 1 card(s) · ability/Mana · field restrictions: [SpellWithColorCount { comparator: EQ, count: 3 }]
  • 1 card(s) · ability/Mana · field restrictions: [SpellWithKeywordKindFromZone { kind: Flashback, zone: Graveyard }]
  • 1 card(s) · ability/Mana · field restrictions: [SpellWithManaValue { comparator: GE, value: 4 }]
  • 1 card(s) · ability/Mana · field restrictions: [TurnPermanentFaceUp]

@matthewevans matthewevans self-assigned this Jul 10, 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. Closes #5507, the third instance of this family after #5492/#5495 (fixed by #5493/#5501), and it fixes the class rather than the instance.

The right seam and the right shape: effect_details is where the parse-diff signature is rendered, and the fix replaces Effect::Mana { produced, .. } with a full destructure of all five fields. I confirmed against types/ability.rs:10388-10406 that produced, restrictions, grants, expiry, and target are exactly the field set — so the .. is genuinely gone and a sixth Effect::Mana field becomes a compile error instead of a fourth silent omission. That converts a recurring silent-omission class into a compiler-enforced invariant, which is the durable fix.

The conditional emission is the detail that makes it safe: each field is pushed only when non-empty/Some, so unqualified mana signatures stay byte-identical and no baseline churn is introduced. The test asserts both directions — grants present when set, absent when empty — so it discriminates on revert in both the positive and the no-churn direction.

quality: exhaustive destructure over .., compile-time enforcement, zero baseline churn, paired positive/negative assertions.

@matthewevans matthewevans added bug Bug fix quality For high-quality minimal to no-churn PRs labels Jul 10, 2026
@matthewevans
matthewevans added this pull request to the merge queue Jul 10, 2026
@matthewevans matthewevans removed their assignment Jul 10, 2026
Merged via the queue into phase-rs:main with commit 26234c0 Jul 10, 2026
13 checks passed
@minion1227
minion1227 deleted the minion_5507 branch July 18, 2026 06:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug fix quality For high-quality minimal to no-churn PRs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

coverage-parse-diff is blind to ManaSpellGrant (third instance, after #5492 and #5495)

2 participants