-
-
Notifications
You must be signed in to change notification settings - Fork 148
fix(parser): stop failing open on an unparseable subject, bind targeting compound subjects (#6965) #7003
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix(parser): stop failing open on an unparseable subject, bind targeting compound subjects (#6965) #7003
Changes from all commits
45878b1
c0fa13f
7b50b70
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -413,25 +413,28 @@ fn two_target_fight_pump_keeps_both_slots_and_buffs_slot_zero() { | |
| } | ||
| } | ||
|
|
||
| /// #4751 review (matthewevans): part 1's sentence-bounding drops only a STRAY | ||
| /// `Effect::unimplemented("you")` head from Life at Stake — it does NOT remove a | ||
| /// functional chooser. "You and target creature's controller each secretly | ||
| /// choose a number" is unimplemented on BOTH `main` and this branch: the real | ||
| /// mechanic is a `Choose { NumberRange }` (with the exile + lose-life tail), | ||
| /// byte-identical either way. On `main` the leading "You" was split off its "and" | ||
| /// by a coincidental " loses " in a LATER sentence into a bare | ||
| /// `Unimplemented { name: "you", description: "You" }`; bounding the verb scan to | ||
| /// the first sentence stops that coincidental split, so the chain now heads at | ||
| /// the real `Choose` node instead of the stray "you" fragment. This pins that the | ||
| /// card's actual choose-a-number mechanic survives (no regression) — the | ||
| /// controller was never a *parsed* chooser to lose (that stays a pre-existing gap | ||
| /// on both, orthogonal to this PR). | ||
| #[test] | ||
| fn life_at_stake_keeps_choose_mechanic_and_drops_only_the_stray_you_stub() { | ||
| /// CR 109.4 + CR 115.1 + CR 608.2c + CR 608.2d: Life at Stake — "You and target | ||
| /// creature's controller each secretly choose a number 0 or greater." | ||
| /// | ||
| /// The compound subject's second conjunct names its player THROUGH an announced | ||
| /// object target, so the parse must produce three things, not one: | ||
| /// 1. a `TargetOnly { creature }` head declaring the CR 115.1 target slot the | ||
| /// possessive reference (and the later "exile that creature" anaphor) read; | ||
| /// 2. a `Choose { NumberRange }` whose chooser is the printed controller | ||
| /// ("you", CR 109.5 — the unscoped resolver default); | ||
| /// 3. a SECOND `Choose { NumberRange }` bound to a DISTINCT chooser via | ||
| /// `player_scope: ParentObjectTargetController` (CR 109.4). | ||
| /// | ||
| /// The two choosers being distinct is the whole mechanic — one `Choose`, or two | ||
| /// that both prompt the caster, is the bug. Predecessor of this test pinned a | ||
| /// single `Choose` head while the controller was an unparsed chooser; that gap | ||
| /// is now closed. | ||
| #[test] | ||
| fn life_at_stake_binds_both_number_choosers_to_distinct_players() { | ||
| use crate::types::ability::ChoiceType; | ||
|
|
||
| fn collect(a: &AbilityDefinition, out: &mut Vec<Effect>) { | ||
| out.push((*a.effect).clone()); | ||
| fn collect<'a>(a: &'a AbilityDefinition, out: &mut Vec<&'a AbilityDefinition>) { | ||
| out.push(a); | ||
| if let Some(s) = a.sub_ability.as_deref() { | ||
| collect(s, out); | ||
| } | ||
|
|
@@ -446,22 +449,47 @@ fn life_at_stake_keeps_choose_mechanic_and_drops_only_the_stray_you_stub() { | |
| let parsed = parse_oracle_text(text, "Life at Stake", &[], &["Instant".to_string()], &[]); | ||
| let ability = parsed.abilities.first().expect("expected a spell ability"); | ||
|
|
||
| // The real mechanic — Choose a number — heads the chain (not a stray | ||
| // `Unimplemented("you")` fragment). | ||
| assert!( | ||
| matches!( | ||
| &*ability.effect, | ||
| Effect::Choose { | ||
| choice_type: ChoiceType::NumberRange { .. }, | ||
| .. | ||
| } | ||
| ), | ||
| "Life at Stake must head at a NumberRange Choose, not a stray you-stub; got {:#?}", | ||
| ability.effect | ||
| // CR 115.1: the announced creature is the ability's target — declared by a | ||
| // slot-only head so the possessive chooser and the "that creature" exile | ||
| // anaphor both have something to resolve against. | ||
| let Effect::TargetOnly { target } = &*ability.effect else { | ||
| panic!( | ||
| "Life at Stake must head at a TargetOnly declaring the creature target, got {:#?}", | ||
| ability.effect | ||
| ); | ||
| }; | ||
| assert_eq!( | ||
| *target, | ||
| TargetFilter::Typed(TypedFilter::creature()), | ||
| "the declared target must be the announced creature" | ||
| ); | ||
|
|
||
| let mut effects = Vec::new(); | ||
| collect(ability, &mut effects); | ||
| let mut links = Vec::new(); | ||
| collect(ability, &mut links); | ||
|
|
||
| // Both halves of the compound subject choose a number, and their choosers | ||
| // are DIFFERENT players: the printed controller (no scope) and the targeted | ||
| // creature's controller (CR 109.4). | ||
| let choosers: Vec<Option<PlayerFilter>> = links | ||
| .iter() | ||
| .filter(|link| { | ||
| matches!( | ||
| &*link.effect, | ||
| Effect::Choose { | ||
| choice_type: ChoiceType::NumberRange { .. }, | ||
| .. | ||
| } | ||
| ) | ||
| }) | ||
| .map(|link| link.player_scope.clone()) | ||
| .collect(); | ||
| assert_eq!( | ||
| choosers, | ||
| vec![None, Some(PlayerFilter::ParentObjectTargetController)], | ||
| "both conjuncts must choose a number, bound to distinct choosers" | ||
| ); | ||
|
|
||
| let effects: Vec<&Effect> = links.iter().map(|link| &*link.effect).collect(); | ||
| assert!( | ||
| effects.iter().any(|e| matches!( | ||
| e, | ||
|
|
@@ -479,9 +507,124 @@ fn life_at_stake_keeps_choose_mechanic_and_drops_only_the_stray_you_stub() { | |
| assert!( | ||
| !effects | ||
| .iter() | ||
| .any(|e| matches!(e, Effect::Unimplemented { name, .. } if name == "you")), | ||
| "the stray Unimplemented(\"you\") head must be gone: {effects:#?}" | ||
| .any(|e| matches!(e, Effect::Unimplemented { .. })), | ||
| "no clause of the choose-a-number sentence may fall back to Unimplemented: {effects:#?}" | ||
| ); | ||
| } | ||
|
|
||
| /// CR 109.4 + CR 115.1 + CR 608.2c: the possessive-actor second-subject axis is | ||
| /// a CLASS, not Life at Stake's card. The same "you and target <filter>'s | ||
| /// controller each <body>" shape must distribute a body that DOES carry a | ||
| /// recipient slot, binding it through the effect's own field rather than | ||
| /// `player_scope` — and the announced target must be declared exactly once. | ||
| #[test] | ||
| fn possessive_actor_compound_subject_distributes_a_recipient_bearing_body() { | ||
| let ability = parse_effect_chain( | ||
| "You and target creature's controller each draw a card.", | ||
| AbilityKind::Spell, | ||
| ); | ||
|
|
||
| let Effect::TargetOnly { target } = &*ability.effect else { | ||
| panic!( | ||
| "expected a TargetOnly target declaration, got {:#?}", | ||
| ability.effect | ||
| ); | ||
| }; | ||
| assert_eq!(*target, TargetFilter::Typed(TypedFilter::creature())); | ||
|
|
||
| let you = ability | ||
| .sub_ability | ||
| .as_deref() | ||
| .expect("expected the caster half"); | ||
| match &*you.effect { | ||
| Effect::Draw { target, .. } => assert_eq!(*target, TargetFilter::OriginalController), | ||
| other => panic!("expected the caster half to Draw, got {other:?}"), | ||
| } | ||
| assert_eq!( | ||
| you.player_scope, None, | ||
| "a recipient-bearing body binds \"you\" on the effect, not as a fan-out" | ||
| ); | ||
|
|
||
| let them = you | ||
| .sub_ability | ||
| .as_deref() | ||
| .expect("expected the possessive half"); | ||
| match &*them.effect { | ||
| Effect::Draw { target, .. } => assert_eq!(*target, TargetFilter::ParentTargetController), | ||
| other => panic!("expected the possessive half to Draw, got {other:?}"), | ||
| } | ||
| assert_eq!( | ||
| them.player_scope, None, | ||
| "a recipient-bearing body must not ALSO fan out — that would double-apply" | ||
| ); | ||
| } | ||
|
|
||
| /// CR 109.4 + CR 608.2d: the recipient-less binding channel generalizes past the | ||
| /// possessive axis. Infernal Offering's "You and that player each sacrifice a | ||
| /// creature" has no `TargetFilter` recipient slot on `Effect::Sacrifice` either, | ||
| /// and its second conjunct is the opponent a preceding "Choose an opponent." | ||
| /// picked — so the second half binds `player_scope: ChosenPlayer`. | ||
| #[test] | ||
| fn recipient_less_body_binds_a_chosen_player_conjunct_by_scope() { | ||
| let parsed = parse_oracle_text( | ||
| "Choose an opponent. You and that player each sacrifice a creature.", | ||
| "Infernal Offering", | ||
| &[], | ||
| &["Sorcery".to_string()], | ||
| &[], | ||
| ); | ||
| let ability = parsed.abilities.first().expect("expected a spell ability"); | ||
|
|
||
| let you = ability | ||
| .sub_ability | ||
| .as_deref() | ||
| .expect("expected the caster half after the Choose"); | ||
| assert!( | ||
| matches!(&*you.effect, Effect::Sacrifice { .. }), | ||
| "the caster half must sacrifice, got {:#?}", | ||
| you.effect | ||
| ); | ||
| assert_eq!(you.player_scope, None, "\"you\" is the unscoped default"); | ||
|
|
||
| let them = you | ||
| .sub_ability | ||
| .as_deref() | ||
| .expect("expected the chosen-player half"); | ||
| assert!( | ||
| matches!(&*them.effect, Effect::Sacrifice { .. }), | ||
| "the chosen-player half must sacrifice, got {:#?}", | ||
| them.effect | ||
| ); | ||
| assert_eq!( | ||
| them.player_scope, | ||
| Some(PlayerFilter::ChosenPlayer { index: 0 }), | ||
| "the chosen opponent must be the acting player of the second half" | ||
| ); | ||
| } | ||
|
|
||
| /// CR 109.4: FAIL-CLOSED contract for the recipient-less binding channel. No | ||
| /// `PlayerFilter` can name a TARGETED player, so "you and target opponent each | ||
| /// flip a coin" (Mana Clash) / "… each secretly choose 1, 2, or 3" | ||
| /// (Expert-Level Safe) must stay an honest `Unimplemented` — binding the body to | ||
| /// `PlayerFilter::Opponent` would make EVERY opponent act in a multiplayer game, | ||
| /// and leaving it unbound would make the caster act twice. | ||
| #[test] | ||
| fn recipient_less_body_with_a_targeted_player_conjunct_fails_closed() { | ||
| for text in [ | ||
| "You and target opponent each flip a coin.", | ||
| "You and target opponent each secretly choose 1, 2, or 3.", | ||
| ] { | ||
| let ability = parse_effect_chain(text, AbilityKind::Spell); | ||
| assert!( | ||
| matches!(&*ability.effect, Effect::Unimplemented { .. }), | ||
| "{text:?} must fail closed, got {:#?}", | ||
| ability.effect | ||
| ); | ||
| assert_eq!( | ||
| ability.player_scope, None, | ||
| "{text:?} must not fabricate a fan-out scope" | ||
| ); | ||
|
Comment on lines
+617
to
+626
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Assert the intended fail-closed reason. This test accepts any As per path instructions, negative parser assertions need a positive reach guard. The PR objective requires unbound subjects to lower as 🤖 Prompt for AI AgentsSource: Path instructions |
||
| } | ||
| } | ||
|
|
||
| /// Recursively walk an ability chain (root effect + `sub_ability` + `else_ability`) | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -182,7 +182,24 @@ pub(crate) enum ClauseAst { | |||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| #[derive(Debug, Clone, PartialEq, Eq, Serialize)] | ||||||||||||||||||||||||||||||
| pub(crate) struct SubjectPhraseAst { | ||||||||||||||||||||||||||||||
| pub(crate) affected: TargetFilter, | ||||||||||||||||||||||||||||||
| /// CR 608.2c ("read the whole text and apply the rules of English to the | ||||||||||||||||||||||||||||||
| /// text"): the subject the predicate applies to, or `None` when the | ||||||||||||||||||||||||||||||
| /// sentence printed a subject the subject grammar could not bind. | ||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||
| /// **`Option`, not a permissive default (issue #6965).** Both sites that | ||||||||||||||||||||||||||||||
| /// re-derive a subject phrase used to substitute `TargetFilter::Any` when | ||||||||||||||||||||||||||||||
| /// [`super::SubjectApplication`] could not be produced. `TargetFilter::Any` | ||||||||||||||||||||||||||||||
| /// matches unconditionally (`game/filter.rs`), so a parse FAILURE emitted a | ||||||||||||||||||||||||||||||
| /// BOARD-WIDE effect — the grant landed on every permanent, lands and | ||||||||||||||||||||||||||||||
| /// artifacts included, while coverage still reported the card as supported. | ||||||||||||||||||||||||||||||
| /// Encoding the unbound state in the type makes that fail-open | ||||||||||||||||||||||||||||||
| /// unrepresentable: every consumer must say what it does with `None`, and | ||||||||||||||||||||||||||||||
| /// the one consumer that actually reads this field | ||||||||||||||||||||||||||||||
| /// (`lower_subject_predicate_ast`'s `ImperativeFallback` arm, the only | ||||||||||||||||||||||||||||||
| /// predicate kind that applies the subject filter) fails closed to | ||||||||||||||||||||||||||||||
| /// `Effect::unimplemented`. Same shape, same reason, as | ||||||||||||||||||||||||||||||
| /// [`EntersUnderSpec::UnboundAnaphor`]. | ||||||||||||||||||||||||||||||
|
Comment on lines
+196
to
+201
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Correct the "one consumer" claim in the doc. The doc states that The invariant the doc wants to state is narrower: 📝 Proposed doc correction- /// unrepresentable: every consumer must say what it does with `None`, and
- /// the one consumer that actually reads this field
- /// (`lower_subject_predicate_ast`'s `ImperativeFallback` arm, the only
- /// predicate kind that applies the subject filter) fails closed to
- /// `Effect::unimplemented`. Same shape, same reason, as
+ /// unrepresentable: every consumer must say what it does with `None`. The
+ /// only consumer that applies this filter as a subject
+ /// (`lower_subject_predicate_ast`'s `ImperativeFallback` arm) fails closed
+ /// to `Effect::unimplemented`; the rebinding helpers
+ /// (`inject_subject_target`, `sync_subject_into_nested_shuffle_sub`) read it
+ /// only as a fallback after `target` and no-op on `None`. Same shape, same
+ /// reason, as
/// [`EntersUnderSpec::UnboundAnaphor`].📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
| pub(crate) affected: Option<TargetFilter>, | ||||||||||||||||||||||||||||||
| pub(crate) target: Option<TargetFilter>, | ||||||||||||||||||||||||||||||
| pub(crate) multi_target: Option<MultiTargetSpec>, | ||||||||||||||||||||||||||||||
| pub(crate) inherits_parent: bool, | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Correct the CR citations.
At Lines 416-426 and 515-519, remove
CR 608.2cunless the test documents resolution in written order. At Lines 562-566 and 605-610, removeCR 109.4because it defines controllers of objects, notthat playerortarget opponent. Use a verified rule that directly describes the tested target, controller, choice, or action behavior.CR 109.4 applies to objects on the stack or battlefield. CR 115.1 defines targets. CR 608.2c only covers following instructions in written order. (media.wizards.com)
As per path instructions, rules-touching code must use a verified CR citation whose text describes the code. Based on learnings, cite CR 608.2c only for written instructions resolved in order.
Also applies to: 515-519, 562-566, 605-610
🤖 Prompt for AI Agents
Sources: Path instructions, Learnings