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