From f4a2951caeaa2ae677b22878f1ade1b7e092b6a7 Mon Sep 17 00:00:00 2001 From: jaytbarimbao-collab <300663773+jaytbarimbao-collab@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:08:23 -0400 Subject: [PATCH 1/3] fix(parser): guard the binary-choice splitter on the target-cardinality list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `try_parse_choose_one_of_inline` splits a clause at a bare " or ", but a divided/distributed effect's target-count quantifier embeds a bare " or " inside its cardinality list ("one, two, or three" / "one or two") that enumerates a target COUNT, not a disjunction of two imperative clauses. Splitting there hands the orphan tail back through the full effect→subject→static→imperative cascade as a trial parse; not being a standalone effect, it re-enters the clause parser several frames deep and can overflow a small parser thread stack. The existing guard keyed on the distribution VERB (`DISTRIBUTION_KEYWORDS`, divided damage only). CR 601.2d divides an effect "(such as damage or counters)", so the verb guard names one half and left the counter-distribution templating ("Distribute three +1/+1 counters among one, two, or three target creatures") unguarded — ~32 cards on `main` (Ajani, Mentor of Heroes; Biogenic Upgrade; Abzan Charm; Armament Corps; Blessing of Frost; …) still mis-split. Key the bail on the target-cardinality list instead — the axis shared by both halves. `BOUNDED_TARGET_CARDINALITY_LISTS` ("one or two target" / "one, two, or three target") is the same typed vocabulary the target-count layer strips via `strip_bounded_target_prefix`; the `target` stem (no trailing `s`) matches both the "… targets" (damage) and "… target " (counters) forms, and it is the complete set of bare-" or "-bearing cardinality lists in the pool. Also removed the "distributed among" divided-damage needle: it matches 0 of 34,632 cards (no Magic card uses that templating). Verified: the ~32 counter cards now parse whole as `PutCounter`/`DealDamage` with the distribution captured; Abzan Charm's 3-mode modal is intact (mode 3 = distribute counters); the divided-damage cards are unregressed. Full parser suite green (7956). New test: `distribute_counters_cardinality_list_is_not_binary_split`. Fixes #5613. --- .../engine/src/parser/oracle_effect/lower.rs | 13 +++++ crates/engine/src/parser/oracle_effect/mod.rs | 50 +++++++++---------- .../engine/src/parser/oracle_effect/tests.rs | 25 ++++++++++ 3 files changed, 61 insertions(+), 27 deletions(-) diff --git a/crates/engine/src/parser/oracle_effect/lower.rs b/crates/engine/src/parser/oracle_effect/lower.rs index 455044ba68..c897242350 100644 --- a/crates/engine/src/parser/oracle_effect/lower.rs +++ b/crates/engine/src/parser/oracle_effect/lower.rs @@ -4964,6 +4964,19 @@ pub(super) const BOUNDED_TARGET_PHRASES: &[(&str, usize, usize)] = &[ ("one, two, or three targets", 1, 3), ]; +/// CR 601.2d: The bare-`" or "`-bearing target-cardinality lists that appear in +/// divided/distributed effects — dividing an effect "(such as damage or +/// counters)" — where the `" or "` enumerates a target COUNT, never a +/// disjunction of two clauses. This is the complete set measured against the +/// full pool (`AtomicCards.json`, 34,632 cards); the `target` (no trailing `s`) +/// matches both the bare `"… targets"` (divided damage) and `"… target +/// "` (distributed counters) forms. `BOUNDED_TARGET_PHRASES` / +/// `strip_bounded_target_prefix` consume the same lists at the target-count +/// layer; the binary-choice splitter reuses this vocabulary to bail rather than +/// keying on a distribution verb (which names only one half of the rule). +pub(super) const BOUNDED_TARGET_CARDINALITY_LISTS: &[&str] = + &["one or two target", "one, two, or three target"]; + /// CR 115.1d + CR 601.2c: Strip exact target-count prefix before a targeted /// phrase. "two target creatures" and "X target creatures" both set the exact /// number of targets, unlike "up to X target creatures". diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index adc3dca851..e0afaa9669 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -4973,32 +4973,27 @@ fn try_parse_choose_one_of_inline( return None; } - // CR 601.2d: The divided/distributed-damage target-count quantifier - // ("divided as you choose among one, two, or three targets") embeds a bare - // " or " inside the cardinality list "one, two, or three", NOT a disjunction - // of two imperative clauses. Splitting there hands the orphan tail - // ("three targets") back through the full effect→subject→static→imperative - // recognizer cascade as a trial parse; because that tail is not a standalone - // effect, the cascade re-enters this clause parser several levels deep, and - // the parser's large per-frame AST locals overflow small (~1-2 MB) thread - // stacks. The dedicated `try_parse_distribute_damage` handler - // (`oracle_effect::lower`) owns this whole clause and reads the count list - // via `strip_distribute_among_target_quantifier`, so skip the generic binary - // splitter for any distribution clause and let that handler claim it. + // CR 601.2d: A divided/distributed effect's target-count quantifier + // ("divided as you choose among one, two, or three targets"; "distribute + // three +1/+1 counters among one, two, or three target creatures") embeds a + // bare " or " inside the cardinality list "one, two, or three" (or "one or + // two"), NOT a disjunction of two imperative clauses. Splitting there hands + // the orphan tail ("three target creatures …") back through the full + // effect→subject→static→imperative recognizer cascade as a trial parse; + // because that tail is not a standalone effect, the cascade re-enters this + // clause parser several levels deep, and the parser's large per-frame AST + // locals overflow small (~1-2 MB) thread stacks. // - // The keyword set is owned by `lower::DISTRIBUTION_KEYWORDS` — the same set the - // handler bounds its quantity slice with. Both must agree: a phrasing the handler - // learned but this guard did not would parse there and still split here. - // - // KNOWN GAP (pre-existing on `main`, not introduced here): this keys on the - // distribution *verb*, but the hazard is the target-cardinality list. CR 601.2d - // covers dividing "damage or counters", and the counter templating ("Distribute - // three +1/+1 counters among one, two, or three target creatures") carries the - // same bare " or " and is still split. The durable fix keys the bail on the - // cardinality list (`lower::BOUNDED_TARGET_PHRASES`), covering both halves at - // once; and the underlying cause is that a failed trial parse re-enters the - // clause cascade with no depth bound. Both are tracked as follow-ups. - if lower::DISTRIBUTION_KEYWORDS + // CR 601.2d divides an effect "(such as damage OR counters)", so the bail + // keys on the target-CARDINALITY list — the axis shared by both halves — + // rather than on a distribution verb (which names only the damage half and + // left the ~32 counter-distribution cards, e.g. Ajani, Mentor of Heroes; + // Biogenic Upgrade; Abzan Charm, still splitting). `BOUNDED_TARGET_CARDINALITY_LISTS` + // is the same typed vocabulary the target-count layer strips via + // `strip_bounded_target_prefix`; it is the complete set of bare-" or "-bearing + // cardinality lists in the pool, so the divide/distribute handlers claim these + // clauses whole. + if lower::BOUNDED_TARGET_CARDINALITY_LISTS .iter() .any(|needle| nom_primitives::scan_contains(tp.lower, needle)) { @@ -7748,9 +7743,10 @@ fn parse_effect_clause_inner(text: &str, ctx: &mut ParseContext) -> ParsedEffect } // CR 601.2d: "deal N damage divided as you choose among [targets]" / - // "distributed among" / "divided evenly" → DealDamage with distribute. + // "divided evenly" → DealDamage with distribute. (The "distributed among" + // needle was removed: it matches 0 of 34,632 cards — no Magic card uses that + // templating — so it only guarded phrasing that does not exist.) if scan_contains_phrase(&lower, "divided as you choose among") - || scan_contains_phrase(&lower, "distributed among") || scan_contains_phrase(&lower, "divided evenly") { if let Some(clause) = try_parse_distribute_damage(&lower, text) { diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 936531091e..16bce4f8d7 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -6892,6 +6892,31 @@ fn blazing_salvo_unless_have_deal_damage() { } } +/// CR 601.2d (#5613): a counter-distribution clause's target-cardinality list +/// ("one, two, or three target creatures") embeds a bare " or " that enumerates a +/// target COUNT, not a disjunction of two clauses. The binary-choice splitter now +/// bails on the cardinality list (previously it keyed only on the divided-DAMAGE +/// verb, leaving ~32 counter cards to mis-split), so the distribute-counters +/// handler claims the whole clause instead of the inline splitter bisecting it. +#[test] +fn distribute_counters_cardinality_list_is_not_binary_split() { + for text in [ + "Distribute three +1/+1 counters among one, two, or three target creatures.", + "Distribute two +1/+1 counters among one or two target creatures.", + ] { + let def = parse_effect_chain(text, AbilityKind::Spell); + assert!( + matches!(*def.effect, Effect::PutCounter { .. }), + "counter distribution must parse whole as PutCounter, got {:?} for {text:?}", + def.effect + ); + assert!( + def.distribute.is_some(), + "the distribution unit must be captured, not lost to a binary split: {text:?}" + ); + } +} + #[test] fn lava_blister_its_controller_unless_have_deal_damage() { let def = parse_effect_chain( From 1a39f12515f2246ddd138d22b8d52bc2ff8d9ba1 Mon Sep 17 00:00:00 2001 From: jaytbarimbao-collab <300663773+jaytbarimbao-collab@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:53:37 -0400 Subject: [PATCH 2/3] refactor(parser): single-authority cardinality vocab + honest keyword doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review on #5619. 1. Collapse the four near-duplicate target-cardinality spellings onto one authority — the cardinality STEM plus its bounds: BOUNDED_TARGET_CARDINALITIES = [("one or two", 1, 2), ("one, two, or three", 1, 3)] Each consumer composes the trailing noun it needs off the stem via nom `tag` sequencing (the combinator mandate) rather than re-spelling the list: - `strip_bounded_targets_placeholder` → stem + " targets" - `strip_bounded_target_prefix` → stem + " target " - `subject`'s "each of one or two targets" arm → stem + " targets" - `oracle_target`'s bare bounded-count target arm → stem + " targets" - the binary-choice splitter bail → stem + " target" A fifth cardinality list is now added in exactly one place. Removes the `BOUNDED_TARGET_PHRASES` and `BOUNDED_TARGET_CARDINALITY_LISTS` copies. 2. Rewrite `DISTRIBUTION_KEYWORDS`'s doc comment, which this PR made false: there is now ONE caller (`try_parse_distribute_damage`), not two; the splitter's coupling to it — and the set-drift hazard the comment warned about — is gone (the splitter now keys on the cardinality axis); and the "durable fix" the comment deferred to the future is this change. Verified: counter cards (Biogenic Upgrade, Abzan Charm) parse whole with the distribution captured; the strip-consumer cards (Prismari Charm "each of one or two targets"; Electrolyze "among one or two target creatures") are unregressed; full parser suite green (7956); gate + clippy clean. --- .../engine/src/parser/oracle_effect/lower.rs | 69 +++++++++---------- crates/engine/src/parser/oracle_effect/mod.rs | 14 ++-- .../src/parser/oracle_effect/subject.rs | 6 +- crates/engine/src/parser/oracle_target.rs | 18 +++-- 4 files changed, 53 insertions(+), 54 deletions(-) diff --git a/crates/engine/src/parser/oracle_effect/lower.rs b/crates/engine/src/parser/oracle_effect/lower.rs index c897242350..34e52045fa 100644 --- a/crates/engine/src/parser/oracle_effect/lower.rs +++ b/crates/engine/src/parser/oracle_effect/lower.rs @@ -4959,23 +4959,20 @@ const MULTI_TARGET_VERBS: &[&str] = &[ "exile", "tap", "untap", "goad", "detain", "return", "destroy", "choose", ]; -pub(super) const BOUNDED_TARGET_PHRASES: &[(&str, usize, usize)] = &[ - ("one or two targets", 1, 2), - ("one, two, or three targets", 1, 3), -]; - -/// CR 601.2d: The bare-`" or "`-bearing target-cardinality lists that appear in -/// divided/distributed effects — dividing an effect "(such as damage or -/// counters)" — where the `" or "` enumerates a target COUNT, never a -/// disjunction of two clauses. This is the complete set measured against the -/// full pool (`AtomicCards.json`, 34,632 cards); the `target` (no trailing `s`) -/// matches both the bare `"… targets"` (divided damage) and `"… target -/// "` (distributed counters) forms. `BOUNDED_TARGET_PHRASES` / -/// `strip_bounded_target_prefix` consume the same lists at the target-count -/// layer; the binary-choice splitter reuses this vocabulary to bail rather than -/// keying on a distribution verb (which names only one half of the rule). -pub(super) const BOUNDED_TARGET_CARDINALITY_LISTS: &[&str] = - &["one or two target", "one, two, or three target"]; +/// CR 115.1d + CR 601.2d: The bounded target-cardinality lists. The stem carries +/// the bare `" or "` that enumerates a target COUNT, never a disjunction of two +/// clauses; each list carries its `(min, max)` count bounds. This is the single +/// authority for the vocabulary — the complete set measured against the full +/// pool (`AtomicCards.json`, 34,632 cards). Every consumer composes the trailing +/// noun it needs off the stem (`" targets"`, `" target "`, `" target"`) rather +/// than re-spelling the list, so a future cardinality ("one, two, three, or +/// four …") is added here once. Consumers: `strip_bounded_targets_placeholder`, +/// `strip_bounded_target_prefix`, `subject::…each-of`, `oracle_target`'s +/// bare-count target arm, and the binary-choice splitter's divide/distribute +/// bail (which keys on this cardinality axis — shared by CR 601.2d's "damage or +/// counters" halves — rather than on a distribution verb). +pub(crate) const BOUNDED_TARGET_CARDINALITIES: &[(&str, usize, usize)] = + &[("one or two", 1, 2), ("one, two, or three", 1, 3)]; /// CR 115.1d + CR 601.2c: Strip exact target-count prefix before a targeted /// phrase. "two target creatures" and "X target creatures" both set the exact @@ -5018,8 +5015,10 @@ fn parse_exact_target_count_expr(input: &str) -> OracleResult<'_, QuantityExpr> /// Returns the unconsumed remainder and a bounded `MultiTargetSpec` with min ≥ 1. fn strip_bounded_targets_placeholder(text: &str) -> Option<(&str, MultiTargetSpec)> { let lower = text.to_ascii_lowercase(); - for &(phrase, min, max) in BOUNDED_TARGET_PHRASES { - if let Ok((rest, _)) = tag::<_, _, OracleError<'_>>(phrase).parse(lower.as_str()) { + for &(stem, min, max) in BOUNDED_TARGET_CARDINALITIES { + if let Ok((rest, _)) = + (tag::<_, _, OracleError<'_>>(stem), tag(" targets")).parse(lower.as_str()) + { let consumed = lower.len() - rest.len(); return Some(( text[consumed..].trim_start(), @@ -5035,11 +5034,10 @@ fn strip_bounded_targets_placeholder(text: &str) -> Option<(&str, MultiTargetSpe /// players"). fn strip_bounded_target_prefix(text: &str) -> Option<(&str, MultiTargetSpec)> { let lower = text.to_ascii_lowercase(); - for (prefix, min, max) in [ - ("one or two target ", 1usize, 2usize), - ("one, two, or three target ", 1, 3), - ] { - if let Ok((rest, _)) = tag::<_, _, OracleError<'_>>(prefix).parse(lower.as_str()) { + for &(stem, min, max) in BOUNDED_TARGET_CARDINALITIES { + if let Ok((rest, _)) = + (tag::<_, _, OracleError<'_>>(stem), tag(" target ")).parse(lower.as_str()) + { let consumed = lower.len() - rest.len(); return Some(( text[consumed..].trim_start(), @@ -5778,20 +5776,15 @@ fn multi_target_for_distribute_among(distribution_amount: &QuantityExpr) -> Mult /// Single authority for the "is this a distribution clause?" membership test — /// extend here, never inline at a call site. /// -/// Two callers must agree on this set. `try_parse_distribute_damage` bounds its -/// Pattern-B quantity slice with it, and `try_parse_choose_one_of_inline` bails out -/// of the generic binary-choice splitter for any clause containing one, because the -/// target-count list embeds a bare " or " ("among one, two, or three targets") that -/// is a cardinality list, not a disjunction of clauses. If the two sets were allowed -/// to drift, a newly supported distribution phrasing would parse correctly here yet -/// still be split there — re-entering the clause cascade and overflowing the stack. -/// -/// SCOPE: damage templating only. CR 601.2d also covers dividing *counters* -/// ("such as damage or counters"), and the counter templating ("Distribute three -/// +1/+1 counters among one, two, or three target creatures") carries the same bare -/// " or " and is NOT guarded here — it is a pre-existing hazard on `main`, tracked -/// separately. The durable fix keys the bail on the target-cardinality list rather -/// than on the distribution verb, which covers both halves of the rule at once. +/// One caller: `try_parse_distribute_damage` bounds its Pattern-B quantity slice +/// with this set. (The binary-choice splitter `try_parse_choose_one_of_inline` +/// formerly also consulted it to bail, but that coupling — and the set-drift +/// hazard it warned about — is gone: the splitter now keys its bail on the +/// target-cardinality list `BOUNDED_TARGET_CARDINALITIES`, the axis CR 601.2d +/// shares across both its "damage or counters" halves. That axis guards the +/// counter-distribution templating ("Distribute three +1/+1 counters among one, +/// two, or three target creatures") this damage-verb set never could, so the two +/// no longer need to agree.) pub(super) const DISTRIBUTION_KEYWORDS: [&str; 2] = ["divided as you choose among", "divided evenly"]; diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index e0afaa9669..49a4891bdc 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -4988,14 +4988,14 @@ fn try_parse_choose_one_of_inline( // keys on the target-CARDINALITY list — the axis shared by both halves — // rather than on a distribution verb (which names only the damage half and // left the ~32 counter-distribution cards, e.g. Ajani, Mentor of Heroes; - // Biogenic Upgrade; Abzan Charm, still splitting). `BOUNDED_TARGET_CARDINALITY_LISTS` - // is the same typed vocabulary the target-count layer strips via - // `strip_bounded_target_prefix`; it is the complete set of bare-" or "-bearing - // cardinality lists in the pool, so the divide/distribute handlers claim these - // clauses whole. - if lower::BOUNDED_TARGET_CARDINALITY_LISTS + // Biogenic Upgrade; Abzan Charm, still splitting). `BOUNDED_TARGET_CARDINALITIES` + // is the single authority for the vocabulary (the same lists the target-count + // layer strips via `strip_bounded_target_prefix`); compose the `" target"` + // stem-suffix it shares with both the "… targets" and "… target " + // forms, so the divide/distribute handlers claim these clauses whole. + if lower::BOUNDED_TARGET_CARDINALITIES .iter() - .any(|needle| nom_primitives::scan_contains(tp.lower, needle)) + .any(|&(stem, _, _)| nom_primitives::scan_contains(tp.lower, &format!("{stem} target"))) { return None; } diff --git a/crates/engine/src/parser/oracle_effect/subject.rs b/crates/engine/src/parser/oracle_effect/subject.rs index c63dec60ef..cde09c47da 100644 --- a/crates/engine/src/parser/oracle_effect/subject.rs +++ b/crates/engine/src/parser/oracle_effect/subject.rs @@ -12,7 +12,7 @@ use super::animation::{ has_in_addition_to_other_types, parse_animation_spec, }; use super::imperative; -use super::lower::BOUNDED_TARGET_PHRASES; +use super::lower::BOUNDED_TARGET_CARDINALITIES; use super::{resolve_it_pronoun, ParseContext}; use crate::parser::oracle_ir::ast::*; use crate::types::ability::{ @@ -2180,8 +2180,8 @@ pub(super) fn parse_subject_application( } // CR 115.1d: "each of one or two targets" — bounded multi-target selection // where the effect applies to each chosen target (Prismari Charm). - for &(phrase, min, max) in BOUNDED_TARGET_PHRASES { - if tag::<_, _, OracleError<'_>>(phrase) + for &(stem, min, max) in BOUNDED_TARGET_CARDINALITIES { + if (tag::<_, _, OracleError<'_>>(stem), tag(" targets")) .parse(remainder) .is_ok() { diff --git a/crates/engine/src/parser/oracle_target.rs b/crates/engine/src/parser/oracle_target.rs index 0d2d33a08c..14fff7749d 100644 --- a/crates/engine/src/parser/oracle_target.rs +++ b/crates/engine/src/parser/oracle_target.rs @@ -628,12 +628,18 @@ pub fn parse_target_with_syntax<'a>( } } - for phrase in [ - "one, two, or three targets", - "one or two targets", - "any number of targets", - "targets", - ] { + // CR 115.1d: bare bounded-count target arms ("one or two targets", "one, + // two, or three targets") share the single `BOUNDED_TARGET_CARDINALITIES` + // authority (composing the plural noun off the stem); the unbounded forms + // ("any number of targets", bare "targets") stay inline. + for &(stem, _, _) in crate::parser::oracle_effect::lower::BOUNDED_TARGET_CARDINALITIES { + if let Ok((rest, _)) = + (tag::<_, _, OracleError<'_>>(stem), tag(" targets")).parse(lower.as_str()) + { + return (TargetFilter::Any, &text[lower.len() - rest.len()..], syntax); + } + } + for phrase in ["any number of targets", "targets"] { if let Ok((rest, _)) = tag::<_, _, OracleError<'_>>(phrase).parse(lower.as_str()) { return (TargetFilter::Any, &text[lower.len() - rest.len()..], syntax); } From 779023d5c9773c7cda68d9bbfe50933692d2ab30 Mon Sep 17 00:00:00 2001 From: jaytbarimbao-collab <300663773+jaytbarimbao-collab@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:11:10 -0400 Subject: [PATCH 3/3] test(parser): make the #5613 guard test discriminating (bounded-stack) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior test asserted only on parse OUTPUT, which the coverage-parse-diff sticky proved is identical on `main` for this templating — so it passed on `main` too and could not fail if the guard were reverted (AI-CONTRIBUTOR.md §5(i)). The only property this fix changes is stack DEPTH: the removed trial parse re-enters the clause cascade several frames deep. Measure that directly. Peak stack for the exemplar clause on the full `parse_oracle_text` pipeline is ~3.5 MiB on `main` vs ~1.4 MiB with the guard; the test parses on a 2.5 MiB thread — mid-gap, ~1.1 MiB headroom here, ~1 MiB overflow margin on `main`. Reintroducing the wasted trial parse overflows this thread's guard page and aborts the binary, so the test fails when the fix is reverted. In-thread asserts still pin the output (PutCounter + captured distribution). --- .../engine/src/parser/oracle_effect/tests.rs | 66 ++++++++++++------- 1 file changed, 43 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 16bce4f8d7..70f31a05cd 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -6892,29 +6892,49 @@ fn blazing_salvo_unless_have_deal_damage() { } } -/// CR 601.2d (#5613): a counter-distribution clause's target-cardinality list -/// ("one, two, or three target creatures") embeds a bare " or " that enumerates a -/// target COUNT, not a disjunction of two clauses. The binary-choice splitter now -/// bails on the cardinality list (previously it keyed only on the divided-DAMAGE -/// verb, leaving ~32 counter cards to mis-split), so the distribute-counters -/// handler claims the whole clause instead of the inline splitter bisecting it. -#[test] -fn distribute_counters_cardinality_list_is_not_binary_split() { - for text in [ - "Distribute three +1/+1 counters among one, two, or three target creatures.", - "Distribute two +1/+1 counters among one or two target creatures.", - ] { - let def = parse_effect_chain(text, AbilityKind::Spell); - assert!( - matches!(*def.effect, Effect::PutCounter { .. }), - "counter distribution must parse whole as PutCounter, got {:?} for {text:?}", - def.effect - ); - assert!( - def.distribute.is_some(), - "the distribution unit must be captured, not lost to a binary split: {text:?}" - ); - } +/// CR 601.2d (#5613): a divided/distributed cardinality-list clause's bare " or " +/// ("… among one, two, or three target creatures") is a target-COUNT enumerator. +/// Before this fix the binary-choice splitter split there and trial-parsed the +/// orphan tail back through the effect→subject→static→imperative cascade. That +/// trial parse FAILS and is discarded, so the final parse OUTPUT is identical on +/// `main` (the pool coverage-parse-diff shows zero card changes) — but it +/// re-enters the clause parser several frames deep. The regression is stack +/// DEPTH, not output, so this asserts it on a bounded-stack thread. +/// +/// Peak stack for this parse (full `parse_oracle_text` pipeline, measured): +/// ~3.5 MiB on `main` (the wasted trial parse) vs ~1.4 MiB with the guard. A +/// 2.5 MiB stack thus survives here (~1.1 MiB headroom) and overflows on `main` +/// (~1 MiB margin): reintroducing the wasted trial parse overflows this thread's +/// guard page and aborts the test binary — so the test *fails when the fix is +/// reverted* (AI-CONTRIBUTOR.md §5(i)). The in-thread asserts additionally pin +/// the parse OUTPUT (`PutCounter` + a captured distribution). +#[test] +fn distribute_cardinality_clause_parses_within_a_bounded_stack() { + let handle = std::thread::Builder::new() + .stack_size(2_621_440) // 2.5 MiB — mid-gap between the ~1.4 / ~3.5 MiB floors + .spawn(|| { + let parsed = parse_oracle_text( + "Distribute three +1/+1 counters among one, two, or three target creatures.", + "Bounded Stack Test", + &[], + &["Sorcery".to_string()], + &[], + ); + let def = parsed.abilities.first().expect("expected a spell ability"); + assert!( + matches!(*def.effect, Effect::PutCounter { .. }), + "counter distribution must parse whole as PutCounter, got {:?}", + def.effect + ); + assert!( + def.distribute.is_some(), + "distribution unit must be captured" + ); + }) + .expect("spawn bounded-stack parse thread"); + handle + .join() + .expect("parsing the cardinality clause must not overflow a 2.5 MiB stack"); } #[test]