From 98fe497174ef2681eb96ec72421076a46f8ddd75 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:41:04 +0800 Subject: [PATCH 1/2] feat(analyzer): extend declarative lint matcher vocabulary (#970) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the #970 vocabulary so the first-party lint package can express the built-in rules the SPEC-309 matcher could not: - `matcher.scope` accepts a list of scopes; each listed scope is enumerated under one rule ID, so the three loop kinds share a rule. - `matcher.values` lists value patterns searched at any depth in every value tree of the scope: rule conditions and live action arguments for `rule`, live body-action arguments for a loop scope (per the owner decision, not the loop's own condition), and the condition list for `if`. Disabled and dead statements stay excluded. - `matcher.report: each-match` anchors one finding per matched node at the node's own span; the default `scope` keeps one finding per scope. - A `values` pattern may carry `within` plus `in-args: { from: N }`, an ancestor constraint limited to ancestor argument positions >= N, so a binder's array-source argument at index 0 stays outside. - A call pattern accepts `names: [...]` as an alternative to `name`; every spelling resolves through the catalog at load time. - First-party package rules may declare `evidence` and `severity` in metadata; the loader rejects both fields for community packages and `--rule-file` rules, as reserved by SPEC-309/ADR-0015. Acceptance checks live in `crates/wright-analyzer/tests/yaml_contract.rs`: span parity of the three expressible rules against the native analyses on `reproducer-bastion-prophet.ws` (16 nested-binder findings — the issue body's "13" predates the fixture's final count recorded in workshop-rs `docs/import-limits.md`) and on lint fixtures, plus the scope-list, each-match, within/in-args, names, and first-party-metadata contract cases. SPEC-309 and ADR-0015 are amended in the same change. --- crates/wright-analyzer/src/canonical.rs | 2 +- .../wright-analyzer/src/canonical/symbols.rs | 2 +- .../src/canonical/traversal.rs | 4 +- crates/wright-analyzer/src/declarative.rs | 819 +++++++----------- .../wright-analyzer/src/declarative/error.rs | 36 + .../src/declarative/pattern.rs | 409 +++++++++ .../wright-analyzer/src/declarative/schema.rs | 248 ++++++ crates/wright-analyzer/tests/yaml_contract.rs | 414 ++++++++- ...nical-facts-and-declarative-lint-policy.md | 8 +- docs/specs/SPEC-309-extensible-lint-rules.md | 38 +- tests/fixtures/README.md | 1 + .../real-world/reproducer-bastion-prophet.ws | 32 + 12 files changed, 1499 insertions(+), 514 deletions(-) create mode 100644 crates/wright-analyzer/src/declarative/pattern.rs create mode 100644 crates/wright-analyzer/src/declarative/schema.rs create mode 100644 tests/fixtures/workshop/real-world/reproducer-bastion-prophet.ws diff --git a/crates/wright-analyzer/src/canonical.rs b/crates/wright-analyzer/src/canonical.rs index 24ba4733..187fb365 100644 --- a/crates/wright-analyzer/src/canonical.rs +++ b/crates/wright-analyzer/src/canonical.rs @@ -11,5 +11,5 @@ pub use cfg::BLOCK_KINDS; pub(crate) use cfg::matching_end; pub use service::{SemanticService, span_json}; pub use symbols::*; -pub(crate) use traversal::{dead_actions, unwrapped}; +pub(crate) use traversal::{dead_actions, unwrapped, value_children, visit_action_roots}; pub use traversal::{live_actions, subroutine_callee}; diff --git a/crates/wright-analyzer/src/canonical/symbols.rs b/crates/wright-analyzer/src/canonical/symbols.rs index 8ac3e955..3eabc614 100644 --- a/crates/wright-analyzer/src/canonical/symbols.rs +++ b/crates/wright-analyzer/src/canonical/symbols.rs @@ -158,7 +158,7 @@ pub struct SemanticIndex { next_value_id: ValueId, } -pub(super) fn value_identity_map(program: &Program) -> HashMap { +pub(crate) fn value_identity_map(program: &Program) -> HashMap { let mut identities = HashMap::new(); for rule in &program.rules { for condition in &rule.conditions { diff --git a/crates/wright-analyzer/src/canonical/traversal.rs b/crates/wright-analyzer/src/canonical/traversal.rs index 1f5c2730..861bdb8b 100644 --- a/crates/wright-analyzer/src/canonical/traversal.rs +++ b/crates/wright-analyzer/src/canonical/traversal.rs @@ -70,7 +70,7 @@ pub fn subroutine_callee(action: &Action) -> Option<&str> { } } -pub(super) fn visit_action_roots<'a>(action: &'a Action, visit: &mut impl FnMut(usize, &'a Value)) { +pub(crate) fn visit_action_roots<'a>(action: &'a Action, visit: &mut impl FnMut(usize, &'a Value)) { match action { Action::SetGlobalVariable { value, .. } | Action::ModifyGlobalVariable { value, .. } => { visit(0, value) @@ -125,7 +125,7 @@ pub(super) fn visit_value_tree<'a>( visit_value_tree_at(value, parent, &mut path, visit); } -pub(super) fn value_children(value: &Value) -> Vec<&Value> { +pub(crate) fn value_children(value: &Value) -> Vec<&Value> { match value { Value::Array(values) | Value::Call { args: values, .. } => values.iter().collect(), Value::Vector { x, y, z } => vec![x, y, z], diff --git a/crates/wright-analyzer/src/declarative.rs b/crates/wright-analyzer/src/declarative.rs index 5c888477..088b7fc9 100644 --- a/crates/wright-analyzer/src/declarative.rs +++ b/crates/wright-analyzer/src/declarative.rs @@ -1,211 +1,40 @@ -use serde::Deserialize; use workshop_rs::catalog::{Catalog, Kind, Locale}; use workshop_rs::source::Span; use workshop_rs::{Action as PublicAction, Program as PublicProgram, Value as PublicValue}; use crate::analysis::{EvidenceClass, Severity}; -use crate::canonical::{Finding, matching_end}; +use crate::canonical::{ + Finding, dead_actions, matching_end, value_identity_map, visit_action_roots, +}; mod error; -pub use error::RuleError; - -const DEFAULT_LOCALE: &str = "en-US"; - -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RuleDefinition { - pub id: String, - #[serde(default = "default_locale")] - pub locale: String, - pub metadata: RuleMetadata, - pub matcher: RuleMatcher, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RuleMetadata { - pub summary: String, - pub rationale: String, - pub documentation: String, - #[serde(rename = "known-limits")] - pub known_limits: String, - pub tags: Vec, -} - -#[derive(Debug, Clone, Deserialize, Default)] -#[serde(rename_all = "kebab-case")] -pub struct RuleMatcher { - pub event: Option, - #[serde(default)] - pub scope: Scope, - pub conditions: Option, - #[serde(default)] - pub actions: Vec, -} - -#[derive(Debug, Clone, Copy, Default, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum Scope { - #[default] - Rule, - While, - ForGlobalVariable, - ForPlayerVariable, - If, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct NodePattern { - #[serde(flatten)] - pub value: ValuePattern, - #[serde(default)] - pub count: Count, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ActionPattern { - pub kind: Option, - pub name: Option, - #[serde(default)] - pub args: Vec, - #[serde(default, alias = "params")] - pub parameters: Vec, - #[serde(default)] - pub count: Count, - #[serde(default = "default_true")] - pub present: bool, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ParameterPattern { - pub name: String, - #[serde(flatten)] - pub value: ValuePattern, -} - -#[derive(Debug, Clone, Copy, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum ActionKind { - Any, - Call, - While, - ForGlobalVariable, - ForPlayerVariable, - If, -} - -#[derive(Debug, Clone, Deserialize, Default)] -#[serde(deny_unknown_fields)] -pub struct Count { - pub min: Option, - pub max: Option, -} +mod pattern; +mod schema; -impl Count { - fn accepts(&self, count: usize) -> bool { - self.min.is_none_or(|min| count >= min) && self.max.is_none_or(|max| count <= max) - } -} - -#[derive(Debug, Clone, Deserialize, Default)] -#[serde(deny_unknown_fields)] -pub struct ValuePattern { - pub number: Option, - pub string: Option, - pub boolean: Option, - pub call: Option, - #[serde(rename = "enum")] - pub enum_value: Option, - pub comparison: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ComparisonPattern { - pub operator: ComparisonOperator, - pub value: Box, -} - -#[derive(Debug, Clone, Copy, Deserialize)] -pub enum ComparisonOperator { - #[serde(rename = "<")] - Less, - #[serde(rename = "<=")] - LessOrEqual, - #[serde(rename = ">")] - Greater, - #[serde(rename = ">=")] - GreaterOrEqual, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct CallPattern { - pub name: String, - #[serde(default)] - pub args: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct EnumPattern { - pub domain: String, - pub member: String, -} +pub use error::RuleError; +pub use schema::{ + ActionKind, ActionPattern, CallPattern, ComparisonOperator, ComparisonPattern, Count, + EnumPattern, InArgs, NodePattern, ParameterPattern, ReportMode, RuleDefinition, RuleMatcher, + RuleMetadata, Scope, Scopes, ValuePattern, WithinPattern, +}; + +use pattern::{ + CanonicalActionPattern, CanonicalNodePattern, CanonicalValuePattern, canonical_action, + canonical_node, canonical_value, public_action_matches, public_value_matches, + search_value_tree, +}; +pub(crate) use schema::default_true; #[derive(Debug, Clone)] pub struct DeclarativeRule { id: String, metadata: RuleMetadata, - scope: Scope, + scopes: Vec, + report: ReportMode, event: Option, conditions: Option, actions: Vec, -} - -#[derive(Debug, Clone)] -struct CanonicalNodePattern { - value: CanonicalValuePattern, - count: Count, -} - -#[derive(Debug, Clone)] -struct CanonicalActionPattern { - kind: Option, - name: Option, - args: Vec, - parameters: Vec<(usize, CanonicalValuePattern)>, - count: Count, - present: bool, -} - -#[derive(Debug, Clone)] -enum CanonicalValuePattern { - Any, - Number(f64), - String(String), - Boolean(bool), - Call { - name: String, - args: Vec, - }, - Enum { - domain: String, - member: String, - }, - Comparison { - operator: ComparisonOperator, - value: Box, - }, -} - -impl RuleDefinition { - pub fn from_yaml_str(input: &str) -> Result { - yaml_serde::from_str(input).map_err(RuleError::Yaml) - } + values: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -241,6 +70,14 @@ impl DeclarativeRule { RuleIdentity::External => validate_identity(&id)?, RuleIdentity::FirstParty => validate_first_party_identity(&id)?, } + if identity != RuleIdentity::FirstParty { + if metadata.evidence.is_some() { + return Err(RuleError::FirstPartyMetadataField { field: "evidence" }); + } + if metadata.severity.is_some() { + return Err(RuleError::FirstPartyMetadataField { field: "severity" }); + } + } let locale = Locale::new(&locale_name); if !catalog.supports(&locale) { return Err(RuleError::UnsupportedLocale(locale_name)); @@ -250,7 +87,15 @@ impl DeclarativeRule { scope, conditions, actions, + values, + report, } = matcher; + if scope.list().is_empty() { + return Err(RuleError::EmptyScopeList); + } + if report == ReportMode::EachMatch && values.is_empty() { + return Err(RuleError::EachMatchNeedsValues); + } let event = event .as_deref() .map(|value| resolve(catalog, Kind::Event, &locale, value)) @@ -262,13 +107,19 @@ impl DeclarativeRule { .into_iter() .map(|pattern| canonical_action(pattern, catalog, &locale)) .collect::, _>>()?; + let values = values + .into_iter() + .map(|pattern| canonical_value(pattern, catalog, &locale, true)) + .collect::, _>>()?; Ok(Self { id, metadata, - scope, + scopes: scope.list().to_vec(), + report, event, conditions, actions, + values, }) } @@ -281,14 +132,16 @@ impl DeclarativeRule { } /// Declarative matchers only report exact structural facts from canonical - /// Workshop representations; they do not claim runtime behavior. + /// Workshop representations; they do not claim runtime behavior. A + /// first-party package rule may declare its class (#970, #616 addendum). pub fn evidence(&self) -> EvidenceClass { - EvidenceClass::Exact + self.metadata.evidence.unwrap_or(EvidenceClass::Exact) } - /// The built-in policy default. Project configuration owns overrides. + /// The built-in policy default — or the first-party package's declared + /// default (#970). Project configuration owns overrides. pub fn default_severity(&self) -> Severity { - Severity::Warning + self.metadata.severity.unwrap_or(Severity::Warning) } /// Run a declarative matcher over the canonical public Workshop program. @@ -321,12 +174,20 @@ impl DeclarativeRule { { return Vec::new(); } - let dead = crate::canonical::dead_actions(&rule_data.actions); - let scopes = public_scopes(program, rule, rule_data, self.scope, &dead); + let dead = dead_actions(&rule_data.actions); + let value_ids = (!self.values.is_empty()).then(|| value_identity_map(program)); + let scopes = public_scopes(program, rule, rule_data, &self.scopes, &dead); let mut findings = Vec::new(); - for (scope_id, actions, values, anchor) in scopes { + for scope in scopes { + let PublicScope { + anchor_action, + actions, + values: gate_values, + roots, + anchor, + } = scope; if let Some(pattern) = &self.conditions { - let count = values + let count = gate_values .iter() .filter(|value| public_value_matches(value, &pattern.value)) .count(); @@ -350,85 +211,231 @@ impl DeclarativeRule { }) { continue; } - let matched = counts.iter().copied().max().unwrap_or(1); - if min_matches.is_some_and(|min| matched < min) + if self.values.is_empty() { + let matched = counts.iter().copied().max().unwrap_or(1); + if min_matches.is_some_and(|min| matched < min) + || max_matches.is_some_and(|max| matched > max) + { + continue; + } + findings.push(self.finding(matched, anchor, rule, anchor_action, None)); + continue; + } + // `values`: search every value tree of the scope for target nodes. + let mut matches: Vec<(Option, Option, Option)> = Vec::new(); + for root in &roots { + let mut path = Vec::new(); + let mut ancestors = Vec::new(); + search_value_tree( + root.value(), + &mut ancestors, + &self.values, + &mut path, + &mut |path, node| { + let span = root.span_of(program, rule, path, anchor); + let value_id = value_ids.as_ref().and_then(|ids| { + ids.get(&(node as *const PublicValue as usize)).copied() + }); + let owner_action = match root { + ValueRoot::Condition { .. } => None, + ValueRoot::Argument { action, .. } => Some(*action), + }; + matches.push((span, value_id, owner_action)); + }, + ); + } + let matched = matches.len(); + if matched == 0 + || min_matches.is_some_and(|min| matched < min) || max_matches.is_some_and(|max| matched > max) { continue; } - findings.push(Finding { - code: self.id().to_string(), - severity: self.default_severity(), - message: format!( - "{} (matched {matched} node{})", - self.metadata().summary, - if matched == 1 { "" } else { "s" } - ), - span: anchor, - rule, - action: scope_id, - value: None, - evidence: self.evidence(), - boundedness: None, - fix: None, - repeated: None, - }); + match self.report { + ReportMode::EachMatch => { + for (span, value_id, owner_action) in matches { + findings.push(self.finding(matched, span, rule, owner_action, value_id)); + } + } + ReportMode::Scope => { + findings.push(self.finding(matched, anchor, rule, anchor_action, None)); + } + } } findings } + + fn finding( + &self, + matched: usize, + span: Option, + rule: usize, + action: Option, + value: Option, + ) -> Finding { + Finding { + code: self.id().to_string(), + severity: self.default_severity(), + message: format!( + "{} (matched {matched} node{})", + self.metadata().summary, + if matched == 1 { "" } else { "s" } + ), + span, + rule, + action, + value, + evidence: self.evidence(), + boundedness: None, + fix: None, + repeated: None, + } + } +} + +/// One scope region a matcher inspects: `anchor_action` is the scope's own +/// action index (`None` for a whole rule), `actions` are the live actions +/// inside the scope for the `actions` matcher, `values` are the gate values +/// for the `conditions` matcher, `roots` are the value trees the `values` +/// matcher searches (#970), and `anchor` is the scope span. +struct PublicScope<'a> { + anchor_action: Option, + actions: Vec<&'a PublicAction>, + values: Vec<&'a PublicValue>, + roots: Vec>, + anchor: Option, +} + +/// A value tree the `values` matcher searches, with enough addressing to +/// anchor each match (#970). +#[derive(Clone, Copy)] +enum ValueRoot<'a> { + /// A live rule condition: spans resolve through + /// `condition_value_span(rule, condition, path)`. + Condition { + condition: usize, + value: &'a PublicValue, + }, + /// A live action argument: spans resolve through + /// `action_argument_value_span(rule, action, argument, path)`. The + /// `action` is also the finding's owning action index. + Argument { + action: usize, + argument: usize, + value: &'a PublicValue, + }, } -type PublicScope<'a> = ( - Option, - Vec<&'a PublicAction>, - Vec<&'a PublicValue>, - Option, -); +impl<'a> ValueRoot<'a> { + fn value(&self) -> &'a PublicValue { + match self { + Self::Condition { value, .. } | Self::Argument { value, .. } => value, + } + } + + /// The match span: the deepest recorded provenance on the path, else the + /// owning statement's span, else the scope anchor, else the rule. + fn span_of( + &self, + program: &PublicProgram, + rule: usize, + path: &[usize], + anchor: Option, + ) -> Option { + match self { + Self::Condition { condition, .. } => (0..=path.len()) + .rev() + .find_map(|len| program.condition_value_span(rule, *condition, &path[..len])) + .or_else(|| program.rule_span(rule)), + Self::Argument { + action, argument, .. + } => (0..=path.len()) + .rev() + .find_map(|len| { + program.action_argument_value_span(rule, *action, *argument, &path[..len]) + }) + .or_else(|| program.action_span(rule, *action)) + .or(anchor) + .or_else(|| program.rule_span(rule)), + } + } +} fn public_scopes<'a>( program: &PublicProgram, rule_id: usize, rule: &'a workshop_rs::Rule, - scope: Scope, + scopes: &[Scope], dead: &[bool], ) -> Vec> { - if matches!(scope, Scope::Rule) { - return vec![( - None, - public_scope_actions(&rule.actions, dead, 0, rule.actions.len()), - rule.conditions + let mut result = Vec::new(); + if scopes.contains(&Scope::Rule) { + let mut roots = Vec::new(); + for (index, condition) in rule.conditions.iter().enumerate() { + if condition.disabled { + continue; + } + roots.push(ValueRoot::Condition { + condition: index, + value: &condition.value, + }); + } + for (index, action) in rule.actions.iter().enumerate() { + if dead[index] { + continue; + } + visit_action_roots(action, &mut |argument, value| { + roots.push(ValueRoot::Argument { + action: index, + argument, + value, + }); + }); + } + result.push(PublicScope { + anchor_action: None, + actions: public_scope_actions(&rule.actions, dead, 0, rule.actions.len()), + values: rule + .conditions .iter() .filter(|condition| !condition.disabled) .map(|condition| &condition.value) .collect(), - program.rule_span(rule_id), - )]; + roots, + anchor: program.rule_span(rule_id), + }); } - - let mut result = Vec::new(); + let wanted = |action: &PublicAction| { + scopes.iter().any(|scope| { + matches!( + (scope, action), + (Scope::While, PublicAction::While { .. }) + | ( + Scope::ForGlobalVariable, + PublicAction::ForGlobalVariable { .. } + ) + | ( + Scope::ForPlayerVariable, + PublicAction::ForPlayerVariable { .. } + ) + | (Scope::If, PublicAction::If { .. }) + ) + }) + }; for (index, action) in rule.actions.iter().enumerate() { - if dead[index] { - continue; - } - let matches_scope = matches!( - (scope, action), - (Scope::While, PublicAction::While { .. }) - | ( - Scope::ForGlobalVariable, - PublicAction::ForGlobalVariable { .. } - ) - | ( - Scope::ForPlayerVariable, - PublicAction::ForPlayerVariable { .. } - ) - | (Scope::If, PublicAction::If { .. }) - ); - if !matches_scope { + if dead[index] || !wanted(action) { continue; } let Some(close) = matching_end(&rule.actions, index) else { continue; }; + // `conditions` gate values keep the established semantics: a `while` + // scope sees its own condition, an `if` scope sees the chain's + // condition list. The `values` search range differs (#970 owner + // decision): a loop scope searches its live body-action arguments + // only — not the loop's own condition — and a nested loop's body is + // searched once per enclosing loop. An `if` scope's `values` search + // covers its condition list. let values = match action { PublicAction::While { condition } => vec![condition], PublicAction::If { condition } => { @@ -436,16 +443,71 @@ fn public_scopes<'a>( } _ => Vec::new(), }; - result.push(( - Some(index), - public_scope_actions(&rule.actions, dead, index + 1, close), + let mut roots = Vec::new(); + match action { + PublicAction::If { condition } => { + roots = public_if_condition_roots(&rule.actions, index, close, condition); + } + _ => { + for (body_index, body_action) in rule.actions[index + 1..close].iter().enumerate() { + let action_id = index + 1 + body_index; + if dead[action_id] { + continue; + } + visit_action_roots(body_action, &mut |argument, value| { + roots.push(ValueRoot::Argument { + action: action_id, + argument, + value, + }); + }); + } + } + } + result.push(PublicScope { + anchor_action: Some(index), + actions: public_scope_actions(&rule.actions, dead, index + 1, close), values, - program.action_span(rule_id, index), - )); + roots, + anchor: program.action_span(rule_id, index), + }); } result } +/// The `values` search roots of an `if` scope: every condition of the +/// chain, addressed as the owning action's argument 0 (#970). +fn public_if_condition_roots<'a>( + actions: &'a [PublicAction], + start: usize, + close: usize, + first: &'a PublicValue, +) -> Vec> { + let mut roots = vec![ValueRoot::Argument { + action: start, + argument: 0, + value: first, + }]; + let mut index = start + 1; + while index < close { + match &actions[index] { + PublicAction::ElseIf { condition } => roots.push(ValueRoot::Argument { + action: index, + argument: 0, + value: condition, + }), + _ => { + if let Some(nested_close) = matching_end(actions, index) { + index = nested_close + 1; + continue; + } + } + } + index += 1; + } + roots +} + fn public_scope_actions<'a>( actions: &'a [PublicAction], dead: &[bool], @@ -489,219 +551,6 @@ fn public_if_conditions<'a>( conditions } -fn public_action_matches(action: &PublicAction, pattern: &CanonicalActionPattern) -> bool { - let kind_matches = match pattern.kind { - None | Some(ActionKind::Any) => true, - Some(ActionKind::Call) => matches!(action, PublicAction::Call { .. }), - Some(ActionKind::While) => matches!(action, PublicAction::While { .. }), - Some(ActionKind::ForGlobalVariable) => { - matches!(action, PublicAction::ForGlobalVariable { .. }) - } - Some(ActionKind::ForPlayerVariable) => { - matches!(action, PublicAction::ForPlayerVariable { .. }) - } - Some(ActionKind::If) => matches!(action, PublicAction::If { .. }), - }; - if !kind_matches { - return false; - } - let PublicAction::Call { name, args } = action else { - return pattern.name.is_none() && pattern.args.is_empty(); - }; - if pattern - .name - .as_deref() - .is_some_and(|expected| expected != name) - { - return false; - } - pattern.args.iter().enumerate().all(|(index, expected)| { - args.get(index) - .is_some_and(|actual| public_value_matches(actual, expected)) - }) && pattern.parameters.iter().all(|(index, expected)| { - args.get(*index) - .is_some_and(|actual| public_value_matches(actual, expected)) - }) -} - -fn public_value_matches(value: &PublicValue, pattern: &CanonicalValuePattern) -> bool { - match (pattern, value) { - (CanonicalValuePattern::Any, _) => true, - (CanonicalValuePattern::Number(expected), PublicValue::Number(actual)) => { - expected == actual - } - (CanonicalValuePattern::String(expected), PublicValue::String(actual)) - | (CanonicalValuePattern::String(expected), PublicValue::LocalizedString(actual)) => { - expected == actual - } - (CanonicalValuePattern::Boolean(expected), PublicValue::Bool(actual)) => expected == actual, - ( - CanonicalValuePattern::Enum { domain, member }, - PublicValue::Enum { value_type, value }, - ) => domain == value_type && member == value, - ( - CanonicalValuePattern::Call { name, args }, - PublicValue::Call { - name: actual, - args: actual_args, - }, - ) => { - name == actual - && args.len() <= actual_args.len() - && args - .iter() - .enumerate() - .all(|(index, expected)| public_value_matches(&actual_args[index], expected)) - } - (CanonicalValuePattern::Comparison { operator, value }, PublicValue::Number(actual)) => { - let CanonicalValuePattern::Number(expected) = value.as_ref() else { - return false; - }; - match operator { - ComparisonOperator::Less => *actual < *expected, - ComparisonOperator::LessOrEqual => *actual <= *expected, - ComparisonOperator::Greater => *actual > *expected, - ComparisonOperator::GreaterOrEqual => *actual >= *expected, - } - } - _ => false, - } -} - -fn canonical_node( - pattern: NodePattern, - catalog: &Catalog, - locale: &Locale, -) -> Result { - Ok(CanonicalNodePattern { - value: canonical_value(pattern.value, catalog, locale)?, - count: pattern.count, - }) -} - -fn canonical_action( - pattern: ActionPattern, - catalog: &Catalog, - locale: &Locale, -) -> Result { - let ActionPattern { - kind, - name, - args, - parameters: named_parameters, - count, - present, - } = pattern; - let name = name - .as_deref() - .map(|name| resolve(catalog, Kind::Action, locale, name)) - .transpose()?; - let action_entry = name - .as_deref() - .and_then(|id| catalog.entry(Kind::Action, id)); - let mut parameters = Vec::new(); - for param in named_parameters { - let Some(entry) = action_entry else { - return Err(RuleError::NamedParameterRequiresAction); - }; - let Some(index) = resolve_parameter(entry, locale, ¶m.name) else { - return Err(RuleError::UnknownParameter { - action: entry.id.clone(), - parameter: param.name, - locale: locale.to_string(), - }); - }; - parameters.push((index, canonical_value(param.value, catalog, locale)?)); - } - Ok(CanonicalActionPattern { - kind, - name, - args: args - .into_iter() - .map(|value| canonical_value(value, catalog, locale)) - .collect::>()?, - parameters, - count, - present, - }) -} - -fn resolve_parameter( - action: &workshop_rs::catalog::CatalogEntry, - locale: &Locale, - spelling: &str, -) -> Option { - action.resolve_param(locale, spelling) -} - -fn canonical_value( - pattern: ValuePattern, - catalog: &Catalog, - locale: &Locale, -) -> Result { - let selected = usize::from(pattern.number.is_some()) - + usize::from(pattern.string.is_some()) - + usize::from(pattern.boolean.is_some()) - + usize::from(pattern.call.is_some()) - + usize::from(pattern.enum_value.is_some()) - + usize::from(pattern.comparison.is_some()); - if selected > 1 { - return Err(RuleError::AmbiguousValuePattern); - } - if let Some(number) = pattern.number { - return Ok(CanonicalValuePattern::Number(number)); - } - if let Some(string) = pattern.string { - return Ok(CanonicalValuePattern::String(string)); - } - if let Some(boolean) = pattern.boolean { - return Ok(CanonicalValuePattern::Boolean(boolean)); - } - if let Some(call) = pattern.call { - return Ok(CanonicalValuePattern::Call { - name: resolve_value_or_operator(catalog, locale, &call.name)?, - args: call - .args - .into_iter() - .map(|value| canonical_value(value, catalog, locale)) - .collect::>()?, - }); - } - if let Some(en) = pattern.enum_value { - let domain = resolve_enum_domain(catalog, locale, &en.domain)?; - let member = catalog - .resolve_enum_member(&domain, locale, &en.member) - .ok_or_else(|| RuleError::UnknownSpelling { - kind: "enum member", - spelling: en.member, - locale: locale.to_string(), - })? - .1; - return Ok(CanonicalValuePattern::Enum { domain, member }); - } - if let Some(comparison) = pattern.comparison { - let value = canonical_value(*comparison.value, catalog, locale)?; - if !matches!(value, CanonicalValuePattern::Number(_)) { - return Err(RuleError::ComparisonNeedsNumber); - } - return Ok(CanonicalValuePattern::Comparison { - operator: comparison.operator, - value: Box::new(value), - }); - } - Ok(CanonicalValuePattern::Any) -} - -fn resolve_value_or_operator( - catalog: &Catalog, - locale: &Locale, - spelling: &str, -) -> Result { - resolve(catalog, Kind::Value, locale, spelling).or_else(|value_error| { - resolve(catalog, Kind::Operator, locale, spelling).or(Err(value_error)) - }) -} - fn resolve( catalog: &Catalog, kind: Kind, @@ -723,26 +572,6 @@ fn resolve( }) } -fn resolve_enum_domain( - catalog: &Catalog, - locale: &Locale, - spelling: &str, -) -> Result { - catalog - .enum_domain(spelling) - .map(|_| spelling.to_string()) - .or_else(|| { - catalog - .resolve_enum_domain(locale, spelling) - .map(str::to_string) - }) - .ok_or_else(|| RuleError::UnknownSpelling { - kind: "enum domain", - spelling: spelling.to_string(), - locale: locale.to_string(), - }) -} - fn validate_identity(id: &str) -> Result<(), RuleError> { let Some((namespace, rule_id)) = id.split_once('/') else { return Err(RuleError::InvalidIdentity(id.to_string())); @@ -769,11 +598,3 @@ fn validate_first_party_identity(id: &str) -> Result<(), RuleError> { Err(RuleError::InvalidFirstPartyIdentity(id.to_string())) } } - -fn default_locale() -> String { - DEFAULT_LOCALE.to_string() -} - -pub(crate) fn default_true() -> bool { - true -} diff --git a/crates/wright-analyzer/src/declarative/error.rs b/crates/wright-analyzer/src/declarative/error.rs index 9b8a2588..7f773677 100644 --- a/crates/wright-analyzer/src/declarative/error.rs +++ b/crates/wright-analyzer/src/declarative/error.rs @@ -19,6 +19,21 @@ pub enum RuleError { NamedParameterRequiresAction, AmbiguousValuePattern, ComparisonNeedsNumber, + /// A call pattern must carry exactly one of `name` or `names` (#970). + AmbiguousCallName, + CallNeedsName, + /// `matcher.scope` listed no scopes (#970). + EmptyScopeList, + /// `report: each-match` has nothing to report without `values` (#970). + EachMatchNeedsValues, + /// `within` is only meaningful inside the ancestor-aware `values` + /// search (#970). + WithinOutsideValues, + /// `evidence`/`severity` metadata are reserved to first-party packages + /// (#970, SPEC-309). + FirstPartyMetadataField { + field: &'static str, + }, } impl fmt::Display for RuleError { @@ -58,6 +73,27 @@ impl fmt::Display for RuleError { RuleError::ComparisonNeedsNumber => { write!(f, "comparison pattern value must be a number pattern") } + RuleError::AmbiguousCallName => { + write!(f, "call pattern must not carry both 'name' and 'names'") + } + RuleError::CallNeedsName => { + write!(f, "call pattern requires 'name' or 'names'") + } + RuleError::EmptyScopeList => { + write!(f, "matcher scope list must not be empty") + } + RuleError::EachMatchNeedsValues => { + write!(f, "report 'each-match' requires a 'values' matcher") + } + RuleError::WithinOutsideValues => { + write!(f, "'within' is only allowed on a 'values' pattern") + } + RuleError::FirstPartyMetadataField { field } => { + write!( + f, + "metadata field '{field}' is only allowed in first-party rule packages" + ) + } } } } diff --git a/crates/wright-analyzer/src/declarative/pattern.rs b/crates/wright-analyzer/src/declarative/pattern.rs new file mode 100644 index 00000000..871677ba --- /dev/null +++ b/crates/wright-analyzer/src/declarative/pattern.rs @@ -0,0 +1,409 @@ +//! Canonicalized matcher forms and the node-level matching predicates. +//! +//! `canonical_*` turns the YAML schema types into catalog-resolved +//! patterns; `public_*_matches` evaluates them against public Workshop +//! nodes, and `search_value_tree` runs the deep `values` search with +//! `within`/`in-args` ancestor constraints (#970). + +use workshop_rs::catalog::{Catalog, Kind, Locale}; +use workshop_rs::{Action as PublicAction, Value as PublicValue}; + +use super::RuleError; +use super::schema::{ActionKind, ActionPattern, CallPattern, Count, NodePattern, ValuePattern}; + +#[derive(Debug, Clone)] +pub(crate) struct CanonicalNodePattern { + pub(crate) value: CanonicalValuePattern, + pub(crate) count: Count, +} + +#[derive(Debug, Clone)] +pub(crate) struct CanonicalActionPattern { + pub(crate) kind: Option, + pub(crate) name: Option, + pub(crate) args: Vec, + pub(crate) parameters: Vec<(usize, CanonicalValuePattern)>, + pub(crate) count: Count, + pub(crate) present: bool, +} + +/// The canonicalized ancestor constraint of a `within` member (#970): +/// `value` must match a strict ancestor of the matched node, and when +/// `in_args` is set only an ancestor whose edge toward the match sits at a +/// child index `>= in_args` qualifies. +#[derive(Debug, Clone)] +pub(crate) struct CanonicalWithin { + pub(crate) value: Box, + pub(crate) in_args: Option, +} + +#[derive(Debug, Clone)] +pub(crate) enum CanonicalValuePattern { + Any, + Number(f64), + String(String), + Boolean(bool), + Call { + names: Vec, + args: Vec, + }, + Enum { + domain: String, + member: String, + }, + Comparison { + operator: super::schema::ComparisonOperator, + value: Box, + }, + Within { + value: Box, + within: CanonicalWithin, + }, +} + +/// Search one value tree at any depth for nodes matching any `values` +/// pattern, reporting `(path, node)` per match (#970). `ancestors` holds the +/// `(node, edge index)` pairs on the path to the current node; a `within` +/// constraint matches when some strict ancestor matches its pattern and the +/// edge below it satisfies `in-args.from`. +pub(crate) fn search_value_tree<'a>( + value: &'a PublicValue, + ancestors: &mut Vec<(&'a PublicValue, usize)>, + patterns: &[CanonicalValuePattern], + path: &mut Vec, + out: &mut impl FnMut(&[usize], &'a PublicValue), +) { + for pattern in patterns { + if pattern_matches(value, pattern, ancestors) { + out(path, value); + } + } + for (index, child) in crate::canonical::value_children(value) + .into_iter() + .enumerate() + { + path.push(index); + ancestors.push((value, index)); + search_value_tree(child, ancestors, patterns, path, out); + ancestors.pop(); + path.pop(); + } +} + +fn pattern_matches( + value: &PublicValue, + pattern: &CanonicalValuePattern, + ancestors: &[(&PublicValue, usize)], +) -> bool { + match pattern { + CanonicalValuePattern::Within { + value: inner, + within, + } => { + public_value_matches(value, inner) + && ancestors.iter().any(|(ancestor, edge)| { + public_value_matches(ancestor, &within.value) + && within.in_args.is_none_or(|from| *edge >= from) + }) + } + _ => public_value_matches(value, pattern), + } +} + +pub(crate) fn public_action_matches( + action: &PublicAction, + pattern: &CanonicalActionPattern, +) -> bool { + let kind_matches = match pattern.kind { + None | Some(ActionKind::Any) => true, + Some(ActionKind::Call) => matches!(action, PublicAction::Call { .. }), + Some(ActionKind::While) => matches!(action, PublicAction::While { .. }), + Some(ActionKind::ForGlobalVariable) => { + matches!(action, PublicAction::ForGlobalVariable { .. }) + } + Some(ActionKind::ForPlayerVariable) => { + matches!(action, PublicAction::ForPlayerVariable { .. }) + } + Some(ActionKind::If) => matches!(action, PublicAction::If { .. }), + }; + if !kind_matches { + return false; + } + let PublicAction::Call { name, args } = action else { + return pattern.name.is_none() && pattern.args.is_empty(); + }; + if pattern + .name + .as_deref() + .is_some_and(|expected| expected != name) + { + return false; + } + pattern.args.iter().enumerate().all(|(index, expected)| { + args.get(index) + .is_some_and(|actual| public_value_matches(actual, expected)) + }) && pattern.parameters.iter().all(|(index, expected)| { + args.get(*index) + .is_some_and(|actual| public_value_matches(actual, expected)) + }) +} + +pub(crate) fn public_value_matches(value: &PublicValue, pattern: &CanonicalValuePattern) -> bool { + use super::schema::ComparisonOperator as Op; + match (pattern, value) { + (CanonicalValuePattern::Any, _) => true, + (CanonicalValuePattern::Number(expected), PublicValue::Number(actual)) => { + expected == actual + } + (CanonicalValuePattern::String(expected), PublicValue::String(actual)) + | (CanonicalValuePattern::String(expected), PublicValue::LocalizedString(actual)) => { + expected == actual + } + (CanonicalValuePattern::Boolean(expected), PublicValue::Bool(actual)) => expected == actual, + ( + CanonicalValuePattern::Enum { domain, member }, + PublicValue::Enum { value_type, value }, + ) => domain == value_type && member == value, + ( + CanonicalValuePattern::Call { names, args }, + PublicValue::Call { + name: actual, + args: actual_args, + }, + ) => { + names.iter().any(|name| name == actual) + && args.len() <= actual_args.len() + && args + .iter() + .enumerate() + .all(|(index, expected)| public_value_matches(&actual_args[index], expected)) + } + (CanonicalValuePattern::Comparison { operator, value }, PublicValue::Number(actual)) => { + let CanonicalValuePattern::Number(expected) = value.as_ref() else { + return false; + }; + match operator { + Op::Less => *actual < *expected, + Op::LessOrEqual => *actual <= *expected, + Op::Greater => *actual > *expected, + Op::GreaterOrEqual => *actual >= *expected, + } + } + // A `within` constraint only has meaning under the `values` search, + // which supplies the ancestor chain. Outside it the inner pattern + // decides on its own. + (CanonicalValuePattern::Within { value: inner, .. }, _) => { + public_value_matches(value, inner) + } + _ => false, + } +} + +pub(crate) fn canonical_node( + pattern: NodePattern, + catalog: &Catalog, + locale: &Locale, +) -> Result { + Ok(CanonicalNodePattern { + value: canonical_value(pattern.value, catalog, locale, false)?, + count: pattern.count, + }) +} + +pub(crate) fn canonical_action( + pattern: ActionPattern, + catalog: &Catalog, + locale: &Locale, +) -> Result { + let ActionPattern { + kind, + name, + args, + parameters: named_parameters, + count, + present, + } = pattern; + let name = name + .as_deref() + .map(|name| resolve(catalog, Kind::Action, locale, name)) + .transpose()?; + let action_entry = name + .as_deref() + .and_then(|id| catalog.entry(Kind::Action, id)); + let mut parameters = Vec::new(); + for param in named_parameters { + let Some(entry) = action_entry else { + return Err(RuleError::NamedParameterRequiresAction); + }; + let Some(index) = resolve_parameter(entry, locale, ¶m.name) else { + return Err(RuleError::UnknownParameter { + action: entry.id.clone(), + parameter: param.name, + locale: locale.to_string(), + }); + }; + parameters.push((index, canonical_value(param.value, catalog, locale, false)?)); + } + Ok(CanonicalActionPattern { + kind, + name, + args: args + .into_iter() + .map(|value| canonical_value(value, catalog, locale, false)) + .collect::>()?, + parameters, + count, + present, + }) +} + +fn resolve_parameter( + action: &workshop_rs::catalog::CatalogEntry, + locale: &Locale, + spelling: &str, +) -> Option { + action.resolve_param(locale, spelling) +} + +pub(crate) fn canonical_value( + pattern: ValuePattern, + catalog: &Catalog, + locale: &Locale, + within_allowed: bool, +) -> Result { + let selected = usize::from(pattern.number.is_some()) + + usize::from(pattern.string.is_some()) + + usize::from(pattern.boolean.is_some()) + + usize::from(pattern.call.is_some()) + + usize::from(pattern.enum_value.is_some()) + + usize::from(pattern.comparison.is_some()); + if selected > 1 { + return Err(RuleError::AmbiguousValuePattern); + } + let within = pattern + .within + .map(|within| { + if !within_allowed { + return Err(RuleError::WithinOutsideValues); + } + Ok::(CanonicalWithin { + value: Box::new(canonical_value(within.value, catalog, locale, false)?), + in_args: within.in_args.map(|in_args| in_args.from), + }) + }) + .transpose()?; + let base = if let Some(number) = pattern.number { + CanonicalValuePattern::Number(number) + } else if let Some(string) = pattern.string { + CanonicalValuePattern::String(string) + } else if let Some(boolean) = pattern.boolean { + CanonicalValuePattern::Boolean(boolean) + } else if let Some(call) = pattern.call { + canonical_call(call, catalog, locale)? + } else if let Some(en) = pattern.enum_value { + let domain = resolve_enum_domain(catalog, locale, &en.domain)?; + let member = catalog + .resolve_enum_member(&domain, locale, &en.member) + .ok_or_else(|| RuleError::UnknownSpelling { + kind: "enum member", + spelling: en.member, + locale: locale.to_string(), + })? + .1; + CanonicalValuePattern::Enum { domain, member } + } else if let Some(comparison) = pattern.comparison { + let value = canonical_value(*comparison.value, catalog, locale, false)?; + if !matches!(value, CanonicalValuePattern::Number(_)) { + return Err(RuleError::ComparisonNeedsNumber); + } + CanonicalValuePattern::Comparison { + operator: comparison.operator, + value: Box::new(value), + } + } else { + CanonicalValuePattern::Any + }; + Ok(match within { + Some(within) => CanonicalValuePattern::Within { + value: Box::new(base), + within, + }, + None => base, + }) +} + +fn canonical_call( + call: CallPattern, + catalog: &Catalog, + locale: &Locale, +) -> Result { + let names = match (call.name, call.names) { + (Some(_), Some(_)) => return Err(RuleError::AmbiguousCallName), + (Some(name), None) => vec![name], + (None, Some(names)) if !names.is_empty() => names, + (None, _) => return Err(RuleError::CallNeedsName), + }; + let names = names + .iter() + .map(|name| resolve_value_or_operator(catalog, locale, name)) + .collect::, _>>()?; + Ok(CanonicalValuePattern::Call { + names, + args: call + .args + .into_iter() + .map(|value| canonical_value(value, catalog, locale, false)) + .collect::>()?, + }) +} + +fn resolve_value_or_operator( + catalog: &Catalog, + locale: &Locale, + spelling: &str, +) -> Result { + resolve(catalog, Kind::Value, locale, spelling).or_else(|value_error| { + resolve(catalog, Kind::Operator, locale, spelling).or(Err(value_error)) + }) +} + +fn resolve( + catalog: &Catalog, + kind: Kind, + locale: &Locale, + spelling: &str, +) -> Result { + catalog + .entry(kind, spelling) + .map(|entry| entry.id.clone()) + .or_else(|| { + catalog + .resolve(kind, locale, spelling) + .map(|entry| entry.id.clone()) + }) + .ok_or_else(|| RuleError::UnknownSpelling { + kind: kind.as_str(), + spelling: spelling.to_string(), + locale: locale.to_string(), + }) +} + +fn resolve_enum_domain( + catalog: &Catalog, + locale: &Locale, + spelling: &str, +) -> Result { + catalog + .enum_domain(spelling) + .map(|_| spelling.to_string()) + .or_else(|| { + catalog + .resolve_enum_domain(locale, spelling) + .map(str::to_string) + }) + .ok_or_else(|| RuleError::UnknownSpelling { + kind: "enum domain", + spelling: spelling.to_string(), + locale: locale.to_string(), + }) +} diff --git a/crates/wright-analyzer/src/declarative/schema.rs b/crates/wright-analyzer/src/declarative/schema.rs new file mode 100644 index 00000000..c2ac851a --- /dev/null +++ b/crates/wright-analyzer/src/declarative/schema.rs @@ -0,0 +1,248 @@ +//! The YAML schema of a declarative lint rule (SPEC-309, #970): the +//! `Deserialize` forms accepted in rule files, before catalog resolution +//! turns spellings into canonical identities (see `super::pattern`). + +use serde::Deserialize; + +use crate::analysis::{EvidenceClass, Severity}; + +use super::RuleError; + +const DEFAULT_LOCALE: &str = "en-US"; + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RuleDefinition { + pub id: String, + #[serde(default = "default_locale")] + pub locale: String, + pub metadata: RuleMetadata, + pub matcher: RuleMatcher, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RuleMetadata { + pub summary: String, + pub rationale: String, + pub documentation: String, + #[serde(rename = "known-limits")] + pub known_limits: String, + pub tags: Vec, + /// First-party packages only: the declared evidence class of this rule's + /// findings (#616 addendum, #970). Rejected for external rules. + #[serde(default)] + pub evidence: Option, + /// First-party packages only: the declared default severity (#616 + /// addendum, #970). Rejected for external rules; project configuration + /// still owns effective severity overrides. + #[serde(default)] + pub severity: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RuleMatcher { + pub event: Option, + /// One scope or a list of scopes the matcher enumerates (#970). + #[serde(default)] + pub scope: Scopes, + pub conditions: Option, + #[serde(default)] + pub actions: Vec, + /// Value patterns searched at any depth inside every value tree of the + /// scope (#970): rule conditions and live action arguments for `rule`, + /// live body-action arguments for a loop scope, and the condition list + /// for `if`. + #[serde(default)] + pub values: Vec, + /// Finding anchoring: `scope` (default) reports once per scope at the + /// scope anchor; `each-match` reports once per matched value node at the + /// node's own span (#970). + #[serde(default)] + pub report: ReportMode, +} + +/// `matcher.scope` accepts a single scope or a list of scopes (#970). +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum Scopes { + One(Scope), + Many(Vec), +} + +impl Default for Scopes { + fn default() -> Self { + Self::One(Scope::Rule) + } +} + +impl Scopes { + pub(crate) fn list(&self) -> &[Scope] { + match self { + Self::One(scope) => std::slice::from_ref(scope), + Self::Many(scopes) => scopes, + } + } +} + +#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum ReportMode { + #[default] + Scope, + EachMatch, +} + +#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum Scope { + #[default] + Rule, + While, + ForGlobalVariable, + ForPlayerVariable, + If, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NodePattern { + #[serde(flatten)] + pub value: ValuePattern, + #[serde(default)] + pub count: Count, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ActionPattern { + pub kind: Option, + pub name: Option, + #[serde(default)] + pub args: Vec, + #[serde(default, alias = "params")] + pub parameters: Vec, + #[serde(default)] + pub count: Count, + #[serde(default = "default_true")] + pub present: bool, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ParameterPattern { + pub name: String, + #[serde(flatten)] + pub value: ValuePattern, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ActionKind { + Any, + Call, + While, + ForGlobalVariable, + ForPlayerVariable, + If, +} + +#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Count { + pub min: Option, + pub max: Option, +} + +impl Count { + pub(crate) fn accepts(&self, count: usize) -> bool { + self.min.is_none_or(|min| count >= min) && self.max.is_none_or(|max| count <= max) + } +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct ValuePattern { + pub number: Option, + pub string: Option, + pub boolean: Option, + pub call: Option, + #[serde(rename = "enum")] + pub enum_value: Option, + pub comparison: Option, + /// An optional ancestor constraint, valid only on a `values` entry + /// (#970): a value pattern that must match a strict ancestor of the + /// matched node. + #[serde(default)] + pub within: Option>, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WithinPattern { + #[serde(flatten)] + pub value: ValuePattern, + /// Restricts which ancestor argument positions carry the constraint: only + /// an ancestor edge at a child index `>= from` qualifies, so a binder's + /// array-source argument at index 0 stays outside (#970). + #[serde(rename = "in-args")] + pub in_args: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InArgs { + pub from: usize, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ComparisonPattern { + pub operator: ComparisonOperator, + pub value: Box, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +pub enum ComparisonOperator { + #[serde(rename = "<")] + Less, + #[serde(rename = "<=")] + LessOrEqual, + #[serde(rename = ">")] + Greater, + #[serde(rename = ">=")] + GreaterOrEqual, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CallPattern { + /// Exactly one of `name` (one canonical or localized spelling) or `names` + /// (a set matched as alternatives) is required (#970). + pub name: Option, + pub names: Option>, + #[serde(default)] + pub args: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EnumPattern { + pub domain: String, + pub member: String, +} + +impl RuleDefinition { + pub fn from_yaml_str(input: &str) -> Result { + yaml_serde::from_str(input).map_err(RuleError::Yaml) + } +} + +fn default_locale() -> String { + DEFAULT_LOCALE.to_string() +} + +pub(crate) fn default_true() -> bool { + true +} diff --git a/crates/wright-analyzer/tests/yaml_contract.rs b/crates/wright-analyzer/tests/yaml_contract.rs index 40a0906f..b356d8c4 100644 --- a/crates/wright-analyzer/tests/yaml_contract.rs +++ b/crates/wright-analyzer/tests/yaml_contract.rs @@ -1,6 +1,9 @@ -use workshop_rs::catalog::Catalog; -use workshop_rs::{Action, Event, Program, Rule, Value}; -use wright_analyzer::analysis::EvidenceClass; +use std::collections::BTreeSet; +use std::path::Path; + +use workshop_rs::catalog::{Catalog, Locale}; +use workshop_rs::{Action, Event, Program, Rule, Value, parser}; +use wright_analyzer::analysis::{EvidenceClass, Severity}; use wright_analyzer::canonical::SemanticService; use wright_analyzer::declarative::{DeclarativeRule, RuleDefinition}; use wright_analyzer::registry::{LintConfig, LintRegistry, RuleRegistryError}; @@ -353,6 +356,411 @@ matcher: )); } +const METADATA: &str = "metadata:\n summary: summary\n rationale: r\n documentation: d\n known-limits: k\n tags: []\n"; + +/// The three loop scopes of #970's table, as a `scope` list. +const LOOP_SCOPES: &str = "[while, for-global-variable, for-player-variable]"; + +#[test] +fn scope_list_matches_each_listed_scope() { + let catalog = Catalog::builtin_ref().unwrap(); + let mut program = Program::new(); + program.rule( + Rule::new("loops", Event::Global) + .action(Action::While { + condition: Value::Bool(true), + }) + .action(Action::call("wait", [Value::Number(1.0)])) + .action(Action::End) + .action(Action::ForGlobalVariable { + variable: "index".into(), + start: Value::Number(0.0), + stop: Value::Number(2.0), + step: Value::Number(1.0), + }) + .action(Action::call("wait", [Value::Number(1.0)])) + .action(Action::End) + .action(Action::ForPlayerVariable { + player: Value::EventPlayer, + variable: "index".into(), + start: Value::Number(0.0), + stop: Value::Number(2.0), + step: Value::Number(1.0), + }) + .action(Action::call("wait", [Value::Number(1.0)])) + .action(Action::End) + .action(Action::If { + condition: Value::Bool(true), + }) + .action(Action::call("wait", [Value::Number(1.0)])) + .action(Action::End), + ); + let yaml = format!( + "id: community/loops\n{METADATA}matcher:\n scope: {LOOP_SCOPES}\n actions:\n - kind: call\n" + ); + let rule = + DeclarativeRule::from_definition(RuleDefinition::from_yaml_str(&yaml).unwrap(), catalog) + .unwrap(); + let findings = rule.run(&program, 0, None, None); + assert_eq!( + findings + .iter() + .map(|finding| finding.action) + .collect::>(), + [Some(0), Some(3), Some(6)], + "one finding per listed loop kind, none for `if`: {findings:?}" + ); + // Two scopes mixed in one list. + let yaml = format!( + "id: community/mixed\n{METADATA}matcher:\n scope: [if, while]\n actions:\n - kind: call\n" + ); + let rule = + DeclarativeRule::from_definition(RuleDefinition::from_yaml_str(&yaml).unwrap(), catalog) + .unwrap(); + let findings = rule.run(&program, 0, None, None); + assert_eq!( + findings + .iter() + .map(|finding| finding.action) + .collect::>(), + [Some(0), Some(9)] + ); + // An empty list rejects the rule rather than silently matching nothing. + let yaml = format!("id: community/empty\n{METADATA}matcher:\n scope: []\n"); + let result = + DeclarativeRule::from_definition(RuleDefinition::from_yaml_str(&yaml).unwrap(), catalog); + assert!(result.is_err(), "an empty scope list is a load error"); +} + +const EACH_MATCH_SOURCE: &str = r#" +variables { + global: + 0: index + 1: other +} +rule ("each match") { + event { + Ongoing - Global; + } + conditions { + Count Of(Filtered Array(All Players(Team 1), Is True For All(Array(Is Alive(Current Array Element)), Current Array Element))) > 0; + disabled Count Of(Filtered Array(All Players(Team 2), Current Array Element)) > 0; + } + actions { + Set Global Variable(other, Filtered Array(All Players(Team 1), Is True For Any(Array(Is Alive(Current Array Element)), Current Array Element))); + disabled Set Global Variable(other, Is True For All(Array(True), Current Array Element)); + } +} +"#; + +#[test] +fn values_with_each_match_reports_one_finding_per_node() { + let catalog = Catalog::builtin_ref().unwrap(); + let program = parse_workshop(EACH_MATCH_SOURCE); + // Two matches live in one value tree (condition 0), a third in a live + // action argument, a fourth inside the disabled action — which must not + // report. + let yaml = format!( + "id: community/binders\n{METADATA}matcher:\n scope: rule\n report: each-match\n values:\n - call:\n names: [filteredArray, isTrueForAll, isTrueForAny, mappedArray, sortedArray]\n" + ); + let rule = + DeclarativeRule::from_definition(RuleDefinition::from_yaml_str(&yaml).unwrap(), catalog) + .unwrap(); + let findings = rule.run(&program, 0, None, None); + assert_eq!( + findings.len(), + 4, + "one finding per matched node: {findings:?}" + ); + assert!( + findings + .iter() + .all(|finding| finding.message.contains("matched 4 nodes")), + "the declarative message carries the scope's matched count" + ); + for finding in &findings { + assert!( + finding.span.is_some(), + "each finding anchors at the node's own span: {finding:?}" + ); + } + // The first two are inside condition 0; the rest are action arguments. + assert_eq!(findings[0].action, None); + assert_eq!(findings[1].action, None); + assert_eq!(findings[2].action, Some(0)); + assert_eq!(findings[3].action, Some(0)); + // Anchored at the node, not at the root: the two condition findings + // point at different nodes. + assert_ne!(findings[0].span, findings[1].span); + assert_eq!( + findings[0].span, + program.condition_value_span(0, 0, &[0, 0]), + "the outer filteredArray call anchors the first match" + ); +} + +#[test] +fn within_in_args_limits_the_ancestor_argument_positions() { + let catalog = Catalog::builtin_ref().unwrap(); + let source = r#" +variables { + global: + 0: pool +} +rule ("within") { + event { + Ongoing - Global; + } + actions { + Set Global Variable(pool, Filtered Array(All Players(Team 1), Is True For Any(Array(True), Current Array Element))); + Set Global Variable(pool, Filtered Array(Sorted Array(All Players(Team 1), Current Array Element), Is Alive(Current Array Element))); + } +} +"#; + let program = parse_workshop(source); + let binders = "names: [filteredArray, isTrueForAll, isTrueForAny, mappedArray, sortedArray]"; + let yaml = format!( + "id: community/nested\n{METADATA}matcher:\n scope: rule\n report: each-match\n values:\n - call:\n {binders}\n within:\n call:\n {binders}\n in-args:\n from: 1\n" + ); + let rule = + DeclarativeRule::from_definition(RuleDefinition::from_yaml_str(&yaml).unwrap(), catalog) + .unwrap(); + let findings = rule.run(&program, 0, None, None); + assert_eq!( + findings.len(), + 1, + "only the binder inside a per-element argument reports: {findings:?}" + ); + assert_eq!(findings[0].action, Some(0)); + let covered = span_text(source, &findings[0].span.unwrap()); + assert!( + covered.contains("Is True For Any"), + "the finding anchors at the inner binder: {covered}" + ); +} + +#[test] +fn call_names_resolve_each_spelling_and_reject_unknown() { + let catalog = Catalog::builtin_ref().unwrap(); + // Canonical ids and localized spellings resolve through the catalog. + let yaml = format!( + "id: community/names\n{METADATA}matcher:\n scope: rule\n report: each-match\n values:\n - call:\n names: [filteredArray, Is True For All]\n" + ); + let rule = + DeclarativeRule::from_definition(RuleDefinition::from_yaml_str(&yaml).unwrap(), catalog) + .unwrap(); + let program = parse_workshop(EACH_MATCH_SOURCE); + let findings = rule.run(&program, 0, None, None); + assert_eq!( + findings.len(), + 3, + "filteredArray + Is True For All match; isTrueForAny does not: {findings:?}" + ); + // `name` and `names` are alternatives, not combinable. + let both = format!( + "id: community/both\n{METADATA}matcher:\n values:\n - call:\n name: filteredArray\n names: [sortedArray]\n" + ); + assert!( + DeclarativeRule::from_definition(RuleDefinition::from_yaml_str(&both).unwrap(), catalog) + .is_err() + ); + // An unknown or ambiguous spelling rejects the rule at load time. + for names in ["[noSuchValueSpelling]", "[filteredArray, noSuchSpelling]"] { + let yaml = format!( + "id: community/unknown\n{METADATA}matcher:\n values:\n - call:\n names: {names}\n" + ); + let result = DeclarativeRule::from_definition( + RuleDefinition::from_yaml_str(&yaml).unwrap(), + catalog, + ); + assert!( + result.is_err(), + "unknown spelling in `names` is a load error: {names}" + ); + } +} + +#[test] +fn first_party_metadata_sets_evidence_and_severity_external_rules_reject_it() { + let catalog = Catalog::builtin_ref().unwrap(); + let mut registry = LintRegistry::default(); + registry + .load_first_party_yaml_str(&format!( + "id: wright/expensive\n{METADATA} evidence: heuristic\n severity: info\nmatcher:\n scope: {LOOP_SCOPES}\n report: each-match\n values:\n - call:\n names: [distance, isInLoS]\n" + )) + .unwrap(); + let descriptors = registry.descriptors(&LintConfig::default()); + let descriptor = descriptors + .iter() + .find(|d| d.id == "wright/expensive") + .unwrap(); + assert_eq!(descriptor.evidence, EvidenceClass::Heuristic); + assert_eq!(descriptor.default_severity, Severity::Info); + // A community package and a `--rule-file` rule may not declare either. + for field in ["evidence: heuristic", "severity: info"] { + let external = format!( + "id: community/policy\nmetadata:\n summary: s\n rationale: r\n documentation: d\n known-limits: k\n tags: []\n {field}\nmatcher: {{}}\n" + ); + assert!( + registry.load_yaml_str(&external).is_err(), + "community package must not declare {field}" + ); + let result = DeclarativeRule::from_definition( + RuleDefinition::from_yaml_str(&external).unwrap(), + catalog, + ); + assert!(result.is_err(), "rule-file metadata {field} rejected"); + } +} + +const BINDER_RULE_YAML: &str = "id: wright/nested-binder-lambda\nmetadata:\n summary: element-binding array call nested inside another binder's per-element argument\n rationale: r\n documentation: d\n known-limits: k\n tags: []\n evidence: static-indicator\nmatcher:\n scope: rule\n report: each-match\n values:\n - call:\n names: [filteredArray, isTrueForAll, isTrueForAny, mappedArray, sortedArray]\n within:\n call:\n names: [filteredArray, isTrueForAll, isTrueForAny, mappedArray, sortedArray]\n in-args:\n from: 1\n"; + +const EXPENSIVE_RULE_YAML: &str = "id: wright/expensive-loop-check\nmetadata:\n summary: geometry predicate evaluated inside a loop body\n rationale: r\n documentation: d\n known-limits: k\n tags: []\n evidence: heuristic\n severity: info\nmatcher:\n scope: [while, for-global-variable, for-player-variable]\n report: each-match\n values:\n - call:\n names: [distance, isInLoS]\n"; + +const MIN_WAIT_RULE_YAML: &str = "id: wright/min-wait-loop\nmetadata:\n summary: loop body waits at the workshop minimum rate\n rationale: r\n documentation: d\n known-limits: k\n tags: []\n evidence: static-indicator\nmatcher:\n scope: [while, for-global-variable, for-player-variable]\n actions:\n - kind: call\n name: Wait\n args:\n - comparison:\n operator: \"<=\"\n value:\n number: 0.016\n count:\n min: 1\n"; + +fn declarative_equivalents() -> LintRegistry { + let mut registry = LintRegistry::default(); + for yaml in [BINDER_RULE_YAML, EXPENSIVE_RULE_YAML, MIN_WAIT_RULE_YAML] { + registry.load_first_party_yaml_str(yaml).unwrap(); + } + registry +} + +fn spans_by_code( + findings: &[wright_analyzer::canonical::Finding], + native: &str, + declarative: &str, +) -> (BTreeSet, BTreeSet) { + let key = |span: Option| { + span.map(|span| format!("{:?}-{:?}", span.start, span.end)) + .unwrap_or_else(|| "none".to_string()) + }; + ( + findings + .iter() + .filter(|finding| finding.code == native) + .map(|finding| key(finding.span)) + .collect(), + findings + .iter() + .filter(|finding| finding.code == declarative) + .map(|finding| key(finding.span)) + .collect(), + ) +} + +#[test] +fn declarative_rules_report_the_native_span_set_on_the_bastion_reproducer() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/fixtures/workshop/real-world/reproducer-bastion-prophet.ws"); + let program = parse_workshop(&std::fs::read_to_string(&path).unwrap()); + let native = LintRegistry::default().run(&program, &LintConfig::default()); + let migrated = declarative_equivalents().run(&program, &LintConfig::default()); + let all = native_and_migrated(&native, &migrated); + for (native_id, migrated_id) in [ + ("nested-binder-lambda", "wright/nested-binder-lambda"), + ("expensive-loop-check", "wright/expensive-loop-check"), + ("min-wait-loop", "wright/min-wait-loop"), + ] { + let (native_spans, migrated_spans) = spans_by_code(&all, native_id, migrated_id); + assert_eq!( + native_spans, migrated_spans, + "{native_id} span parity on the reproducer: {native_spans:?} vs {migrated_spans:?}" + ); + } + let (binder_native, binder_migrated) = + spans_by_code(&all, "nested-binder-lambda", "wright/nested-binder-lambda"); + // The native rule is the reference (#970 criterion 6); it reports 16 on + // the current reproducer (the issue body's "13" predates the final + // fixture — workshop-rs `docs/import-limits.md` records 16). + assert_eq!(binder_native.len(), 16, "native reference count"); + assert_eq!(binder_migrated.len(), 16); +} + +fn native_and_migrated( + native: &[wright_analyzer::canonical::Finding], + migrated: &[wright_analyzer::canonical::Finding], +) -> Vec { + native.iter().chain(migrated.iter()).cloned().collect() +} + +#[test] +fn declarative_rules_report_the_native_span_set_on_lint_fixtures() { + let source = r#" +variables { + global: + 0: index + 1: pool + 2: flag +} +rule ("parity") { + event { + Ongoing - Global; + } + conditions { + Count Of(Filtered Array(All Players(Team 1), Is True For Any(Array(Is Alive(Current Array Element)), Current Array Element))) > 0; + } + actions { + Set Global Variable(pool, Filtered Array(All Players(Team 1), Is True For All(Array(Is Alive(Current Array Element)), Current Array Element))); + Set Global Variable(flag, Filtered Array(All Players(Team 1), Compare(Filtered Array(All Players(Team 2), Is Alive(Current Array Element)), !=, Empty Array))); + While(Compare(Global.index, <, 3)); + Set Global Variable(index, Distance Between(Position Of(Event Player), Vector(0, 0, 0))); + If(Compare(Global.index, ==, 1)); + Set Global Variable(flag, Ray Cast Hit Position(Eye Position(Event Player), Direction Of(Angles(Global.index)), All Players(All Teams), Event Player, False)); + End; + Wait(0.016, Ignore Condition); + End; + For Global Variable(index, 0, 3, 1); + Wait(0.016, Ignore Condition); + End; + For Player Variable(Event Player, index, 0, 3, 1); + Set Global Variable(index, Add(Is In Line of Sight(Eye Position(Event Player), Eye Position(Event Player), All Players(All Teams)), 1)); + Wait(0.5, Ignore Condition); + End; + } +} +"#; + let program = parse_workshop(source); + let native = LintRegistry::default().run(&program, &LintConfig::default()); + let migrated = declarative_equivalents().run(&program, &LintConfig::default()); + let all = native_and_migrated(&native, &migrated); + for (native_id, migrated_id) in [ + ("nested-binder-lambda", "wright/nested-binder-lambda"), + ("expensive-loop-check", "wright/expensive-loop-check"), + ("min-wait-loop", "wright/min-wait-loop"), + ] { + let (native_spans, migrated_spans) = spans_by_code(&all, native_id, migrated_id); + assert_eq!( + native_spans, migrated_spans, + "{native_id} span parity: {native_spans:?} vs {migrated_spans:?}" + ); + assert!( + !native_spans.is_empty(), + "the fixture exercises {native_id}" + ); + } +} + +fn parse_workshop(source: &str) -> Program { + let catalog = Catalog::builtin_ref().unwrap(); + parser::parse_with_context(source, catalog, &Locale::new("en-US"), catalog) + .unwrap_or_else(|error| panic!("Workshop source must parse: {error}")) +} + +fn span_text(source: &str, span: &workshop_rs::source::Span) -> String { + let (start, end) = (span.start, span.end); + assert_eq!( + start.line, end.line, + "test spans stay on one line: {span:?}" + ); + let line = source.lines().nth(start.line as usize - 1).unwrap(); + line.chars() + .skip(start.col as usize - 1) + .take((end.col - start.col) as usize) + .collect() +} + #[test] fn invalid_rule_yaml_remains_an_explicit_error() { let error = RuleDefinition::from_yaml_str("id: community/broken\nmetadata: []\nmatcher: {}\n") diff --git a/docs/adr/0015-canonical-facts-and-declarative-lint-policy.md b/docs/adr/0015-canonical-facts-and-declarative-lint-policy.md index 4379abc8..0f60bece 100644 --- a/docs/adr/0015-canonical-facts-and-declarative-lint-policy.md +++ b/docs/adr/0015-canonical-facts-and-declarative-lint-policy.md @@ -55,7 +55,13 @@ declarative matcher would distort a richer analysis. External rules use a namespaced identity; Wright-owned built-ins retain their existing bare stable IDs and reserve the `wright` namespace. Declarative metadata may explain support and limitations, but cannot self-certify -correctness or override Wright's support classification. +correctness or override Wright's support classification. The first-party +package — and only it — may declare `evidence` and a default `severity` in +rule metadata (#970): loading those fields from a community package or a +`--rule-file` rule is a `lint-rule-error`. The matcher vocabulary stays +bounded: scope lists, a deep `values` search with `within`/`in-args` ancestor +constraints, `names` sets, and `report: each-match` (#970) extend coverage to +per-node structural findings without becoming a general query language. ## Alternatives considered diff --git a/docs/specs/SPEC-309-extensible-lint-rules.md b/docs/specs/SPEC-309-extensible-lint-rules.md index 1fa11b02..6b28bd7b 100644 --- a/docs/specs/SPEC-309-extensible-lint-rules.md +++ b/docs/specs/SPEC-309-extensible-lint-rules.md @@ -62,19 +62,38 @@ matcher: The public matcher vocabulary is intentionally Workshop-shaped: -- `scope` is `rule`, `while`, `for-global-variable`, - `for-player-variable`, or `if`. A loop/branch scope includes nested action - nodes in its body; nested loops are also independently matched. +- `scope` is one of `rule`, `while`, `for-global-variable`, + `for-player-variable`, or `if`, or a list of those scopes (#970). A list + enumerates every listed scope under one rule ID, so the three loop kinds can + share a rule. A loop/branch scope includes nested action nodes in its body; + nested loops are also independently matched. - `actions` uses direct predicates: `kind`, localized or canonical `name`, positional `args`, catalog-backed named `parameters`, `count.min`, `count.max`, and `present: false` for an absence predicate. Named parameters use canonical catalog labels with locale-independent normalization. An omitted `kind` matches any action. A call name and argument shape only match call actions. +- `values` lists value patterns searched at any depth inside every value tree + of the scope (#970): rule conditions and live action arguments for `rule`; + live body-action arguments for a loop scope — not the loop's own + condition, and a nested loop's body is searched once per enclosing loop; + the condition list for `if`. Disabled and dead statements are excluded, as + for `actions`. +- `report` is `scope` (default) or `each-match` (#970). `scope` anchors one + finding per scope at the scope anchor; `each-match` anchors one finding per + matched value node at the node's own span and requires `values`. +- A `values` value pattern may carry `within` (#970): a value pattern that + must match a strict ancestor of the matched node. `in-args: { from: N }` + limits the constraint to ancestor argument positions at index `>= N`, so a + binder's array-source argument at index 0 stays outside and + `Filtered Array(Sorted Array(x, r), c)` is not reported. `within` is only + valid inside `values`. - value patterns are one of `number`, `string`, `boolean`, `call`, `enum`, or a numeric `comparison` using `<`, `<=`, `>`, or `>=`. - A call and enum recursively use the same canonical name/domain/member - resolution. An empty value pattern means any value. + A call pattern accepts `name` (one spelling) or `names` (a set matched as + alternatives, #970); exactly one is required. A call and enum recursively + use the same canonical name/domain/member resolution. An empty value + pattern means any value. - counts and options are static matched-node counts. They are evidence about the authored WIR shape, not a runtime execution count. @@ -110,12 +129,17 @@ including in CI; an unfetchable SHA fails with an explicit diagnostic and produces no lint findings. Rule ID collisions between packages are errors. The first-party package repository is the only source allowed bare rule IDs, and listing it with a SHA replaces the embedded default rules it -covers instead of colliding. +covers instead of colliding. The first-party package may also declare +`evidence` and `severity` in rule metadata (#616 addendum, #970): the +former sets the finding's evidence class, the latter the rule's default +severity. Both fields are rejected with a `lint-rule-error` diagnostic in +community packages and `--rule-file` rules. `enabled` defaults to `true`; `severity` may be `off`, `warn`, or `error`; the only shared rule options are the bounded `min-matches` and `max-matches` limits. Evidence classification and a declarative rule's default finding -severity are assigned by Wright from the canonical matcher contract; external +severity are assigned by Wright from the canonical matcher contract — or +declared by the first-party package under the boundary above; external metadata cannot override them. Unknown configuration keys are rejected. CLI flags apply to the same configuration object: `wright lint --lint-config project.yaml --rule-file rules/ diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index 2ab5b52a..1b594aad 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -15,6 +15,7 @@ specific product tests: | --- | --- | --- | | `workshop/real-world/bastion.ow` | `wright-driver` rule-content span and finding-containment test | `OWBastion/Bastion` `src/main.opy`, commit `c010e1a2d468ec7140f474e334067e5ab8d02d89`, generated with `pnpm run build:main:en` (OverPy 9.7.10, Node 24, pnpm 11.10.0); copied verbatim from `wrightkit/workshop-rs` v1.25.2 `crates/workshop-rs/tests/fixtures/real-projects/bastion.ow`, SHA-256 `44e453ddf7f373be65aea82d019abd45dd60f5ecb57c8d1607d3576a8bc60259`; Apache-2.0, see `workshop/real-world/bastion.LICENSE` | | `workshop/real-world/overpy-cake.ws` | `wright-driver` semantic comparison test and benchmark | `Zezombye/overpy` `examples/cake.opy`, commit `eea67adbcf6926c4004e35e25ab4be072624a44e`, GPL-3.0-only | +| `workshop/real-world/reproducer-bastion-prophet.ws` | `wright-analyzer` declarative-matcher span-parity test (#970) | the `[Event/先知] Create per-slot prophet next-event texts` rule extracted verbatim from `build/8a4b594-en.ow`, built from `OWBastion/Bastion` commit `8a4b5947d5762690ff88e148582f39be5d795ccf` (en-US); copied verbatim from `wrightkit/workshop-rs` `crates/workshop-rs/tests/fixtures/import-limits/reproducer-bastion-prophet.ws`, SHA-256 `3a1cb89148242ca2ebe40d611ff033ea315b01d8246bde32b5f57c94b584c922`; Apache-2.0, see `workshop/real-world/bastion.LICENSE` | | `workshop/real-world/overpy-client-to-server.ws` | CLI diagnostics-schema test | `Zezombye/overpy` `examples/clientToServer.opy`, commit `eea67adbcf6926c400e35e25ab4be072624a44e`, GPL-3.0-only | | `workshop/real-world/overpy-pixelart.ws` | CLI analyzer report test | `Zezombye/overpy` `examples/pixelart.opy`, commit `eea67adbcf6926c400e35e25ab4be072624a44e`, GPL-3.0-only | diff --git a/tests/fixtures/workshop/real-world/reproducer-bastion-prophet.ws b/tests/fixtures/workshop/real-world/reproducer-bastion-prophet.ws new file mode 100644 index 00000000..be87e49a --- /dev/null +++ b/tests/fixtures/workshop/real-world/reproducer-bastion-prophet.ws @@ -0,0 +1,32 @@ +variables { + global: + 0: prophetSlotTextsCreated + 1: eventName + player: + 0: eventNextIndex + 1: eventType + 2: eventId + 3: playerTitleAndColor + 4: rgb_vect +} + +rule ("[Event/先知] Create per-slot prophet next-event texts") { + event { + Ongoing - Global; + } + conditions { + Global.prophetSlotTextsCreated == False; + Count Of(All Players(Team 2)) > Null; + } + actions { + Create In-World Text(If-Then-Else(Compare(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, Null))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, Null))), Null), !=, Null), Filtered Array(All Players(Team 2), And(And(Compare(Current Array Element, !=, If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, Null))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, Null))), Null)), Compare((Current Array Element).eventType, ==, 2)), Compare((Current Array Element).eventId, ==, 23))), Empty Array), If-Then-Else(Compare(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, Null))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, Null))), Null), !=, Null), Custom String("→ {0}", If-Then-Else(Compare((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, Null))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, Null))), Null)).eventNextIndex, !=, Null), Value In Array(Global.eventName, (If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, Null))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, Null))), Null)).eventNextIndex), Custom String("未知"))), Char In String(Empty Array, False)), If-Then-Else(Compare(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, Null))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, Null))), Null), !=, Null), Add(Eye Position(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, Null))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, Null))), Null)), Multiply(0.3, Up)), Multiply(-1000, Up)), 0.7, Do Not Clip, Visible To Position String and Color, If-Then-Else(And(Compare(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, Null))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, Null))), Null), !=, Null), Compare((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, Null))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, Null))), Null)).playerTitleAndColor, !=, Null)), Custom Color(X Component Of((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, Null))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, Null))), Null)).rgb_vect), Y Component Of((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, Null))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, Null))), Null)).rgb_vect), Z Component Of((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, Null))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, Null))), Null)).rgb_vect), 255), Custom Color(186, 120, 255, 255)), Default Visibility); + Create In-World Text(If-Then-Else(Compare(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 1))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 1))), Null), !=, Null), Filtered Array(All Players(Team 2), And(And(Compare(Current Array Element, !=, If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 1))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 1))), Null)), Compare((Current Array Element).eventType, ==, 2)), Compare((Current Array Element).eventId, ==, 23))), Empty Array), If-Then-Else(Compare(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 1))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 1))), Null), !=, Null), Custom String("→ {0}", If-Then-Else(Compare((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 1))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 1))), Null)).eventNextIndex, !=, Null), Value In Array(Global.eventName, (If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 1))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 1))), Null)).eventNextIndex), Custom String("未知"))), Char In String(Empty Array, False)), If-Then-Else(Compare(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 1))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 1))), Null), !=, Null), Add(Eye Position(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 1))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 1))), Null)), Multiply(0.3, Up)), Multiply(-1000, Up)), 0.7, Do Not Clip, Visible To Position String and Color, If-Then-Else(And(Compare(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 1))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 1))), Null), !=, Null), Compare((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 1))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 1))), Null)).playerTitleAndColor, !=, Null)), Custom Color(X Component Of((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 1))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 1))), Null)).rgb_vect), Y Component Of((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 1))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 1))), Null)).rgb_vect), Z Component Of((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 1))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 1))), Null)).rgb_vect), 255), Custom Color(186, 120, 255, 255)), Default Visibility); + Create In-World Text(If-Then-Else(Compare(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 2))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 2))), Null), !=, Null), Filtered Array(All Players(Team 2), And(And(Compare(Current Array Element, !=, If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 2))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 2))), Null)), Compare((Current Array Element).eventType, ==, 2)), Compare((Current Array Element).eventId, ==, 23))), Empty Array), If-Then-Else(Compare(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 2))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 2))), Null), !=, Null), Custom String("→ {0}", If-Then-Else(Compare((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 2))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 2))), Null)).eventNextIndex, !=, Null), Value In Array(Global.eventName, (If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 2))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 2))), Null)).eventNextIndex), Custom String("未知"))), Char In String(Empty Array, False)), If-Then-Else(Compare(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 2))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 2))), Null), !=, Null), Add(Eye Position(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 2))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 2))), Null)), Multiply(0.3, Up)), Multiply(-1000, Up)), 0.7, Do Not Clip, Visible To Position String and Color, If-Then-Else(And(Compare(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 2))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 2))), Null), !=, Null), Compare((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 2))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 2))), Null)).playerTitleAndColor, !=, Null)), Custom Color(X Component Of((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 2))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 2))), Null)).rgb_vect), Y Component Of((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 2))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 2))), Null)).rgb_vect), Z Component Of((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 2))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 2))), Null)).rgb_vect), 255), Custom Color(186, 120, 255, 255)), Default Visibility); + Create In-World Text(If-Then-Else(Compare(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 3))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 3))), Null), !=, Null), Filtered Array(All Players(Team 2), And(And(Compare(Current Array Element, !=, If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 3))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 3))), Null)), Compare((Current Array Element).eventType, ==, 2)), Compare((Current Array Element).eventId, ==, 23))), Empty Array), If-Then-Else(Compare(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 3))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 3))), Null), !=, Null), Custom String("→ {0}", If-Then-Else(Compare((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 3))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 3))), Null)).eventNextIndex, !=, Null), Value In Array(Global.eventName, (If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 3))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 3))), Null)).eventNextIndex), Custom String("未知"))), Char In String(Empty Array, False)), If-Then-Else(Compare(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 3))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 3))), Null), !=, Null), Add(Eye Position(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 3))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 3))), Null)), Multiply(0.3, Up)), Multiply(-1000, Up)), 0.7, Do Not Clip, Visible To Position String and Color, If-Then-Else(And(Compare(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 3))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 3))), Null), !=, Null), Compare((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 3))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 3))), Null)).playerTitleAndColor, !=, Null)), Custom Color(X Component Of((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 3))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 3))), Null)).rgb_vect), Y Component Of((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 3))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 3))), Null)).rgb_vect), Z Component Of((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 3))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 3))), Null)).rgb_vect), 255), Custom Color(186, 120, 255, 255)), Default Visibility); + Create In-World Text(If-Then-Else(Compare(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 4))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 4))), Null), !=, Null), Filtered Array(All Players(Team 2), And(And(Compare(Current Array Element, !=, If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 4))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 4))), Null)), Compare((Current Array Element).eventType, ==, 2)), Compare((Current Array Element).eventId, ==, 23))), Empty Array), If-Then-Else(Compare(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 4))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 4))), Null), !=, Null), Custom String("→ {0}", If-Then-Else(Compare((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 4))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 4))), Null)).eventNextIndex, !=, Null), Value In Array(Global.eventName, (If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 4))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 4))), Null)).eventNextIndex), Custom String("未知"))), Char In String(Empty Array, False)), If-Then-Else(Compare(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 4))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 4))), Null), !=, Null), Add(Eye Position(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 4))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 4))), Null)), Multiply(0.3, Up)), Multiply(-1000, Up)), 0.7, Do Not Clip, Visible To Position String and Color, If-Then-Else(And(Compare(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 4))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 4))), Null), !=, Null), Compare((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 4))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 4))), Null)).playerTitleAndColor, !=, Null)), Custom Color(X Component Of((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 4))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 4))), Null)).rgb_vect), Y Component Of((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 4))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 4))), Null)).rgb_vect), Z Component Of((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 4))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 4))), Null)).rgb_vect), 255), Custom Color(186, 120, 255, 255)), Default Visibility); + Create In-World Text(If-Then-Else(Compare(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 5))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 5))), Null), !=, Null), Filtered Array(All Players(Team 2), And(And(Compare(Current Array Element, !=, If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 5))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 5))), Null)), Compare((Current Array Element).eventType, ==, 2)), Compare((Current Array Element).eventId, ==, 23))), Empty Array), If-Then-Else(Compare(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 5))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 5))), Null), !=, Null), Custom String("→ {0}", If-Then-Else(Compare((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 5))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 5))), Null)).eventNextIndex, !=, Null), Value In Array(Global.eventName, (If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 5))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 5))), Null)).eventNextIndex), Custom String("未知"))), Char In String(Empty Array, False)), If-Then-Else(Compare(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 5))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 5))), Null), !=, Null), Add(Eye Position(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 5))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 5))), Null)), Multiply(0.3, Up)), Multiply(-1000, Up)), 0.7, Do Not Clip, Visible To Position String and Color, If-Then-Else(And(Compare(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 5))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 5))), Null), !=, Null), Compare((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 5))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 5))), Null)).playerTitleAndColor, !=, Null)), Custom Color(X Component Of((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 5))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 5))), Null)).rgb_vect), Y Component Of((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 5))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 5))), Null)).rgb_vect), Z Component Of((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 5))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 5))), Null)).rgb_vect), 255), Custom Color(186, 120, 255, 255)), Default Visibility); + Create In-World Text(If-Then-Else(Compare(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 6))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 6))), Null), !=, Null), Filtered Array(All Players(Team 2), And(And(Compare(Current Array Element, !=, If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 6))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 6))), Null)), Compare((Current Array Element).eventType, ==, 2)), Compare((Current Array Element).eventId, ==, 23))), Empty Array), If-Then-Else(Compare(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 6))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 6))), Null), !=, Null), Custom String("→ {0}", If-Then-Else(Compare((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 6))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 6))), Null)).eventNextIndex, !=, Null), Value In Array(Global.eventName, (If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 6))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 6))), Null)).eventNextIndex), Custom String("未知"))), Char In String(Empty Array, False)), If-Then-Else(Compare(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 6))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 6))), Null), !=, Null), Add(Eye Position(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 6))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 6))), Null)), Multiply(0.3, Up)), Multiply(-1000, Up)), 0.7, Do Not Clip, Visible To Position String and Color, If-Then-Else(And(Compare(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 6))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 6))), Null), !=, Null), Compare((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 6))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 6))), Null)).playerTitleAndColor, !=, Null)), Custom Color(X Component Of((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 6))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 6))), Null)).rgb_vect), Y Component Of((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 6))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 6))), Null)).rgb_vect), Z Component Of((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 6))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 6))), Null)).rgb_vect), 255), Custom Color(186, 120, 255, 255)), Default Visibility); + Create In-World Text(If-Then-Else(Compare(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 7))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 7))), Null), !=, Null), Filtered Array(All Players(Team 2), And(And(Compare(Current Array Element, !=, If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 7))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 7))), Null)), Compare((Current Array Element).eventType, ==, 2)), Compare((Current Array Element).eventId, ==, 23))), Empty Array), If-Then-Else(Compare(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 7))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 7))), Null), !=, Null), Custom String("→ {0}", If-Then-Else(Compare((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 7))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 7))), Null)).eventNextIndex, !=, Null), Value In Array(Global.eventName, (If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 7))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 7))), Null)).eventNextIndex), Custom String("未知"))), Char In String(Empty Array, False)), If-Then-Else(Compare(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 7))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 7))), Null), !=, Null), Add(Eye Position(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 7))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 7))), Null)), Multiply(0.3, Up)), Multiply(-1000, Up)), 0.7, Do Not Clip, Visible To Position String and Color, If-Then-Else(And(Compare(If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 7))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 7))), Null), !=, Null), Compare((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 7))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 7))), Null)).playerTitleAndColor, !=, Null)), Custom Color(X Component Of((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 7))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 7))), Null)).rgb_vect), Y Component Of((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 7))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 7))), Null)).rgb_vect), Z Component Of((If-Then-Else(Compare(Count Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 7))), >, Null), First Of(Filtered Array(All Players(Team 2), Compare(Slot Of(Current Array Element), ==, 7))), Null)).rgb_vect), 255), Custom Color(186, 120, 255, 255)), Default Visibility); + Set Global Variable(prophetSlotTextsCreated, True); + } +} From 849418e170e53e3bff7805751278b6dd950aa183 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:07:43 +0800 Subject: [PATCH 2/2] fix(analyzer): address each-match finding action to the scope anchor Each-match findings under a non-`rule` scope now address the scope's anchor action (the loop/if), matching the native loop rules instead of the statement that owns the matched value. A `rule` scope has no anchor, so it keeps the owning-statement semantics the native `nested-binder-lambda` rule uses. The native-vs-declarative parity tests now key on (span, action) so the addressed node is asserted, not just the span. Refs #970 --- crates/wright-analyzer/src/declarative.rs | 7 ++++++- crates/wright-analyzer/tests/yaml_contract.rs | 15 ++++++++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/crates/wright-analyzer/src/declarative.rs b/crates/wright-analyzer/src/declarative.rs index 088b7fc9..2eafebef 100644 --- a/crates/wright-analyzer/src/declarative.rs +++ b/crates/wright-analyzer/src/declarative.rs @@ -254,7 +254,12 @@ impl DeclarativeRule { match self.report { ReportMode::EachMatch => { for (span, value_id, owner_action) in matches { - findings.push(self.finding(matched, span, rule, owner_action, value_id)); + // The native loop rules address the scope anchor + // (the loop), not the statement owning the matched + // value; a `rule` scope has no anchor, so it keeps + // the owning statement (`nested-binder-lambda`). + let action = anchor_action.or(owner_action); + findings.push(self.finding(matched, span, rule, action, value_id)); } } ReportMode::Scope => { diff --git a/crates/wright-analyzer/tests/yaml_contract.rs b/crates/wright-analyzer/tests/yaml_contract.rs index b356d8c4..28766327 100644 --- a/crates/wright-analyzer/tests/yaml_contract.rs +++ b/crates/wright-analyzer/tests/yaml_contract.rs @@ -632,20 +632,25 @@ fn spans_by_code( native: &str, declarative: &str, ) -> (BTreeSet, BTreeSet) { - let key = |span: Option| { - span.map(|span| format!("{:?}-{:?}", span.start, span.end)) - .unwrap_or_else(|| "none".to_string()) + // `action` is part of the machine-readable payload (`wright inspect + // cost`), so parity covers the addressed node as well as the span. + let key = |finding: &wright_analyzer::canonical::Finding| { + let span = finding + .span + .map(|span| format!("{:?}-{:?}", span.start, span.end)) + .unwrap_or_else(|| "none".to_string()); + format!("{span}@{:?}", finding.action) }; ( findings .iter() .filter(|finding| finding.code == native) - .map(|finding| key(finding.span)) + .map(&key) .collect(), findings .iter() .filter(|finding| finding.code == declarative) - .map(|finding| key(finding.span)) + .map(&key) .collect(), ) }