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