Skip to main content

opy_rs/manifest/
mod.rs

1//! The OPY semantic compatibility manifest (issue #109).
2//!
3//! This module owns the Wright-authored, reference-validated semantic table
4//! that the frontend resolves builtin names, member functions, receiver
5//! categories, signatures/arity, parameter enum-domain identities, and
6//! non-contextual source aliases against — the authoritative replacement for
7//! the hardcoded `KNOWN_ENUMS` table and the semantic catalog-coverage gap
8//! behind `unknown-action`/`unknown-value`/`unsupported-member` emission
9//! failures.
10//!
11//! * The data lives in [`data/manifest.json`](data/manifest.json) (schema
12//!   v1, per the compatibility-manifest spec).
13//! * Every entry records the pinned-oracle probe that validates it
14//!   (`probes/probes.json`); `probes/validate.py` runs each probe against the
15//!   pinned OverPy 9.7.10 oracle and verifies accept/reject, emission hash,
16//!   and diagnostic category deterministically.
17//! * `catalogId` links each entry to the Workshop emission catalog by
18//!   canonical identity without duplicating localization/output spelling
19//!   data. The wright repository cross-checks every declared id against its
20//!   emission catalog; opy-rs does not copy the catalog itself.
21//!
22//! Ownership boundary: the **function**, **alias**, and **module** tables are
23//! OPY *source-language API* metadata (OverPy's documented language API;
24//! Wright-authored, probe-validated) and are not Workshop content data.
25//! Workshop *content/catalog* data — enum member lists, settings keys,
26//! mode/team/hero/map names — is Workshop-owned and is not carried here:
27//! `param.domain` and the contextual-domain machinery are catalog *identity*
28//! links only (no member validation), and validation that would need the
29//! canonical Workshop enum catalog is `lowering-dependent` (issue #8),
30//! never approximated.
31//!
32//! The manifest is language-compatibility metadata, not runtime content data
33//! (issue #96 stays deferred), and it is Wright-authored data validated
34//! against observed oracle behavior — never a mechanical conversion of
35//! OverPy's GPL-3.0 data files (ADR-0004, `docs/licensing.md` in the wright
36//! repository).
37
38use std::collections::{HashMap, HashSet};
39use std::sync::OnceLock;
40
41use serde::{Deserialize, Serialize};
42
43/// The embedded schema-v1 manifest data.
44pub const MANIFEST_DATA: &str = include_str!("data/manifest.json");
45
46/// The embedded probe evidence record for the manifest data.
47pub const PROBES_DATA: &str = include_str!("probes/probes.json");
48
49/// The pinned reference identity the manifest data is validated against.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct Reference {
52    pub name: String,
53    pub version: String,
54    #[serde(rename = "contentCommit")]
55    pub content_commit: String,
56    pub integrity: String,
57}
58
59/// Provenance of the manifest data.
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61pub struct Provenance {
62    pub generator: String,
63    pub license: String,
64    pub reviewed: bool,
65}
66
67/// The kind of a builtin function entry.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase")]
70pub enum FunctionKind {
71    /// A generic action (`chaseOverTime(...)` as a statement).
72    Action,
73    /// A generic value (`isGameInProgress()` in an expression).
74    Value,
75    /// An action called on a receiver (`eventPlayer.setMoveSpeed(100)`).
76    MemberAction,
77    /// A value called on a receiver (`eventPlayer.isAlive()`).
78    MemberValue,
79}
80
81/// How the frontend-owned function identity connects to Workshop lowering.
82///
83/// `canonical` entries carry a `catalogId`; the other variants are explicit
84/// reasons why a source-level function does not have a direct catalog entry.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
86#[serde(rename_all = "kebab-case")]
87pub enum CatalogLink {
88    #[default]
89    Canonical,
90    SpecialLowering,
91    LegacyAlias,
92    CatalogGap,
93}
94
95impl FunctionKind {
96    /// Whether this kind is an action (statement-position builtin).
97    pub fn is_action(self) -> bool {
98        matches!(self, FunctionKind::Action | FunctionKind::MemberAction)
99    }
100
101    /// Whether this kind is a value (expression-position builtin).
102    pub fn is_value(self) -> bool {
103        matches!(self, FunctionKind::Value | FunctionKind::MemberValue)
104    }
105
106    /// Whether this kind is a receiver member function.
107    pub fn is_member(self) -> bool {
108        matches!(self, FunctionKind::MemberAction | FunctionKind::MemberValue)
109    }
110}
111
112/// The declared receiver category of a member function.
113///
114/// `Player` is the metadata category for player-oriented members (the pinned
115/// reference does not type-check those receivers, so the frontend does not
116/// reject them); `Variable` and `String` are enforced where the reference
117/// semantics are clear (`.append` requires an assignable receiver, `.format`
118/// requires a string literal).
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(rename_all = "PascalCase")]
121pub enum ReceiverCategory {
122    Player,
123    Variable,
124    String,
125    Vector,
126    Any,
127}
128
129impl ReceiverCategory {
130    /// A human-readable description of the category for diagnostics.
131    pub fn describe(self) -> &'static str {
132        match self {
133            ReceiverCategory::Player => "a player-valued expression",
134            ReceiverCategory::Variable => "an assignable variable",
135            ReceiverCategory::String => "a string literal",
136            ReceiverCategory::Vector => "a vector-valued expression",
137            ReceiverCategory::Any => "any expression",
138        }
139    }
140}
141
142/// A parameter default that the frontend expands: a function call with
143/// optional numeric arguments, enum member (`"MEMBER"`), scalar (`0.016`),
144/// or `Null` (`{"null": true}`).
145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
146#[serde(untagged)]
147pub enum ParamDefault {
148    Call {
149        call: String,
150        #[serde(default)]
151        args: Vec<f64>,
152    },
153    Null {
154        null: bool,
155    },
156    EnumMember(String),
157    Bool(bool),
158    Number(f64),
159}
160
161/// One ordered parameter of a function entry.
162#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
163#[serde(rename_all = "camelCase")]
164pub struct Param {
165    pub name: String,
166    /// The enum domain this parameter requires, when it is an enum argument.
167    #[serde(default)]
168    pub domain: Option<String>,
169    /// An explicit default the frontend may expand; see [`ParamDefault`].
170    #[serde(default)]
171    pub default: Option<ParamDefault>,
172    /// Whether the argument is omittable without an emitted expansion
173    /// (`"optional": true`; the reference accepts the short form).
174    #[serde(default)]
175    pub optional: bool,
176    /// Whether the argument must be passed as a keyword (`name = expr`):
177    /// the reference `chase` form requires its 3rd argument to be
178    /// `rate = ...` or `duration = ...` (issue #110).
179    #[serde(default)]
180    pub keyword_only: bool,
181    /// Whether the argument can only be passed positionally (keyword
182    /// binding is rejected): the reference `chase` form's leading arguments
183    /// (issue #110).
184    #[serde(default)]
185    pub positional_only: bool,
186    /// Additional accepted keyword spellings for this parameter (the
187    /// reference `chase` form accepts both `rate` and `duration` for its
188    /// 3rd argument).
189    #[serde(default)]
190    pub alternate_names: Vec<String>,
191    /// Whether the argument must be a variable reference (a global variable
192    /// or a player variable); the chase family requires a variable first
193    /// argument to select the global/player emission form.
194    #[serde(default)]
195    pub variable: bool,
196}
197
198/// One builtin function entry (generic action/value or member function).
199#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
200#[serde(rename_all = "camelCase")]
201pub struct Function {
202    pub id: String,
203    pub kind: FunctionKind,
204    /// The receiver category of member functions.
205    #[serde(default)]
206    pub receiver: Option<ReceiverCategory>,
207    #[serde(default)]
208    pub params: Vec<Param>,
209    /// Whether the argument count is unbounded (`.format` placeholders).
210    #[serde(default)]
211    pub unbounded: bool,
212    /// Whether keyword arguments are accepted (`name = expr`). Defaults to
213    /// `true` (the reference's `parseArgs` applies to every workshop
214    /// function); entries the reference routes around that mechanism
215    /// (`range`, `random.*`, `.format`) declare `"keywordArgs": false`
216    /// (issue #110).
217    #[serde(default = "default_keyword_args")]
218    pub keyword_args: bool,
219    /// The canonical Workshop catalog id this entry emits through; absent
220    /// when emission is special-cased or not yet catalog-covered.
221    #[serde(default)]
222    #[serde(rename = "catalogId")]
223    pub catalog_id: Option<String>,
224    /// The explicit reason a source-level function has no direct catalog id.
225    #[serde(default)]
226    pub catalog_link: CatalogLink,
227    /// The probe ids that validate this entry against the pinned oracle.
228    #[serde(default)]
229    pub evidence: Vec<String>,
230}
231
232impl Function {
233    /// The (minimum, maximum) argument count: positional binding needs every
234    /// parameter up to the last one without a default or `optional` marker;
235    /// `unbounded` entries accept any count.
236    pub fn arity_bounds(&self) -> (usize, Option<usize>) {
237        if self.unbounded {
238            return (0, None);
239        }
240        let min = self
241            .params
242            .iter()
243            .rposition(|param| param.default.is_none() && !param.optional)
244            .map_or(0, |index| index + 1);
245        (min, Some(self.params.len()))
246    }
247}
248
249fn default_keyword_args() -> bool {
250    true
251}
252
253/// A non-contextual source alias: a pure name rewrite to a declared entry.
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255#[serde(rename_all = "camelCase")]
256pub struct Alias {
257    pub source: String,
258    pub target: String,
259    pub kind: AliasKind,
260    #[serde(default)]
261    pub evidence: Vec<String>,
262}
263
264/// The alias target class; `functionAlias` targets a generic function,
265/// `memberAlias` a member function.
266#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
267#[serde(rename_all = "camelCase")]
268pub enum AliasKind {
269    FunctionAlias,
270    MemberAlias,
271}
272
273/// One recorded probe in the embedded evidence record.
274#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
275#[serde(rename_all = "camelCase")]
276pub struct Probe {
277    pub id: String,
278    pub source: String,
279    pub sha256: String,
280    pub expect: String,
281    #[serde(default)]
282    pub output_sha256: Option<String>,
283    #[serde(default)]
284    pub diagnostic_contains: Option<String>,
285}
286
287/// A validation failure while loading the manifest.
288#[derive(Debug, Clone, PartialEq, Eq)]
289pub struct ManifestError(pub String);
290
291impl std::fmt::Display for ManifestError {
292    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
293        f.write_str(&self.0)
294    }
295}
296
297impl std::error::Error for ManifestError {}
298
299/// The validated OPY semantic compatibility manifest.
300#[derive(Debug, Clone)]
301pub struct Manifest {
302    pub schema_version: u32,
303    pub reference: Reference,
304    pub functions: Vec<Function>,
305    pub aliases: Vec<Alias>,
306    pub provenance: Provenance,
307    /// The recorded probe evidence (`probes/probes.json`).
308    pub probes: Vec<Probe>,
309    by_function: HashMap<String, usize>,
310    by_member: HashMap<String, usize>,
311    alias_by_source: HashMap<String, usize>,
312    /// The declared enum-domain identities: every `param.domain` and
313    /// contextual option domain in the function table. Identity links only —
314    /// member lists are Workshop-owned catalog content and are not carried
315    /// here (lowering-dependent validation, #8).
316    domain_identities: HashSet<String>,
317}
318
319#[derive(Serialize, Deserialize)]
320#[serde(rename_all = "camelCase")]
321struct ManifestFile {
322    schema_version: u32,
323    reference: Reference,
324    #[serde(default)]
325    functions: Vec<Function>,
326    #[serde(default)]
327    aliases: Vec<Alias>,
328    provenance: Provenance,
329}
330
331#[derive(Serialize, Deserialize)]
332#[serde(rename_all = "camelCase")]
333struct ProbesFile {
334    schema_version: u32,
335    #[serde(default)]
336    probes: Vec<Probe>,
337}
338
339impl Manifest {
340    /// Parse and validate manifest data plus its probe evidence record.
341    pub fn load(manifest_json: &str, probes_json: &str) -> Result<Manifest, ManifestError> {
342        let file: ManifestFile = serde_json::from_str(manifest_json)
343            .map_err(|error| ManifestError(format!("manifest data: {error}")))?;
344        if file.schema_version != 1 {
345            return Err(ManifestError(format!(
346                "unsupported manifest schemaVersion {}",
347                file.schema_version
348            )));
349        }
350        let probes_file: ProbesFile = serde_json::from_str(probes_json)
351            .map_err(|error| ManifestError(format!("probes data: {error}")))?;
352        if probes_file.schema_version != 1 {
353            return Err(ManifestError(format!(
354                "unsupported probes schemaVersion {}",
355                probes_file.schema_version
356            )));
357        }
358        let ManifestFile {
359            schema_version,
360            reference,
361            functions,
362            aliases,
363            provenance,
364        } = file;
365        let mut manifest = Manifest {
366            schema_version,
367            reference,
368            functions: Vec::with_capacity(functions.len()),
369            aliases: Vec::with_capacity(aliases.len()),
370            provenance,
371            probes: probes_file.probes,
372            by_function: HashMap::new(),
373            by_member: HashMap::new(),
374            alias_by_source: HashMap::new(),
375            domain_identities: HashSet::new(),
376        };
377        manifest.validate(functions, aliases)?;
378        Ok(manifest)
379    }
380
381    fn validate(
382        &mut self,
383        functions: Vec<Function>,
384        aliases: Vec<Alias>,
385    ) -> Result<(), ManifestError> {
386        // Probe ids must be unique and must record the accept probes the
387        // entries reference.
388        let mut probes: HashMap<&str, &Probe> = HashMap::new();
389        for probe in &self.probes {
390            if probes.insert(&probe.id, probe).is_some() {
391                return Err(ManifestError(format!("duplicate probe id '{}'", probe.id)));
392            }
393        }
394
395        // Functions: unique ids, member-only receiver/kind combinations,
396        // declared enum domains, declared enum-default members, and probe
397        // evidence that records acceptance.
398        for function in functions {
399            if self.by_function.contains_key(&function.id) {
400                return Err(ManifestError(format!(
401                    "duplicate function id '{}'",
402                    function.id
403                )));
404            }
405            match function.kind {
406                FunctionKind::MemberAction | FunctionKind::MemberValue => {
407                    if function.receiver.is_none() {
408                        return Err(ManifestError(format!(
409                            "member function '{}' declares no receiver category",
410                            function.id
411                        )));
412                    }
413                }
414                FunctionKind::Action | FunctionKind::Value => {
415                    if function.receiver.is_some() {
416                        return Err(ManifestError(format!(
417                            "non-member function '{}' declares a receiver category",
418                            function.id
419                        )));
420                    }
421                }
422            }
423            for param in function.params.iter() {
424                if let Some(domain) = &param.domain {
425                    // A parameter may declare the function's own contextual
426                    // domain (`chase`'s `ChaseReeval`): it resolves only in
427                    // this signature's context and is not a standalone
428                    // identity.
429                    let is_contextual = crate::lower::policy::is_contextual_domain(domain);
430                    if !is_contextual {
431                        self.domain_identities.insert(domain.clone());
432                    }
433                } else if matches!(param.default, Some(ParamDefault::EnumMember(_))) {
434                    return Err(ManifestError(format!(
435                        "function '{}' parameter '{}' has an enum-member default but no \
436                         declared domain",
437                        function.id, param.name
438                    )));
439                }
440                if param.keyword_only && param.positional_only {
441                    return Err(ManifestError(format!(
442                        "function '{}' parameter '{}' cannot be both keyword-only and \
443                         positional-only",
444                        function.id, param.name
445                    )));
446                }
447                for alternate in &param.alternate_names {
448                    if alternate == &param.name {
449                        return Err(ManifestError(format!(
450                            "function '{}' parameter '{}' repeats its name as an \
451                             alternate keyword spelling",
452                            function.id, param.name
453                        )));
454                    }
455                    if function.params.iter().any(|other| {
456                        !std::ptr::eq(other, param)
457                            && (&other.name == alternate
458                                || other.alternate_names.contains(alternate))
459                    }) {
460                        return Err(ManifestError(format!(
461                            "function '{}' alternate keyword spelling '{alternate}' \
462                             collides with another parameter",
463                            function.id
464                        )));
465                    }
466                }
467            }
468            match (&function.catalog_id, function.catalog_link) {
469                (Some(_), CatalogLink::Canonical)
470                | (None, CatalogLink::SpecialLowering)
471                | (None, CatalogLink::LegacyAlias)
472                | (None, CatalogLink::CatalogGap) => {}
473                (Some(id), link) => {
474                    return Err(ManifestError(format!(
475                        "function '{}' has catalogId '{id}' but catalogLink is {:?}",
476                        function.id, link
477                    )));
478                }
479                (None, CatalogLink::Canonical) => {
480                    return Err(ManifestError(format!(
481                        "function '{}' has no catalogId or explicit catalogLink reason",
482                        function.id
483                    )));
484                }
485            }
486            crate::lower::policy::validate(&function).map_err(ManifestError)?;
487            if let Some(contextual) = crate::lower::policy::contextual_domain(&function.id) {
488                for option in contextual.options {
489                    self.domain_identities.insert(option.domain.to_string());
490                }
491            }
492            self.check_evidence(&function.id, &function.evidence, &probes)?;
493            if function.kind.is_member() {
494                self.by_member
495                    .insert(function.id.clone(), self.functions.len());
496            } else {
497                self.by_function
498                    .insert(function.id.clone(), self.functions.len());
499            }
500            self.functions.push(function);
501        }
502
503        // Aliases: unique sources, declared targets of the matching class,
504        // no collision with declared function ids.
505        for alias in aliases {
506            if self.alias_by_source.contains_key(&alias.source) {
507                return Err(ManifestError(format!(
508                    "duplicate alias source '{}'",
509                    alias.source
510                )));
511            }
512            if self.by_function.contains_key(&alias.source)
513                || self.by_member.contains_key(&alias.source)
514            {
515                return Err(ManifestError(format!(
516                    "alias source '{}' collides with a declared function",
517                    alias.source
518                )));
519            }
520            match alias.kind {
521                AliasKind::FunctionAlias => {
522                    if self.function(&alias.target).is_none() {
523                        return Err(ManifestError(format!(
524                            "alias '{}' targets '{}' which is not a generic function",
525                            alias.source, alias.target
526                        )));
527                    }
528                }
529                AliasKind::MemberAlias => {
530                    if self.member(&alias.target).is_none() {
531                        return Err(ManifestError(format!(
532                            "alias '{}' targets '{}' which is not a member function",
533                            alias.source, alias.target
534                        )));
535                    }
536                }
537            }
538            self.check_evidence(&alias.source, &alias.evidence, &probes)?;
539            self.alias_by_source
540                .insert(alias.source.clone(), self.aliases.len());
541            self.aliases.push(alias);
542        }
543
544        Ok(())
545    }
546
547    fn check_evidence(
548        &self,
549        owner: &str,
550        evidence: &[String],
551        probes: &HashMap<&str, &Probe>,
552    ) -> Result<(), ManifestError> {
553        if evidence.is_empty() {
554            return Err(ManifestError(format!(
555                "entry '{owner}' records no oracle probe evidence"
556            )));
557        }
558        for probe_id in evidence {
559            let probe = probes.get(probe_id.as_str()).ok_or_else(|| {
560                ManifestError(format!(
561                    "entry '{owner}' references undeclared probe '{probe_id}'"
562                ))
563            })?;
564            if probe.expect != "success" {
565                return Err(ManifestError(format!(
566                    "entry '{owner}' references probe '{probe_id}' which does not record \
567                     oracle acceptance"
568                )));
569            }
570        }
571        Ok(())
572    }
573
574    /// The built-in manifest, loaded once from the embedded data.
575    pub fn builtin() -> Result<&'static Manifest, ManifestError> {
576        static MANIFEST: OnceLock<Result<Manifest, ManifestError>> = OnceLock::new();
577        MANIFEST
578            .get_or_init(|| Manifest::load(MANIFEST_DATA, PROBES_DATA))
579            .as_ref()
580            .map_err(Clone::clone)
581    }
582
583    fn resolve_alias(&self, name: &str, kind: AliasKind) -> Option<&str> {
584        let alias = &self.aliases[*self.alias_by_source.get(name)?];
585        (alias.kind == kind).then_some(alias.target.as_str())
586    }
587
588    /// A generic (non-member) function by source name, alias-aware.
589    pub fn resolve_function(&self, name: &str) -> Option<&Function> {
590        self.function(name).or_else(|| {
591            self.resolve_alias(name, AliasKind::FunctionAlias)
592                .and_then(|target| self.function(target))
593        })
594    }
595
596    /// A member function by source name, alias-aware.
597    pub fn resolve_member(&self, name: &str) -> Option<&Function> {
598        self.member(name).or_else(|| {
599            self.resolve_alias(name, AliasKind::MemberAlias)
600                .and_then(|target| self.member(target))
601        })
602    }
603
604    /// The function entry with the given id, if declared.
605    pub fn function(&self, id: &str) -> Option<&Function> {
606        self.by_function.get(id).map(|i| &self.functions[*i])
607    }
608
609    /// The member function entry with the given id, if declared.
610    pub fn member(&self, id: &str) -> Option<&Function> {
611        self.by_member.get(id).map(|i| &self.functions[*i])
612    }
613
614    /// Whether the name is a declared enum-domain identity: a `param.domain`
615    /// or contextual option domain in the function table. These are OPY
616    /// signature metadata (catalog identity links); the domain *member
617    /// lists* are Workshop-owned catalog content and are not carried here,
618    /// so member validation is `lowering-dependent` (issue #8).
619    pub fn domain_identity(&self, name: &str) -> bool {
620        self.domain_identities.contains(name)
621    }
622}
623
624/// Canonicalize manifest data: parse, validate, and re-serialize
625/// deterministically (object keys sorted, stable formatting). Re-running on
626/// the same input produces byte-identical output, so the data is
627/// reproducible and the committed file must equal its canonical form.
628pub fn canonicalize(manifest_json: &str, probes_json: &str) -> Result<String, ManifestError> {
629    Manifest::load(manifest_json, probes_json)?;
630    let value: serde_json::Value = serde_json::from_str(manifest_json)
631        .map_err(|error| ManifestError(format!("manifest data: {error}")))?;
632    serde_json::to_string_pretty(&value)
633        .map(|mut out| {
634            out.push('\n');
635            out
636        })
637        .map_err(|error| ManifestError(format!("cannot serialize manifest: {error}")))
638}
639
640#[cfg(test)]
641mod tests {
642    use super::*;
643
644    #[test]
645    fn builtin_manifest_loads_and_validates() {
646        let manifest = Manifest::builtin().expect("embedded manifest must validate");
647        assert_eq!(manifest.schema_version, 1);
648        assert_eq!(manifest.reference.name, "overpy");
649        assert_eq!(manifest.reference.version, "9.7.10");
650        assert_eq!(
651            manifest.reference.content_commit,
652            "889d9749d1def17f146548cbddb94ea1ab015847"
653        );
654        assert!(!manifest.functions.is_empty());
655        assert!(!manifest.aliases.is_empty());
656        // Enum-domain *identities* come from the function signatures
657        // (param.domain / contextual option domains); member lists are
658        // Workshop-owned catalog content and are not carried here. Every
659        // member entry declares a receiver; every entry has evidence.
660        for domain in ["Invis", "ChaseTimeReeval", "Team", "LosCheck", "Color"] {
661            assert!(manifest.domain_identity(domain), "{domain}");
662        }
663        assert_eq!(
664            manifest
665                .function("chase")
666                .expect("chase entry")
667                .catalog_link,
668            CatalogLink::SpecialLowering
669        );
670        assert_eq!(
671            manifest
672                .member("getHero")
673                .expect("getHero entry")
674                .catalog_link,
675            CatalogLink::Canonical
676        );
677        assert!(
678            !manifest.domain_identity("ChaseReeval"),
679            "contextual domains are not standalone identities"
680        );
681        for function in &manifest.functions {
682            assert!(!function.evidence.is_empty(), "{}", function.id);
683            if function.kind.is_member() {
684                assert!(function.receiver.is_some(), "{}", function.id);
685            }
686        }
687    }
688
689    #[test]
690    fn manifest_data_is_canonical() {
691        // The committed data file must equal its deterministic canonical
692        // rewrite (the `build` path), so the data pipeline is reproducible.
693        let canonical = canonicalize(MANIFEST_DATA, PROBES_DATA).expect("canonicalizes");
694        assert_eq!(canonical, MANIFEST_DATA, "manifest.json must be canonical");
695        // Idempotency: re-canonicalizing the canonical form is byte-stable.
696        assert_eq!(
697            canonicalize(&canonical, PROBES_DATA).expect("re-canonicalizes"),
698            canonical
699        );
700    }
701
702    #[test]
703    fn manifest_omits_contextual_lowering_policy() {
704        assert!(!MANIFEST_DATA.contains("\"contextualDomain\""));
705        assert!(!MANIFEST_DATA.contains("\"context\": \"forIterable\""));
706    }
707
708    #[test]
709    fn validation_rejects_duplicates_and_missing_evidence() {
710        fn mutate(mutate: impl FnOnce(&mut ManifestFile)) -> Result<Manifest, ManifestError> {
711            let mut file: ManifestFile = serde_json::from_str(MANIFEST_DATA).unwrap();
712            mutate(&mut file);
713            Manifest::load(&serde_json::to_string(&file).unwrap(), PROBES_DATA)
714        }
715        // duplicate function id
716        let error = mutate(|file| file.functions.push(file.functions[0].clone()))
717            .expect_err("duplicate function id must fail");
718        assert!(error.0.contains("duplicate function id"));
719        // A direct catalog link must be explicit about being canonical.
720        let error = mutate(|file| file.functions[0].catalog_link = CatalogLink::CatalogGap)
721            .expect_err("canonical catalog id must not carry a gap reason");
722        assert!(error.0.contains("catalogLink"));
723        // entry without evidence
724        let error = mutate(|file| file.functions[0].evidence.clear())
725            .expect_err("missing evidence must fail");
726        assert!(error.0.contains("no oracle probe evidence"));
727        // enum-member default without a declared domain is a data-integrity
728        // error (the default cannot be expanded without an identity)
729        let error = mutate(|file| {
730            file.functions[0].params.push(Param {
731                name: "bad".to_string(),
732                domain: None,
733                default: Some(ParamDefault::EnumMember("X".to_string())),
734                optional: false,
735                keyword_only: false,
736                positional_only: false,
737                alternate_names: Vec::new(),
738                variable: false,
739            })
740        })
741        .expect_err("enum default without a domain must fail");
742        assert!(error.0.contains("no declared domain"));
743    }
744
745    #[test]
746    fn arity_bounds_follow_defaults_and_unbounded() {
747        let manifest = Manifest::builtin().expect("builtin");
748        let chase = manifest.function("chaseOverTime").expect("entry");
749        assert_eq!(chase.arity_bounds(), (3, Some(4)));
750        let radius = manifest.function("getPlayersInRadius").expect("entry");
751        assert_eq!(radius.arity_bounds(), (2, Some(4)));
752        let status = manifest.member("setStatusEffect").expect("entry");
753        assert_eq!(status.arity_bounds(), (3, Some(3)));
754        let format = manifest.member("format").expect("entry");
755        assert_eq!(format.arity_bounds(), (0, None));
756        let range = manifest.function("range").expect("entry");
757        assert_eq!(range.arity_bounds(), (1, Some(3)));
758        assert_eq!(
759            crate::lower::policy::function_context(&range.id),
760            Some(crate::lower::policy::FunctionContext::ForIterable)
761        );
762    }
763
764    #[test]
765    fn aliases_resolve_to_declared_targets() {
766        let manifest = Manifest::builtin().expect("builtin");
767        let alias = manifest
768            .resolve_function("stopChasingVariable")
769            .expect("alias");
770        assert_eq!(alias.id, "stopChasingVariable");
771        assert!(alias.kind.is_action());
772        let member = manifest.resolve_member("getCurrentHero").expect("alias");
773        assert_eq!(member.id, "getHero");
774        assert!(member.kind.is_value());
775        // Unknown names stay unresolved.
776        assert!(manifest.resolve_function("frobnicate").is_none());
777        assert!(manifest.resolve_member("frobnicate").is_none());
778    }
779
780    #[test]
781    fn canonical_ids_are_not_source_spellings() {
782        // #410: a `catalogId` is the canonical Workshop identity, not an OPY
783        // source spelling. The manifest only accepts names the pinned oracle
784        // accepts; canonical-only ids stay unresolved while the upstream
785        // spellings resolve to the same canonical identity.
786        let manifest = Manifest::builtin().expect("builtin");
787        for (canonical, upstream, catalog_id) in [
788            ("evaluateOnce", "evalOnce", "evaluateOnce"),
789            (
790                "lastCreatedEntity",
791                "getLastCreatedEntity",
792                "lastCreatedEntity",
793            ),
794            ("lastTextId", "getLastCreatedText", "lastTextId"),
795            ("allTankHeroes", "getTankHeroes", "allTankHeroes"),
796            ("allDamageHeroes", "getDamageHeroes", "allDamageHeroes"),
797            ("allSupportHeroes", "getSupportHeroes", "allSupportHeroes"),
798            (
799                "destroyAllHudText",
800                "destroyAllHudTexts",
801                "destroyAllHudText",
802            ),
803        ] {
804            assert!(
805                manifest.resolve_function(canonical).is_none(),
806                "{canonical} must not be a callable source spelling"
807            );
808            let entry = manifest
809                .resolve_function(upstream)
810                .unwrap_or_else(|| panic!("{upstream} must resolve"));
811            assert_eq!(
812                entry.catalog_id.as_deref(),
813                Some(catalog_id),
814                "{upstream} must keep the canonical identity"
815            );
816        }
817        assert!(manifest.resolve_member("isButtonHeld").is_none());
818        let held = manifest
819            .resolve_member("isHoldingButton")
820            .expect("isHoldingButton");
821        assert_eq!(held.catalog_id.as_deref(), Some("isButtonHeld"));
822        // Vector components are accessors, not callable members.
823        for axis in ["x", "y", "z"] {
824            assert!(manifest.resolve_member(axis).is_none(), "{axis}()");
825        }
826    }
827}