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