Skip to main content

workshop_rs_cli/
census.rs

1//! Deterministic, sharded census of the canonical Workshop surface.
2//!
3//! The census is derived from this crate's catalog, settings table, and WIR
4//! capabilities. It is tooling that runs contract/regression probes and
5//! produces structured results, not a source-language inventory or a
6//! live-client oracle.
7
8use std::collections::{HashMap, HashSet};
9
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12
13use crate::conformance::{
14    CONFORMANCE_SCHEMA_VERSION, Comparison, ConformanceReason, ConformanceResult,
15    ConformanceStatus, Equivalence, FeatureId, FeatureKind, FeatureNamespace, ReasonCode,
16    TestArtifact, is_sha256_digest,
17};
18use workshop_rs::catalog::{Catalog, CatalogEntry, EnumDomain, Kind, Locale};
19use workshop_rs::gameplay::{self, HeroId};
20use workshop_rs::settings::{
21    self as settings_schema, SettingDefinition, SettingTarget, SettingTargetKind,
22    SettingValueDomain,
23};
24use workshop_rs::{WorkshopError, convert, emitter, parser, roundtrip};
25
26#[derive(Clone, Copy)]
27enum CensusCapabilityKind {
28    Variable,
29    PlayerVariable,
30    Subroutine,
31    ControlFlow,
32    String,
33}
34
35struct CensusCapability {
36    kind: CensusCapabilityKind,
37    name: &'static str,
38}
39
40const CENSUS_CAPABILITIES: &[CensusCapability] = &[
41    CensusCapability {
42        kind: CensusCapabilityKind::Variable,
43        name: "global",
44    },
45    CensusCapability {
46        kind: CensusCapabilityKind::PlayerVariable,
47        name: "player",
48    },
49    CensusCapability {
50        kind: CensusCapabilityKind::Subroutine,
51        name: "declaration-and-call",
52    },
53    CensusCapability {
54        kind: CensusCapabilityKind::ControlFlow,
55        name: "if",
56    },
57    CensusCapability {
58        kind: CensusCapabilityKind::ControlFlow,
59        name: "else-if",
60    },
61    CensusCapability {
62        kind: CensusCapabilityKind::ControlFlow,
63        name: "else",
64    },
65    CensusCapability {
66        kind: CensusCapabilityKind::ControlFlow,
67        name: "while",
68    },
69    CensusCapability {
70        kind: CensusCapabilityKind::ControlFlow,
71        name: "for-global-variable",
72    },
73    CensusCapability {
74        kind: CensusCapabilityKind::String,
75        name: "custom-string",
76    },
77];
78
79pub const CENSUS_SCHEMA_VERSION: u32 = 1;
80pub const CENSUS_IDENTITY_SCHEMA_VERSION: u32 = 1;
81const EN_US: &str = "en-US";
82const ZH_CN: &str = "zh-CN";
83const LOCALIZATION_EN_US_SOURCE: &str = r#"rule ("Localization") {
84    event {
85        Ongoing - Global;
86    }
87    actions {
88        Disable Inspector Recording;
89    }
90}
91"#;
92const LOCALIZATION_ZH_CN_SOURCE: &str = r#"rule ("Localization") {
93    event {
94        持续 - 全局;
95    }
96    actions {
97        禁用查看器录制;
98    }
99}
100"#;
101
102/// An explicit support classification for a census case.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(tag = "kind", rename_all = "kebab-case")]
105pub enum CensusSupport {
106    Exercise,
107    Unsupported { detail: String },
108    KnownGap { detail: String },
109    Inconclusive { detail: String },
110}
111
112/// One deterministic case with explicit source-locale provenance.
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114pub struct CensusCase {
115    pub case_id: String,
116    pub features: Vec<FeatureId>,
117    /// Locale of `source`; a zh-CN case is a real input, not an
118    /// implementation-generated conversion.
119    #[serde(default = "default_source_locale")]
120    pub source_locale: String,
121    pub source: String,
122    /// Independently recorded source text, when this case has an offline
123    /// expectation. None means the case remains inconclusive offline.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub reference_source: Option<String>,
126    pub support: CensusSupport,
127}
128
129/// A named collection of independently attributable cases.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct CensusShard {
132    pub shard_id: String,
133    pub cases: Vec<CensusCase>,
134}
135
136impl CensusShard {
137    pub fn new(
138        shard_id: impl Into<String>,
139        mut cases: Vec<CensusCase>,
140    ) -> Result<Self, CensusError> {
141        let shard_id = shard_id.into();
142        validate_name("shard_id", &shard_id)?;
143        cases.sort_by(|left, right| left.case_id.cmp(&right.case_id));
144        for case in &cases {
145            case.validate()?;
146        }
147        if cases
148            .windows(2)
149            .any(|pair| pair[0].case_id == pair[1].case_id)
150        {
151            return Err(CensusError::new(format!(
152                "shard '{shard_id}' contains duplicate case IDs"
153            )));
154        }
155        Ok(Self { shard_id, cases })
156    }
157}
158
159impl CensusCase {
160    /// A generated en-US probe case without an independent expectation.
161    fn probe(case_id: impl Into<String>, features: Vec<FeatureId>, source: String) -> Self {
162        Self {
163            case_id: case_id.into(),
164            features,
165            source_locale: EN_US.to_string(),
166            source,
167            reference_source: None,
168            support: generated_probe_support(),
169        }
170    }
171
172    /// Mark the case exercised by its generated source.
173    fn exercise(mut self) -> Self {
174        self.support = CensusSupport::Exercise;
175        self
176    }
177
178    /// Record a non-default source locale.
179    fn locale(mut self, locale: &str) -> Self {
180        self.source_locale = locale.to_string();
181        self
182    }
183
184    /// Override the support classification.
185    #[cfg(test)]
186    fn with_support(mut self, support: CensusSupport) -> Self {
187        self.support = support;
188        self
189    }
190
191    fn validate(&self) -> Result<(), CensusError> {
192        validate_name("case_id", &self.case_id)?;
193        if self.features.is_empty() {
194            return Err(CensusError::new(format!(
195                "case '{}' has no feature IDs",
196                self.case_id
197            )));
198        }
199        if self.source.trim().is_empty() {
200            return Err(CensusError::new(format!(
201                "case '{}' has no source",
202                self.case_id
203            )));
204        }
205        validate_name("source_locale", &self.source_locale)?;
206        if self
207            .reference_source
208            .as_deref()
209            .is_some_and(|source| source.trim().is_empty())
210        {
211            return Err(CensusError::new(format!(
212                "case '{}' has an empty reference source",
213                self.case_id
214            )));
215        }
216        let mut features = HashSet::new();
217        if self
218            .features
219            .iter()
220            .any(|feature| !features.insert(feature))
221        {
222            return Err(CensusError::new(format!(
223                "case '{}' contains duplicate feature IDs",
224                self.case_id
225            )));
226        }
227        Ok(())
228    }
229}
230
231/// The complete census assembled from deterministic shards.
232#[derive(Debug, Clone, PartialEq, Eq)]
233pub struct Census {
234    shards: Vec<CensusShard>,
235}
236
237/// The stable identity of a reviewed census definition.
238#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
239#[serde(rename_all = "camelCase")]
240pub struct CensusIdentity {
241    pub schema_version: u32,
242    pub digest: String,
243    pub shards: Vec<String>,
244}
245
246impl Census {
247    /// Assemble shards in stable shard and case order.
248    pub fn assemble(mut shards: Vec<CensusShard>) -> Result<Self, CensusError> {
249        shards.sort_by(|left, right| left.shard_id.cmp(&right.shard_id));
250        let mut shard_ids = HashSet::new();
251        let mut case_ids = HashSet::new();
252        for shard in &shards {
253            if !shard_ids.insert(shard.shard_id.clone()) {
254                return Err(CensusError::new(format!(
255                    "duplicate census shard '{}'",
256                    shard.shard_id
257                )));
258            }
259            for case in &shard.cases {
260                if !case_ids.insert(case.case_id.clone()) {
261                    return Err(CensusError::new(format!(
262                        "duplicate census case '{}'",
263                        case.case_id
264                    )));
265                }
266            }
267        }
268        Ok(Self { shards })
269    }
270
271    /// Derive the current surface from the canonical catalog, settings table,
272    /// and WIR capability names owned by this crate.
273    pub fn builtin(catalog: &Catalog) -> Result<Self, CensusError> {
274        Self::assemble(vec![
275            catalog_shard(catalog, Kind::Event, "catalog-events")?,
276            catalog_shard(catalog, Kind::Action, "catalog-actions")?,
277            catalog_shard(catalog, Kind::Value, "catalog-values")?,
278            catalog_shard(catalog, Kind::Operator, "catalog-operators")?,
279            catalog_shard(catalog, Kind::Structural, "catalog-structural")?,
280            enum_shard(catalog)?,
281            settings_shard(catalog)?,
282            wir_shard()?,
283            localization_shard(catalog)?,
284            content_id_shard(catalog)?,
285        ])
286    }
287
288    pub fn shards(&self) -> &[CensusShard] {
289        &self.shards
290    }
291
292    pub fn cases(&self) -> impl Iterator<Item = &CensusCase> {
293        self.shards.iter().flat_map(|shard| shard.cases.iter())
294    }
295
296    /// Execute all cases. No result state is dropped or converted to success.
297    /// Cases are independent and the report sorts results by case ID, so the
298    /// run parallelizes over available cores without changing the output.
299    pub fn run(&self, catalog: &Catalog) -> CensusReport {
300        let cases: Vec<(&str, &CensusCase)> = self
301            .shards
302            .iter()
303            .flat_map(|shard| {
304                shard
305                    .cases
306                    .iter()
307                    .map(move |case| (shard.shard_id.as_str(), case))
308            })
309            .collect();
310        let workers = std::thread::available_parallelism()
311            .map(std::num::NonZero::get)
312            .unwrap_or(1)
313            .min(cases.len().max(1));
314        let results: Vec<ConformanceResult> = if workers <= 1 {
315            cases
316                .iter()
317                .map(|(shard_id, case)| run_case(case, shard_id, catalog))
318                .collect()
319        } else {
320            let next = std::sync::atomic::AtomicUsize::new(0);
321            std::thread::scope(|scope| {
322                let handles: Vec<_> = (0..workers)
323                    .map(|_| {
324                        scope.spawn(|| {
325                            let mut worker_results = Vec::new();
326                            loop {
327                                let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
328                                let Some((shard_id, case)) = cases.get(index) else {
329                                    break;
330                                };
331                                worker_results.push(run_case(case, shard_id, catalog));
332                            }
333                            worker_results
334                        })
335                    })
336                    .collect();
337                handles
338                    .into_iter()
339                    .flat_map(|handle| handle.join().expect("census worker panicked"))
340                    .collect()
341            })
342        };
343        let mut results = results;
344        results.sort_by(|left, right| left.case_id.cmp(&right.case_id));
345        CensusReport {
346            schema_version: CENSUS_SCHEMA_VERSION,
347            conformance_schema_version: CONFORMANCE_SCHEMA_VERSION,
348            catalog: catalog.identity(),
349            census: self.identity(),
350            results,
351        }
352    }
353
354    /// Return the deterministic identity of this census definition.
355    pub fn identity(&self) -> CensusIdentity {
356        let definition = self
357            .export_json()
358            .expect("census definitions must remain serializable");
359        CensusIdentity {
360            schema_version: CENSUS_IDENTITY_SCHEMA_VERSION,
361            digest: sha256(&definition),
362            shards: self
363                .shards
364                .iter()
365                .map(|shard| shard.shard_id.clone())
366                .collect(),
367        }
368    }
369
370    /// Export shard definitions without executing them.
371    pub fn export_json(&self) -> Result<String, CensusError> {
372        serde_json::to_string_pretty(&self.shards)
373            .map_err(|error| CensusError::new(format!("cannot serialize census shards: {error}")))
374    }
375}
376
377/// Machine-readable output from a census run.
378#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
379#[serde(rename_all = "camelCase")]
380pub struct CensusReport {
381    pub schema_version: u32,
382    pub conformance_schema_version: u32,
383    pub catalog: workshop_rs::catalog::CatalogIdentity,
384    pub census: CensusIdentity,
385    pub results: Vec<ConformanceResult>,
386}
387
388impl CensusReport {
389    pub fn validate(&self) -> Result<(), CensusError> {
390        let catalog =
391            Catalog::builtin().map_err(|error| CensusError::new(format!("catalog: {error}")))?;
392        self.validate_against(&catalog)
393    }
394
395    pub fn validate_against(&self, catalog: &Catalog) -> Result<(), CensusError> {
396        if self.schema_version != CENSUS_SCHEMA_VERSION {
397            return Err(CensusError::new("unsupported census schema version"));
398        }
399        if self.conformance_schema_version != CONFORMANCE_SCHEMA_VERSION {
400            return Err(CensusError::new("unsupported conformance schema version"));
401        }
402        if self.catalog != catalog.identity() {
403            return Err(CensusError::new(
404                "report catalog identity does not match the loaded catalog",
405            ));
406        }
407        if self.census.schema_version != CENSUS_IDENTITY_SCHEMA_VERSION {
408            return Err(CensusError::new(
409                "unsupported census identity schema version",
410            ));
411        }
412        if !is_sha256_digest(&self.census.digest) {
413            return Err(CensusError::new(
414                "census identity digest must be a SHA-256 hex digest",
415            ));
416        }
417        if self.census.shards.is_empty()
418            || self.census.shards.windows(2).any(|pair| pair[0] >= pair[1])
419        {
420            return Err(CensusError::new(
421                "report shards must be non-empty and strictly sorted",
422            ));
423        }
424        for result in &self.results {
425            result
426                .validate_against(catalog)
427                .map_err(|error| CensusError::new(error.to_string()))?;
428            let matching_shards = self
429                .census
430                .shards
431                .iter()
432                .filter(|shard| {
433                    result
434                        .case_id
435                        .strip_prefix(shard.as_str())
436                        .is_some_and(|rest| rest.starts_with('/'))
437                })
438                .count();
439            if matching_shards != 1 {
440                return Err(CensusError::new(format!(
441                    "result '{}' does not map to exactly one census shard",
442                    result.case_id
443                )));
444            }
445        }
446        Ok(())
447    }
448
449    pub fn to_json(&self) -> Result<String, CensusError> {
450        self.validate()?;
451        serde_json::to_string_pretty(self)
452            .map_err(|error| CensusError::new(format!("cannot serialize census report: {error}")))
453    }
454}
455
456#[derive(Debug, Clone, PartialEq, Eq)]
457pub struct CensusError {
458    pub message: String,
459}
460
461impl CensusError {
462    fn new(message: impl Into<String>) -> Self {
463        Self {
464            message: message.into(),
465        }
466    }
467}
468
469impl std::fmt::Display for CensusError {
470    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
471        formatter.write_str(&self.message)
472    }
473}
474
475impl std::error::Error for CensusError {}
476
477fn validate_name(field: &str, value: &str) -> Result<(), CensusError> {
478    if value.trim().is_empty() || value.chars().any(char::is_control) {
479        Err(CensusError::new(format!(
480            "{field} must be non-empty and printable"
481        )))
482    } else {
483        Ok(())
484    }
485}
486
487fn default_source_locale() -> String {
488    EN_US.to_string()
489}
490
491fn feature(namespace: FeatureNamespace, kind: FeatureKind, name: impl Into<String>) -> FeatureId {
492    FeatureId::new(namespace, kind, name).expect("canonical census feature ID")
493}
494
495fn catalog_feature(kind: Kind, id: &str) -> FeatureId {
496    FeatureId::from_catalog(kind, id).expect("catalog IDs are validated by Catalog::load")
497}
498
499fn catalog_shard(
500    catalog: &Catalog,
501    kind: Kind,
502    shard_id: &str,
503) -> Result<CensusShard, CensusError> {
504    let cases = catalog
505        .entries_of(kind)
506        .map(|entry| {
507            let source = match kind {
508                Kind::Event => event_probe(catalog, entry),
509                Kind::Action => action_probe(catalog, entry),
510                Kind::Value => value_probe(catalog, entry),
511                Kind::Operator => operator_probe(catalog, entry),
512                Kind::Structural => structural_probe(catalog, entry),
513                Kind::Setting => unreachable!("settings use the settings table"),
514                Kind::Enum => unreachable!("enum domains use the enum shard"),
515            };
516            CensusCase::probe(
517                format!("{shard_id}/{}", entry.id),
518                vec![catalog_feature(kind, &entry.id)],
519                source,
520            )
521        })
522        .collect();
523    CensusShard::new(shard_id, cases)
524}
525
526fn enum_shard(catalog: &Catalog) -> Result<CensusShard, CensusError> {
527    enum_member_cases(catalog, "catalog-enums", false, true)
528}
529
530fn content_id_shard(catalog: &Catalog) -> Result<CensusShard, CensusError> {
531    enum_member_cases(catalog, "content-ids", true, false)
532}
533
534/// Emit one probe case per enum member. `content_only` restricts to the
535/// Hero/Map content-id domains; `domain_feature` also records the domain
536/// itself as a covered feature.
537fn enum_member_cases(
538    catalog: &Catalog,
539    shard_id: &str,
540    content_only: bool,
541    domain_feature: bool,
542) -> Result<CensusShard, CensusError> {
543    let mut cases = Vec::new();
544    for domain in catalog.enum_domains() {
545        if content_only && !matches!(domain.domain.as_str(), "Hero" | "Map") {
546            continue;
547        }
548        for member in &domain.members {
549            let mut features = Vec::with_capacity(2);
550            if domain_feature {
551                features.push(catalog_feature(Kind::Enum, &domain.domain));
552            }
553            features.push(
554                FeatureId::from_enum_member(&domain.domain, &member.member)
555                    .expect("canonical enum member ID"),
556            );
557            cases.push(CensusCase::probe(
558                format!("{shard_id}/{}/{}", domain.domain, member.member),
559                features,
560                enum_probe(catalog, domain, &member.member),
561            ));
562        }
563    }
564    CensusShard::new(shard_id, cases)
565}
566fn settings_shard(catalog: &Catalog) -> Result<CensusShard, CensusError> {
567    let cases = settings_schema::definitions()
568        .map(|definition| {
569            let path = definition.path().to_string();
570            CensusCase::probe(
571                format!("settings/{path}"),
572                vec![feature(
573                    FeatureNamespace::Settings,
574                    FeatureKind::Setting,
575                    path,
576                )],
577                settings_probe(&definition, catalog),
578            )
579        })
580        .collect();
581    CensusShard::new("settings", cases)
582}
583
584fn wir_shard() -> Result<CensusShard, CensusError> {
585    let cases = CENSUS_CAPABILITIES
586        .iter()
587        .map(|capability| match capability.kind {
588            CensusCapabilityKind::Variable => wir_case(
589                "variables-global",
590                FeatureKind::Variable,
591                capability.name,
592                variables_source(),
593            ),
594            CensusCapabilityKind::PlayerVariable => CensusCase::probe(
595                "wir/variables-player".to_string(),
596                vec![feature(
597                    FeatureNamespace::Wir,
598                    FeatureKind::Variable,
599                    capability.name,
600                )],
601                player_variable_source(),
602            ),
603            CensusCapabilityKind::Subroutine => wir_case(
604                "subroutine",
605                FeatureKind::Subroutine,
606                capability.name,
607                subroutine_source(),
608            ),
609            CensusCapabilityKind::ControlFlow => {
610                let actions = match capability.name {
611                    "if" => "If(True);\n    Wait(0);\nEnd;",
612                    "else-if" => "If(True);\n    Wait(0);\nElse If(False);\n    Wait(0);\nEnd;",
613                    "else" => "If(True);\n    Wait(0);\nElse;\n    Wait(0);\nEnd;",
614                    "while" => "While(True);\n    Wait(0);\nEnd;",
615                    "for-global-variable" => {
616                        "For Global Variable(probe, 0, 1, 1);\n    Wait(0);\nEnd;"
617                    }
618                    _ => unreachable!("unknown WIR control-flow census capability"),
619                };
620                control_flow_case(capability.name, actions)
621            }
622            CensusCapabilityKind::String => CensusCase::probe(
623                "wir/string/custom-string".to_string(),
624                vec![feature(
625                    FeatureNamespace::Wir,
626                    FeatureKind::String,
627                    capability.name,
628                )],
629                rule_source(
630                    "String",
631                    "Set Global Variable(probe, Custom String(\"census\"));",
632                ),
633            )
634            .exercise(),
635        })
636        .collect();
637    CensusShard::new("wir", cases)
638}
639
640fn localization_shard(catalog: &Catalog) -> Result<CensusShard, CensusError> {
641    let mut cases = vec![
642        CensusCase::probe(
643            "localization/en-us-to-zh-cn".to_string(),
644            vec![feature(
645                FeatureNamespace::Localization,
646                FeatureKind::Localization,
647                "en-us-to-zh-cn",
648            )],
649            LOCALIZATION_EN_US_SOURCE.to_string(),
650        ),
651        CensusCase::probe(
652            "localization/zh-cn-to-en-us".to_string(),
653            vec![feature(
654                FeatureNamespace::Localization,
655                FeatureKind::Localization,
656                "zh-cn-to-en-us",
657            )],
658            LOCALIZATION_ZH_CN_SOURCE.to_string(),
659        )
660        .locale(ZH_CN),
661    ];
662    // #448: every spelling the catalog declares for a locale gets a case
663    // written in that spelling, so an alias the frontend cannot represent
664    // shows up as an explicit result instead of never being exercised.
665    let settings_index = SettingsSpellings::build();
666    for locale in catalog.locales() {
667        for kind in [
668            Kind::Event,
669            Kind::Action,
670            Kind::Value,
671            Kind::Operator,
672            Kind::Structural,
673        ] {
674            for entry in catalog.entries_of(kind) {
675                for (index, spelling) in entry.spellings(locale).iter().enumerate() {
676                    cases.push(
677                        CensusCase::probe(
678                            format!(
679                                "localization/{}/{}/{}/{}",
680                                locale.as_str(),
681                                kind.as_str(),
682                                entry.id,
683                                index
684                            ),
685                            vec![catalog_feature(kind, &entry.id)],
686                            localized_probe(catalog, locale, kind, entry, spelling),
687                        )
688                        .locale(locale.as_str()),
689                    );
690                }
691            }
692        }
693        for domain in catalog.enum_domains() {
694            let domain_spelling = CONSTRUCTOR_DOMAINS
695                .contains(&domain.domain.as_str())
696                .then(|| domain.spelling(locale))
697                .flatten();
698            if let Some(spelling) = domain_spelling {
699                let Some(member) = domain.members.first() else {
700                    continue;
701                };
702                let member_spelling =
703                    localized_member(catalog, &domain.domain, &member.member, locale);
704                cases.push(
705                    CensusCase::probe(
706                        format!(
707                            "localization/{}/enum-domain/{}",
708                            locale.as_str(),
709                            domain.domain
710                        ),
711                        vec![catalog_feature(Kind::Enum, &domain.domain)],
712                        global_probe(catalog, locale, &format!("{spelling}({member_spelling})")),
713                    )
714                    .locale(locale.as_str()),
715                );
716            }
717            for member in &domain.members {
718                for (index, spelling) in member.spellings(locale).iter().enumerate() {
719                    cases.push(
720                        CensusCase::probe(
721                            format!(
722                                "localization/{}/enum/{}/{}/{}",
723                                locale.as_str(),
724                                domain.domain,
725                                member.member,
726                                index
727                            ),
728                            vec![
729                                FeatureId::from_enum_member(&domain.domain, &member.member)
730                                    .expect("canonical enum member ID"),
731                            ],
732                            member_probe(catalog, locale, domain, spelling),
733                        )
734                        .locale(locale.as_str()),
735                    );
736                }
737            }
738        }
739        for entry in catalog.localized_strings() {
740            for (index, spelling) in entry.spellings(locale).iter().enumerate() {
741                let string_call = catalog
742                    .spelling(Kind::Value, locale, "string")
743                    .or_else(|| catalog.spelling(Kind::Value, &Locale::new(EN_US), "string"))
744                    .unwrap_or("string");
745                cases.push(
746                    CensusCase::probe(
747                        format!(
748                            "localization/{}/localized-string/{}/{}",
749                            locale.as_str(),
750                            entry.id,
751                            index
752                        ),
753                        vec![feature(
754                            FeatureNamespace::Localization,
755                            FeatureKind::String,
756                            entry.id.clone(),
757                        )],
758                        global_probe(catalog, locale, &format!("{string_call}({spelling})")),
759                    )
760                    .locale(locale.as_str()),
761                );
762            }
763        }
764        for (row, name) in settings_schema::localized_setting_names()
765            .iter()
766            .enumerate()
767        {
768            if !name.locale.eq_ignore_ascii_case(locale.as_str()) {
769                continue;
770            }
771            let case_id = format!(
772                "localization/{}/settings/{}/{}",
773                locale.as_str(),
774                name.section,
775                row
776            );
777            let feature_id = feature(
778                FeatureNamespace::Settings,
779                FeatureKind::Setting,
780                format!("{}/{}", name.section, slug(name.english)),
781            );
782            match localized_settings_probe(catalog, &settings_index, name, locale) {
783                Ok(source) => cases.push(
784                    CensusCase::probe(case_id, vec![feature_id], source).locale(locale.as_str()),
785                ),
786                Err(detail) => cases.push(
787                    CensusCase {
788                        case_id,
789                        features: vec![feature_id],
790                        source_locale: locale.as_str().to_string(),
791                        // The row has no writable context; the source is a
792                        // placeholder only (known-gap cases are not run).
793                        source: "settings {\n}".to_string(),
794                        reference_source: None,
795                        support: CensusSupport::KnownGap {
796                            detail: format!(
797                                "settings {} {:?} for {}: {detail}",
798                                name.section, name.spelling, name.english
799                            ),
800                        },
801                    }
802                    .locale(locale.as_str()),
803                ),
804            }
805        }
806    }
807    CensusShard::new("localization", cases)
808}
809
810/// Enum domains whose members are written in `Domain(Member)` constructor
811/// form (#448 census probes mirror `Catalog::enum_member_form`).
812const CONSTRUCTOR_DOMAINS: &[&str] = &["Hero", "Button", "Color", "Map"];
813
814/// A feature-safe name fragment: catalog `english` display names can carry
815/// spaces (`"Flag Carrier Abilities"`), which `FeatureId` rejects.
816fn slug(name: &str) -> String {
817    name.chars()
818        .map(|character| {
819            if character.is_whitespace() || character == '/' {
820                '-'
821            } else {
822                character
823            }
824        })
825        .collect()
826}
827
828/// The declared spelling of `id` in `locale`, falling back to en-US.
829fn localized_spelling(catalog: &Catalog, kind: Kind, locale: &Locale, id: &str) -> String {
830    catalog
831        .spelling(kind, locale, id)
832        .or_else(|| catalog.spelling(kind, &Locale::new(EN_US), id))
833        .unwrap_or(id)
834        .to_string()
835}
836
837/// The declared member spelling in `locale`, falling back to en-US.
838fn localized_member(catalog: &Catalog, domain: &str, member: &str, locale: &Locale) -> String {
839    catalog
840        .enum_spelling(domain, locale, member)
841        .or_else(|| catalog.enum_spelling(domain, &Locale::new(EN_US), member))
842        .unwrap_or(member)
843        .to_string()
844}
845
846/// A source-form argument for `param` of `entry` in `locale`, used to fill
847/// the non-tested positions of a probe call (#448). Declared defaults are
848/// written verbatim-resolved so the emission of a call stays stable.
849fn probe_filler(catalog: &Catalog, locale: &Locale, entry: &CatalogEntry, index: usize) -> String {
850    if let Some(domain) = entry.param_domain(index) {
851        let member = catalog
852            .enum_domain(domain)
853            .and_then(|domain| domain.members.first())
854            .map(|member| member.member.as_str())
855            .unwrap_or("ALL");
856        let spelling = localized_member(catalog, domain, member, locale);
857        if CONSTRUCTOR_DOMAINS.contains(&domain) {
858            let domain_spelling = catalog
859                .enum_domain(domain)
860                .and_then(|domain| domain.spelling(locale))
861                .unwrap_or(domain);
862            return format!("{domain_spelling}({spelling})");
863        }
864        return spelling;
865    }
866    if let Some(default) = entry.param_default(index) {
867        return param_default_filler(catalog, locale, default);
868    }
869    match entry.param_type(index) {
870        Some("Variable") => "probe".to_string(),
871        Some("Subroutine") | Some("Subroutines") => "probe".to_string(),
872        Some("Bool") => localized_spelling(catalog, Kind::Value, locale, "true"),
873        // `Hero`-typed params take the member spelling bare; the enclosing
874        // `Hero(...)` call is the value under test or the entry's own name.
875        Some("Hero") => {
876            let member = catalog
877                .enum_domain("Hero")
878                .and_then(|domain| domain.members.first())
879                .map(|member| member.member.as_str())
880                .unwrap_or("ANA");
881            localized_member(catalog, "Hero", member, locale)
882        }
883        // String params emit canonically as `Custom String("...")`, so the
884        // probe writes that form instead of a bare literal (#448).
885        Some("String") => {
886            let custom_string = localized_spelling(catalog, Kind::Value, locale, "customString");
887            format!("{custom_string}(\"x\")")
888        }
889        Some(kind) if kind.contains("Player") || kind.contains("EntityId") => {
890            localized_spelling(catalog, Kind::Value, locale, "eventPlayer")
891        }
892        _ => "0".to_string(),
893    }
894}
895
896/// Source for a declared `paramDefaults` value: `Domain.MEMBER` resolves
897/// through the enum tables, `Hello` is a localized-string id, `null` and
898/// value ids resolve through the value catalog (#448).
899fn param_default_filler(catalog: &Catalog, locale: &Locale, default: &str) -> String {
900    if let Some((domain, member)) = default.split_once('.') {
901        let spelling = localized_member(catalog, domain, member, locale);
902        if CONSTRUCTOR_DOMAINS.contains(&domain) {
903            let domain_spelling = catalog
904                .enum_domain(domain)
905                .and_then(|domain| domain.spelling(locale))
906                .unwrap_or(domain);
907            return format!("{domain_spelling}({spelling})");
908        }
909        return spelling;
910    }
911    if catalog.entry(Kind::Value, default).is_some() {
912        return localized_spelling(catalog, Kind::Value, locale, default);
913    }
914    if let Some(spelling) = catalog.localized_string_spelling(locale, default) {
915        return spelling.to_string();
916    }
917    default.to_string()
918}
919
920fn probe_args(catalog: &Catalog, locale: &Locale, entry: &CatalogEntry) -> String {
921    (0..entry.param_count())
922        .map(|index| probe_filler(catalog, locale, entry, index))
923        .collect::<Vec<_>>()
924        .join(", ")
925}
926
927/// A probe rule written fully in `locale` (#448): every fixed token —
928/// section keywords, the header event, `Wait`, `If`, `End` — carries that
929/// locale's declared spelling so a cross-locale round trip compares the
930/// tested spelling rather than fallback noise in the boilerplate.
931fn probe_rule(catalog: &Catalog, locale: &Locale, actions: &str) -> String {
932    let variables = localized_spelling(catalog, Kind::Structural, locale, "variables");
933    let global = localized_spelling(catalog, Kind::Structural, locale, "global");
934    let player = localized_spelling(catalog, Kind::Structural, locale, "player");
935    let subroutines = localized_spelling(catalog, Kind::Structural, locale, "subroutines");
936    let rule = localized_spelling(catalog, Kind::Structural, locale, "rule");
937    let event = localized_spelling(catalog, Kind::Structural, locale, "event");
938    let ongoing_global = localized_spelling(catalog, Kind::Event, locale, "global");
939    let actions_section = localized_spelling(catalog, Kind::Structural, locale, "actions");
940    format!(
941        "{variables} {{\n    {global}:\n        0: probe\n    {player}:\n        0: probe\n}}\n\n{subroutines} {{\n    0: probe\n}}\n\n{rule} (\"probe\") {{\n    {event} {{\n        {ongoing_global};\n    }}\n    {actions_section} {{\n        {actions}\n    }}\n}}\n"
942    )
943}
944
945fn global_probe(catalog: &Catalog, locale: &Locale, operand: &str) -> String {
946    let set_global = localized_spelling(catalog, Kind::Structural, locale, "setGlobalVariable");
947    probe_rule(catalog, locale, &format!("{set_global}(probe, {operand});"))
948}
949
950/// A probe source that writes `spelling` where `entry` belongs, for a
951/// catalog kind case in `locale` (#448).
952fn localized_probe(
953    catalog: &Catalog,
954    locale: &Locale,
955    kind: Kind,
956    entry: &CatalogEntry,
957    spelling: &str,
958) -> String {
959    match kind {
960        Kind::Event => {
961            let extra = match entry.id.as_str() {
962                "global" => String::new(),
963                "subroutine" => "        probe;\n".to_string(),
964                _ => format!(
965                    "        {};\n        {};\n",
966                    localized_member(catalog, "EventTeam", "ALL", locale),
967                    localized_member(catalog, "EventPlayer", "ALL", locale),
968                ),
969            };
970            let subroutines = localized_spelling(catalog, Kind::Structural, locale, "subroutines");
971            let rule = localized_spelling(catalog, Kind::Structural, locale, "rule");
972            let event = localized_spelling(catalog, Kind::Structural, locale, "event");
973            let actions = localized_spelling(catalog, Kind::Structural, locale, "actions");
974            let wait = localized_spelling(catalog, Kind::Action, locale, "wait");
975            format!(
976                "{subroutines} {{\n    0: probe\n}}\n\n{rule} (\"e\") {{\n    {event} {{\n        {spelling};\n{extra}    }}\n    {actions} {{\n        {wait};\n    }}\n}}\n"
977            )
978        }
979        Kind::Action => {
980            let call = if entry.param_count() == 0 {
981                format!("{spelling};")
982            } else {
983                format!("{spelling}({});", probe_args(catalog, locale, entry))
984            };
985            // `For * Variable` actions open a block that needs `End`.
986            let call = if entry.id.starts_with("__for") {
987                let wait = localized_spelling(catalog, Kind::Action, locale, "wait");
988                let end = localized_spelling(catalog, Kind::Structural, locale, "end");
989                format!("{call}\n        {wait}(0);\n    {end};")
990            } else {
991                call
992            };
993            probe_rule(catalog, locale, &call)
994        }
995        Kind::Value => {
996            let operand = match entry.id.as_str() {
997                "string" => {
998                    let hello = catalog
999                        .localized_string_spelling(locale, "hello")
1000                        .or_else(|| catalog.localized_string_spelling(&Locale::new(EN_US), "hello"))
1001                        .unwrap_or("Hello");
1002                    format!("{spelling}({hello})")
1003                }
1004                "compare" => {
1005                    let eq = localized_spelling(catalog, Kind::Operator, locale, "==");
1006                    format!("{spelling}(1, {eq}, 1)")
1007                }
1008                _ if entry.param_count() == 0 => spelling.to_string(),
1009                _ => format!("{spelling}({})", probe_args(catalog, locale, entry)),
1010            };
1011            global_probe(catalog, locale, &operand)
1012        }
1013        Kind::Operator => {
1014            if matches!(entry.id.as_str(), "==" | "!=" | "<" | "<=" | ">" | ">=") {
1015                let if_ = localized_spelling(catalog, Kind::Structural, locale, "if");
1016                let wait = localized_spelling(catalog, Kind::Action, locale, "wait");
1017                let end = localized_spelling(catalog, Kind::Structural, locale, "end");
1018                probe_rule(
1019                    catalog,
1020                    locale,
1021                    &format!("{if_}(1 {spelling} 1);\n        {wait}(0);\n    {end};"),
1022                )
1023            } else {
1024                let modify =
1025                    localized_spelling(catalog, Kind::Structural, locale, "modifyGlobalVariable");
1026                probe_rule(catalog, locale, &format!("{modify}(probe, {spelling}, 1);"))
1027            }
1028        }
1029        Kind::Structural => structural_spelling_probe(catalog, locale, &entry.id, spelling),
1030        _ => unreachable!("settings and enum spellings use their own probes"),
1031    }
1032}
1033
1034fn structural_spelling_probe(
1035    catalog: &Catalog,
1036    locale: &Locale,
1037    id: &str,
1038    spelling: &str,
1039) -> String {
1040    let kw = |name: &str| localized_spelling(catalog, Kind::Structural, locale, name);
1041    let wait = localized_spelling(catalog, Kind::Action, locale, "wait");
1042    let true_ = localized_spelling(catalog, Kind::Value, locale, "true");
1043    let ongoing_global = localized_spelling(catalog, Kind::Event, locale, "global");
1044    let event_player = localized_spelling(catalog, Kind::Value, locale, "eventPlayer");
1045    let end = kw("end");
1046    let block = |head: &str| format!("{head}\n        {wait}(0);\n    {end};");
1047    match id {
1048        "settings" => format!("{spelling} {{\n}}\n"),
1049        "workshop" => format!("{} {{\n    {spelling} {{\n    }}\n}}\n", kw("settings")),
1050        "variables" => format!(
1051            "{spelling} {{\n    {}:\n        0: probe\n}}\n",
1052            kw("global")
1053        ),
1054        "global" | "player" => {
1055            format!(
1056                "{} {{\n    {spelling}:\n        0: probe\n}}\n",
1057                kw("variables")
1058            )
1059        }
1060        "subroutines" => format!("{spelling} {{\n    0: probe\n}}\n"),
1061        "rule" => format!(
1062            "{spelling} (\"r\") {{\n    {} {{\n        {ongoing_global};\n    }}\n    {} {{\n        {wait};\n    }}\n}}\n",
1063            kw("event"),
1064            kw("actions"),
1065        ),
1066        "event" => format!(
1067            "{} (\"r\") {{\n    {spelling} {{\n        {ongoing_global};\n    }}\n    {} {{\n        {wait};\n    }}\n}}\n",
1068            kw("rule"),
1069            kw("actions"),
1070        ),
1071        "conditions" => {
1072            // Conditions emit canonically in `a == b` comparison form.
1073            let equals = localized_spelling(catalog, Kind::Operator, locale, "==");
1074            format!(
1075                "{} (\"c\") {{\n    {} {{\n        {ongoing_global};\n    }}\n    {spelling} {{\n        {true_} {equals} {true_};\n    }}\n    {} {{\n        {wait};\n    }}\n}}\n",
1076                kw("rule"),
1077                kw("event"),
1078                kw("actions"),
1079            )
1080        }
1081        "actions" => format!(
1082            "{} (\"r\") {{\n    {} {{\n        {ongoing_global};\n    }}\n    {spelling} {{\n        {wait};\n    }}\n}}\n",
1083            kw("rule"),
1084            kw("event"),
1085        ),
1086        "disabled" => probe_rule(catalog, locale, &format!("{spelling} {wait}(0);")),
1087        "if" => probe_rule(catalog, locale, &block(&format!("{spelling}({true_});"))),
1088        "elseIf" => {
1089            let if_ = kw("if");
1090            probe_rule(
1091                catalog,
1092                locale,
1093                &format!(
1094                    "{if_}({true_});\n        {wait}(0);\n    {spelling}({true_});\n        {wait}(0);\n    {end};"
1095                ),
1096            )
1097        }
1098        "else" => {
1099            let if_ = kw("if");
1100            probe_rule(
1101                catalog,
1102                locale,
1103                &format!(
1104                    "{if_}({true_});\n        {wait}(0);\n    {spelling};\n        {wait}(0);\n    {end};"
1105                ),
1106            )
1107        }
1108        "end" => {
1109            let if_ = kw("if");
1110            probe_rule(
1111                catalog,
1112                locale,
1113                &format!("{if_}({true_});\n        {wait}(0);\n    {spelling};"),
1114            )
1115        }
1116        "while" => probe_rule(catalog, locale, &block(&format!("{spelling}({true_});"))),
1117        "forGlobalVariable" => probe_rule(
1118            catalog,
1119            locale,
1120            &block(&format!("{spelling}(probe, 0, 1, 1);")),
1121        ),
1122        "forPlayerVariable" => probe_rule(
1123            catalog,
1124            locale,
1125            &block(&format!("{spelling}({event_player}, probe, 0, 1, 1);")),
1126        ),
1127        "setGlobalVariable" => probe_rule(catalog, locale, &format!("{spelling}(probe, 1);")),
1128        "modifyGlobalVariable" => {
1129            let add = localized_spelling(catalog, Kind::Operator, locale, "add");
1130            probe_rule(catalog, locale, &format!("{spelling}(probe, {add}, 1);"))
1131        }
1132        "setPlayerVariable" => probe_rule(
1133            catalog,
1134            locale,
1135            &format!("{spelling}({event_player}, probe, 1);"),
1136        ),
1137        "modifyPlayerVariable" => {
1138            let add = localized_spelling(catalog, Kind::Operator, locale, "add");
1139            probe_rule(
1140                catalog,
1141                locale,
1142                &format!("{spelling}({event_player}, probe, {add}, 1);"),
1143            )
1144        }
1145        "callSubroutine" => probe_rule(catalog, locale, &format!("{spelling}(probe);")),
1146        _ => probe_rule(catalog, locale, &format!("{spelling};")),
1147    }
1148}
1149
1150/// A source that writes `spelling` where a member of `domain` belongs
1151/// (#448): constructor domains wrap it, `EventTeam`/`EventPlayer` sit in
1152/// the event header, and other domains prefer a declared host parameter,
1153/// falling back to a bare member.
1154fn member_probe(catalog: &Catalog, locale: &Locale, domain: &EnumDomain, spelling: &str) -> String {
1155    if CONSTRUCTOR_DOMAINS.contains(&domain.domain.as_str()) {
1156        let domain_spelling = domain.spelling(locale).unwrap_or(&domain.domain);
1157        return global_probe(catalog, locale, &format!("{domain_spelling}({spelling})"));
1158    }
1159    if domain.domain == "EventTeam" || domain.domain == "EventPlayer" {
1160        let event = localized_spelling(catalog, Kind::Event, locale, "eachPlayer");
1161        let other = if domain.domain == "EventTeam" {
1162            localized_member(catalog, "EventPlayer", "ALL", locale)
1163        } else {
1164            localized_member(catalog, "EventTeam", "ALL", locale)
1165        };
1166        let (team, player) = if domain.domain == "EventTeam" {
1167            (spelling.to_string(), other)
1168        } else {
1169            (other, spelling.to_string())
1170        };
1171        let rule = localized_spelling(catalog, Kind::Structural, locale, "rule");
1172        let event_kw = localized_spelling(catalog, Kind::Structural, locale, "event");
1173        let actions = localized_spelling(catalog, Kind::Structural, locale, "actions");
1174        let wait = localized_spelling(catalog, Kind::Action, locale, "wait");
1175        return format!(
1176            "{rule} (\"e\") {{\n    {event_kw} {{\n        {event};\n        {team};\n        {player};\n    }}\n    {actions} {{\n        {wait};\n    }}\n}}\n"
1177        );
1178    }
1179    let host = [Kind::Value, Kind::Action]
1180        .into_iter()
1181        .flat_map(|kind| catalog.entries_of(kind))
1182        .filter(|entry| {
1183            (0..entry.param_count()).any(|index| entry.param_domain(index) == Some(&domain.domain))
1184        })
1185        .min_by_key(|entry| entry.required_param_count());
1186    let Some(host) = host else {
1187        return global_probe(catalog, locale, spelling);
1188    };
1189    let target = (0..host.param_count())
1190        .find(|index| host.param_domain(*index) == Some(&domain.domain))
1191        .expect("host was selected for carrying this domain");
1192    let mut call_args: Vec<String> = (0..host.param_count())
1193        .map(|index| probe_filler(catalog, locale, host, index))
1194        .collect();
1195    call_args[target] = spelling.to_string();
1196    let host_spelling = localized_spelling(catalog, host.kind, locale, &host.id);
1197    let call = format!("{host_spelling}({})", call_args.join(", "));
1198    match host.kind {
1199        Kind::Value => global_probe(catalog, locale, &call),
1200        _ => probe_rule(catalog, locale, &format!("{call};")),
1201    }
1202}
1203
1204/// Settings definitions indexed by the label and enum-member names they
1205/// render, so a declared locale row can be placed in a real settings
1206/// context (#448).
1207struct SettingsSpellings {
1208    by_label: HashMap<String, Vec<SettingDefinition>>,
1209    by_member: HashMap<String, Vec<SettingDefinition>>,
1210    /// `(locale, section, english)` → declared spelling, for the fixed
1211    /// settings group/leaf names around the tested row (#448).
1212    names: HashMap<(String, String, String), String>,
1213}
1214
1215impl SettingsSpellings {
1216    fn build() -> Self {
1217        let mut by_label: HashMap<String, Vec<SettingDefinition>> = HashMap::new();
1218        let mut by_member: HashMap<String, Vec<SettingDefinition>> = HashMap::new();
1219        for definition in settings_schema::definitions() {
1220            by_label
1221                .entry(definition.presentation().english_name.to_string())
1222                .or_default()
1223                .push(definition.clone());
1224            for member in definition.enum_members() {
1225                by_member
1226                    .entry(member.english_name().to_string())
1227                    .or_default()
1228                    .push(definition.clone());
1229            }
1230        }
1231        let mut names = HashMap::new();
1232        for row in settings_schema::localized_setting_names() {
1233            names.insert(
1234                (
1235                    row.locale.to_string(),
1236                    row.section.to_string(),
1237                    row.english.to_string(),
1238                ),
1239                row.spelling.to_string(),
1240            );
1241        }
1242        Self {
1243            by_label,
1244            by_member,
1245            names,
1246        }
1247    }
1248
1249    /// The declared `section` spelling for `english` in `locale`, falling
1250    /// back to the en-US spelling then to the English name itself.
1251    fn name(&self, locale: &str, section: &str, english: &str) -> String {
1252        self.lookup(locale, section, english)
1253            .or_else(|| self.lookup(EN_US, section, english))
1254            .unwrap_or(english)
1255            .to_string()
1256    }
1257
1258    fn lookup(&self, locale: &str, section: &str, english: &str) -> Option<&str> {
1259        self.names
1260            .get(&(locale.to_string(), section.to_string(), english.to_string()))
1261            .map(String::as_str)
1262    }
1263}
1264
1265/// A probe hero for `definition`'s `<hero>` slot: ability-scoped defs need
1266/// a hero that owns the slot; prefer a hero whose emitted name equals the
1267/// tested label so generic labels exercise the typed path (#448).
1268fn settings_probe_hero(definition: &SettingDefinition, english: &str) -> Option<Option<String>> {
1269    if !definition.path().contains(".<hero>.") && !definition.path().ends_with(".<hero>") {
1270        return Some(None);
1271    }
1272    let data = gameplay::data::builtin().ok()?;
1273    let heroes = data.query().heroes().to_vec();
1274    let hero_ids: Vec<String> = match definition.target_kind() {
1275        SettingTargetKind::HeroAbility { slot, variant } => heroes
1276            .iter()
1277            .filter(|hero| {
1278                data.query()
1279                    .ability_name(hero.id().as_str(), slot.as_str(), variant.as_ref(), EN_US)
1280                    .is_ok()
1281            })
1282            .map(|hero| hero.id().as_str().to_string())
1283            .collect(),
1284        _ => heroes
1285            .iter()
1286            .map(|hero| hero.id().as_str().to_string())
1287            .collect(),
1288    };
1289    // Prefer a hero that actually renders the tested label (generic
1290    // spellings are shadowed by hero-specific ability names), but any hero
1291    // carrying the slot still writes the spelling verbatim.
1292    let mut matching = Vec::new();
1293    let mut any = Vec::new();
1294    for id in hero_ids {
1295        let target = match definition.target_kind() {
1296            SettingTargetKind::HeroAbility { slot, variant } => SettingTarget::HeroAbility {
1297                team: None,
1298                hero: HeroId::new(id.as_str()),
1299                slot,
1300                variant,
1301            },
1302            _ => SettingTarget::Hero {
1303                team: None,
1304                hero: HeroId::new(id.as_str()),
1305            },
1306        };
1307        let emitted = definition
1308            .localized_name(EN_US, &target)
1309            .ok()
1310            .flatten()
1311            .map(str::to_string);
1312        any.push(id.clone());
1313        if emitted.as_deref() == Some(english) {
1314            matching.push(id);
1315        }
1316    }
1317    matching.into_iter().chain(any).next().map(Some)
1318}
1319
1320/// Substitute the ability slot's localized name into a `%1$s` template
1321/// spelling — the only form in which such a label can appear in source.
1322/// Returns None when the definition is not hero-ability scoped or the hero
1323/// has no ability name to substitute (#448).
1324fn substitute_template(
1325    definition: &SettingDefinition,
1326    spelling: &str,
1327    hero: Option<&str>,
1328    locale: &str,
1329) -> Option<String> {
1330    if !spelling.contains("%1$s") {
1331        return Some(spelling.to_string());
1332    }
1333    let SettingTargetKind::HeroAbility { slot, variant } = definition.target_kind() else {
1334        return None;
1335    };
1336    let ability = gameplay::data::builtin()
1337        .ok()?
1338        .query()
1339        .ability_name(hero?, slot.as_str(), variant.as_ref(), locale)
1340        .ok()?
1341        .to_string();
1342    Some(spelling.replace("%1$s", &ability))
1343}
1344
1345/// The `name { ... }` group stack for a definition's path, plus the leaf
1346/// (#448), with every fixed group name in `locale`'s declared spelling.
1347/// `hero` is the written hero group name when the path carries a `<hero>`
1348/// slot.
1349fn localized_settings_context(
1350    catalog: &Catalog,
1351    index: &SettingsSpellings,
1352    locale: &Locale,
1353    definition: &SettingDefinition,
1354    leaf: &str,
1355    hero: Option<&str>,
1356) -> String {
1357    let hero_name = hero
1358        .and_then(|id| {
1359            gameplay::data::builtin()
1360                .ok()?
1361                .query()
1362                .heroes()
1363                .iter()
1364                .find(|hero| hero.id().as_str() == id)
1365                .and_then(|hero| hero.name().value().get(EN_US).map(str::to_string))
1366        })
1367        .map(|english| index.name(locale.as_str(), "heroes", &english))
1368        .unwrap_or_else(|| index.name(locale.as_str(), "heroes", "Ana"));
1369    let settings = localized_spelling(catalog, Kind::Structural, locale, "settings");
1370    let mut lines = vec![format!("{settings} {{")];
1371    let mut depth = 1;
1372    let parts: Vec<_> = definition.path().split('.').collect();
1373    // The last part is the leaf key, not a group.
1374    for (part_index, part) in parts.iter().take(parts.len() - 1).enumerate() {
1375        let name = match *part {
1376            "gamemodes" => index.name(locale.as_str(), "namespaces", "modes"),
1377            "heroes" => index.name(locale.as_str(), "namespaces", "heroes"),
1378            "<team>" | "general" => index.name(locale.as_str(), "teams", "General"),
1379            "<hero>" => hero_name.clone(),
1380            value if part_index == 1 && parts.first() == Some(&"gamemodes") => {
1381                let english = catalog_enum_spelling(catalog, "Gamemode", value)
1382                    .unwrap_or_else(|| value.to_string());
1383                index.name(locale.as_str(), "modes", &english)
1384            }
1385            value => index.name(locale.as_str(), "labels", value),
1386        };
1387        lines.push(format!("{}{} {{", "    ".repeat(depth), name));
1388        depth += 1;
1389    }
1390    lines.push(format!("{}{leaf}", "    ".repeat(depth)));
1391    while depth > 1 {
1392        depth -= 1;
1393        lines.push(format!("{}{}", "    ".repeat(depth), "}"));
1394    }
1395    lines.push("}".to_string());
1396    lines.join("\n")
1397}
1398
1399/// A settings leaf value for `definition` in `locale`: the first declared
1400/// member spelling for enum-like domains, literal forms otherwise (#448).
1401fn leaf_value(
1402    index: &SettingsSpellings,
1403    locale: &Locale,
1404    definition: &SettingDefinition,
1405) -> String {
1406    match definition.domain() {
1407        SettingValueDomain::PresenceOnly => String::new(),
1408        SettingValueDomain::String => ": \"x\"".to_string(),
1409        SettingValueDomain::Boolean | SettingValueDomain::Enum { .. } => {
1410            let member = definition
1411                .enum_members()
1412                .next()
1413                .map(|member| {
1414                    // Settings members are declared under `enums`; the
1415                    // boolean tokens (On/Off/Yes/No) live under `tokens`.
1416                    index
1417                        .lookup(locale.as_str(), "enums", member.english_name())
1418                        .or_else(|| index.lookup(locale.as_str(), "tokens", member.english_name()))
1419                        .unwrap_or(member.english_name())
1420                        .to_string()
1421                })
1422                .unwrap_or_else(|| index.name(locale.as_str(), "tokens", "On"));
1423            format!(": {member}")
1424        }
1425        SettingValueDomain::Number(_) => ": 1".to_string(),
1426        SettingValueDomain::Percent(_) => ": 100%".to_string(),
1427        SettingValueDomain::HeroList | SettingValueDomain::MapList => " {\n}".to_string(),
1428        _ => ": 0".to_string(),
1429    }
1430}
1431
1432/// A probe source writing `name.spelling` in a real settings context for
1433/// `locale`, or Err when no context can carry the row (for example a bare
1434/// `%1$s` template without a hero-ability slot, #448).
1435fn localized_settings_probe(
1436    catalog: &Catalog,
1437    index: &SettingsSpellings,
1438    name: &settings_schema::LocalizedSettingName,
1439    locale: &Locale,
1440) -> Result<String, String> {
1441    let english = name.english;
1442    let spelling = name.spelling;
1443    let loc = locale.as_str();
1444    let settings = localized_spelling(catalog, Kind::Structural, locale, "settings");
1445    let modes = index.name(loc, "namespaces", "modes");
1446    let heroes = index.name(loc, "namespaces", "heroes");
1447    let general = index.name(loc, "teams", "General");
1448    let assault = index.name(loc, "modes", "Assault");
1449    match name.section {
1450        "namespaces" => Ok(format!("{settings} {{\n    {spelling} {{\n    }}\n}}")),
1451        "modes" => Ok(format!(
1452            "{settings} {{\n    {modes} {{\n        {spelling} {{\n        }}\n    }}\n}}"
1453        )),
1454        "teams" => Ok(format!(
1455            "{settings} {{\n    {heroes} {{\n        {spelling} {{\n            {} {{\n            }}\n        }}\n    }}\n}}",
1456            index.name(loc, "heroes", "Ana")
1457        )),
1458        "heroes" => Ok(format!(
1459            "{settings} {{\n    {heroes} {{\n        {general} {{\n            {spelling} {{\n            }}\n        }}\n    }}\n}}"
1460        )),
1461        "maps" => Ok(format!(
1462            "{settings} {{\n    {modes} {{\n        {assault} {{\n            {} {{\n                {spelling}\n            }}\n        }}\n    }}\n}}",
1463            index.name(loc, "labels", "enabled maps")
1464        )),
1465        "tokens" => match english {
1466            "disabled" => Ok(format!(
1467                "{settings} {{\n    {modes} {{\n        {spelling} {assault} {{\n        }}\n    }}\n}}"
1468            )),
1469            _ => Ok(format!(
1470                "{settings} {{\n    {modes} {{\n        {assault} {{\n            {}: {spelling}\n        }}\n    }}\n}}",
1471                index.name(loc, "labels", "Competitive Rules")
1472            )),
1473        },
1474        "labels" => {
1475            let Some(defs) = index.by_label.get(english) else {
1476                return Err(format!("no settings definition named {english:?}"));
1477            };
1478            let mut reasons = Vec::new();
1479            for definition in defs {
1480                let hero = settings_probe_hero(definition, english);
1481                let leaf = match substitute_template(
1482                    definition,
1483                    spelling,
1484                    hero.as_ref().and_then(|hero| hero.as_deref()),
1485                    loc,
1486                ) {
1487                    Some(leaf) => leaf,
1488                    None => {
1489                        reasons.push("template needs a hero-ability context".to_string());
1490                        continue;
1491                    }
1492                };
1493                return Ok(localized_settings_context(
1494                    catalog,
1495                    index,
1496                    locale,
1497                    definition,
1498                    &format!("{leaf}{}", leaf_value(index, locale, definition)),
1499                    hero.as_ref().and_then(|hero| hero.as_deref()),
1500                ));
1501            }
1502            Err(reasons
1503                .into_iter()
1504                .next()
1505                .unwrap_or_else(|| "no probe context".to_string()))
1506        }
1507        "enums" => {
1508            let Some(defs) = index.by_member.get(english) else {
1509                return Err(format!("no settings definition takes member {english:?}"));
1510            };
1511            let definition = &defs[0];
1512            let hero = settings_probe_hero(definition, definition.presentation().english_name);
1513            let label = index.name(loc, "labels", definition.presentation().english_name);
1514            Ok(localized_settings_context(
1515                catalog,
1516                index,
1517                locale,
1518                definition,
1519                &format!("{label}: {spelling}"),
1520                hero.as_ref().and_then(|hero| hero.as_deref()),
1521            ))
1522        }
1523        other => Err(format!("unhandled settings section {other:?}")),
1524    }
1525}
1526
1527fn wir_case(case_id: &str, kind: FeatureKind, name: &str, source: String) -> CensusCase {
1528    CensusCase::probe(
1529        format!("wir/{case_id}"),
1530        vec![feature(FeatureNamespace::Wir, kind, name)],
1531        source,
1532    )
1533    .exercise()
1534}
1535
1536fn control_flow_case(name: &str, actions: &str) -> CensusCase {
1537    CensusCase::probe(
1538        format!("wir/control-flow/{name}"),
1539        vec![feature(
1540            FeatureNamespace::Wir,
1541            FeatureKind::ControlFlow,
1542            name,
1543        )],
1544        rule_source(name, actions),
1545    )
1546}
1547
1548fn generated_probe_support() -> CensusSupport {
1549    CensusSupport::Inconclusive {
1550        detail: "generated probe has no independently recorded expected result".to_string(),
1551    }
1552}
1553
1554fn rule_source(name: &str, actions: &str) -> String {
1555    format!(
1556        "variables {{\n    global:\n        0: probe\n}}\n\nrule (\"{name}\") {{\n    event {{\n        Ongoing - Global;\n    }}\n    actions {{\n        {actions}\n    }}\n}}\n"
1557    )
1558}
1559
1560fn variables_source() -> String {
1561    "variables {\n    global:\n        0: probe\n}\n\nrule (\"Global variable\") {\n    event {\n        Ongoing - Global;\n    }\n    actions {\n        Set Global Variable(probe, 1);\n    }\n}\n"
1562        .to_string()
1563}
1564
1565fn player_variable_source() -> String {
1566    "variables {\n    player:\n        0: probe\n}\n\nrule (\"Player variable\") {\n    event {\n        Ongoing - Each Player;\n        All;\n        All;\n    }\n    actions {\n        Set Player Variable(Event Player, probe, 1);\n    }\n}\n"
1567        .to_string()
1568}
1569
1570fn subroutine_source() -> String {
1571    "subroutines {\n    0: probe\n}\n\nrule (\"Subroutine\") {\n    event {\n        Subroutine;\n        probe;\n    }\n    actions {\n        Call Subroutine(probe);\n    }\n}\n"
1572        .to_string()
1573}
1574
1575fn event_probe(catalog: &Catalog, entry: &CatalogEntry) -> String {
1576    let spelling = catalog
1577        .spelling(Kind::Event, &Locale::new(EN_US), &entry.id)
1578        .unwrap_or(&entry.id);
1579    let filters = if matches!(entry.id.as_str(), "global" | "subroutine") {
1580        String::new()
1581    } else {
1582        "        All;\n        All;\n".to_string()
1583    };
1584    let subroutine = if entry.id == "subroutine" {
1585        "        probe;\n"
1586    } else {
1587        ""
1588    };
1589    format!(
1590        "subroutines {{\n    0: probe\n}}\n\nrule (\"Event\") {{\n    event {{\n        {spelling};\n{filters}{subroutine}    }}\n    actions {{\n        Wait;\n    }}\n}}\n"
1591    )
1592}
1593
1594fn action_probe(catalog: &Catalog, entry: &CatalogEntry) -> String {
1595    let spelling = catalog
1596        .spelling(Kind::Action, &Locale::new(EN_US), &entry.id)
1597        .unwrap_or(&entry.id);
1598    let call = if matches!(
1599        entry.id.as_str(),
1600        "chasePlayerVariableAtRate" | "chasePlayerVariableOverTime"
1601    ) {
1602        format!("{spelling}(Event Player, probe, 0, 1, 0);")
1603    } else {
1604        format!("{spelling};")
1605    };
1606    rule_source("Action", &call)
1607}
1608
1609fn value_probe(catalog: &Catalog, entry: &CatalogEntry) -> String {
1610    let spelling = catalog
1611        .spelling(Kind::Value, &Locale::new(EN_US), &entry.id)
1612        .unwrap_or(&entry.id);
1613    rule_source("Value", &format!("Set Global Variable(probe, {spelling});"))
1614}
1615
1616fn operator_probe(catalog: &Catalog, entry: &CatalogEntry) -> String {
1617    let spelling = catalog
1618        .spelling(Kind::Operator, &Locale::new(EN_US), &entry.id)
1619        .unwrap_or(&entry.id);
1620    rule_source(
1621        "Operator",
1622        &format!("If(1 {spelling} 1);\n    Wait(0);\nEnd;"),
1623    )
1624}
1625
1626fn structural_probe(catalog: &Catalog, entry: &CatalogEntry) -> String {
1627    let spelling = catalog
1628        .spelling(Kind::Structural, &Locale::new(EN_US), &entry.id)
1629        .unwrap_or(&entry.id);
1630    let actions = match entry.id.as_str() {
1631        "if" => format!("{spelling}(True);\n    Wait(0);\nEnd;"),
1632        "elseIf" => format!("If(True);\n    Wait(0);\n{spelling}(False);\n    Wait(0);\nEnd;"),
1633        "else" => format!("If(True);\n    Wait(0);\n{spelling};\n    Wait(0);\nEnd;"),
1634        "end" => format!("If(True);\n    Wait(0);\n{spelling};"),
1635        "while" => format!("{spelling}(True);\n    Wait(0);\nEnd;"),
1636        "forGlobalVariable" => format!("{spelling}(probe, 0, 1, 1);\n    Wait(0);\nEnd;"),
1637        "setGlobalVariable" => format!("{spelling}(probe, 1);"),
1638        "modifyGlobalVariable" => format!("{spelling}(probe, Add, 1);"),
1639        "setPlayerVariable" => format!("{spelling}(Event Player, probe, 1);"),
1640        "modifyPlayerVariable" => format!("{spelling}(Event Player, probe, Add, 1);"),
1641        "callSubroutine" => format!("{spelling}(probe);"),
1642        _ => format!("{spelling};"),
1643    };
1644    let prefix = match entry.id.as_str() {
1645        "setPlayerVariable" | "modifyPlayerVariable" => {
1646            "variables {\n    player:\n        0: probe\n}\n\n"
1647        }
1648        "callSubroutine" => "subroutines {\n    0: probe\n}\n\n",
1649        _ => "",
1650    };
1651    format!("{prefix}{}", rule_source("Structural", &actions))
1652}
1653
1654fn enum_probe(catalog: &Catalog, domain: &EnumDomain, member: &str) -> String {
1655    let locale = Locale::new(EN_US);
1656    let domain_spelling = catalog
1657        .spelling(Kind::Value, &locale, &domain.domain)
1658        .unwrap_or(&domain.domain);
1659    let member_spelling = catalog
1660        .enum_spelling(&domain.domain, &locale, member)
1661        .unwrap_or(member);
1662    // Only the constructor-form domains parse `Domain(Member)`; every other
1663    // domain writes the member bare (mirrors `Catalog::enum_member_form`).
1664    let operand = if matches!(domain.domain.as_str(), "Hero" | "Button" | "Color" | "Map") {
1665        format!("{domain_spelling}({member_spelling})")
1666    } else {
1667        member_spelling.to_string()
1668    };
1669    rule_source("Enum", &format!("Set Global Variable(probe, {operand});"))
1670}
1671
1672fn settings_probe(definition: &SettingDefinition, catalog: &Catalog) -> String {
1673    let mut lines = vec!["settings {".to_string()];
1674    let mut depth = 1;
1675    let parts: Vec<_> = definition.path().split('.').collect();
1676    for (index, part) in parts.iter().enumerate() {
1677        let name = match *part {
1678            "gamemodes" => "modes".to_string(),
1679            "heroes" => "heroes".to_string(),
1680            "<team>" => "General".to_string(),
1681            "<hero>" => {
1682                catalog_enum_spelling(catalog, "Hero", "mei").unwrap_or_else(|| "Mei".to_string())
1683            }
1684            "general" => "General".to_string(),
1685            value if index == 1 && parts.first() == Some(&"gamemodes") => {
1686                mode_spelling(catalog, value)
1687            }
1688            value => value.to_string(),
1689        };
1690        lines.push(format!("{}{} {{", "    ".repeat(depth), name));
1691        depth += 1;
1692    }
1693    let indent = "    ".repeat(depth);
1694    let name = definition.presentation().english_name;
1695    match definition.domain() {
1696        SettingValueDomain::PresenceOnly => lines.push(format!("{indent}{name}")),
1697        SettingValueDomain::String => lines.push(format!("{indent}{name}: \"census\"")),
1698        SettingValueDomain::Boolean => {
1699            let value = definition
1700                .enum_members()
1701                .next()
1702                .map(|member| member.english_name().to_string())
1703                .unwrap_or_else(|| "On".to_string());
1704            lines.push(format!("{indent}{name}: {value}"));
1705        }
1706        SettingValueDomain::Number(_) => lines.push(format!("{indent}{name}: 1")),
1707        SettingValueDomain::Percent(_) => lines.push(format!("{indent}{name}: 100%")),
1708        SettingValueDomain::Enum { .. } => {
1709            let value = definition
1710                .enum_members()
1711                .next()
1712                .map(|member| member.english_name().to_string())
1713                .unwrap_or_else(|| "Off".to_string());
1714            lines.push(format!("{indent}{name}: {value}"));
1715        }
1716        SettingValueDomain::HeroList | SettingValueDomain::MapList => {
1717            lines.push(format!("{indent}{name} {{"));
1718            lines.push(format!("{indent}}}"));
1719        }
1720        other => unreachable!("census has no probe for settings value domain {other:?}"),
1721    }
1722    while depth > 1 {
1723        depth -= 1;
1724        lines.push(format!("{}{}", "    ".repeat(depth), "}"));
1725    }
1726    lines.push("}".to_string());
1727    lines.join("\n")
1728}
1729
1730fn mode_spelling(catalog: &Catalog, key: &str) -> String {
1731    catalog_enum_spelling(catalog, "Gamemode", key).unwrap_or_else(|| key.to_string())
1732}
1733
1734fn catalog_enum_spelling(catalog: &Catalog, domain: &str, key: &str) -> Option<String> {
1735    let normalize = |value: &str| {
1736        value
1737            .chars()
1738            .filter(|character| character.is_ascii_alphanumeric())
1739            .flat_map(char::to_lowercase)
1740            .collect::<String>()
1741    };
1742    let normalized = normalize(key);
1743    let member = catalog
1744        .enum_domains()
1745        .find(|candidate| candidate.domain == domain)?
1746        .members
1747        .iter()
1748        .find(|candidate| normalize(&candidate.member) == normalized)?;
1749    catalog
1750        .enum_spelling(domain, &Locale::new(EN_US), &member.member)
1751        .map(str::to_string)
1752}
1753
1754fn run_case(case: &CensusCase, shard_id: &str, catalog: &Catalog) -> ConformanceResult {
1755    let source = artifact(
1756        format!("census/{shard_id}/{}.ws", case.case_id),
1757        &case.source,
1758    );
1759    let base = |status, comparison, reason, locale| ConformanceResult {
1760        schema_version: CONFORMANCE_SCHEMA_VERSION,
1761        case_id: case.case_id.clone(),
1762        features: case.features.clone(),
1763        status,
1764        comparison,
1765        source: source.clone(),
1766        catalog: catalog.identity(),
1767        locale,
1768        reason,
1769    };
1770    match &case.support {
1771        CensusSupport::Unsupported { detail } => base(
1772            ConformanceStatus::Unsupported,
1773            not_comparable(),
1774            Some(reason(ReasonCode::Unsupported, detail)),
1775            None,
1776        ),
1777        CensusSupport::KnownGap { detail } => base(
1778            ConformanceStatus::KnownGap,
1779            not_comparable(),
1780            Some(reason(ReasonCode::KnownGap, detail)),
1781            None,
1782        ),
1783        CensusSupport::Inconclusive { detail } => execute_case(case, base, catalog, Some(detail)),
1784        CensusSupport::Exercise => execute_case(case, base, catalog, None),
1785    }
1786}
1787
1788/// A pipeline failure carrying the stage's reason code and locale.
1789struct Fail {
1790    code: ReasonCode,
1791    detail: String,
1792    locale: Locale,
1793}
1794
1795impl Fail {
1796    fn workshop(error: &WorkshopError, locale: &Locale) -> Self {
1797        let code = match error {
1798            WorkshopError::Unsupported { .. } => ReasonCode::Unsupported,
1799            WorkshopError::MissingMapping { .. } => ReasonCode::KnownGap,
1800            _ => ReasonCode::UnexpectedRegression,
1801        };
1802        Self {
1803            code,
1804            detail: error.to_string(),
1805            locale: locale.clone(),
1806        }
1807    }
1808
1809    fn regression(detail: impl Into<String>, locale: &Locale) -> Self {
1810        Self {
1811            code: ReasonCode::UnexpectedRegression,
1812            detail: detail.into(),
1813            locale: locale.clone(),
1814        }
1815    }
1816}
1817
1818fn execute_case(
1819    case: &CensusCase,
1820    base: impl Fn(
1821        ConformanceStatus,
1822        Comparison,
1823        Option<ConformanceReason>,
1824        Option<Locale>,
1825    ) -> ConformanceResult,
1826    catalog: &Catalog,
1827    inconclusive_detail: Option<&str>,
1828) -> ConformanceResult {
1829    match run_pipeline(case, catalog, inconclusive_detail) {
1830        Ok((comparison, locale)) => {
1831            base(ConformanceStatus::Matched, comparison, None, Some(locale))
1832        }
1833        Err(fail) => failed_text(base, fail.code, fail.detail, Some(fail.locale)),
1834    }
1835}
1836
1837/// Run the census probe pipeline: en-US parse/validate/emit/reparse checks,
1838/// the target-locale round trip, and the independent-reference comparison.
1839/// Returns the matched comparison record and source locale on success.
1840fn run_pipeline(
1841    case: &CensusCase,
1842    catalog: &Catalog,
1843    inconclusive_detail: Option<&str>,
1844) -> Result<(Comparison, Locale), Fail> {
1845    let source_locale = Locale::new(&case.source_locale);
1846    let target_locale = if source_locale.as_str() == ZH_CN {
1847        Locale::new(EN_US)
1848    } else {
1849        Locale::new(ZH_CN)
1850    };
1851
1852    let parse = |source: &str, locale: &Locale| {
1853        parser::parse_with_context(source, catalog, locale, catalog)
1854            .map_err(|error| Fail::workshop(&error, locale))
1855    };
1856    let validate = |program: &workshop_rs::Program, locale: &Locale| {
1857        program
1858            .validate()
1859            .map_err(|error| Fail::regression(error.to_string(), locale))
1860    };
1861    let emit = |program: &workshop_rs::Program, locale: &Locale| {
1862        emitter::emit(program, catalog, locale).map_err(|error| Fail::workshop(&error, locale))
1863    };
1864    let convert_or =
1865        |source: &str, from: &Locale, to: &Locale| -> Result<convert::Conversion, Fail> {
1866            convert::convert(source, catalog, from, to, &Default::default())
1867                .map_err(|error| Fail::workshop(&error, to))
1868        };
1869
1870    // Some declared spellings are inherently ambiguous in a locale: zh-CN
1871    // writes `下` for `down`, `Vector.DOWN`, and `Rounding.DOWN` alike, so a
1872    // faithful conversion reparses to a preserved `__ambiguous_enum` rather
1873    // than the resolved node. When the target program emits byte-identical
1874    // canonical text, the client-observable surface is right and only the
1875    // resolved-identity check is undecidable — report that explicitly as
1876    // Inconclusive instead of a regression (#448).
1877    let surface_equivalent =
1878        |a: &workshop_rs::Program, b: &workshop_rs::Program, locale: &Locale| {
1879            let (Ok(x), Ok(y)) = (emit(a, locale), emit(b, locale)) else {
1880                return false;
1881            };
1882            normalize_workshop(&x) == normalize_workshop(&y)
1883        };
1884
1885    let program = parse(&case.source, &source_locale)?;
1886    validate(&program, &source_locale)?;
1887    let emitted = emit(&program, &source_locale)?;
1888    let reparsed = parse(&emitted, &source_locale)?;
1889    validate(&reparsed, &source_locale)?;
1890    let reemitted = emit(&reparsed, &source_locale)?;
1891    if !roundtrip::equivalent(&program, &reparsed)
1892        || normalize_workshop(&emitted) != normalize_workshop(&reemitted)
1893    {
1894        return Err(Fail::regression(
1895            "en-US semantic or normalized gate diverged",
1896            &source_locale,
1897        ));
1898    }
1899
1900    let converted = convert_or(&case.source, &source_locale, &target_locale)?;
1901    let target_program = parse(&converted.text, &target_locale)?;
1902    validate(&target_program, &target_locale)?;
1903    if !roundtrip::equivalent(&program, &target_program) {
1904        if surface_equivalent(&program, &target_program, &target_locale) {
1905            return Err(Fail {
1906                code: ReasonCode::Inconclusive,
1907                detail: "converted text matches the canonical emission, but the \
1908                         target-locale spelling is ambiguous across enum domains; \
1909                         resolved semantics cannot be independently verified"
1910                    .to_string(),
1911                locale: target_locale,
1912            });
1913        }
1914        return Err(Fail::regression(
1915            "zh-CN conversion changed canonical WIR semantics",
1916            &target_locale,
1917        ));
1918    }
1919    let back = convert_or(&converted.text, &target_locale, &source_locale)?;
1920    let back_program = parse(&back.text, &source_locale)?;
1921    // Compare against the canonical emission, not the raw source: probes
1922    // written in a non-canonical declared alias (#448) still must land on
1923    // the same canonical program and surface form after a round trip.
1924    if !roundtrip::equivalent(&program, &back_program)
1925        || normalize_workshop(&back.text) != normalize_workshop(&emitted)
1926    {
1927        if surface_equivalent(&program, &back_program, &source_locale) {
1928            return Err(Fail {
1929                code: ReasonCode::Inconclusive,
1930                detail: "round-trip text matches the canonical emission, but the \
1931                         source-locale spelling is ambiguous across enum domains; \
1932                         resolved semantics cannot be independently verified"
1933                    .to_string(),
1934                locale: source_locale,
1935            });
1936        }
1937        return Err(Fail::regression(
1938            "cross-locale semantic or normalized gate diverged",
1939            &source_locale,
1940        ));
1941    }
1942
1943    let Some(reference_source) = case.reference_source.as_ref() else {
1944        return Err(Fail {
1945            code: ReasonCode::Inconclusive,
1946            detail: inconclusive_detail
1947                .unwrap_or(
1948                    "offline semantic and locale gates passed, but no independent expectation artifact is recorded",
1949                )
1950                .to_string(),
1951            locale: source_locale,
1952        });
1953    };
1954    let expected_program = parse(reference_source, &target_locale)?;
1955    if !roundtrip::equivalent(&program, &expected_program)
1956        || normalize_workshop(&converted.text) != normalize_workshop(reference_source)
1957    {
1958        return Err(Fail::regression(
1959            "conversion differed from the independent reference source",
1960            &target_locale,
1961        ));
1962    }
1963    Ok((
1964        Comparison {
1965            mode: Equivalence::Semantic,
1966            expected: Some(reference_artifact(case, reference_source)),
1967            observed: Some(artifact(
1968                format!("census/{}/converted-output.ws", case.case_id),
1969                &converted.text,
1970            )),
1971            normalizer: Some("canonical-wir;normalized-workshop-text".to_string()),
1972        },
1973        source_locale,
1974    ))
1975}
1976
1977fn failed_text(
1978    base: impl Fn(
1979        ConformanceStatus,
1980        Comparison,
1981        Option<ConformanceReason>,
1982        Option<Locale>,
1983    ) -> ConformanceResult,
1984    code: ReasonCode,
1985    detail: String,
1986    locale: Option<Locale>,
1987) -> ConformanceResult {
1988    let status = match code {
1989        ReasonCode::Unsupported => ConformanceStatus::Unsupported,
1990        ReasonCode::KnownGap => ConformanceStatus::KnownGap,
1991        ReasonCode::UnexpectedRegression => ConformanceStatus::UnexpectedRegression,
1992        ReasonCode::Inconclusive => ConformanceStatus::Inconclusive,
1993    };
1994    let comparison = if code == ReasonCode::UnexpectedRegression {
1995        Comparison {
1996            mode: Equivalence::Normalized,
1997            expected: None,
1998            observed: None,
1999            normalizer: Some("census-stage".to_string()),
2000        }
2001    } else {
2002        not_comparable()
2003    };
2004    base(status, comparison, Some(reason(code, &detail)), locale)
2005}
2006
2007fn reason(code: ReasonCode, detail: &str) -> ConformanceReason {
2008    ConformanceReason {
2009        code,
2010        detail: detail.to_string(),
2011    }
2012}
2013
2014fn not_comparable() -> Comparison {
2015    Comparison {
2016        mode: Equivalence::NotComparable,
2017        expected: None,
2018        observed: None,
2019        normalizer: None,
2020    }
2021}
2022
2023fn artifact(name: impl Into<String>, content: &str) -> TestArtifact {
2024    TestArtifact {
2025        name: name.into(),
2026        revision: None,
2027        path: None,
2028        sha256: Some(sha256(content)),
2029        license: Some("MIT".to_string()),
2030    }
2031}
2032
2033fn reference_artifact(case: &CensusCase, content: &str) -> TestArtifact {
2034    TestArtifact {
2035        name: format!("census reference for {}", case.case_id),
2036        revision: Some("census-v1".to_string()),
2037        path: Some("tests/fixtures/census/reference.ws".to_string()),
2038        sha256: Some(sha256(content)),
2039        license: Some("MIT".to_string()),
2040    }
2041}
2042
2043fn sha256(content: &str) -> String {
2044    let mut hasher = Sha256::new();
2045    hasher.update(content.as_bytes());
2046    format!("{:x}", hasher.finalize())
2047}
2048
2049fn normalize_workshop(text: &str) -> String {
2050    text.split_whitespace().collect::<Vec<_>>().join(" ")
2051}
2052
2053#[cfg(test)]
2054mod tests {
2055    use super::*;
2056
2057    #[test]
2058    fn builtin_census_is_derived_and_deterministic() {
2059        let catalog = Catalog::builtin().expect("builtin catalog");
2060        let first = Census::builtin(&catalog).expect("census");
2061        let second = Census::builtin(&catalog).expect("census");
2062        assert_eq!(first, second);
2063        assert_eq!(first.shards().first().unwrap().shard_id, "catalog-actions");
2064        assert!(
2065            first
2066                .cases()
2067                .any(|case| case.features.iter().any(|feature| feature.name == "wait"))
2068        );
2069        assert!(first.cases().any(|case| {
2070            case.features
2071                .iter()
2072                .any(|feature| feature.kind == FeatureKind::Setting)
2073        }));
2074        assert!(first.cases().any(|case| {
2075            case.features
2076                .iter()
2077                .any(|feature| feature.kind == FeatureKind::ControlFlow)
2078        }));
2079        assert_eq!(first.export_json().unwrap(), second.export_json().unwrap());
2080        assert_eq!(first.identity(), second.identity());
2081        assert_eq!(first.identity().digest.len(), 64);
2082    }
2083
2084    #[test]
2085    fn settings_probes_use_catalog_gamemode_spellings() {
2086        let catalog = Catalog::builtin().expect("builtin catalog");
2087
2088        for (mode, expected) in [
2089            ("ffa", "Deathmatch"),
2090            ("tdm", "Team Deathmatch"),
2091            ("ctf", "Capture The Flag"),
2092        ] {
2093            let prefix = format!("gamemodes.{mode}.");
2094            let definition = settings_schema::definitions()
2095                .find(|definition| definition.path().starts_with(&prefix))
2096                .unwrap_or_else(|| panic!("missing settings definition for {mode}"));
2097            let probe = settings_probe(&definition, &catalog);
2098            assert!(
2099                probe.contains(&format!("{expected} {{")),
2100                "{mode} probe used the wrong mode spelling:\n{probe}"
2101            );
2102        }
2103    }
2104
2105    #[test]
2106    fn explicit_non_matching_states_remain_machine_readable() {
2107        let feature_case = |id: &str, support| {
2108            CensusCase::probe(
2109                format!("state-tests/{id}"),
2110                vec![feature(FeatureNamespace::Wir, FeatureKind::Structural, id)],
2111                format!("rule (\"{id}\") {{}}"),
2112            )
2113            .with_support(support)
2114        };
2115        let shard = CensusShard::new(
2116            "state-tests",
2117            vec![
2118                feature_case(
2119                    "unsupported",
2120                    CensusSupport::Unsupported {
2121                        detail: "not declared".to_string(),
2122                    },
2123                ),
2124                feature_case(
2125                    "known-gap",
2126                    CensusSupport::KnownGap {
2127                        detail: "missing mapping".to_string(),
2128                    },
2129                ),
2130                feature_case(
2131                    "inconclusive",
2132                    CensusSupport::Inconclusive {
2133                        detail: "no oracle".to_string(),
2134                    },
2135                ),
2136            ],
2137        )
2138        .unwrap();
2139        let report = Census::assemble(vec![shard])
2140            .unwrap()
2141            .run(&Catalog::builtin().unwrap());
2142        report
2143            .validate()
2144            .expect("states use the current conformance adapter");
2145        let json = report.to_json().unwrap();
2146        assert!(json.contains("unsupported"));
2147        assert!(json.contains("known-gap"));
2148        assert!(json.contains("inconclusive"));
2149    }
2150
2151    #[test]
2152    fn builtin_census_report_validates_against_the_catalog() {
2153        let catalog = Catalog::builtin().expect("builtin catalog");
2154        let census = Census::builtin(&catalog).expect("census");
2155        let report = census.run(&catalog);
2156        report
2157            .validate_against(&catalog)
2158            .expect("census results use canonical catalog identities");
2159        assert_eq!(report.census, census.identity());
2160        // Every declared case produces exactly one attributable result; the
2161        // localization shard carries the two conversion probes plus one case
2162        // per declared localized spelling (#448).
2163        assert_eq!(
2164            report.results.len(),
2165            census.cases().count(),
2166            "census drops or duplicates cases"
2167        );
2168        let localization_cases = census
2169            .cases()
2170            .filter(|case| case.case_id.starts_with("localization/"))
2171            .count();
2172        assert_eq!(
2173            report
2174                .results
2175                .iter()
2176                .filter(|result| result.case_id.starts_with("localization/"))
2177                .count(),
2178            localization_cases
2179        );
2180        assert!(
2181            localization_cases > 2,
2182            "localization shard must enumerate per-spelling cases"
2183        );
2184        // Aliases that cannot be written in any context are explicit
2185        // known-gap results, not silent passes or hidden inconclusive rows.
2186        assert!(
2187            report.results.iter().any(|result| {
2188                result.case_id.starts_with("localization/")
2189                    && result.status == ConformanceStatus::KnownGap
2190            }),
2191            "unwritable localized settings spellings must report as known gaps"
2192        );
2193    }
2194
2195    #[test]
2196    fn census_report_rejects_a_malformed_identity_digest() {
2197        let catalog = Catalog::builtin().expect("builtin catalog");
2198        let census = Census::assemble(vec![
2199            CensusShard::new(
2200                "state-tests",
2201                vec![CensusCase::probe(
2202                    "state-tests/probe".to_string(),
2203                    vec![feature(
2204                        FeatureNamespace::Wir,
2205                        FeatureKind::Structural,
2206                        "probe",
2207                    )],
2208                    "rule (\"probe\") {}".to_string(),
2209                )],
2210            )
2211            .unwrap(),
2212        ])
2213        .unwrap();
2214        let mut report = census.run(&catalog);
2215        report.census.digest = "not-a-digest".to_string();
2216
2217        let error = report
2218            .validate_against(&catalog)
2219            .expect_err("report identity must carry a SHA-256 digest");
2220        assert!(error.to_string().contains("SHA-256"));
2221    }
2222}
2223
2224#[cfg(test)]
2225mod probe_debug {
2226    use super::*;
2227
2228    #[test]
2229    fn dump_failing_localization_probes() {
2230        let catalog = Catalog::builtin().unwrap();
2231        let census = Census::builtin(&catalog).unwrap();
2232        let wanted = [
2233            "localization/es-mx/value/hero/0",
2234            "localization/fr-fr/value/hero/0",
2235        ];
2236        for case in census.cases() {
2237            if !wanted.contains(&case.case_id.as_str()) {
2238                continue;
2239            }
2240            println!("==== {} ====", case.case_id);
2241            println!("{}", case.source);
2242            let result = run_case(case, "localization", &catalog);
2243            println!(
2244                "-> {:?} {:?}",
2245                result.status,
2246                result.reason.map(|r| r.detail)
2247            );
2248        }
2249    }
2250}