Skip to main content

lashlang/
linker.rs

1use std::borrow::Borrow;
2use std::collections::{BTreeMap, BTreeSet};
3
4use serde::{Deserialize, Serialize};
5use thiserror::Error;
6
7use crate::artifact::{
8    ModuleArtifact, SurfaceRequirements, surface_requirements_for_program_with_catalog,
9};
10use crate::ast::{
11    AssignPathStep, AstString, Declaration, Expr, ProcessDecl, ProcessParam, Program,
12    ResourceRefExpr, TypeExpr, TypeField, format_type_expr,
13};
14use crate::lexer::Span;
15
16#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
17pub struct ResourceCatalog {
18    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
19    module_instances: BTreeMap<String, ModuleInstanceCatalog>,
20    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
21    resource_types: BTreeMap<String, ResourceTypeCatalog>,
22    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
23    named_data_types: BTreeMap<String, NamedDataType>,
24    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
25    value_constructors: BTreeMap<String, ValueConstructorBinding>,
26    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
27    trigger_sources: BTreeMap<String, TriggerSourceBinding>,
28}
29
30impl ResourceCatalog {
31    pub fn new() -> Self {
32        Self::default()
33    }
34
35    pub fn tool_default(operations: impl IntoIterator<Item = impl Into<String>>) -> Self {
36        let mut catalog = Self::new();
37        for operation in operations {
38            let operation = operation.into();
39            catalog.add_module_operation(
40                ["tools"],
41                "Tools",
42                operation.clone(),
43                operation,
44                TypeExpr::Any,
45                TypeExpr::Any,
46            );
47        }
48        catalog
49    }
50
51    pub fn add_module_instance(
52        &mut self,
53        module_path: impl IntoIterator<Item = impl Into<String>>,
54        resource_type: impl Into<String>,
55    ) -> Result<(), ResourceCatalogError> {
56        let path = module_path.into_iter().map(Into::into).collect::<Vec<_>>();
57        assert!(!path.is_empty(), "module path must not be empty");
58        let resource_type = resource_type.into();
59        let key = module_path_key(&path);
60        if let Some(existing) = self.module_instances.get(&key) {
61            if existing.resource_type != resource_type {
62                return Err(ResourceCatalogError::ConflictingModuleInstance {
63                    alias: key,
64                    existing: existing.resource_type.clone(),
65                    incoming: resource_type,
66                });
67            }
68            self.ensure_resource_type(resource_type);
69            return Ok(());
70        }
71        self.module_instances.insert(
72            key.clone(),
73            ModuleInstanceCatalog {
74                path,
75                resource_type: resource_type.clone(),
76                alias: key,
77                operations: BTreeMap::new(),
78            },
79        );
80        self.ensure_resource_type(resource_type);
81        Ok(())
82    }
83
84    pub fn ensure_resource_type(&mut self, resource_type: impl Into<String>) {
85        self.resource_types.entry(resource_type.into()).or_default();
86    }
87
88    pub fn add_operation(
89        &mut self,
90        resource_type: impl Into<String>,
91        operation: impl Into<String>,
92        input_ty: TypeExpr,
93        output_ty: TypeExpr,
94    ) {
95        self.resource_types
96            .entry(resource_type.into())
97            .or_default()
98            .operations
99            .insert(
100                operation.into(),
101                ResourceOperationBinding {
102                    input_ty,
103                    output_ty,
104                },
105            );
106    }
107
108    pub fn add_module_operation(
109        &mut self,
110        module_path: impl IntoIterator<Item = impl Into<String>>,
111        resource_type: impl Into<String>,
112        operation: impl Into<String>,
113        host_operation: impl Into<String>,
114        input_ty: TypeExpr,
115        output_ty: TypeExpr,
116    ) {
117        let path = module_path.into_iter().map(Into::into).collect::<Vec<_>>();
118        assert!(!path.is_empty(), "module path must not be empty");
119        let resource_type = resource_type.into();
120        let operation = operation.into();
121        self.add_module_instance(path.iter().map(String::as_str), resource_type.clone())
122            .expect("module operation resource type cannot conflict with existing module alias");
123        self.add_operation(resource_type, operation.clone(), input_ty, output_ty);
124        let key = module_path_key(&path);
125        self.module_instances
126            .get_mut(&key)
127            .expect("module instance was just inserted")
128            .operations
129            .insert(
130                operation,
131                ModuleOperationBinding {
132                    host_operation: host_operation.into(),
133                },
134            );
135    }
136
137    pub fn add_value_constructor(
138        &mut self,
139        path: impl IntoIterator<Item = impl Into<String>>,
140        input_ty: TypeExpr,
141        output_ty: TypeExpr,
142    ) {
143        let path = path.into_iter().map(Into::into).collect::<Vec<_>>();
144        assert!(!path.is_empty(), "constructor path must not be empty");
145        let key = module_path_key(&path);
146        self.value_constructors.insert(
147            key.clone(),
148            ValueConstructorBinding {
149                path,
150                type_name: format_type_expr(&output_ty),
151                input_ty,
152                output_ty,
153            },
154        );
155    }
156
157    pub fn add_named_data_type(
158        &mut self,
159        data_type: NamedDataType,
160    ) -> Result<(), ResourceCatalogError> {
161        self.merge_named_data_type(data_type)
162    }
163
164    pub fn add_trigger_source_constructor(
165        &mut self,
166        path: impl IntoIterator<Item = impl Into<String>>,
167        input_ty: TypeExpr,
168        event_ty: NamedDataType,
169    ) -> Result<(), ResourceCatalogError> {
170        let path = path.into_iter().map(Into::into).collect::<Vec<_>>();
171        assert!(!path.is_empty(), "constructor path must not be empty");
172        let source_type = module_path_key(&path);
173        self.check_named_data_type(&event_ty)?;
174        if let Some(existing) = self.trigger_sources.get(source_type.as_str())
175            && existing.event_type() != &event_ty
176        {
177            return Err(ResourceCatalogError::ConflictingTriggerSource {
178                source_type,
179                existing: existing.event_type().name().to_string(),
180                incoming: event_ty.name().to_string(),
181            });
182        }
183        self.add_value_constructor(path, input_ty, TypeExpr::Ref(source_type.clone().into()));
184        self.add_trigger_source_type(source_type, event_ty)?;
185        Ok(())
186    }
187
188    pub(crate) fn add_trigger_source_type(
189        &mut self,
190        source_ty: impl Into<String>,
191        event_ty: NamedDataType,
192    ) -> Result<(), ResourceCatalogError> {
193        self.merge_named_data_type(event_ty.clone())?;
194        self.trigger_sources
195            .insert(source_ty.into(), TriggerSourceBinding::new(event_ty));
196        Ok(())
197    }
198
199    pub fn extend(&mut self, other: Self) {
200        self.try_extend(other)
201            .expect("conflicting resource catalog entries");
202    }
203
204    pub fn try_extend(&mut self, other: Self) -> Result<(), ResourceCatalogError> {
205        for (resource_type, incoming) in other.resource_types {
206            let entry = self.resource_types.entry(resource_type).or_default();
207            entry.operations.extend(incoming.operations);
208        }
209        for (alias, incoming) in other.module_instances {
210            match self.module_instances.get_mut(&alias) {
211                Some(existing)
212                    if existing.path == incoming.path
213                        && existing.resource_type == incoming.resource_type
214                        && existing.alias == incoming.alias =>
215                {
216                    existing.operations.extend(incoming.operations);
217                }
218                Some(existing) => {
219                    return Err(ResourceCatalogError::ConflictingModuleInstance {
220                        alias,
221                        existing: existing.resource_type.clone(),
222                        incoming: incoming.resource_type,
223                    });
224                }
225                None => {
226                    self.module_instances.insert(alias, incoming);
227                }
228            }
229        }
230        for data_type in other.named_data_types.into_values() {
231            self.merge_named_data_type(data_type)?;
232        }
233        self.value_constructors.extend(other.value_constructors);
234        self.trigger_sources.extend(other.trigger_sources);
235        Ok(())
236    }
237
238    pub fn union(mut self, other: Self) -> Self {
239        self.extend(other);
240        self
241    }
242
243    pub fn satisfies(&self, required: &Self) -> bool {
244        for (path, required_module) in &required.module_instances {
245            let Some(module) = self.module_instances.get(path) else {
246                return false;
247            };
248            if module.path != required_module.path
249                || module.resource_type != required_module.resource_type
250                || module.alias != required_module.alias
251            {
252                return false;
253            }
254            for (operation, required_binding) in &required_module.operations {
255                if module.operations.get(operation) != Some(required_binding) {
256                    return false;
257                }
258            }
259        }
260        for (resource_type, required_catalog) in &required.resource_types {
261            let Some(catalog) = self.resource_types.get(resource_type) else {
262                return false;
263            };
264            for (operation, required_binding) in &required_catalog.operations {
265                if catalog.operations.get(operation) != Some(required_binding) {
266                    return false;
267                }
268            }
269        }
270        for (path, required_constructor) in &required.value_constructors {
271            if self.value_constructors.get(path) != Some(required_constructor) {
272                return false;
273            }
274        }
275        for (name, required_data_type) in &required.named_data_types {
276            if self.named_data_types.get(name) != Some(required_data_type) {
277                return false;
278            }
279        }
280        for (source_type, required_binding) in &required.trigger_sources {
281            if self.trigger_sources.get(source_type) != Some(required_binding) {
282                return false;
283            }
284        }
285        true
286    }
287
288    pub fn has_resource_type(&self, resource_type: &str) -> bool {
289        self.resource_types.contains_key(resource_type)
290    }
291
292    pub fn has_named_data_type(&self, name: &str) -> bool {
293        self.named_data_types.contains_key(name)
294    }
295
296    pub fn is_known_opaque_value_type(&self, name: &str) -> bool {
297        self.trigger_sources.contains_key(name)
298            || self.value_constructors.values().any(|constructor| {
299                matches!(&constructor.output_ty, TypeExpr::Ref(type_name) if type_name == name)
300            })
301    }
302
303    pub fn decode_host_value_as<T: serde::de::DeserializeOwned>(
304        &self,
305        source_type: &str,
306        value: serde_json::Value,
307    ) -> Result<T, crate::HostValueError> {
308        if !self.is_known_opaque_value_type(source_type) {
309            return Err(crate::HostValueError::UnknownSourceType {
310                source_type: source_type.to_string(),
311            });
312        }
313        serde_json::from_value(value).map_err(|err| crate::HostValueError::MalformedPayload {
314            source_type: source_type.to_string(),
315            message: err.to_string(),
316        })
317    }
318
319    pub fn module_instances(&self) -> impl Iterator<Item = (&str, &ModuleInstanceCatalog)> {
320        self.module_instances
321            .iter()
322            .map(|(path, module)| (path.as_str(), module))
323    }
324
325    pub fn resource_types(&self) -> impl Iterator<Item = (&str, &ResourceTypeCatalog)> {
326        self.resource_types
327            .iter()
328            .map(|(resource_type, catalog)| (resource_type.as_str(), catalog))
329    }
330
331    pub fn named_data_types(&self) -> impl Iterator<Item = (&str, &NamedDataType)> {
332        self.named_data_types
333            .iter()
334            .map(|(name, data_type)| (name.as_str(), data_type))
335    }
336
337    pub fn value_constructors(&self) -> impl Iterator<Item = (&str, &ValueConstructorBinding)> {
338        self.value_constructors
339            .iter()
340            .map(|(path, constructor)| (path.as_str(), constructor))
341    }
342
343    pub fn trigger_sources(&self) -> impl Iterator<Item = (&str, &TriggerSourceBinding)> {
344        self.trigger_sources
345            .iter()
346            .map(|(source_type, binding)| (source_type.as_str(), binding))
347    }
348
349    pub fn resolve_named_data_type(&self, name: &str) -> Option<&NamedDataType> {
350        self.named_data_types.get(name)
351    }
352
353    pub fn resolve_trigger_source(&self, source_ty: &str) -> Option<&TriggerSourceBinding> {
354        self.trigger_sources.get(source_ty)
355    }
356
357    pub fn resolve_module_path(&self, path: &[impl AsRef<str>]) -> Option<ResourceRefExpr> {
358        let key = module_path_key(path);
359        let module = self.module_instances.get(&key)?;
360        Some(ResourceRefExpr::resolved(
361            module
362                .path
363                .iter()
364                .map(|segment| segment.as_str().into())
365                .collect(),
366            module.resource_type.clone(),
367            module.alias.clone(),
368        ))
369    }
370
371    pub fn resolve_alias(&self, resource: &ResourceRefExpr) -> Option<&ResourceTypeCatalog> {
372        if !resource.resource_type.is_empty() {
373            return self.resource_types.get(resource.resource_type.as_str());
374        }
375        let resolved = self.resolve_module_path(&resource.path)?;
376        self.resource_types.get(resolved.resource_type.as_str())
377    }
378
379    pub fn resolve_operation(
380        &self,
381        resource_type: &str,
382        operation: &str,
383    ) -> Option<&ResourceOperationBinding> {
384        self.resource_types
385            .get(resource_type)?
386            .operations
387            .get(operation)
388    }
389
390    pub fn has_operations(&self) -> bool {
391        self.resource_types
392            .values()
393            .any(|resource_type| !resource_type.operations.is_empty())
394    }
395
396    pub fn resolve_module_operation(
397        &self,
398        resource_type: &str,
399        alias: &str,
400        operation: &str,
401    ) -> Option<&ModuleOperationBinding> {
402        let module = self.module_instances.get(alias)?;
403        (module.resource_type == resource_type).then_some(())?;
404        module.operations.get(operation)
405    }
406
407    pub fn resolve_value_constructor(
408        &self,
409        path: &[impl AsRef<str>],
410    ) -> Option<&ValueConstructorBinding> {
411        self.value_constructors.get(&module_path_key(path))
412    }
413
414    pub fn trigger_source_event(&self, source_ty: &TypeExpr) -> Option<TypeExpr> {
415        let TypeExpr::Ref(name) = source_ty else {
416            return None;
417        };
418        self.trigger_sources
419            .get(name.as_str())
420            .map(|binding| binding.event_type().to_ref_ty())
421    }
422
423    pub fn operation_suggestions_for_host(&self, host_operation: &str) -> Vec<String> {
424        let mut suggestions = Vec::new();
425        for module in self.module_instances.values() {
426            for (operation, binding) in &module.operations {
427                if binding.host_operation == host_operation {
428                    suggestions.push(format!("{}.{}", module.alias, operation));
429                }
430            }
431        }
432        suggestions.sort();
433        suggestions.dedup();
434        suggestions
435    }
436
437    pub fn operation_suggestions_for_prefix(
438        &self,
439        prefix: &[impl AsRef<str>],
440        operation: &str,
441    ) -> Vec<String> {
442        let prefix = module_path_key(prefix);
443        let mut suggestions = Vec::new();
444        for module in self.module_instances.values() {
445            if module.alias == prefix || !module.alias.starts_with(&format!("{prefix}.")) {
446                continue;
447            }
448            if self
449                .resolve_operation(&module.resource_type, operation)
450                .is_some()
451            {
452                suggestions.push(format!("{}.{}", module.alias, operation));
453            }
454        }
455        suggestions.sort();
456        suggestions.dedup();
457        suggestions
458    }
459
460    fn check_named_data_type(&self, data_type: &NamedDataType) -> Result<(), ResourceCatalogError> {
461        if let Some(existing) = self.named_data_types.get(data_type.name())
462            && existing != data_type
463        {
464            return Err(ResourceCatalogError::ConflictingNamedDataType {
465                name: data_type.name().to_string(),
466            });
467        }
468        Ok(())
469    }
470
471    fn merge_named_data_type(
472        &mut self,
473        data_type: NamedDataType,
474    ) -> Result<(), ResourceCatalogError> {
475        self.check_named_data_type(&data_type)?;
476        self.named_data_types
477            .entry(data_type.name().to_string())
478            .or_insert(data_type);
479        Ok(())
480    }
481}
482
483#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
484pub struct NamedDataType {
485    name: String,
486    ty: TypeExpr,
487}
488
489impl NamedDataType {
490    pub fn new(name: impl Into<String>, ty: TypeExpr) -> Result<Self, NamedDataTypeError> {
491        let name = name.into();
492        if !is_qualified_type_name(&name) {
493            return Err(NamedDataTypeError::InvalidName { name });
494        }
495        if !matches!(ty, TypeExpr::Object(_)) {
496            return Err(NamedDataTypeError::ExpectedObject { name });
497        }
498        validate_named_data_shape(&ty)?;
499        Ok(Self { name, ty })
500    }
501
502    pub fn object(
503        name: impl Into<String>,
504        fields: Vec<TypeField>,
505    ) -> Result<Self, NamedDataTypeError> {
506        Self::new(name, TypeExpr::Object(fields))
507    }
508
509    pub fn name(&self) -> &str {
510        &self.name
511    }
512
513    pub fn ty(&self) -> &TypeExpr {
514        &self.ty
515    }
516
517    pub fn to_ref_ty(&self) -> TypeExpr {
518        TypeExpr::Ref(self.name.clone().into())
519    }
520}
521
522#[derive(Clone, Debug, PartialEq, Eq, Error)]
523pub enum NamedDataTypeError {
524    #[error("host data type name `{name}` must be qualified")]
525    InvalidName { name: String },
526    #[error("host data type `{name}` must be an object type")]
527    ExpectedObject { name: String },
528    #[error("host data type object has duplicate field `{field}`")]
529    DuplicateField { field: String },
530    #[error("host data type enum has duplicate value `{value}`")]
531    DuplicateEnumValue { value: String },
532    #[error("host data type shape cannot contain nested type ref `{name}`")]
533    NestedRef { name: String },
534    #[error("host data type shape cannot contain {ty}")]
535    UnsupportedType { ty: &'static str },
536}
537
538#[derive(Clone, Debug, PartialEq, Eq, Error)]
539pub enum ResourceCatalogError {
540    #[error("conflicting host data type definition `{name}`")]
541    ConflictingNamedDataType { name: String },
542    #[error(
543        "module `{alias}` already has resource type `{existing}`, cannot change it to `{incoming}`"
544    )]
545    ConflictingModuleInstance {
546        alias: String,
547        existing: String,
548        incoming: String,
549    },
550    #[error(
551        "trigger source `{source_type}` already emits `{existing}`, cannot change it to `{incoming}`"
552    )]
553    ConflictingTriggerSource {
554        source_type: String,
555        existing: String,
556        incoming: String,
557    },
558}
559
560fn is_qualified_type_name(name: &str) -> bool {
561    let mut segments = name.split('.');
562    let mut count = 0usize;
563    for segment in segments.by_ref() {
564        count += 1;
565        let mut chars = segment.chars();
566        let Some(first) = chars.next() else {
567            return false;
568        };
569        if !(first.is_ascii_alphabetic() || first == '_') {
570            return false;
571        }
572        if !chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_') {
573            return false;
574        }
575    }
576    count >= 2
577}
578
579fn validate_named_data_shape(ty: &TypeExpr) -> Result<(), NamedDataTypeError> {
580    match ty {
581        TypeExpr::Any
582        | TypeExpr::Str
583        | TypeExpr::Int
584        | TypeExpr::Float
585        | TypeExpr::Bool
586        | TypeExpr::Dict
587        | TypeExpr::Null => Ok(()),
588        TypeExpr::Enum(values) => {
589            let mut seen = BTreeSet::new();
590            for value in values {
591                if !seen.insert(value.to_string()) {
592                    return Err(NamedDataTypeError::DuplicateEnumValue {
593                        value: value.to_string(),
594                    });
595                }
596            }
597            Ok(())
598        }
599        TypeExpr::List(item) => validate_named_data_shape(item),
600        TypeExpr::Object(fields) => {
601            let mut seen = BTreeSet::new();
602            for field in fields {
603                if !seen.insert(field.name.to_string()) {
604                    return Err(NamedDataTypeError::DuplicateField {
605                        field: field.name.to_string(),
606                    });
607                }
608                validate_named_data_shape(&field.ty)?;
609            }
610            Ok(())
611        }
612        TypeExpr::Union(items) => {
613            for item in items {
614                validate_named_data_shape(item)?;
615            }
616            Ok(())
617        }
618        TypeExpr::Ref(name) => Err(NamedDataTypeError::NestedRef {
619            name: name.to_string(),
620        }),
621        TypeExpr::Process { .. } => Err(NamedDataTypeError::UnsupportedType { ty: "process" }),
622        TypeExpr::TriggerHandle(_) => Err(NamedDataTypeError::UnsupportedType {
623            ty: "trigger handle",
624        }),
625    }
626}
627
628#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
629pub struct ResourceTypeCatalog {
630    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
631    pub operations: BTreeMap<String, ResourceOperationBinding>,
632}
633
634#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
635pub struct ModuleInstanceCatalog {
636    pub path: Vec<String>,
637    pub resource_type: String,
638    pub alias: String,
639    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
640    pub operations: BTreeMap<String, ModuleOperationBinding>,
641}
642
643#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
644pub struct ResourceOperationBinding {
645    pub input_ty: TypeExpr,
646    pub output_ty: TypeExpr,
647}
648
649#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
650pub struct ModuleOperationBinding {
651    pub host_operation: String,
652}
653
654#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
655pub struct ValueConstructorBinding {
656    pub path: Vec<String>,
657    pub type_name: String,
658    pub input_ty: TypeExpr,
659    pub output_ty: TypeExpr,
660}
661
662#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
663pub struct TriggerSourceBinding {
664    event_type: NamedDataType,
665}
666
667impl TriggerSourceBinding {
668    fn new(event_type: NamedDataType) -> Self {
669        Self { event_type }
670    }
671
672    pub fn event_type(&self) -> &NamedDataType {
673        &self.event_type
674    }
675
676    pub fn event_ty(&self) -> &TypeExpr {
677        self.event_type.ty()
678    }
679
680    pub fn event_type_name(&self) -> &str {
681        self.event_type.name()
682    }
683}
684
685#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
686pub struct LashlangSurface {
687    #[serde(default)]
688    pub resources: ResourceCatalog,
689    #[serde(default)]
690    pub abilities: LashlangAbilities,
691    #[serde(default)]
692    pub language_features: LashlangLanguageFeatures,
693}
694
695impl LashlangSurface {
696    pub fn new(resources: ResourceCatalog, abilities: LashlangAbilities) -> Self {
697        Self {
698            resources,
699            abilities,
700            language_features: LashlangLanguageFeatures::default(),
701        }
702    }
703
704    pub fn with_language_features(mut self, language_features: LashlangLanguageFeatures) -> Self {
705        self.language_features = language_features;
706        self
707    }
708
709    pub fn satisfies(&self, requirements: &SurfaceRequirements) -> bool {
710        self.abilities.satisfies(requirements.abilities)
711            && self
712                .language_features
713                .satisfies(requirements.language_features)
714            && self.resources.satisfies(&requirements.resources)
715    }
716}
717
718#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
719#[serde(default)]
720pub struct LashlangLanguageFeatures {
721    pub label_annotations: bool,
722}
723
724impl LashlangLanguageFeatures {
725    pub fn union(self, other: Self) -> Self {
726        Self {
727            label_annotations: self.label_annotations || other.label_annotations,
728        }
729    }
730
731    pub fn satisfies(self, required: Self) -> bool {
732        !required.label_annotations || self.label_annotations
733    }
734
735    pub fn with_label_annotations(mut self) -> Self {
736        self.label_annotations = true;
737        self
738    }
739}
740
741#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
742#[serde(default)]
743pub struct LashlangAbilities {
744    pub processes: bool,
745    pub sleep: bool,
746    pub process_signals: bool,
747    pub triggers: bool,
748}
749
750impl LashlangAbilities {
751    pub fn union(self, other: Self) -> Self {
752        Self {
753            processes: self.processes || other.processes,
754            sleep: self.sleep || other.sleep,
755            process_signals: self.process_signals || other.process_signals,
756            triggers: self.triggers || other.triggers,
757        }
758    }
759
760    pub fn satisfies(self, required: Self) -> bool {
761        (!required.processes || self.processes)
762            && (!required.sleep || self.sleep)
763            && (!required.process_signals || self.process_signals)
764            && (!required.triggers || self.triggers)
765    }
766
767    pub fn with_processes(mut self) -> Self {
768        self.processes = true;
769        self
770    }
771
772    pub fn with_sleep(mut self) -> Self {
773        self.sleep = true;
774        self
775    }
776
777    pub fn with_process_signals(mut self) -> Self {
778        self.process_signals = true;
779        self
780    }
781
782    pub fn with_triggers(mut self) -> Self {
783        self.triggers = true;
784        self
785    }
786
787    pub fn all() -> Self {
788        Self::default()
789            .with_sleep()
790            .with_processes()
791            .with_process_signals()
792            .with_triggers()
793    }
794}
795
796fn module_path_key(path: &[impl AsRef<str>]) -> String {
797    path.iter()
798        .map(|segment| segment.as_ref())
799        .collect::<Vec<_>>()
800        .join(".")
801}
802
803#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
804pub struct LinkedModule {
805    pub module_ref: crate::ModuleRef,
806    pub required_surface_ref: crate::RequiredSurfaceRef,
807    pub artifact: ModuleArtifact,
808    #[serde(skip)]
809    linked_program: Option<Program>,
810}
811
812impl LinkedModule {
813    pub fn link(
814        program: Program,
815        surface: impl Borrow<LashlangSurface>,
816    ) -> Result<Self, LinkError> {
817        let surface = surface.borrow();
818        let mut linker = Linker::new(&program, surface);
819        let program = linker.link_program()?;
820        let requirements =
821            surface_requirements_for_program_with_catalog(&program, &surface.resources);
822        let artifact =
823            ModuleArtifact::from_program_with_requirements(program.clone(), requirements).map_err(
824                |err| LinkError::ModuleHash {
825                    message: err.to_string(),
826                },
827            )?;
828        Ok(Self {
829            module_ref: artifact.module_ref.clone(),
830            required_surface_ref: artifact.required_surface_ref.clone(),
831            artifact,
832            linked_program: Some(program),
833        })
834    }
835
836    pub fn program(&self) -> &Program {
837        self.linked_program
838            .as_ref()
839            .unwrap_or(&self.artifact.canonical_ir)
840    }
841}
842
843#[derive(Clone, Debug, Error, PartialEq, Eq)]
844pub enum LinkError {
845    #[error("duplicate declaration `{name}`")]
846    DuplicateDeclaration { name: String, span: Option<Span> },
847    #[error("duplicate process parameter `{name}`")]
848    DuplicateProcessParam { name: String, span: Option<Span> },
849    #[error("unknown process `{name}`")]
850    UnknownProcess { name: String, span: Option<Span> },
851    #[error("process `{process}` is missing argument `{arg}`")]
852    MissingProcessArgument {
853        process: String,
854        arg: String,
855        span: Option<Span>,
856    },
857    #[error("process `{process}` does not accept argument `{arg}`")]
858    UnexpectedProcessArgument {
859        process: String,
860        arg: String,
861        span: Option<Span>,
862    },
863    #[error("duplicate process argument `{arg}`")]
864    DuplicateProcessArgument { arg: String, span: Option<Span> },
865    #[error("unknown name `{name}`")]
866    UnknownName { name: String, span: Option<Span> },
867    #[error("unknown builtin `{name}`")]
868    UnknownBuiltin { name: String, span: Option<Span> },
869    #[error("unknown module `{path}`")]
870    UnknownResource { path: String, span: Option<Span> },
871    #[error("unknown type `{name}`")]
872    UnknownType { name: String, span: Option<Span> },
873    #[error("constructor `{path}` expects {expected}, got {actual}")]
874    IncompatibleConstructorInput {
875        path: String,
876        expected: String,
877        actual: String,
878        span: Option<Span>,
879    },
880    #[error("operation `{operation}` expects {expected}, got {actual}")]
881    IncompatibleOperationInput {
882        operation: String,
883        expected: String,
884        actual: String,
885        span: Option<Span>,
886    },
887    #[error("process `{process}` return type is incompatible: expected {expected}, got {actual}")]
888    IncompatibleProcessReturn {
889        process: String,
890        expected: String,
891        actual: String,
892        span: Option<Span>,
893    },
894    #[error("trigger registration requires {{ source, target, inputs, name? }}")]
895    InvalidTriggerRegistration { span: Option<Span> },
896    #[error("trigger registration `inputs` must be a literal record")]
897    InvalidTriggerInputs { span: Option<Span> },
898    #[error("trigger registration input `{input}` is duplicated")]
899    DuplicateTriggerInput { input: String, span: Option<Span> },
900    #[error("trigger target `{process}` input `{input}` is not mapped")]
901    MissingTriggerInput {
902        process: String,
903        input: String,
904        span: Option<Span>,
905    },
906    #[error("trigger target `{process}` has no input `{input}`")]
907    UnknownTriggerInput {
908        process: String,
909        input: String,
910        span: Option<Span>,
911    },
912    #[error("trigger registration `inputs` must map at least one param to `trigger.event`")]
913    MissingTriggerEventInput { span: Option<Span> },
914    #[error("`trigger.event` is only valid as a direct value inside `triggers.register` inputs")]
915    TriggerEventOutsideInputs { span: Option<Span> },
916    #[error(
917        "`trigger.event` represents the whole event; projections such as `trigger.event.field` are not supported"
918    )]
919    TriggerEventProjection { span: Option<Span> },
920    #[error("trigger listing requires {{ target }}")]
921    InvalidTriggerList { span: Option<Span> },
922    #[error("trigger cancellation requires {{ handle }}")]
923    InvalidTriggerCancel { span: Option<Span> },
924    #[error("trigger source type `{source_ty}` is not registered as a TriggerSource")]
925    UnknownTriggerSourceType {
926        source_ty: String,
927        span: Option<Span>,
928    },
929    #[error("trigger target must be a process value, got {actual}")]
930    InvalidTriggerTarget { actual: String, span: Option<Span> },
931    #[error("trigger source emits {event}, but target input `{input_name}` expects {input}")]
932    TriggerEventMismatch {
933        event: String,
934        input_name: String,
935        input: String,
936        span: Option<Span>,
937    },
938    #[error("receiver for operation `{operation}` is not a module authority")]
939    UnresolvedReceiver {
940        operation: String,
941        span: Option<Span>,
942    },
943    #[error("resource type `{resource_type}` does not expose operation `{operation}`")]
944    UnknownResourceOperation {
945        resource_type: String,
946        operation: String,
947        span: Option<Span>,
948    },
949    #[error("module `{module_path}` does not expose operation `{operation}`; available identity-qualified paths: {}", suggestions.join(", "))]
950    AmbiguousModuleOperation {
951        module_path: String,
952        operation: String,
953        suggestions: Vec<String>,
954        span: Option<Span>,
955    },
956    #[error("tools must be called through module paths, e.g. `{suggestion}`")]
957    BareToolCall {
958        name: String,
959        suggestion: String,
960        span: Option<Span>,
961    },
962    #[error(
963        "process `{process}` argument `{arg}` has incompatible authority type: expected {expected}, got {actual}"
964    )]
965    IncompatibleProcessArgument {
966        process: String,
967        arg: String,
968        expected: String,
969        actual: String,
970        span: Option<Span>,
971    },
972    #[error("lashlang feature `{feature}` is disabled by this host")]
973    FeatureDisabled {
974        feature: &'static str,
975        span: Option<Span>,
976    },
977    #[error("`{keyword}` can only be used inside a process body")]
978    ProcessLifecycleOutsideProcess {
979        keyword: &'static str,
980        span: Option<Span>,
981    },
982    #[error("cannot access `{access}` on opaque host value `{type_name}`")]
983    OpaqueHostValueAccess {
984        type_name: String,
985        access: String,
986        span: Option<Span>,
987    },
988    #[error("failed to hash linked module: {message}")]
989    ModuleHash { message: String },
990}
991
992impl LinkError {
993    pub fn span(&self) -> Option<Span> {
994        match self {
995            Self::DuplicateDeclaration { span, .. }
996            | Self::DuplicateProcessParam { span, .. }
997            | Self::UnknownProcess { span, .. }
998            | Self::MissingProcessArgument { span, .. }
999            | Self::UnexpectedProcessArgument { span, .. }
1000            | Self::DuplicateProcessArgument { span, .. }
1001            | Self::UnknownName { span, .. }
1002            | Self::UnknownBuiltin { span, .. }
1003            | Self::UnknownResource { span, .. }
1004            | Self::UnknownType { span, .. }
1005            | Self::IncompatibleConstructorInput { span, .. }
1006            | Self::IncompatibleOperationInput { span, .. }
1007            | Self::IncompatibleProcessReturn { span, .. }
1008            | Self::InvalidTriggerRegistration { span }
1009            | Self::InvalidTriggerInputs { span }
1010            | Self::DuplicateTriggerInput { span, .. }
1011            | Self::MissingTriggerInput { span, .. }
1012            | Self::UnknownTriggerInput { span, .. }
1013            | Self::MissingTriggerEventInput { span }
1014            | Self::TriggerEventOutsideInputs { span }
1015            | Self::TriggerEventProjection { span }
1016            | Self::InvalidTriggerList { span }
1017            | Self::InvalidTriggerCancel { span }
1018            | Self::UnknownTriggerSourceType { span, .. }
1019            | Self::InvalidTriggerTarget { span, .. }
1020            | Self::TriggerEventMismatch { span, .. }
1021            | Self::UnresolvedReceiver { span, .. }
1022            | Self::UnknownResourceOperation { span, .. }
1023            | Self::AmbiguousModuleOperation { span, .. }
1024            | Self::BareToolCall { span, .. }
1025            | Self::IncompatibleProcessArgument { span, .. }
1026            | Self::FeatureDisabled { span, .. }
1027            | Self::ProcessLifecycleOutsideProcess { span, .. }
1028            | Self::OpaqueHostValueAccess { span, .. } => *span,
1029            Self::ModuleHash { .. } => None,
1030        }
1031    }
1032}
1033
1034#[derive(Clone, Debug, PartialEq, Eq)]
1035enum Binding {
1036    Value(TypeExpr),
1037    Resource { resource_type: String },
1038}
1039
1040struct Linker<'module> {
1041    program: &'module Program,
1042    surface: &'module LashlangSurface,
1043    process_names: BTreeSet<String>,
1044    process_types: BTreeMap<String, TypeExpr>,
1045    type_names: BTreeSet<String>,
1046    type_defs: BTreeMap<String, TypeExpr>,
1047}
1048
1049impl<'module> Linker<'module> {
1050    fn new(program: &'module Program, surface: &'module LashlangSurface) -> Self {
1051        Self {
1052            program,
1053            surface,
1054            process_names: BTreeSet::new(),
1055            process_types: BTreeMap::new(),
1056            type_names: BTreeSet::new(),
1057            type_defs: BTreeMap::new(),
1058        }
1059    }
1060
1061    fn link_program(&mut self) -> Result<Program, LinkError> {
1062        // Single walk: collect declaration metadata, then lower (and validate)
1063        // declarations in source order, then lower main. Declaration errors
1064        // therefore still surface before main errors, matching the prior
1065        // two-pass (validate-then-lower) ordering.
1066        self.collect_declarations()?;
1067        let declarations = self
1068            .program
1069            .declarations
1070            .iter()
1071            .enumerate()
1072            .map(|(index, declaration)| {
1073                let span = self.program.declaration_spans.get(index).copied();
1074                self.lower_declaration(declaration, span)
1075            })
1076            .collect::<Result<Vec<_>, _>>()?;
1077        let mut scope = Scope::new(true, false, None);
1078        let main = self.lower_expr(&self.program.main, &mut scope)?.0;
1079        Ok(Program {
1080            declarations,
1081            main,
1082            declaration_spans: self.program.declaration_spans.clone(),
1083            expression_spans: self.program.expression_spans.clone(),
1084        })
1085    }
1086
1087    fn collect_declarations(&mut self) -> Result<(), LinkError> {
1088        self.ensure_label_annotations_enabled_for_program()?;
1089        let mut names = BTreeSet::new();
1090        for (index, declaration) in self.program.declarations.iter().enumerate() {
1091            let span = self.program.declaration_spans.get(index).copied();
1092            let (namespace, name) = match declaration {
1093                Declaration::Type(decl) => {
1094                    let name = decl.name.as_str();
1095                    if !names.insert(("type", name.to_string())) {
1096                        return Err(LinkError::DuplicateDeclaration {
1097                            name: name.to_string(),
1098                            span,
1099                        });
1100                    }
1101                    self.type_names.insert(decl.name.to_string());
1102                    self.type_defs
1103                        .insert(decl.name.to_string(), decl.ty.clone());
1104                    continue;
1105                }
1106                Declaration::Process(decl) => {
1107                    self.ensure_feature(self.surface.abilities.processes, "processes", span)?;
1108                    ("process", decl.name.as_str())
1109                }
1110            };
1111            if !names.insert((namespace, name.to_string())) {
1112                return Err(LinkError::DuplicateDeclaration {
1113                    name: name.to_string(),
1114                    span,
1115                });
1116            }
1117            if let Declaration::Process(decl) = declaration {
1118                self.process_names.insert(decl.name.to_string());
1119            }
1120        }
1121        for declaration in &self.program.declarations {
1122            match declaration {
1123                Declaration::Type(type_decl) => self.validate_type_refs(&type_decl.ty, None)?,
1124                Declaration::Process(process) => {
1125                    for param in &process.params {
1126                        self.validate_type_refs(&param.ty, None)?;
1127                    }
1128                    if let Some(return_ty) = &process.return_ty {
1129                        self.validate_type_refs(return_ty, None)?;
1130                    }
1131                }
1132            }
1133        }
1134        for declaration in &self.program.declarations {
1135            if let Declaration::Process(process) = declaration {
1136                self.process_types.insert(
1137                    process.name.to_string(),
1138                    process_type_for_decl(process, TypeExpr::Any),
1139                );
1140            }
1141        }
1142        for (index, declaration) in self.program.declarations.iter().enumerate() {
1143            let Declaration::Process(process) = declaration else {
1144                continue;
1145            };
1146            let span = self.program.declaration_spans.get(index).copied();
1147            let output = self.infer_process_output(process, span)?;
1148            if let Some(expected) = &process.return_ty
1149                && !self.is_type_assignable(&output, expected)
1150            {
1151                return Err(LinkError::IncompatibleProcessReturn {
1152                    process: process.name.to_string(),
1153                    expected: format_type_expr(&self.resolve_type_aliases(expected)),
1154                    actual: format_type_expr(&self.resolve_type_aliases(&output)),
1155                    span,
1156                });
1157            }
1158            self.process_types.insert(
1159                process.name.to_string(),
1160                process_type_for_decl(process, output),
1161            );
1162        }
1163        Ok(())
1164    }
1165
1166    fn ensure_label_annotations_enabled_for_program(&self) -> Result<(), LinkError> {
1167        if self.surface.language_features.label_annotations {
1168            return Ok(());
1169        }
1170        for (index, declaration) in self.program.declarations.iter().enumerate() {
1171            let span = self.program.declaration_spans.get(index).copied();
1172            if let Declaration::Process(process) = declaration {
1173                if process.label.is_some() || expr_has_label_annotation(&process.body) {
1174                    return Err(LinkError::FeatureDisabled {
1175                        feature: "label annotations",
1176                        span,
1177                    });
1178                }
1179            }
1180        }
1181        if expr_has_label_annotation(&self.program.main) {
1182            return Err(LinkError::FeatureDisabled {
1183                feature: "label annotations",
1184                span: self.program.expression_spans.first().copied(),
1185            });
1186        }
1187        Ok(())
1188    }
1189
1190    fn binding_for_type(&self, ty: &TypeExpr) -> Binding {
1191        match self.resource_type_for_type(ty) {
1192            Some(resource_type) => Binding::Resource { resource_type },
1193            _ => Binding::Value(ty.clone()),
1194        }
1195    }
1196
1197    fn resource_type_for_type(&self, ty: &TypeExpr) -> Option<String> {
1198        match self.resolve_type_aliases(ty) {
1199            TypeExpr::Ref(name) if self.surface.resources.has_resource_type(name.as_str()) => {
1200                Some(name.to_string())
1201            }
1202            _ => None,
1203        }
1204    }
1205
1206    fn resolve_type_aliases(&self, ty: &TypeExpr) -> TypeExpr {
1207        self.resolve_type_aliases_inner(ty, &mut BTreeSet::new())
1208    }
1209
1210    fn resolve_type_aliases_inner(&self, ty: &TypeExpr, seen: &mut BTreeSet<String>) -> TypeExpr {
1211        match ty {
1212            TypeExpr::Ref(name) => {
1213                if !seen.insert(name.to_string()) {
1214                    return ty.clone();
1215                }
1216                let resolved = if let Some(ty) = self.type_defs.get(name.as_str()) {
1217                    self.resolve_type_aliases_inner(ty, seen)
1218                } else if let Some(data_type) = self
1219                    .surface
1220                    .resources
1221                    .resolve_named_data_type(name.as_str())
1222                {
1223                    data_type.ty().clone()
1224                } else {
1225                    ty.clone()
1226                };
1227                seen.remove(name.as_str());
1228                resolved
1229            }
1230            TypeExpr::List(item) => {
1231                TypeExpr::List(Box::new(self.resolve_type_aliases_inner(item, seen)))
1232            }
1233            TypeExpr::Object(fields) => TypeExpr::Object(
1234                fields
1235                    .iter()
1236                    .map(|field| TypeField {
1237                        name: field.name.clone(),
1238                        ty: self.resolve_type_aliases_inner(&field.ty, seen),
1239                        optional: field.optional,
1240                    })
1241                    .collect(),
1242            ),
1243            TypeExpr::Union(items) => TypeExpr::Union(
1244                items
1245                    .iter()
1246                    .map(|item| self.resolve_type_aliases_inner(item, seen))
1247                    .collect(),
1248            ),
1249            TypeExpr::Process {
1250                input,
1251                output,
1252                input_count,
1253            } => TypeExpr::Process {
1254                input: Box::new(self.resolve_type_aliases_inner(input, seen)),
1255                output: Box::new(self.resolve_type_aliases_inner(output, seen)),
1256                input_count: *input_count,
1257            },
1258            TypeExpr::TriggerHandle(event) => {
1259                TypeExpr::TriggerHandle(Box::new(self.resolve_type_aliases_inner(event, seen)))
1260            }
1261            TypeExpr::Any
1262            | TypeExpr::Str
1263            | TypeExpr::Int
1264            | TypeExpr::Float
1265            | TypeExpr::Bool
1266            | TypeExpr::Dict
1267            | TypeExpr::Null
1268            | TypeExpr::Enum(_) => ty.clone(),
1269        }
1270    }
1271
1272    fn is_type_assignable(&self, source: &TypeExpr, target: &TypeExpr) -> bool {
1273        let source = self.resolve_type_aliases(source);
1274        let target = self.resolve_type_aliases(target);
1275        crate::trigger::is_resolved_type_assignable(&source, &target)
1276    }
1277
1278    fn validate_type_refs(&self, ty: &TypeExpr, span: Option<Span>) -> Result<(), LinkError> {
1279        match ty {
1280            TypeExpr::Ref(name) => {
1281                if self.type_defs.contains_key(name.as_str())
1282                    || self.surface.resources.has_resource_type(name.as_str())
1283                    || self.surface.resources.has_named_data_type(name.as_str())
1284                    || self
1285                        .surface
1286                        .resources
1287                        .is_known_opaque_value_type(name.as_str())
1288                {
1289                    Ok(())
1290                } else {
1291                    Err(LinkError::UnknownType {
1292                        name: name.to_string(),
1293                        span,
1294                    })
1295                }
1296            }
1297            TypeExpr::List(item) => self.validate_type_refs(item, span),
1298            TypeExpr::Object(fields) => {
1299                for field in fields {
1300                    self.validate_type_refs(&field.ty, span)?;
1301                }
1302                Ok(())
1303            }
1304            TypeExpr::Union(items) => {
1305                for item in items {
1306                    self.validate_type_refs(item, span)?;
1307                }
1308                Ok(())
1309            }
1310            TypeExpr::Process { input, output, .. } => {
1311                self.validate_type_refs(input, span)?;
1312                self.validate_type_refs(output, span)
1313            }
1314            TypeExpr::TriggerHandle(event) => self.validate_type_refs(event, span),
1315            TypeExpr::Any
1316            | TypeExpr::Str
1317            | TypeExpr::Int
1318            | TypeExpr::Float
1319            | TypeExpr::Bool
1320            | TypeExpr::Dict
1321            | TypeExpr::Null
1322            | TypeExpr::Enum(_) => Ok(()),
1323        }
1324    }
1325
1326    fn field_type(
1327        &self,
1328        target: &TypeExpr,
1329        field: &str,
1330        span: Option<Span>,
1331    ) -> Result<TypeExpr, LinkError> {
1332        let target = self.resolve_type_aliases(target);
1333        field_type(&target, field, span, |name| {
1334            self.surface.resources.is_known_opaque_value_type(name)
1335        })
1336    }
1337
1338    fn index_type(&self, target: &TypeExpr, span: Option<Span>) -> Result<TypeExpr, LinkError> {
1339        let target = self.resolve_type_aliases(target);
1340        index_type(&target, span, |name| {
1341            self.surface.resources.is_known_opaque_value_type(name)
1342        })
1343    }
1344
1345    fn ensure_feature(
1346        &self,
1347        enabled: bool,
1348        feature: &'static str,
1349        span: Option<Span>,
1350    ) -> Result<(), LinkError> {
1351        if enabled {
1352            Ok(())
1353        } else {
1354            Err(LinkError::FeatureDisabled { feature, span })
1355        }
1356    }
1357
1358    fn validate_resource_ref(
1359        &self,
1360        resource: &ResourceRefExpr,
1361        span: Option<Span>,
1362    ) -> Result<ResourceRefExpr, LinkError> {
1363        if !resource.resource_type.is_empty() {
1364            return self
1365                .surface
1366                .resources
1367                .resolve_alias(resource)
1368                .map(|_| resource.clone())
1369                .ok_or_else(|| LinkError::UnknownResource {
1370                    path: resource.path_string(),
1371                    span,
1372                });
1373        }
1374        self.surface
1375            .resources
1376            .resolve_module_path(&resource.path)
1377            .ok_or_else(|| LinkError::UnknownResource {
1378                path: resource.path_string(),
1379                span,
1380            })
1381    }
1382
1383    fn lower_declaration(
1384        &self,
1385        declaration: &Declaration,
1386        span: Option<Span>,
1387    ) -> Result<Declaration, LinkError> {
1388        Ok(match declaration {
1389            Declaration::Type(type_decl) => Declaration::Type(type_decl.clone()),
1390            Declaration::Process(process) => {
1391                self.ensure_feature(self.surface.abilities.processes, "processes", span)?;
1392                if process.label.is_some() {
1393                    self.ensure_feature(
1394                        self.surface.language_features.label_annotations,
1395                        "label annotations",
1396                        span,
1397                    )?;
1398                }
1399                let mut scope = Scope::new(false, true, span);
1400                let mut seen = BTreeSet::new();
1401                for param in &process.params {
1402                    if !seen.insert(param.name.to_string()) {
1403                        return Err(LinkError::DuplicateProcessParam {
1404                            name: param.name.to_string(),
1405                            span,
1406                        });
1407                    }
1408                    scope.bind(param.name.as_str(), self.binding_for_type(&param.ty));
1409                }
1410                scope.bind("input", Binding::Value(process_input_type(process)));
1411                scope.bind("inputs", Binding::Value(process_input_record_type(process)));
1412                let body = self.lower_expr(&process.body, &mut scope)?.0;
1413                Declaration::Process(ProcessDecl {
1414                    name: process.name.clone(),
1415                    params: process.params.clone(),
1416                    return_ty: process.return_ty.clone(),
1417                    label: process.label.clone(),
1418                    body,
1419                })
1420            }
1421        })
1422    }
1423
1424    fn lower_expr(
1425        &self,
1426        expr: &Expr,
1427        scope: &mut Scope,
1428    ) -> Result<(Expr, Option<Binding>), LinkError> {
1429        self.reject_trigger_event_special_form(expr, scope.span)?;
1430        if matches!(expr, Expr::Variable(_) | Expr::Field { .. })
1431            && let Some(resource) = self.resolve_module_expr(expr, scope)
1432        {
1433            return Ok((
1434                Expr::ResourceRef(resource.clone()),
1435                Some(Binding::Resource {
1436                    resource_type: resource.resource_type.to_string(),
1437                }),
1438            ));
1439        }
1440        Ok(match expr {
1441            Expr::Block(expressions) => {
1442                let mut lowered = Vec::with_capacity(expressions.len());
1443                let mut last = None;
1444                for expression in expressions {
1445                    let (expr, binding) = self.lower_expr(expression, scope)?;
1446                    lowered.push(expr);
1447                    last = binding;
1448                }
1449                (Expr::Block(lowered), last)
1450            }
1451            Expr::LabelAnnotated { label, expr } => {
1452                self.ensure_feature(
1453                    self.surface.language_features.label_annotations,
1454                    "label annotations",
1455                    scope.span,
1456                )?;
1457                let (expr, binding) = self.lower_expr(expr, scope)?;
1458                (
1459                    Expr::LabelAnnotated {
1460                        label: label.clone(),
1461                        expr: Box::new(expr),
1462                    },
1463                    binding,
1464                )
1465            }
1466            Expr::Variable(name) => {
1467                if let Some(binding) = scope.get(name) {
1468                    (Expr::Variable(name.clone()), Some(binding))
1469                } else if let Some(process_ty) = self.process_types.get(name.as_str()) {
1470                    (
1471                        Expr::ProcessRef {
1472                            process: name.clone(),
1473                        },
1474                        Some(Binding::Value(process_ty.clone())),
1475                    )
1476                } else if scope.allow_unknown_globals {
1477                    // Top-level unknown globals are permitted; they surface as
1478                    // runtime errors rather than link errors.
1479                    (
1480                        Expr::Variable(name.clone()),
1481                        Some(Binding::Value(TypeExpr::Any)),
1482                    )
1483                } else {
1484                    return Err(LinkError::UnknownName {
1485                        name: name.to_string(),
1486                        span: scope.span,
1487                    });
1488                }
1489            }
1490            Expr::Null
1491            | Expr::Bool(_)
1492            | Expr::Number(_)
1493            | Expr::String(_)
1494            | Expr::Break
1495            | Expr::Continue
1496            | Expr::TypeLiteral(_) => (expr.clone(), Some(Binding::Value(literal_type(expr)))),
1497            Expr::List(items) => {
1498                let mut lowered = Vec::with_capacity(items.len());
1499                let mut item_types = Vec::with_capacity(items.len());
1500                for item in items {
1501                    let (item, binding) = self.lower_expr(item, scope)?;
1502                    lowered.push(item);
1503                    item_types.push(binding_type(binding.as_ref()));
1504                }
1505                (
1506                    Expr::List(lowered),
1507                    Some(Binding::Value(TypeExpr::List(Box::new(union_type(
1508                        item_types,
1509                    ))))),
1510                )
1511            }
1512            Expr::Record(entries) => {
1513                let mut lowered = Vec::with_capacity(entries.len());
1514                let mut fields = Vec::with_capacity(entries.len());
1515                for (name, value) in entries {
1516                    let (value, binding) = self.lower_expr(value, scope)?;
1517                    fields.push(TypeField {
1518                        name: name.clone(),
1519                        ty: binding_type(binding.as_ref()),
1520                        optional: false,
1521                    });
1522                    lowered.push((name.clone(), value));
1523                }
1524                (
1525                    Expr::Record(lowered),
1526                    Some(Binding::Value(TypeExpr::Object(fields))),
1527                )
1528            }
1529            Expr::Assign { target, expr } => {
1530                for step in &target.steps {
1531                    if let AssignPathStep::Index(index) = step {
1532                        self.lower_expr(index, scope)?;
1533                    }
1534                }
1535                let (lowered, binding) = self.lower_expr(expr, scope)?;
1536                if target.steps.is_empty() {
1537                    scope.bind(
1538                        target.root.as_str(),
1539                        binding.clone().unwrap_or(any_binding()),
1540                    );
1541                } else if scope.get(&target.root).is_none() && !scope.allow_unknown_globals {
1542                    return Err(LinkError::UnknownName {
1543                        name: target.root.to_string(),
1544                        span: scope.span,
1545                    });
1546                }
1547                (
1548                    Expr::Assign {
1549                        target: target.clone(),
1550                        expr: Box::new(lowered),
1551                    },
1552                    binding,
1553                )
1554            }
1555            Expr::If {
1556                condition,
1557                then_block,
1558                else_block,
1559            } => {
1560                let condition = self.lower_expr(condition, scope)?.0;
1561                let mut then_scope = scope.clone();
1562                let (then_block, then_binding) = self.lower_expr(then_block, &mut then_scope)?;
1563                let mut else_scope = scope.clone();
1564                let (else_block, else_binding) = self.lower_expr(else_block, &mut else_scope)?;
1565                scope.merge_from(then_scope);
1566                scope.merge_from(else_scope);
1567                (
1568                    Expr::If {
1569                        condition: Box::new(condition),
1570                        then_block: Box::new(then_block),
1571                        else_block: Box::new(else_block),
1572                    },
1573                    Some(Binding::Value(union_type(vec![
1574                        binding_type(then_binding.as_ref()),
1575                        binding_type(else_binding.as_ref()),
1576                    ]))),
1577                )
1578            }
1579            Expr::For {
1580                binding,
1581                iterable,
1582                body,
1583            } => {
1584                let iterable = self.lower_expr(iterable, scope)?.0;
1585                let previous = scope.bind(binding.as_str(), Binding::Value(TypeExpr::Any));
1586                let body = self.lower_expr(body, scope)?.0;
1587                scope.restore(binding.as_str(), previous);
1588                (
1589                    Expr::For {
1590                        binding: binding.clone(),
1591                        iterable: Box::new(iterable),
1592                        body: Box::new(body),
1593                    },
1594                    Some(Binding::Value(TypeExpr::Null)),
1595                )
1596            }
1597            Expr::While { condition, body } => {
1598                let condition = self.lower_expr(condition, scope)?.0;
1599                let body = self.lower_expr(body, scope)?.0;
1600                (
1601                    Expr::While {
1602                        condition: Box::new(condition),
1603                        body: Box::new(body),
1604                    },
1605                    Some(Binding::Value(TypeExpr::Null)),
1606                )
1607            }
1608            Expr::StartProcess(start) => {
1609                self.ensure_feature(self.surface.abilities.processes, "processes", scope.span)?;
1610                let Some(process) = self.program.process(start.process.as_str()) else {
1611                    return Err(LinkError::UnknownProcess {
1612                        name: start.process.to_string(),
1613                        span: scope.span,
1614                    });
1615                };
1616                let mut seen = BTreeSet::new();
1617                let mut lowered_args = Vec::with_capacity(start.args.len());
1618                for (arg, value) in &start.args {
1619                    if !seen.insert(arg.to_string()) {
1620                        return Err(LinkError::DuplicateProcessArgument {
1621                            arg: arg.to_string(),
1622                            span: scope.span,
1623                        });
1624                    }
1625                    let Some(param) = process.params.iter().find(|param| param.name == *arg) else {
1626                        return Err(LinkError::UnexpectedProcessArgument {
1627                            process: process.name.to_string(),
1628                            arg: arg.to_string(),
1629                            span: scope.span,
1630                        });
1631                    };
1632                    let (lowered, binding) = self.lower_expr(value, scope)?;
1633                    self.validate_process_arg_binding(
1634                        process.name.as_str(),
1635                        arg.as_str(),
1636                        &param.ty,
1637                        binding.as_ref(),
1638                        scope.span,
1639                    )?;
1640                    lowered_args.push((arg.clone(), lowered));
1641                }
1642                for param in &process.params {
1643                    if !seen.contains(param.name.as_str()) {
1644                        return Err(LinkError::MissingProcessArgument {
1645                            process: process.name.to_string(),
1646                            arg: param.name.to_string(),
1647                            span: scope.span,
1648                        });
1649                    }
1650                }
1651                (
1652                    Expr::StartProcess(crate::ast::ProcessStartExpr {
1653                        process: start.process.clone(),
1654                        args: lowered_args,
1655                    }),
1656                    Some(Binding::Value(TypeExpr::Any)),
1657                )
1658            }
1659            Expr::ProcessRef { process } => {
1660                let Some(process_ty) = self.process_types.get(process.as_str()) else {
1661                    return Err(LinkError::UnknownProcess {
1662                        name: process.to_string(),
1663                        span: scope.span,
1664                    });
1665                };
1666                (
1667                    Expr::ProcessRef {
1668                        process: process.clone(),
1669                    },
1670                    Some(Binding::Value(process_ty.clone())),
1671                )
1672            }
1673            Expr::HostValueConstructor { type_name, input } => (
1674                Expr::HostValueConstructor {
1675                    type_name: type_name.clone(),
1676                    input: Box::new(self.lower_expr(input, scope)?.0),
1677                },
1678                Some(Binding::Value(TypeExpr::Ref(type_name.clone()))),
1679            ),
1680            Expr::ResourceRef(resource) => {
1681                let resource = self.validate_resource_ref(resource, scope.span)?;
1682                (
1683                    Expr::ResourceRef(resource.clone()),
1684                    Some(Binding::Resource {
1685                        resource_type: resource.resource_type.to_string(),
1686                    }),
1687                )
1688            }
1689            Expr::ReceiverCall {
1690                receiver,
1691                operation,
1692                args,
1693            } => {
1694                if let Some(mut path) = module_path_for_expr(receiver) {
1695                    path.push(operation.clone());
1696                    if let Some(constructor) =
1697                        self.surface.resources.resolve_value_constructor(&path)
1698                    {
1699                        if args.len() != 1 {
1700                            return Err(LinkError::IncompatibleConstructorInput {
1701                                path: module_path_key(&path),
1702                                expected: format_type_expr(&constructor.input_ty),
1703                                actual: format!("{} arguments", args.len()),
1704                                span: scope.span,
1705                            });
1706                        }
1707                        let (input, input_binding) = self.lower_expr(&args[0], scope)?;
1708                        let actual_ty = binding_type(input_binding.as_ref());
1709                        if !self.is_type_assignable(&actual_ty, &constructor.input_ty) {
1710                            return Err(LinkError::IncompatibleConstructorInput {
1711                                path: module_path_key(&path),
1712                                expected: format_type_expr(
1713                                    &self.resolve_type_aliases(&constructor.input_ty),
1714                                ),
1715                                actual: format_type_expr(&self.resolve_type_aliases(&actual_ty)),
1716                                span: scope.span,
1717                            });
1718                        }
1719                        return Ok((
1720                            Expr::HostValueConstructor {
1721                                type_name: constructor.type_name.clone().into(),
1722                                input: Box::new(input),
1723                            },
1724                            Some(Binding::Value(constructor.output_ty.clone())),
1725                        ));
1726                    }
1727                }
1728                let resolved_receiver = self.resolve_module_expr(receiver, scope);
1729                let (lowered_receiver, resource_type, receiver_alias) =
1730                    if let Some(resource) = resolved_receiver.as_ref() {
1731                        (
1732                            Expr::ResourceRef(resource.clone()),
1733                            Some(resource.resource_type.to_string()),
1734                            Some(resource.alias.to_string()),
1735                        )
1736                    } else {
1737                        let (lowered_receiver, binding) = self.lower_expr(receiver, scope)?;
1738                        let resource_type = match binding {
1739                            Some(Binding::Resource { resource_type }) => Some(resource_type),
1740                            _ => None,
1741                        };
1742                        (lowered_receiver, resource_type, None)
1743                    };
1744                let Some(resource_type) = resource_type else {
1745                    if let Some(path) = module_path_for_expr(receiver) {
1746                        let suggestions = self
1747                            .surface
1748                            .resources
1749                            .operation_suggestions_for_prefix(&path, operation.as_str());
1750                        if !suggestions.is_empty() {
1751                            return Err(LinkError::AmbiguousModuleOperation {
1752                                module_path: module_path_key(&path),
1753                                operation: operation.to_string(),
1754                                suggestions,
1755                                span: scope.span,
1756                            });
1757                        }
1758                    }
1759                    return Err(LinkError::UnresolvedReceiver {
1760                        operation: operation.to_string(),
1761                        span: scope.span,
1762                    });
1763                };
1764                if let Some(alias) = receiver_alias.as_deref()
1765                    && self
1766                        .surface
1767                        .resources
1768                        .resolve_module_operation(&resource_type, alias, operation.as_str())
1769                        .is_none()
1770                {
1771                    return Err(LinkError::UnknownResourceOperation {
1772                        resource_type: resource_type.clone(),
1773                        operation: operation.to_string(),
1774                        span: scope.span,
1775                    });
1776                }
1777                let Some(operation_binding) = self
1778                    .surface
1779                    .resources
1780                    .resolve_operation(&resource_type, operation)
1781                    .cloned()
1782                else {
1783                    return Err(LinkError::UnknownResourceOperation {
1784                        resource_type: resource_type.clone(),
1785                        operation: operation.to_string(),
1786                        span: scope.span,
1787                    });
1788                };
1789                let trigger_operation = if crate::is_trigger_resource_type(&resource_type) {
1790                    crate::TriggerHostOperation::from_receiver_method(operation.as_str())
1791                } else {
1792                    None
1793                };
1794                if let Some(trigger_operation) = trigger_operation {
1795                    self.ensure_feature(self.surface.abilities.triggers, "triggers", scope.span)?;
1796                    let (lowered_args, output_ty) =
1797                        self.lower_trigger_operation_args(trigger_operation, args, scope)?;
1798                    return Ok((
1799                        Expr::ReceiverCall {
1800                            receiver: Box::new(lowered_receiver),
1801                            operation: operation.clone(),
1802                            args: lowered_args,
1803                        },
1804                        Some(Binding::Value(output_ty)),
1805                    ));
1806                }
1807                let mut lowered_args = Vec::with_capacity(args.len());
1808                let mut arg_types = Vec::with_capacity(args.len());
1809                for arg in args {
1810                    let (arg, binding) = self.lower_expr(arg, scope)?;
1811                    lowered_args.push(arg);
1812                    arg_types.push(binding_type(binding.as_ref()));
1813                }
1814                let actual_input = call_input_type(arg_types);
1815                if !self.is_type_assignable(&actual_input, &operation_binding.input_ty) {
1816                    return Err(LinkError::IncompatibleOperationInput {
1817                        operation: operation.to_string(),
1818                        expected: format_type_expr(
1819                            &self.resolve_type_aliases(&operation_binding.input_ty),
1820                        ),
1821                        actual: format_type_expr(&self.resolve_type_aliases(&actual_input)),
1822                        span: scope.span,
1823                    });
1824                }
1825                (
1826                    Expr::ReceiverCall {
1827                        receiver: Box::new(lowered_receiver),
1828                        operation: operation.clone(),
1829                        args: lowered_args,
1830                    },
1831                    Some(Binding::Value(operation_binding.output_ty.clone())),
1832                )
1833            }
1834            Expr::Await(inner) => {
1835                let (inner, binding) = self.lower_expr(inner, scope)?;
1836                (Expr::Await(Box::new(inner)), binding)
1837            }
1838            Expr::SleepFor(inner) => {
1839                self.ensure_feature(self.surface.abilities.sleep, "sleep", scope.span)?;
1840                (
1841                    Expr::SleepFor(Box::new(self.lower_expr(inner, scope)?.0)),
1842                    Some(Binding::Value(TypeExpr::Null)),
1843                )
1844            }
1845            Expr::SleepUntil(inner) => {
1846                self.ensure_feature(self.surface.abilities.sleep, "sleep", scope.span)?;
1847                (
1848                    Expr::SleepUntil(Box::new(self.lower_expr(inner, scope)?.0)),
1849                    Some(Binding::Value(TypeExpr::Null)),
1850                )
1851            }
1852            Expr::WaitSignal => {
1853                self.ensure_feature(
1854                    self.surface.abilities.process_signals,
1855                    "process signals",
1856                    scope.span,
1857                )?;
1858                if !scope.process_body {
1859                    return Err(LinkError::ProcessLifecycleOutsideProcess {
1860                        keyword: "wait signal",
1861                        span: scope.span,
1862                    });
1863                }
1864                (Expr::WaitSignal, Some(Binding::Value(TypeExpr::Any)))
1865            }
1866            Expr::SignalRun { run, payload } => {
1867                self.ensure_feature(
1868                    self.surface.abilities.process_signals,
1869                    "process signals",
1870                    scope.span,
1871                )?;
1872                // `signal run` (sending) is a control-plane op like `await` /
1873                // `cancel`, valid from the foreground turn as well as inside a
1874                // process body. Only `wait signal` (receiving) is process-only.
1875                (
1876                    Expr::SignalRun {
1877                        run: Box::new(self.lower_expr(run, scope)?.0),
1878                        payload: Box::new(self.lower_expr(payload, scope)?.0),
1879                    },
1880                    Some(Binding::Value(TypeExpr::Null)),
1881                )
1882            }
1883            Expr::ResultUnwrap(inner) => {
1884                let (inner, binding) = self.lower_expr(inner, scope)?;
1885                (Expr::ResultUnwrap(Box::new(inner)), binding)
1886            }
1887            Expr::Cancel(inner) => (
1888                Expr::Cancel(Box::new(self.lower_expr(inner, scope)?.0)),
1889                Some(Binding::Value(TypeExpr::Any)),
1890            ),
1891            Expr::Print(inner) => (
1892                Expr::Print(Box::new(self.lower_expr(inner, scope)?.0)),
1893                Some(Binding::Value(TypeExpr::Null)),
1894            ),
1895            Expr::Submit(inner) => (
1896                Expr::Submit(
1897                    inner
1898                        .as_deref()
1899                        .map(|inner| {
1900                            self.lower_expr(inner, scope)
1901                                .map(|(expr, _)| Box::new(expr))
1902                        })
1903                        .transpose()?,
1904                ),
1905                Some(Binding::Value(TypeExpr::Null)),
1906            ),
1907            Expr::Yield(inner) => (
1908                Expr::Yield(Box::new(self.lower_expr(inner, scope)?.0)),
1909                Some(Binding::Value(TypeExpr::Null)),
1910            ),
1911            Expr::Wake(inner) => (
1912                Expr::Wake(Box::new(self.lower_expr(inner, scope)?.0)),
1913                Some(Binding::Value(TypeExpr::Null)),
1914            ),
1915            Expr::Finish(inner) => {
1916                let mut finish_ty = TypeExpr::Null;
1917                let inner = inner
1918                    .as_deref()
1919                    .map(|inner| {
1920                        let (expr, binding) = self.lower_expr(inner, scope)?;
1921                        finish_ty = binding_type(binding.as_ref());
1922                        Ok(Box::new(expr))
1923                    })
1924                    .transpose()?;
1925                (Expr::Finish(inner), Some(Binding::Value(finish_ty)))
1926            }
1927            Expr::Fail(inner) => (
1928                Expr::Fail(Box::new(self.lower_expr(inner, scope)?.0)),
1929                Some(Binding::Value(TypeExpr::Null)),
1930            ),
1931            Expr::BuiltinCall { name, args } => {
1932                if !crate::builtins::is_builtin(name.as_str()) {
1933                    if let Some(suggestion) = self
1934                        .surface
1935                        .resources
1936                        .operation_suggestions_for_host(name.as_str())
1937                        .into_iter()
1938                        .next()
1939                    {
1940                        return Err(LinkError::BareToolCall {
1941                            name: name.to_string(),
1942                            suggestion,
1943                            span: scope.span,
1944                        });
1945                    }
1946                    return Err(LinkError::UnknownBuiltin {
1947                        name: name.to_string(),
1948                        span: scope.span,
1949                    });
1950                }
1951                (
1952                    Expr::BuiltinCall {
1953                        name: name.clone(),
1954                        args: args
1955                            .iter()
1956                            .map(|arg| self.lower_expr(arg, scope).map(|(expr, _)| expr))
1957                            .collect::<Result<Vec<_>, _>>()?,
1958                    },
1959                    Some(Binding::Value(builtin_return_type(name.as_str()))),
1960                )
1961            }
1962            Expr::Field { target, field } => {
1963                let (target, binding) = self.lower_expr(target, scope)?;
1964                let ty =
1965                    self.field_type(&binding_type(binding.as_ref()), field.as_str(), scope.span)?;
1966                (
1967                    Expr::Field {
1968                        target: Box::new(target),
1969                        field: field.clone(),
1970                    },
1971                    Some(Binding::Value(ty)),
1972                )
1973            }
1974            Expr::Index { target, index } => {
1975                let (target, target_binding) = self.lower_expr(target, scope)?;
1976                let index = self.lower_expr(index, scope)?.0;
1977                (
1978                    Expr::Index {
1979                        target: Box::new(target),
1980                        index: Box::new(index),
1981                    },
1982                    Some(Binding::Value(self.index_type(
1983                        &binding_type(target_binding.as_ref()),
1984                        scope.span,
1985                    )?)),
1986                )
1987            }
1988            Expr::Unary { op, expr } => (
1989                Expr::Unary {
1990                    op: *op,
1991                    expr: Box::new(self.lower_expr(expr, scope)?.0),
1992                },
1993                Some(Binding::Value(match op {
1994                    crate::ast::UnaryOp::Not => TypeExpr::Bool,
1995                    crate::ast::UnaryOp::Negate => TypeExpr::Float,
1996                })),
1997            ),
1998            Expr::Binary { left, op, right } => (
1999                Expr::Binary {
2000                    left: Box::new(self.lower_expr(left, scope)?.0),
2001                    op: *op,
2002                    right: Box::new(self.lower_expr(right, scope)?.0),
2003                },
2004                Some(Binding::Value(binary_return_type(*op))),
2005            ),
2006        })
2007    }
2008
2009    fn resolve_module_expr(&self, expr: &Expr, scope: &Scope) -> Option<ResourceRefExpr> {
2010        let path = module_path_for_expr(expr)?;
2011        if path
2012            .first()
2013            .and_then(|root| scope.get_str(root.as_str()))
2014            .is_some()
2015        {
2016            return None;
2017        }
2018        self.surface.resources.resolve_module_path(&path)
2019    }
2020
2021    fn reject_trigger_event_special_form(
2022        &self,
2023        expr: &Expr,
2024        span: Option<Span>,
2025    ) -> Result<(), LinkError> {
2026        if is_trigger_event_projection_expr(expr) {
2027            return Err(LinkError::TriggerEventProjection { span });
2028        }
2029        if is_trigger_event_expr(expr) {
2030            return Err(LinkError::TriggerEventOutsideInputs { span });
2031        }
2032        Ok(())
2033    }
2034
2035    fn validate_process_arg_binding(
2036        &self,
2037        process: &str,
2038        arg: &str,
2039        expected_ty: &TypeExpr,
2040        actual: Option<&Binding>,
2041        span: Option<Span>,
2042    ) -> Result<(), LinkError> {
2043        let Some(expected_resource) = self.resource_type_for_type(expected_ty) else {
2044            return Ok(());
2045        };
2046        match actual {
2047            Some(Binding::Resource { resource_type }) if *resource_type == expected_resource => {
2048                Ok(())
2049            }
2050            Some(Binding::Resource { resource_type }) => {
2051                Err(LinkError::IncompatibleProcessArgument {
2052                    process: process.to_string(),
2053                    arg: arg.to_string(),
2054                    expected: expected_resource,
2055                    actual: resource_type.clone(),
2056                    span,
2057                })
2058            }
2059            _ => Err(LinkError::IncompatibleProcessArgument {
2060                process: process.to_string(),
2061                arg: arg.to_string(),
2062                expected: expected_resource,
2063                actual: "value".to_string(),
2064                span,
2065            }),
2066        }
2067    }
2068
2069    fn validate_trigger_operation_args(
2070        &self,
2071        operation: crate::TriggerHostOperation,
2072        args: &[Expr],
2073        scope: &Scope,
2074    ) -> Result<TypeExpr, LinkError> {
2075        match operation {
2076            crate::TriggerHostOperation::Register => {
2077                let call = crate::register_call_args(args)
2078                    .map_err(|_| LinkError::InvalidTriggerRegistration { span: scope.span })?;
2079                let source_ty = self.infer_expr_type(call.source, &mut scope.clone())?;
2080                let event_ty = self
2081                    .surface
2082                    .resources
2083                    .trigger_source_event(&source_ty)
2084                    .ok_or_else(|| LinkError::UnknownTriggerSourceType {
2085                        source_ty: format_type_expr(&source_ty),
2086                        span: scope.span,
2087                    })?;
2088                let target_ty = self.infer_expr_type(call.target, &mut scope.clone())?;
2089                let params = self.trigger_target_params(call.target, &target_ty, scope.span)?;
2090                let mut validation_scope = scope.clone();
2091                self.lower_trigger_input_record(
2092                    trigger_target_process_label(call.target).as_str(),
2093                    &params,
2094                    &event_ty,
2095                    call.inputs,
2096                    &mut validation_scope,
2097                )?;
2098                Ok(TypeExpr::TriggerHandle(Box::new(event_ty)))
2099            }
2100            crate::TriggerHostOperation::List => {
2101                let call = crate::list_call_args(args)
2102                    .map_err(|_| LinkError::InvalidTriggerList { span: scope.span })?;
2103                for (name, expr) in call.entries {
2104                    match name.as_str() {
2105                        "target" => {
2106                            let target_ty = self.infer_expr_type(expr, &mut scope.clone())?;
2107                            if !matches!(target_ty, TypeExpr::Process { .. }) {
2108                                return Err(LinkError::InvalidTriggerTarget {
2109                                    actual: format_type_expr(&target_ty),
2110                                    span: scope.span,
2111                                });
2112                            }
2113                        }
2114                        "name" | "source_type" => {
2115                            let filter_ty = self.infer_expr_type(expr, &mut scope.clone())?;
2116                            if !self.is_type_assignable(&filter_ty, &TypeExpr::Str) {
2117                                return Err(LinkError::IncompatibleOperationInput {
2118                                    operation: operation.receiver_method().to_string(),
2119                                    expected: format_type_expr(&TypeExpr::Str),
2120                                    actual: format_type_expr(&filter_ty),
2121                                    span: scope.span,
2122                                });
2123                            }
2124                        }
2125                        "enabled" => {
2126                            let filter_ty = self.infer_expr_type(expr, &mut scope.clone())?;
2127                            if !self.is_type_assignable(&filter_ty, &TypeExpr::Bool) {
2128                                return Err(LinkError::IncompatibleOperationInput {
2129                                    operation: operation.receiver_method().to_string(),
2130                                    expected: format_type_expr(&TypeExpr::Bool),
2131                                    actual: format_type_expr(&filter_ty),
2132                                    span: scope.span,
2133                                });
2134                            }
2135                        }
2136                        _ => unreachable!("list_call_args rejects unknown trigger filters"),
2137                    }
2138                }
2139                Ok(operation.output_ty())
2140            }
2141            crate::TriggerHostOperation::Cancel => {
2142                crate::cancel_call_args(args)
2143                    .map_err(|_| LinkError::InvalidTriggerCancel { span: scope.span })?;
2144                Ok(operation.output_ty())
2145            }
2146        }
2147    }
2148
2149    fn lower_trigger_operation_args(
2150        &self,
2151        operation: crate::TriggerHostOperation,
2152        args: &[Expr],
2153        scope: &mut Scope,
2154    ) -> Result<(Vec<Expr>, TypeExpr), LinkError> {
2155        match operation {
2156            crate::TriggerHostOperation::Register => {
2157                self.lower_trigger_registration_args(args, scope)
2158            }
2159            crate::TriggerHostOperation::List => {
2160                let call = crate::list_call_args(args)
2161                    .map_err(|_| LinkError::InvalidTriggerList { span: scope.span })?;
2162                let mut entries = Vec::with_capacity(call.entries.len());
2163                for (name, expr) in call.entries {
2164                    match name.as_str() {
2165                        "target" => {
2166                            let target_ty = self.infer_expr_type(expr, &mut scope.clone())?;
2167                            if !matches!(target_ty, TypeExpr::Process { .. }) {
2168                                return Err(LinkError::InvalidTriggerTarget {
2169                                    actual: format_type_expr(&target_ty),
2170                                    span: scope.span,
2171                                });
2172                            }
2173                        }
2174                        "name" | "source_type" => {
2175                            let filter_ty = self.infer_expr_type(expr, &mut scope.clone())?;
2176                            if !self.is_type_assignable(&filter_ty, &TypeExpr::Str) {
2177                                return Err(LinkError::IncompatibleOperationInput {
2178                                    operation: operation.receiver_method().to_string(),
2179                                    expected: format_type_expr(&TypeExpr::Str),
2180                                    actual: format_type_expr(&filter_ty),
2181                                    span: scope.span,
2182                                });
2183                            }
2184                        }
2185                        "enabled" => {
2186                            let filter_ty = self.infer_expr_type(expr, &mut scope.clone())?;
2187                            if !self.is_type_assignable(&filter_ty, &TypeExpr::Bool) {
2188                                return Err(LinkError::IncompatibleOperationInput {
2189                                    operation: operation.receiver_method().to_string(),
2190                                    expected: format_type_expr(&TypeExpr::Bool),
2191                                    actual: format_type_expr(&filter_ty),
2192                                    span: scope.span,
2193                                });
2194                            }
2195                        }
2196                        _ => unreachable!("list_call_args rejects unknown trigger filters"),
2197                    }
2198                    entries.push((name.clone(), self.lower_expr(expr, scope)?.0));
2199                }
2200                Ok((vec![Expr::Record(entries)], operation.output_ty()))
2201            }
2202            crate::TriggerHostOperation::Cancel => {
2203                let call = crate::cancel_call_args(args)
2204                    .map_err(|_| LinkError::InvalidTriggerCancel { span: scope.span })?;
2205                Ok((
2206                    vec![Expr::Record(vec![(
2207                        "handle".into(),
2208                        self.lower_expr(call.handle, scope)?.0,
2209                    )])],
2210                    operation.output_ty(),
2211                ))
2212            }
2213        }
2214    }
2215
2216    fn lower_trigger_registration_args(
2217        &self,
2218        args: &[Expr],
2219        scope: &mut Scope,
2220    ) -> Result<(Vec<Expr>, TypeExpr), LinkError> {
2221        let call = crate::register_call_args(args)
2222            .map_err(|_| LinkError::InvalidTriggerRegistration { span: scope.span })?;
2223        let source_ty = self.infer_expr_type(call.source, &mut scope.clone())?;
2224        let event_ty = self
2225            .surface
2226            .resources
2227            .trigger_source_event(&source_ty)
2228            .ok_or_else(|| LinkError::UnknownTriggerSourceType {
2229                source_ty: format_type_expr(&source_ty),
2230                span: scope.span,
2231            })?;
2232        let target_ty = self.infer_expr_type(call.target, &mut scope.clone())?;
2233        let params = self.trigger_target_params(call.target, &target_ty, scope.span)?;
2234        let process = trigger_target_process_label(call.target);
2235
2236        let source = self.lower_expr(call.source, scope)?.0;
2237        let target = self.lower_expr(call.target, scope)?.0;
2238        let inputs = self.lower_trigger_input_record(
2239            process.as_str(),
2240            &params,
2241            &event_ty,
2242            call.inputs,
2243            scope,
2244        )?;
2245        let mut entries = vec![
2246            ("source".into(), source),
2247            ("target".into(), target),
2248            ("inputs".into(), inputs),
2249        ];
2250        if let Some(name) = call.name {
2251            entries.push(("name".into(), self.lower_expr(name, scope)?.0));
2252        }
2253        Ok((
2254            vec![Expr::Record(entries)],
2255            TypeExpr::TriggerHandle(Box::new(event_ty)),
2256        ))
2257    }
2258
2259    fn lower_trigger_input_record(
2260        &self,
2261        process: &str,
2262        params: &[ProcessParam],
2263        event_ty: &TypeExpr,
2264        inputs: &Expr,
2265        scope: &mut Scope,
2266    ) -> Result<Expr, LinkError> {
2267        let Expr::Record(entries) = inputs else {
2268            return Err(LinkError::InvalidTriggerInputs { span: scope.span });
2269        };
2270        let mut seen = BTreeSet::new();
2271        let mut saw_event = false;
2272        let mut lowered = Vec::with_capacity(entries.len());
2273        for (name, value) in entries {
2274            if !seen.insert(name.to_string()) {
2275                return Err(LinkError::DuplicateTriggerInput {
2276                    input: name.to_string(),
2277                    span: scope.span,
2278                });
2279            }
2280            let Some(param) = params.iter().find(|param| param.name == *name) else {
2281                return Err(LinkError::UnknownTriggerInput {
2282                    process: process.to_string(),
2283                    input: name.to_string(),
2284                    span: scope.span,
2285                });
2286            };
2287            if is_trigger_event_projection_expr(value) {
2288                return Err(LinkError::TriggerEventProjection { span: scope.span });
2289            }
2290            if is_trigger_event_expr(value) {
2291                saw_event = true;
2292                if !self.is_type_assignable(event_ty, &param.ty) {
2293                    return Err(LinkError::TriggerEventMismatch {
2294                        event: format_type_expr(&self.resolve_type_aliases(event_ty)),
2295                        input_name: name.to_string(),
2296                        input: format_type_expr(&self.resolve_type_aliases(&param.ty)),
2297                        span: scope.span,
2298                    });
2299                }
2300                lowered.push((name.clone(), crate::trigger_event_placeholder_expr()));
2301                continue;
2302            }
2303            let (lowered_value, binding) = self.lower_expr(value, scope)?;
2304            self.validate_process_arg_binding(
2305                process,
2306                name.as_str(),
2307                &param.ty,
2308                binding.as_ref(),
2309                scope.span,
2310            )?;
2311            lowered.push((name.clone(), lowered_value));
2312        }
2313        for param in params {
2314            if !seen.contains(param.name.as_str()) {
2315                return Err(LinkError::MissingTriggerInput {
2316                    process: process.to_string(),
2317                    input: param.name.to_string(),
2318                    span: scope.span,
2319                });
2320            }
2321        }
2322        if !saw_event {
2323            return Err(LinkError::MissingTriggerEventInput { span: scope.span });
2324        }
2325        Ok(Expr::Record(lowered))
2326    }
2327
2328    fn trigger_target_params(
2329        &self,
2330        target: &Expr,
2331        target_ty: &TypeExpr,
2332        span: Option<Span>,
2333    ) -> Result<Vec<ProcessParam>, LinkError> {
2334        if let Some(process_name) = trigger_target_process_name(target)
2335            && let Some(process) = self.program.process(process_name.as_str())
2336        {
2337            return Ok(process.params.clone());
2338        }
2339        let TypeExpr::Process {
2340            input, input_count, ..
2341        } = target_ty
2342        else {
2343            return Err(LinkError::InvalidTriggerTarget {
2344                actual: format_type_expr(target_ty),
2345                span,
2346            });
2347        };
2348        match (input_count, input.as_ref()) {
2349            (0, _) => Ok(Vec::new()),
2350            (count, TypeExpr::Object(fields)) if *count > 1 => Ok(fields
2351                .iter()
2352                .map(|field| ProcessParam {
2353                    name: field.name.clone(),
2354                    ty: field.ty.clone(),
2355                })
2356                .collect()),
2357            _ => Err(LinkError::InvalidTriggerTarget {
2358                actual: format_type_expr(target_ty),
2359                span,
2360            }),
2361        }
2362    }
2363
2364    fn infer_process_output(
2365        &self,
2366        process: &ProcessDecl,
2367        span: Option<Span>,
2368    ) -> Result<TypeExpr, LinkError> {
2369        let mut scope = Scope::new(false, true, span);
2370        for param in &process.params {
2371            scope.bind(param.name.as_str(), self.binding_for_type(&param.ty));
2372        }
2373        scope.bind("input", Binding::Value(process_input_type(process)));
2374        scope.bind("inputs", Binding::Value(process_input_record_type(process)));
2375        let completion = self.infer_completion(&process.body, &mut scope)?;
2376        let mut outputs = completion.finishes;
2377        if completion.can_fallthrough {
2378            outputs.push(TypeExpr::Null);
2379        }
2380        Ok(union_type(outputs))
2381    }
2382
2383    fn infer_completion(&self, expr: &Expr, scope: &mut Scope) -> Result<Completion, LinkError> {
2384        match expr {
2385            Expr::LabelAnnotated { expr, .. } => self.infer_completion(expr, scope),
2386            Expr::Finish(Some(value)) => Ok(Completion {
2387                finishes: vec![self.infer_expr_type(value, scope)?],
2388                can_fallthrough: false,
2389            }),
2390            Expr::Finish(None) => Ok(Completion {
2391                finishes: vec![TypeExpr::Null],
2392                can_fallthrough: false,
2393            }),
2394            Expr::Fail(_) => Ok(Completion {
2395                finishes: Vec::new(),
2396                can_fallthrough: false,
2397            }),
2398            Expr::Block(expressions) => {
2399                let mut finishes = Vec::new();
2400                let mut can_fallthrough = true;
2401                for expression in expressions {
2402                    if !can_fallthrough {
2403                        break;
2404                    }
2405                    let completion = self.infer_completion(expression, scope)?;
2406                    finishes.extend(completion.finishes);
2407                    can_fallthrough = completion.can_fallthrough;
2408                }
2409                Ok(Completion {
2410                    finishes,
2411                    can_fallthrough,
2412                })
2413            }
2414            Expr::If {
2415                condition,
2416                then_block,
2417                else_block,
2418            } => {
2419                self.infer_expr_type(condition, scope)?;
2420                let mut then_scope = scope.clone();
2421                let then_completion = self.infer_completion(then_block, &mut then_scope)?;
2422                let mut else_scope = scope.clone();
2423                let else_completion = self.infer_completion(else_block, &mut else_scope)?;
2424                scope.merge_from(then_scope);
2425                scope.merge_from(else_scope);
2426                let mut finishes = then_completion.finishes;
2427                finishes.extend(else_completion.finishes);
2428                Ok(Completion {
2429                    finishes,
2430                    can_fallthrough: then_completion.can_fallthrough
2431                        || else_completion.can_fallthrough,
2432                })
2433            }
2434            Expr::For {
2435                binding,
2436                iterable,
2437                body,
2438            } => {
2439                self.infer_expr_type(iterable, scope)?;
2440                let previous = scope.bind(binding.as_str(), Binding::Value(TypeExpr::Any));
2441                let mut completion = self.infer_completion(body, scope)?;
2442                scope.restore(binding.as_str(), previous);
2443                completion.can_fallthrough = true;
2444                Ok(completion)
2445            }
2446            Expr::While { condition, body } => {
2447                self.infer_expr_type(condition, scope)?;
2448                let mut completion = self.infer_completion(body, scope)?;
2449                completion.can_fallthrough = true;
2450                Ok(completion)
2451            }
2452            Expr::Assign { target, expr } if target.steps.is_empty() => {
2453                let ty = self.infer_expr_type(expr, scope)?;
2454                scope.bind(target.root.as_str(), self.binding_for_type(&ty));
2455                Ok(Completion::fallthrough())
2456            }
2457            other => {
2458                self.infer_expr_type(other, scope)?;
2459                Ok(Completion::fallthrough())
2460            }
2461        }
2462    }
2463
2464    fn infer_expr_type(&self, expr: &Expr, scope: &mut Scope) -> Result<TypeExpr, LinkError> {
2465        self.reject_trigger_event_special_form(expr, scope.span)?;
2466        if matches!(expr, Expr::Variable(_) | Expr::Field { .. })
2467            && let Some(resource) = self.resolve_module_expr(expr, scope)
2468        {
2469            return Ok(TypeExpr::Ref(resource.resource_type));
2470        }
2471        Ok(match expr {
2472            Expr::LabelAnnotated { expr, .. } => self.infer_expr_type(expr, scope)?,
2473            Expr::Block(expressions) => {
2474                let mut last = TypeExpr::Null;
2475                for expression in expressions {
2476                    last = self.infer_expr_type(expression, scope)?;
2477                }
2478                last
2479            }
2480            Expr::Null
2481            | Expr::Bool(_)
2482            | Expr::Number(_)
2483            | Expr::String(_)
2484            | Expr::Break
2485            | Expr::Continue
2486            | Expr::TypeLiteral(_) => literal_type(expr),
2487            Expr::Variable(name) => {
2488                if let Some(binding) = scope.get(name) {
2489                    binding_type(Some(&binding))
2490                } else if let Some(process_ty) = self.process_types.get(name.as_str()) {
2491                    process_ty.clone()
2492                } else if scope.allow_unknown_globals {
2493                    TypeExpr::Any
2494                } else {
2495                    return Err(LinkError::UnknownName {
2496                        name: name.to_string(),
2497                        span: scope.span,
2498                    });
2499                }
2500            }
2501            Expr::ProcessRef { process } => self
2502                .process_types
2503                .get(process.as_str())
2504                .cloned()
2505                .ok_or_else(|| LinkError::UnknownProcess {
2506                    name: process.to_string(),
2507                    span: scope.span,
2508                })?,
2509            Expr::HostValueConstructor { type_name, .. } => TypeExpr::Ref(type_name.clone()),
2510            Expr::List(items) => TypeExpr::List(Box::new(union_type(
2511                items
2512                    .iter()
2513                    .map(|item| self.infer_expr_type(item, scope))
2514                    .collect::<Result<Vec<_>, _>>()?,
2515            ))),
2516            Expr::Record(entries) => TypeExpr::Object(
2517                entries
2518                    .iter()
2519                    .map(|(name, value)| {
2520                        Ok(TypeField {
2521                            name: name.clone(),
2522                            ty: self.infer_expr_type(value, scope)?,
2523                            optional: false,
2524                        })
2525                    })
2526                    .collect::<Result<Vec<_>, LinkError>>()?,
2527            ),
2528            Expr::Assign { target, expr } => {
2529                let ty = self.infer_expr_type(expr, scope)?;
2530                if target.steps.is_empty() {
2531                    scope.bind(target.root.as_str(), self.binding_for_type(&ty));
2532                }
2533                ty
2534            }
2535            Expr::If {
2536                condition,
2537                then_block,
2538                else_block,
2539            } => {
2540                self.infer_expr_type(condition, scope)?;
2541                let mut then_scope = scope.clone();
2542                let then_ty = self.infer_expr_type(then_block, &mut then_scope)?;
2543                let mut else_scope = scope.clone();
2544                let else_ty = self.infer_expr_type(else_block, &mut else_scope)?;
2545                scope.merge_from(then_scope);
2546                scope.merge_from(else_scope);
2547                union_type(vec![then_ty, else_ty])
2548            }
2549            Expr::For {
2550                binding,
2551                iterable,
2552                body,
2553            } => {
2554                self.infer_expr_type(iterable, scope)?;
2555                let previous = scope.bind(binding.as_str(), Binding::Value(TypeExpr::Any));
2556                self.infer_expr_type(body, scope)?;
2557                scope.restore(binding.as_str(), previous);
2558                TypeExpr::Null
2559            }
2560            Expr::While { condition, body } => {
2561                self.infer_expr_type(condition, scope)?;
2562                self.infer_expr_type(body, scope)?;
2563                TypeExpr::Null
2564            }
2565            Expr::StartProcess(_) => TypeExpr::Any,
2566            Expr::ResourceRef(resource) => TypeExpr::Ref(resource.resource_type.clone()),
2567            Expr::ReceiverCall {
2568                receiver,
2569                operation,
2570                args,
2571            } => {
2572                if let Some(mut path) = module_path_for_expr(receiver) {
2573                    path.push(operation.clone());
2574                    if let Some(constructor) =
2575                        self.surface.resources.resolve_value_constructor(&path)
2576                    {
2577                        return Ok(constructor.output_ty.clone());
2578                    }
2579                }
2580                let resolved_receiver = self.resolve_module_expr(receiver, scope);
2581                let (resource_type, receiver_alias) =
2582                    if let Some(resource) = resolved_receiver.as_ref() {
2583                        (
2584                            resource.resource_type.to_string(),
2585                            Some(resource.alias.to_string()),
2586                        )
2587                    } else {
2588                        let receiver_ty = self.infer_expr_type(receiver, scope)?;
2589                        (
2590                            self.resource_type_for_type(&receiver_ty).ok_or_else(|| {
2591                                LinkError::UnresolvedReceiver {
2592                                    operation: operation.to_string(),
2593                                    span: scope.span,
2594                                }
2595                            })?,
2596                            None,
2597                        )
2598                    };
2599                if let Some(alias) = receiver_alias.as_deref()
2600                    && self
2601                        .surface
2602                        .resources
2603                        .resolve_module_operation(&resource_type, alias, operation.as_str())
2604                        .is_none()
2605                {
2606                    return Err(LinkError::UnknownResourceOperation {
2607                        resource_type: resource_type.clone(),
2608                        operation: operation.to_string(),
2609                        span: scope.span,
2610                    });
2611                }
2612                let binding = self
2613                    .surface
2614                    .resources
2615                    .resolve_operation(&resource_type, operation)
2616                    .ok_or_else(|| LinkError::UnknownResourceOperation {
2617                        resource_type: resource_type.clone(),
2618                        operation: operation.to_string(),
2619                        span: scope.span,
2620                    })?;
2621                if crate::is_trigger_resource_type(&resource_type)
2622                    && let Some(trigger_operation) =
2623                        crate::TriggerHostOperation::from_receiver_method(operation.as_str())
2624                {
2625                    self.validate_trigger_operation_args(trigger_operation, args, scope)?
2626                } else {
2627                    binding.output_ty.clone()
2628                }
2629            }
2630            Expr::Await(inner) | Expr::ResultUnwrap(inner) => self.infer_expr_type(inner, scope)?,
2631            Expr::SleepFor(_) | Expr::SleepUntil(_) => TypeExpr::Null,
2632            Expr::WaitSignal => TypeExpr::Any,
2633            Expr::SignalRun { .. }
2634            | Expr::Cancel(_)
2635            | Expr::Print(_)
2636            | Expr::Submit(_)
2637            | Expr::Yield(_)
2638            | Expr::Wake(_)
2639            | Expr::Fail(_) => TypeExpr::Null,
2640            Expr::Finish(Some(inner)) => self.infer_expr_type(inner, scope)?,
2641            Expr::Finish(None) => TypeExpr::Null,
2642            Expr::BuiltinCall { name, .. } => builtin_return_type(name.as_str()),
2643            Expr::Field { target, field } => {
2644                self.field_type(&self.infer_expr_type(target, scope)?, field, scope.span)?
2645            }
2646            Expr::Index { target, .. } => {
2647                self.index_type(&self.infer_expr_type(target, scope)?, scope.span)?
2648            }
2649            Expr::Unary { op, .. } => match op {
2650                crate::ast::UnaryOp::Not => TypeExpr::Bool,
2651                crate::ast::UnaryOp::Negate => TypeExpr::Float,
2652            },
2653            Expr::Binary { op, .. } => binary_return_type(*op),
2654        })
2655    }
2656}
2657
2658#[derive(Clone)]
2659struct Scope {
2660    bindings: BTreeMap<String, Binding>,
2661    allow_unknown_globals: bool,
2662    process_body: bool,
2663    span: Option<Span>,
2664}
2665
2666impl Scope {
2667    fn new(allow_unknown_globals: bool, process_body: bool, span: Option<Span>) -> Self {
2668        Self {
2669            bindings: BTreeMap::new(),
2670            allow_unknown_globals,
2671            process_body,
2672            span,
2673        }
2674    }
2675
2676    fn bind(&mut self, name: &str, binding: Binding) -> Option<Binding> {
2677        self.bindings.insert(name.to_string(), binding)
2678    }
2679
2680    fn restore(&mut self, name: &str, previous: Option<Binding>) {
2681        match previous {
2682            Some(binding) => {
2683                self.bindings.insert(name.to_string(), binding);
2684            }
2685            None => {
2686                self.bindings.remove(name);
2687            }
2688        }
2689    }
2690
2691    fn get(&self, name: &AstString) -> Option<Binding> {
2692        self.bindings.get(name.as_str()).cloned()
2693    }
2694
2695    fn get_str(&self, name: &str) -> Option<Binding> {
2696        self.bindings.get(name).cloned()
2697    }
2698
2699    fn merge_from(&mut self, other: Scope) {
2700        for (name, binding) in other.bindings {
2701            self.bindings.entry(name).or_insert(binding);
2702        }
2703    }
2704}
2705
2706struct Completion {
2707    finishes: Vec<TypeExpr>,
2708    can_fallthrough: bool,
2709}
2710
2711impl Completion {
2712    fn fallthrough() -> Self {
2713        Self {
2714            finishes: Vec::new(),
2715            can_fallthrough: true,
2716        }
2717    }
2718}
2719
2720fn any_binding() -> Binding {
2721    Binding::Value(TypeExpr::Any)
2722}
2723
2724fn binding_type(binding: Option<&Binding>) -> TypeExpr {
2725    match binding {
2726        Some(Binding::Value(ty)) => ty.clone(),
2727        Some(Binding::Resource { resource_type }) => TypeExpr::Ref(resource_type.as_str().into()),
2728        None => TypeExpr::Any,
2729    }
2730}
2731
2732fn literal_type(expr: &Expr) -> TypeExpr {
2733    match expr {
2734        Expr::Null => TypeExpr::Null,
2735        Expr::Bool(_) => TypeExpr::Bool,
2736        Expr::Number(_) => TypeExpr::Float,
2737        Expr::String(_) => TypeExpr::Str,
2738        Expr::TypeLiteral(_) => TypeExpr::Any,
2739        Expr::Break | Expr::Continue => TypeExpr::Null,
2740        Expr::LabelAnnotated { expr, .. } => literal_type(expr),
2741        _ => TypeExpr::Any,
2742    }
2743}
2744
2745fn union_type(items: Vec<TypeExpr>) -> TypeExpr {
2746    let mut flattened = Vec::new();
2747    for item in items {
2748        match item {
2749            TypeExpr::Union(items) => flattened.extend(items),
2750            other => flattened.push(other),
2751        }
2752    }
2753    let mut unique = Vec::new();
2754    for item in flattened {
2755        if !unique.contains(&item) {
2756            unique.push(item);
2757        }
2758    }
2759    match unique.as_slice() {
2760        [] => TypeExpr::Null,
2761        [one] => one.clone(),
2762        _ => TypeExpr::Union(unique),
2763    }
2764}
2765
2766fn call_input_type(arg_types: Vec<TypeExpr>) -> TypeExpr {
2767    match arg_types.as_slice() {
2768        [] => TypeExpr::Null,
2769        [one] => one.clone(),
2770        _ => TypeExpr::List(Box::new(union_type(arg_types))),
2771    }
2772}
2773
2774fn field_type(
2775    target: &TypeExpr,
2776    field: &str,
2777    span: Option<Span>,
2778    is_opaque: impl Fn(&str) -> bool + Copy,
2779) -> Result<TypeExpr, LinkError> {
2780    match target {
2781        TypeExpr::Any | TypeExpr::Dict => Ok(TypeExpr::Any),
2782        TypeExpr::Ref(name) if is_opaque(name.as_str()) => Err(LinkError::OpaqueHostValueAccess {
2783            type_name: name.to_string(),
2784            access: format!(".{field}"),
2785            span,
2786        }),
2787        TypeExpr::Ref(_) => Ok(TypeExpr::Any),
2788        TypeExpr::Object(fields) => Ok(fields
2789            .iter()
2790            .find(|candidate| candidate.name.as_str() == field)
2791            .map(|field| field.ty.clone())
2792            .unwrap_or(TypeExpr::Any)),
2793        TypeExpr::Union(items) => {
2794            let fields = items
2795                .iter()
2796                .map(|item| field_type(item, field, span, is_opaque))
2797                .collect::<Result<Vec<_>, _>>()?;
2798            Ok(union_type(fields))
2799        }
2800        _ => Ok(TypeExpr::Any),
2801    }
2802}
2803
2804fn index_type(
2805    target: &TypeExpr,
2806    span: Option<Span>,
2807    is_opaque: impl Fn(&str) -> bool + Copy,
2808) -> Result<TypeExpr, LinkError> {
2809    match target {
2810        TypeExpr::List(item) => Ok(*item.clone()),
2811        TypeExpr::Ref(name) if is_opaque(name.as_str()) => Err(LinkError::OpaqueHostValueAccess {
2812            type_name: name.to_string(),
2813            access: "[]".to_string(),
2814            span,
2815        }),
2816        TypeExpr::Ref(_) => Ok(TypeExpr::Any),
2817        TypeExpr::Union(items) => {
2818            let items = items
2819                .iter()
2820                .map(|item| index_type(item, span, is_opaque))
2821                .collect::<Result<Vec<_>, _>>()?;
2822            Ok(union_type(items))
2823        }
2824        _ => Ok(TypeExpr::Any),
2825    }
2826}
2827
2828fn builtin_return_type(name: &str) -> TypeExpr {
2829    match name {
2830        "len" | "find" | "to_int" | "ceil_div" | "floor_div" => TypeExpr::Int,
2831        "empty" | "contains" | "starts_with" | "ends_with" => TypeExpr::Bool,
2832        "to_float" => TypeExpr::Float,
2833        "to_string" | "trim" | "join" => TypeExpr::Str,
2834        "keys" | "values" | "split" | "grep_text" | "range" | "push" => {
2835            TypeExpr::List(Box::new(TypeExpr::Any))
2836        }
2837        "json_parse" | "validate" | "format" => TypeExpr::Any,
2838        _ => TypeExpr::Any,
2839    }
2840}
2841
2842fn binary_return_type(op: crate::ast::BinaryOp) -> TypeExpr {
2843    match op {
2844        crate::ast::BinaryOp::Equal
2845        | crate::ast::BinaryOp::NotEqual
2846        | crate::ast::BinaryOp::Less
2847        | crate::ast::BinaryOp::LessEqual
2848        | crate::ast::BinaryOp::Greater
2849        | crate::ast::BinaryOp::GreaterEqual
2850        | crate::ast::BinaryOp::And
2851        | crate::ast::BinaryOp::Or => TypeExpr::Bool,
2852        crate::ast::BinaryOp::Add
2853        | crate::ast::BinaryOp::Subtract
2854        | crate::ast::BinaryOp::Multiply
2855        | crate::ast::BinaryOp::Divide
2856        | crate::ast::BinaryOp::Modulo => TypeExpr::Float,
2857    }
2858}
2859
2860fn process_input_type(process: &ProcessDecl) -> TypeExpr {
2861    match process.params.as_slice() {
2862        [] => TypeExpr::Null,
2863        [param] => param.ty.clone(),
2864        _ => process_input_record_type(process),
2865    }
2866}
2867
2868fn process_input_record_type(process: &ProcessDecl) -> TypeExpr {
2869    TypeExpr::Object(
2870        process
2871            .params
2872            .iter()
2873            .map(|param| TypeField {
2874                name: param.name.clone(),
2875                ty: param.ty.clone(),
2876                optional: false,
2877            })
2878            .collect(),
2879    )
2880}
2881
2882fn process_type_for_decl(process: &ProcessDecl, output: TypeExpr) -> TypeExpr {
2883    TypeExpr::Process {
2884        input: Box::new(process_input_type(process)),
2885        output: Box::new(output),
2886        input_count: process.params.len(),
2887    }
2888}
2889
2890fn module_path_for_expr(expr: &Expr) -> Option<Vec<AstString>> {
2891    match expr {
2892        Expr::LabelAnnotated { expr, .. } => module_path_for_expr(expr),
2893        Expr::Variable(name) => Some(vec![name.clone()]),
2894        Expr::Field { target, field } => {
2895            let mut path = module_path_for_expr(target)?;
2896            path.push(field.clone());
2897            Some(path)
2898        }
2899        Expr::ResourceRef(resource) => Some(resource.path.clone()),
2900        _ => None,
2901    }
2902}
2903
2904fn is_trigger_event_expr(expr: &Expr) -> bool {
2905    matches!(
2906        module_path_for_expr(expr).as_deref(),
2907        Some([trigger, event]) if trigger.as_str() == "trigger" && event.as_str() == "event"
2908    )
2909}
2910
2911fn is_trigger_event_projection_expr(expr: &Expr) -> bool {
2912    module_path_for_expr(expr).is_some_and(|path| {
2913        path.len() > 2 && path[0].as_str() == "trigger" && path[1].as_str() == "event"
2914    })
2915}
2916
2917fn trigger_target_process_name(expr: &Expr) -> Option<String> {
2918    match expr {
2919        Expr::LabelAnnotated { expr, .. } => trigger_target_process_name(expr),
2920        Expr::Variable(name) | Expr::ProcessRef { process: name } => Some(name.to_string()),
2921        _ => None,
2922    }
2923}
2924
2925fn trigger_target_process_label(expr: &Expr) -> String {
2926    trigger_target_process_name(expr).unwrap_or_else(|| "target".to_string())
2927}
2928
2929fn expr_has_label_annotation(expr: &Expr) -> bool {
2930    match expr {
2931        Expr::LabelAnnotated { .. } => true,
2932        other => other.children().any(expr_has_label_annotation),
2933    }
2934}
2935
2936#[cfg(test)]
2937mod tests {
2938    use super::*;
2939
2940    fn resources() -> ResourceCatalog {
2941        let mut catalog = ResourceCatalog::new();
2942        catalog.add_module_operation(
2943            ["tools"],
2944            "Tools",
2945            "read_file",
2946            "read_file",
2947            TypeExpr::Object(vec![TypeField {
2948                name: "path".into(),
2949                ty: TypeExpr::Str,
2950                optional: false,
2951            }]),
2952            TypeExpr::Str,
2953        );
2954        catalog.add_module_operation(
2955            ["tools"],
2956            "Tools",
2957            "echo",
2958            "echo",
2959            TypeExpr::Any,
2960            TypeExpr::Any,
2961        );
2962        crate::add_trigger_resource_operations(&mut catalog);
2963        catalog
2964            .add_trigger_source_constructor(
2965                ["timer", "Schedule"],
2966                TypeExpr::Object(vec![
2967                    TypeField {
2968                        name: "expr".into(),
2969                        ty: TypeExpr::Str,
2970                        optional: false,
2971                    },
2972                    TypeField {
2973                        name: "tz".into(),
2974                        ty: TypeExpr::Str,
2975                        optional: true,
2976                    },
2977                ]),
2978                NamedDataType::object(
2979                    "timer.Tick",
2980                    vec![TypeField {
2981                        name: "fired_at".into(),
2982                        ty: TypeExpr::Str,
2983                        optional: false,
2984                    }],
2985                )
2986                .expect("valid timer tick type"),
2987            )
2988            .expect("valid timer trigger source");
2989        catalog
2990    }
2991
2992    fn full_surface() -> LashlangSurface {
2993        LashlangSurface::new(resources(), LashlangAbilities::all())
2994    }
2995
2996    fn full_label_surface() -> LashlangSurface {
2997        full_surface()
2998            .with_language_features(LashlangLanguageFeatures::default().with_label_annotations())
2999    }
3000
3001    fn timer_tick_type_with_field(field: &'static str) -> NamedDataType {
3002        NamedDataType::object(
3003            "timer.Tick",
3004            vec![TypeField {
3005                name: field.into(),
3006                ty: TypeExpr::Str,
3007                optional: false,
3008            }],
3009        )
3010        .expect("valid timer tick type")
3011    }
3012
3013    fn resources_with_timer_event(event_type: NamedDataType) -> ResourceCatalog {
3014        let mut catalog = ResourceCatalog::new();
3015        crate::add_trigger_resource_operations(&mut catalog);
3016        catalog
3017            .add_trigger_source_constructor(
3018                ["timer", "Schedule"],
3019                TypeExpr::Object(vec![TypeField {
3020                    name: "expr".into(),
3021                    ty: TypeExpr::Str,
3022                    optional: false,
3023                }]),
3024                event_type,
3025            )
3026            .expect("valid timer trigger source");
3027        catalog
3028    }
3029
3030    #[test]
3031    fn named_host_data_type_validation_rejects_invalid_shapes() {
3032        let duplicate_field = NamedDataType::object(
3033            "timer.Tick",
3034            vec![
3035                TypeField {
3036                    name: "fired_at".into(),
3037                    ty: TypeExpr::Str,
3038                    optional: false,
3039                },
3040                TypeField {
3041                    name: "fired_at".into(),
3042                    ty: TypeExpr::Str,
3043                    optional: false,
3044                },
3045            ],
3046        )
3047        .expect_err("duplicate fields should be rejected");
3048        assert!(matches!(
3049            duplicate_field,
3050            NamedDataTypeError::DuplicateField { .. }
3051        ));
3052
3053        let nested_ref = NamedDataType::object(
3054            "timer.Tick",
3055            vec![TypeField {
3056                name: "nested".into(),
3057                ty: TypeExpr::Ref("Other.Type".into()),
3058                optional: false,
3059            }],
3060        )
3061        .expect_err("nested refs should be rejected");
3062        assert!(matches!(nested_ref, NamedDataTypeError::NestedRef { .. }));
3063
3064        let duplicate_enum = NamedDataType::object(
3065            "timer.Tick",
3066            vec![TypeField {
3067                name: "kind".into(),
3068                ty: TypeExpr::Enum(vec!["Red".into(), "Red".into()]),
3069                optional: false,
3070            }],
3071        )
3072        .expect_err("duplicate enum values should be rejected");
3073        assert!(matches!(
3074            duplicate_enum,
3075            NamedDataTypeError::DuplicateEnumValue { .. }
3076        ));
3077
3078        let simple_name = NamedDataType::object("Tick", vec![])
3079            .expect_err("host data type names must be qualified");
3080        assert!(matches!(
3081            simple_name,
3082            NamedDataTypeError::InvalidName { .. }
3083        ));
3084    }
3085
3086    #[test]
3087    fn resource_catalog_rejects_conflicting_named_host_data_type_definitions() {
3088        let mut catalog = ResourceCatalog::new();
3089        catalog
3090            .add_named_data_type(timer_tick_type_with_field("fired_at"))
3091            .expect("first definition");
3092        let err = catalog
3093            .add_named_data_type(timer_tick_type_with_field("delivered_at"))
3094            .expect_err("same host type name with different shape should be rejected");
3095
3096        assert!(matches!(
3097            err,
3098            ResourceCatalogError::ConflictingNamedDataType { .. }
3099        ));
3100    }
3101
3102    #[test]
3103    fn linked_module_accepts_named_processes_resource_params_and_activations() {
3104        let program = crate::parse(
3105            r#"
3106            type ChangeEvent = { path: str }
3107            process scan(tool: Tools, event: ChangeEvent) {
3108              text = await tool.read_file({ path: "changed.txt" })?
3109              finish text
3110            }
3111            process watcher(run: any) {
3112              sleep for "0ms"
3113              signal = wait signal
3114              signal run run with signal
3115              finish signal
3116            }
3117            process from_tick(tick: timer.Tick) {
3118              finish tick.fired_at
3119            }
3120            source = timer.Schedule({ expr: "0 8 * * *", tz: "UTC" })
3121            handle = await triggers.register({
3122              source: source,
3123              target: from_tick,
3124              inputs: { tick: trigger.event },
3125              name: "changed"
3126            })?
3127            submit handle
3128            "#,
3129        )
3130        .expect("parse module");
3131
3132        let linked = LinkedModule::link(program, full_surface()).expect("link module");
3133
3134        assert!(
3135            linked
3136                .module_ref
3137                .as_str()
3138                .starts_with("lashlang:v1:sha256:")
3139        );
3140    }
3141
3142    #[test]
3143    fn linked_module_allows_trigger_registration_name_to_match_target_process() {
3144        let program = crate::parse(
3145            r#"
3146            process changed(tick: timer.Tick) {
3147              finish true
3148            }
3149            source = timer.Schedule({ expr: "0 8 * * *" })
3150            await triggers.register({
3151              source: source,
3152              target: changed,
3153              inputs: { tick: trigger.event },
3154              name: "changed"
3155            })?
3156            "#,
3157        )
3158        .expect("parse module");
3159
3160        LinkedModule::link(program, full_surface())
3161            .expect("trigger registration names and process names occupy different namespaces");
3162    }
3163
3164    #[test]
3165    fn linked_module_resolves_host_named_data_refs_for_fields_and_structural_assignability() {
3166        let direct_ref = crate::parse(
3167            r#"
3168            process from_tick(tick: timer.Tick) {
3169              finish tick.fired_at
3170            }
3171            submit true
3172            "#,
3173        )
3174        .expect("parse direct host data ref");
3175        LinkedModule::link(direct_ref, full_surface()).expect("host data ref fields should link");
3176
3177        let structural_input = crate::parse(
3178            r#"
3179            process from_tick(tick: { fired_at: str }) {
3180              finish tick.fired_at
3181            }
3182            source = timer.Schedule({ expr: "0 8 * * *" })
3183            await triggers.register({
3184              source: source,
3185              target: from_tick,
3186              inputs: { tick: trigger.event }
3187            })?
3188            "#,
3189        )
3190        .expect("parse structural target input");
3191        LinkedModule::link(structural_input, full_surface())
3192            .expect("host data shape should be structurally assignable");
3193    }
3194
3195    #[test]
3196    fn linked_module_rejects_unknown_host_data_refs_and_opaque_source_field_access() {
3197        let unknown = crate::parse(
3198            r#"
3199            process from_tick(tick: foo.Tick) {
3200              finish true
3201            }
3202            submit true
3203            "#,
3204        )
3205        .expect("parse unknown host type");
3206        assert!(matches!(
3207            LinkedModule::link(unknown, full_surface()),
3208            Err(LinkError::UnknownType { name, .. }) if name == "foo.Tick"
3209        ));
3210
3211        let opaque = crate::parse(
3212            r#"
3213            source = timer.Schedule({ expr: "0 8 * * *" })
3214            submit source.expr
3215            "#,
3216        )
3217        .expect("parse opaque source access");
3218        assert!(matches!(
3219            LinkedModule::link(opaque, full_surface()),
3220            Err(LinkError::OpaqueHostValueAccess { type_name, .. }) if type_name == "timer.Schedule"
3221        ));
3222    }
3223
3224    #[test]
3225    fn required_surface_ref_tracks_host_named_data_type_shape_changes() {
3226        let program = crate::parse(
3227            r#"
3228            process from_tick(tick: any) {
3229              finish true
3230            }
3231            source = timer.Schedule({ expr: "0 8 * * *" })
3232            await triggers.register({
3233              source: source,
3234              target: from_tick,
3235              inputs: { tick: trigger.event }
3236            })?
3237            "#,
3238        )
3239        .expect("parse trigger registration");
3240        let first = LinkedModule::link(
3241            program.clone(),
3242            LashlangSurface::new(
3243                resources_with_timer_event(timer_tick_type_with_field("fired_at")),
3244                LashlangAbilities::all(),
3245            ),
3246        )
3247        .expect("link first host event shape");
3248        let second = LinkedModule::link(
3249            program,
3250            LashlangSurface::new(
3251                resources_with_timer_event(timer_tick_type_with_field("delivered_at")),
3252                LashlangAbilities::all(),
3253            ),
3254        )
3255        .expect("link changed host event shape");
3256
3257        assert_ne!(first.required_surface_ref, second.required_surface_ref);
3258    }
3259
3260    #[test]
3261    fn linked_module_accepts_top_level_sleep() {
3262        let program = crate::parse("sleep for 1").expect("parse sleep");
3263
3264        LinkedModule::link(program, full_surface()).expect("top-level sleep should link");
3265    }
3266
3267    #[test]
3268    fn linked_module_rejects_process_lifecycle_outside_process_body() {
3269        let program = crate::parse("payload = wait signal").expect("parse wait signal");
3270
3271        let err = LinkedModule::link(program, full_surface())
3272            .expect_err("top-level process lifecycle should be rejected");
3273
3274        assert!(
3275            matches!(
3276                err,
3277                LinkError::ProcessLifecycleOutsideProcess {
3278                    keyword: "wait signal",
3279                    ..
3280                }
3281            ),
3282            "{err}"
3283        );
3284    }
3285
3286    #[test]
3287    fn linked_module_accepts_top_level_signal_run() {
3288        // `signal run` (sending) mirrors `await` / `cancel`: legal from the
3289        // foreground turn, unlike the process-only `wait signal`.
3290        let program =
3291            crate::parse("signal run \"handle\" with \"ping\"").expect("parse signal run");
3292
3293        LinkedModule::link(program, full_surface()).expect("top-level signal run should link");
3294    }
3295
3296    #[test]
3297    fn linked_module_rejects_bad_process_args_and_unresolved_operations() {
3298        let missing_arg = crate::parse(
3299            r#"
3300            process scan(tool: Tools, path: str) { finish path }
3301            start scan(tool: tools)
3302            "#,
3303        )
3304        .expect("parse missing arg");
3305        assert!(matches!(
3306            LinkedModule::link(missing_arg, full_surface()),
3307            Err(LinkError::MissingProcessArgument { arg, .. }) if arg == "path"
3308        ));
3309
3310        let bad_operation = crate::parse(
3311            r#"
3312            process scan(tool: Tools) {
3313              finish await tool.missing({})?
3314            }
3315            "#,
3316        )
3317        .expect("parse bad operation");
3318        assert!(matches!(
3319            LinkedModule::link(bad_operation, full_surface()),
3320            Err(LinkError::UnknownResourceOperation { operation, .. }) if operation == "missing"
3321        ));
3322    }
3323
3324    #[test]
3325    fn linked_module_rejects_disabled_abilities() {
3326        let process =
3327            crate::parse("process worker() { finish null }").expect("parse disabled process");
3328        assert!(matches!(
3329            LinkedModule::link(
3330                process,
3331                LashlangSurface::new(resources(), LashlangAbilities::default())
3332            ),
3333            Err(LinkError::FeatureDisabled {
3334                feature: "processes",
3335                ..
3336            })
3337        ));
3338
3339        let start = crate::parse("start worker()").expect("parse disabled start");
3340        assert!(matches!(
3341            LinkedModule::link(
3342                start,
3343                LashlangSurface::new(resources(), LashlangAbilities::default())
3344            ),
3345            Err(LinkError::FeatureDisabled {
3346                feature: "processes",
3347                ..
3348            })
3349        ));
3350
3351        let sleep = crate::parse("sleep for \"1s\"").expect("parse disabled sleep");
3352        assert!(matches!(
3353            LinkedModule::link(
3354                sleep,
3355                LashlangSurface::new(resources(), LashlangAbilities::default())
3356            ),
3357            Err(LinkError::FeatureDisabled {
3358                feature: "sleep",
3359                ..
3360            })
3361        ));
3362
3363        let signal = crate::parse("process worker() { payload = wait signal }")
3364            .expect("parse disabled process signal");
3365        assert!(matches!(
3366            LinkedModule::link(
3367                signal,
3368                LashlangSurface::new(resources(), LashlangAbilities::default().with_processes())
3369            ),
3370            Err(LinkError::FeatureDisabled {
3371                feature: "process signals",
3372                ..
3373            })
3374        ));
3375
3376        let trigger = crate::parse(
3377            r#"
3378            process worker(tick: timer.Tick) { finish true }
3379            source = timer.Schedule({ expr: "0 8 * * *" })
3380            await triggers.register({
3381              source: source,
3382              target: worker,
3383              inputs: { tick: trigger.event }
3384            })?
3385            "#,
3386        )
3387        .expect("parse disabled trigger");
3388        assert!(matches!(
3389            LinkedModule::link(
3390                trigger,
3391                LashlangSurface::new(resources(), LashlangAbilities::default().with_processes())
3392            ),
3393            Err(LinkError::FeatureDisabled {
3394                feature: "triggers",
3395                ..
3396            })
3397        ));
3398    }
3399
3400    #[test]
3401    fn linked_module_validates_value_constructors_and_trigger_registry_ops() {
3402        let program = crate::parse(
3403            r#"
3404            process scan(tick: timer.Tick) -> bool {
3405              finish true
3406            }
3407            source = timer.Schedule({ expr: "0 8 * * *", tz: "UTC" })
3408            handle = await triggers.register({
3409              source: source,
3410              target: scan,
3411              inputs: { tick: trigger.event },
3412              name: "scan"
3413            })?
3414            registrations = await triggers.list({ target: scan })?
3415            cancelled = await triggers.cancel({ handle: handle })?
3416            submit { handle: handle, registrations: registrations, cancelled: cancelled }
3417            "#,
3418        )
3419        .expect("parse trigger registry program");
3420        assert!(LinkedModule::link(program, full_surface()).is_ok());
3421    }
3422
3423    #[test]
3424    fn linked_module_accepts_explicit_trigger_input_mappings() {
3425        let repeated_event = crate::parse(
3426            r#"
3427            process scan(a: timer.Tick, b: { fired_at: str }) {
3428              finish { a: a.fired_at, b: b.fired_at }
3429            }
3430            source = timer.Schedule({ expr: "0 8 * * *" })
3431            await triggers.register({
3432              source: source,
3433              target: scan,
3434              inputs: { a: trigger.event, b: trigger.event }
3435            })?
3436            "#,
3437        )
3438        .expect("parse repeated event mapping");
3439        LinkedModule::link(repeated_event, full_surface())
3440            .expect("event payload should map to multiple assignable params");
3441
3442        let fixed_authority = crate::parse(
3443            r#"
3444            process scan(tick: timer.Tick, tool: Tools) {
3445              text = await tool.read_file({ path: tick.fired_at })?
3446              finish text
3447            }
3448            source = timer.Schedule({ expr: "0 8 * * *" })
3449            await triggers.register({
3450              source: source,
3451              target: scan,
3452              inputs: { tick: trigger.event, tool: tools }
3453            })?
3454            "#,
3455        )
3456        .expect("parse fixed authority mapping");
3457        LinkedModule::link(fixed_authority, full_surface())
3458            .expect("fixed resource inputs should satisfy process authority params");
3459    }
3460
3461    #[test]
3462    fn linked_module_captures_concrete_process_body_resources_statically() {
3463        let program = crate::parse(
3464            r#"
3465            process scan(tick: timer.Tick) {
3466              text = await tools.read_file({ path: tick.fired_at })?
3467              finish text
3468            }
3469            source = timer.Schedule({ expr: "0 8 * * *" })
3470            await triggers.register({
3471              source: source,
3472              target: scan,
3473              inputs: { tick: trigger.event }
3474            })?
3475            "#,
3476        )
3477        .expect("parse captured authority process");
3478        let linked = LinkedModule::link(program, full_surface())
3479            .expect("process body should capture concrete host resources");
3480        let process = linked
3481            .artifact
3482            .canonical_ir
3483            .process("scan")
3484            .expect("scan process");
3485        fn contains_resource_ref(expr: &Expr, path: &str) -> bool {
3486            matches!(expr, Expr::ResourceRef(resource) if resource.path_string() == path)
3487                || expr
3488                    .children()
3489                    .any(|child| contains_resource_ref(child, path))
3490        }
3491        assert!(
3492            contains_resource_ref(&process.body, "tools"),
3493            "linked process body should contain a persisted tools resource ref"
3494        );
3495
3496        let shadowed = crate::parse(
3497            r#"
3498            tool = tools
3499            process scan(tick: timer.Tick) {
3500              text = await tool.read_file({ path: tick.fired_at })?
3501              finish text
3502            }
3503            source = timer.Schedule({ expr: "0 8 * * *" })
3504            await triggers.register({
3505              source: source,
3506              target: scan,
3507              inputs: { tick: trigger.event }
3508            })?
3509            "#,
3510        )
3511        .expect("parse foreground variable capture");
3512        assert!(matches!(
3513            LinkedModule::link(shadowed, full_surface()),
3514            Err(LinkError::UnknownName { name, .. }) if name == "tool"
3515        ));
3516    }
3517
3518    #[test]
3519    fn linked_module_accepts_button_trigger_source_constructor() {
3520        let mut resources = resources();
3521        resources
3522            .add_trigger_source_constructor(
3523                ["ui", "button", "pressed"],
3524                TypeExpr::Object(vec![]),
3525                NamedDataType::object(
3526                    "ui.button.Pressed",
3527                    vec![
3528                        TypeField {
3529                            name: "button".into(),
3530                            ty: TypeExpr::Union(vec![
3531                                TypeExpr::Enum(vec!["Red".into()]),
3532                                TypeExpr::Enum(vec!["Blue".into()]),
3533                            ]),
3534                            optional: false,
3535                        },
3536                        TypeField {
3537                            name: "message".into(),
3538                            ty: TypeExpr::Str,
3539                            optional: false,
3540                        },
3541                        TypeField {
3542                            name: "pressed_at".into(),
3543                            ty: TypeExpr::Str,
3544                            optional: false,
3545                        },
3546                    ],
3547                )
3548                .expect("valid button event type"),
3549            )
3550            .expect("valid button trigger source");
3551        let program = crate::parse(
3552            r#"
3553            process on_button(event: ui.button.Pressed) {
3554              wake { kind: "button_pressed", button: event.button, message: event.message }
3555              finish true
3556            }
3557
3558            handle = await triggers.register({
3559              source: ui.button.pressed({}),
3560              target: on_button,
3561              inputs: { event: trigger.event },
3562              name: "button watcher"
3563            })?
3564            submit handle
3565            "#,
3566        )
3567        .expect("parse button trigger source");
3568
3569        LinkedModule::link(
3570            program,
3571            LashlangSurface::new(resources, LashlangAbilities::all()),
3572        )
3573        .expect("button trigger source should link");
3574    }
3575
3576    #[test]
3577    fn linked_module_rejects_bad_trigger_registry_bindings() {
3578        let missing = crate::parse(
3579            r#"
3580            process scan(tick: timer.Tick) { finish true }
3581            source = timer.Schedule({ expr: "0 8 * * *" })
3582            await triggers.register({ target: scan })?
3583            "#,
3584        )
3585        .expect("parse missing source");
3586        assert!(matches!(
3587            LinkedModule::link(missing, full_surface()),
3588            Err(LinkError::InvalidTriggerRegistration { .. })
3589        ));
3590
3591        let missing_inputs = crate::parse(
3592            r#"
3593            process scan(tick: timer.Tick) { finish true }
3594            source = timer.Schedule({ expr: "0 8 * * *" })
3595            await triggers.register({ source: source, target: scan })?
3596            "#,
3597        )
3598        .expect("parse missing inputs");
3599        assert!(matches!(
3600            LinkedModule::link(missing_inputs, full_surface()),
3601            Err(LinkError::InvalidTriggerRegistration { .. })
3602        ));
3603
3604        let wrong_source = crate::parse(
3605            r#"
3606            process scan(tick: timer.Tick) { finish true }
3607            await triggers.register({
3608              source: { expr: "0 8 * * *" },
3609              target: scan,
3610              inputs: { tick: trigger.event }
3611            })?
3612            "#,
3613        )
3614        .expect("parse wrong source");
3615        assert!(matches!(
3616            LinkedModule::link(wrong_source, full_surface()),
3617            Err(LinkError::UnknownTriggerSourceType { .. })
3618        ));
3619
3620        let payload_mismatch = crate::parse(
3621            r#"
3622            process scan(tick: str) { finish tick }
3623            source = timer.Schedule({ expr: "0 8 * * *" })
3624            await triggers.register({
3625              source: source,
3626              target: scan,
3627              inputs: { tick: trigger.event }
3628            })?
3629            "#,
3630        )
3631        .expect("parse payload mismatch");
3632        assert!(matches!(
3633            LinkedModule::link(payload_mismatch, full_surface()),
3634            Err(LinkError::TriggerEventMismatch { .. })
3635        ));
3636
3637        let unknown_input = crate::parse(
3638            r#"
3639            process scan(tick: timer.Tick) { finish true }
3640            source = timer.Schedule({ expr: "0 8 * * *" })
3641            await triggers.register({
3642              source: source,
3643              target: scan,
3644              inputs: { tick: trigger.event, extra: "nope" }
3645            })?
3646            "#,
3647        )
3648        .expect("parse unknown input");
3649        assert!(matches!(
3650            LinkedModule::link(unknown_input, full_surface()),
3651            Err(LinkError::UnknownTriggerInput { input, .. }) if input == "extra"
3652        ));
3653
3654        let duplicate_input = crate::parse(
3655            r#"
3656            process scan(tick: timer.Tick) { finish true }
3657            source = timer.Schedule({ expr: "0 8 * * *" })
3658            await triggers.register({
3659              source: source,
3660              target: scan,
3661              inputs: { tick: trigger.event, tick: trigger.event }
3662            })?
3663            "#,
3664        )
3665        .expect("parse duplicate input");
3666        assert!(matches!(
3667            LinkedModule::link(duplicate_input, full_surface()),
3668            Err(LinkError::DuplicateTriggerInput { input, .. }) if input == "tick"
3669        ));
3670
3671        let no_event_input = crate::parse(
3672            r#"
3673            process scan(tick: timer.Tick, label: str) { finish label }
3674            source = timer.Schedule({ expr: "0 8 * * *" })
3675            await triggers.register({
3676              source: source,
3677              target: scan,
3678              inputs: { tick: { fired_at: "static" }, label: "static" }
3679            })?
3680            "#,
3681        )
3682        .expect("parse no event input");
3683        assert!(matches!(
3684            LinkedModule::link(no_event_input, full_surface()),
3685            Err(LinkError::MissingTriggerEventInput { .. })
3686        ));
3687
3688        let event_projection = crate::parse(
3689            r#"
3690            process scan(fired_at: str) { finish fired_at }
3691            source = timer.Schedule({ expr: "0 8 * * *" })
3692            await triggers.register({
3693              source: source,
3694              target: scan,
3695              inputs: { fired_at: trigger.event.fired_at }
3696            })?
3697            "#,
3698        )
3699        .expect("parse event projection");
3700        assert!(matches!(
3701            LinkedModule::link(event_projection, full_surface()),
3702            Err(LinkError::TriggerEventProjection { .. })
3703        ));
3704
3705        let event_outside_inputs = crate::parse(
3706            r#"
3707            process scan(tick: timer.Tick) { finish true }
3708            submit trigger.event
3709            "#,
3710        )
3711        .expect("parse event outside inputs");
3712        assert!(matches!(
3713            LinkedModule::link(event_outside_inputs, full_surface()),
3714            Err(LinkError::TriggerEventOutsideInputs { .. })
3715        ));
3716
3717        let multi_input = crate::parse(
3718            r#"
3719            process scan(tick: timer.Tick, extra: str) { finish extra }
3720            source = timer.Schedule({ expr: "0 8 * * *" })
3721            await triggers.register({
3722              source: source,
3723              target: scan,
3724              inputs: { tick: trigger.event }
3725            })?
3726            "#,
3727        )
3728        .expect("parse multi-input target");
3729        assert!(matches!(
3730            LinkedModule::link(multi_input, full_surface()),
3731            Err(LinkError::MissingTriggerInput { input, .. }) if input == "extra"
3732        ));
3733
3734        let target_is_not_process = crate::parse(
3735            r#"
3736            process scan(tick: timer.Tick) { finish true }
3737            source = timer.Schedule({ expr: "0 8 * * *" })
3738            await triggers.register({
3739              source: source,
3740              target: source,
3741              inputs: { tick: trigger.event }
3742            })?
3743            "#,
3744        )
3745        .expect("parse non-process target");
3746        assert!(matches!(
3747            LinkedModule::link(target_is_not_process, full_surface()),
3748            Err(LinkError::InvalidTriggerTarget { .. })
3749        ));
3750
3751        let list_without_filters = crate::parse(
3752            r#"
3753            process scan(tick: timer.Tick) { finish true }
3754            await triggers.list({})?
3755            "#,
3756        )
3757        .expect("parse trigger list without filters");
3758        assert!(LinkedModule::link(list_without_filters, full_surface()).is_ok());
3759
3760        let list_with_filters = crate::parse(
3761            r#"
3762            process scan(tick: timer.Tick) { finish true }
3763            await triggers.list({
3764              target: scan,
3765              name: "daily",
3766              source_type: "timer.Schedule",
3767              enabled: true
3768            })?
3769            "#,
3770        )
3771        .expect("parse trigger list filters");
3772        assert!(LinkedModule::link(list_with_filters, full_surface()).is_ok());
3773
3774        let list_target_is_not_process = crate::parse(
3775            r#"
3776            process scan(tick: timer.Tick) { finish true }
3777            source = timer.Schedule({ expr: "0 8 * * *" })
3778            await triggers.list({ target: source })?
3779            "#,
3780        )
3781        .expect("parse trigger list non-process target");
3782        assert!(matches!(
3783            LinkedModule::link(list_target_is_not_process, full_surface()),
3784            Err(LinkError::InvalidTriggerTarget { .. })
3785                | Err(LinkError::IncompatibleOperationInput { .. })
3786        ));
3787
3788        let constructor_mismatch = crate::parse(
3789            r#"
3790            source = timer.Schedule({ expr: 1 })
3791            submit source
3792            "#,
3793        )
3794        .expect("parse constructor mismatch");
3795        assert!(matches!(
3796            LinkedModule::link(constructor_mismatch, full_surface()),
3797            Err(LinkError::IncompatibleConstructorInput { .. })
3798        ));
3799
3800        let operation_mismatch = crate::parse(
3801            r#"
3802            await tools.read_file({ path: 1 })?
3803            "#,
3804        )
3805        .expect("parse operation mismatch");
3806        assert!(matches!(
3807            LinkedModule::link(operation_mismatch, full_surface()),
3808            Err(LinkError::IncompatibleOperationInput { .. })
3809        ));
3810    }
3811
3812    #[test]
3813    fn linked_module_infers_process_output_and_validates_return_annotations() {
3814        let inferred = crate::parse(
3815            r#"
3816            process done(tick: timer.Tick) -> bool {
3817              finish true
3818            }
3819            source = timer.Schedule({ expr: "0 8 * * *" })
3820            await triggers.register({
3821              source: source,
3822              target: done,
3823              inputs: { tick: trigger.event }
3824            })?
3825            "#,
3826        )
3827        .expect("parse inferred output");
3828        assert!(LinkedModule::link(inferred, full_surface()).is_ok());
3829
3830        let union_mismatch = crate::parse(
3831            r#"
3832            process done(tick: timer.Tick) -> bool {
3833              if true {
3834                finish true
3835              }
3836              finish "done"
3837            }
3838            "#,
3839        )
3840        .expect("parse union mismatch");
3841        assert!(matches!(
3842            LinkedModule::link(union_mismatch, full_surface()),
3843            Err(LinkError::IncompatibleProcessReturn { .. })
3844        ));
3845    }
3846
3847    #[test]
3848    fn linked_module_hash_ignores_unused_host_abilities() {
3849        let program = crate::parse("submit 1").expect("parse");
3850        let minimal = LinkedModule::link(
3851            program.clone(),
3852            LashlangSurface::new(resources(), LashlangAbilities::default()),
3853        )
3854        .expect("link minimal");
3855        let processes = LinkedModule::link(
3856            program,
3857            LashlangSurface::new(resources(), LashlangAbilities::default().with_processes()),
3858        )
3859        .expect("link process ability");
3860
3861        assert_eq!(minimal.module_ref, processes.module_ref);
3862        assert_eq!(minimal.required_surface_ref, processes.required_surface_ref);
3863    }
3864
3865    #[test]
3866    fn label_annotations_require_enabled_language_feature() {
3867        let program = crate::parse(
3868            r#"
3869            @label(title: "Scan files")
3870            process scan(tool: Tools) {
3871              @label(title: "Read file")
3872              text = await tool.read_file({ path: "." })?
3873              finish text
3874            }
3875            "#,
3876        )
3877        .expect("parse annotated process");
3878
3879        let err = LinkedModule::link(program.clone(), full_surface())
3880            .expect_err("default surface should reject label annotations");
3881        assert!(matches!(
3882            err,
3883            LinkError::FeatureDisabled {
3884                feature: "label annotations",
3885                ..
3886            }
3887        ));
3888
3889        let linked =
3890            LinkedModule::link(program, full_label_surface()).expect("enabled surface should link");
3891        assert!(
3892            linked
3893                .artifact
3894                .required_surface
3895                .language_features
3896                .label_annotations
3897        );
3898        let process = linked.program().process("scan").expect("linked process");
3899        assert_eq!(
3900            process.label.as_ref().map(|label| label.title.as_str()),
3901            Some("Scan files")
3902        );
3903    }
3904
3905    #[test]
3906    fn label_annotation_text_inside_strings_does_not_require_feature() {
3907        let linked = LinkedModule::link(
3908            crate::parse(r####"submit r'''@label(title: "Plain text")'''"####)
3909                .expect("parse string"),
3910            full_surface(),
3911        )
3912        .expect("disabled label annotations should not reject string text");
3913
3914        assert!(
3915            !linked
3916                .artifact
3917                .required_surface
3918                .language_features
3919                .label_annotations
3920        );
3921    }
3922
3923    #[test]
3924    fn label_metadata_round_trips_and_changes_artifact_identity() {
3925        let first = LinkedModule::link(
3926            crate::parse(
3927                r#"
3928                @label(title: "Scan files")
3929                process scan(tool: Tools) {
3930                  @label(title: "Read file", description: "Load source text")
3931                  text = await tool.read_file({ path: "." })?
3932                  @label(title: "Finish")
3933                  finish text
3934                }
3935                "#,
3936            )
3937            .expect("parse first"),
3938            full_label_surface(),
3939        )
3940        .expect("link first");
3941        let changed = LinkedModule::link(
3942            crate::parse(
3943                r#"
3944                @label(title: "Scan files")
3945                process scan(tool: Tools) {
3946                  @label(title: "Read source", description: "Load source text")
3947                  text = await tool.read_file({ path: "." })?
3948                  @label(title: "Finish")
3949                  finish text
3950                }
3951                "#,
3952            )
3953            .expect("parse changed"),
3954            full_label_surface(),
3955        )
3956        .expect("link changed");
3957
3958        let bytes = first
3959            .artifact
3960            .to_store_bytes()
3961            .expect("encode annotated artifact");
3962        let decoded = ModuleArtifact::from_store_bytes(&bytes).expect("decode annotated artifact");
3963        assert_eq!(decoded, first.artifact);
3964        assert_ne!(first.module_ref, changed.module_ref);
3965        assert_ne!(
3966            first.artifact.process_ref("scan"),
3967            changed.artifact.process_ref("scan")
3968        );
3969    }
3970
3971    #[test]
3972    fn module_ref_ignores_spans_and_formatting() {
3973        let compact = LinkedModule::link(
3974            crate::parse("process scan(root: str) { finish root }").expect("parse compact"),
3975            full_surface(),
3976        )
3977        .expect("link compact");
3978        let formatted = LinkedModule::link(
3979            crate::parse(
3980                r#"
3981                process scan(root: str) {
3982                    finish root
3983                }
3984                "#,
3985            )
3986            .expect("parse formatted"),
3987            full_surface(),
3988        )
3989        .expect("link formatted");
3990
3991        assert_eq!(compact.module_ref, formatted.module_ref);
3992    }
3993
3994    #[test]
3995    fn process_ref_tracks_abi_and_body_but_not_local_binder_names() {
3996        let original = LinkedModule::link(
3997            crate::parse("process scan(root: str) { value = root\nfinish value }")
3998                .expect("parse original"),
3999            full_surface(),
4000        )
4001        .expect("link original");
4002        let renamed_local = LinkedModule::link(
4003            crate::parse("process scan(root: str) { renamed = root\nfinish renamed }")
4004                .expect("parse renamed local"),
4005            full_surface(),
4006        )
4007        .expect("link renamed local");
4008        let renamed_param = LinkedModule::link(
4009            crate::parse("process scan(path: str) { value = path\nfinish value }")
4010                .expect("parse renamed param"),
4011            full_surface(),
4012        )
4013        .expect("link renamed param");
4014        let changed_body = LinkedModule::link(
4015            crate::parse("process scan(root: str) { value = root\nfinish { value: value } }")
4016                .expect("parse changed body"),
4017            full_surface(),
4018        )
4019        .expect("link changed body");
4020
4021        assert_eq!(
4022            original.artifact.process_ref("scan"),
4023            renamed_local.artifact.process_ref("scan")
4024        );
4025        assert_ne!(
4026            original.artifact.process_ref("scan"),
4027            renamed_param.artifact.process_ref("scan")
4028        );
4029        assert_ne!(
4030            original.artifact.process_ref("scan"),
4031            changed_body.artifact.process_ref("scan")
4032        );
4033    }
4034
4035    #[test]
4036    fn required_surface_ref_tracks_resource_requirements_not_unrelated_tools() {
4037        let mut with_extra = resources();
4038        with_extra.add_module_operation(
4039            ["tools"],
4040            "Tools",
4041            "unrelated",
4042            "unrelated",
4043            TypeExpr::Any,
4044            TypeExpr::Any,
4045        );
4046        let program = crate::parse(
4047            "process scan(tool: Tools) { finish (await tool.read_file({ path: \".\" }))? }",
4048        )
4049        .expect("parse process");
4050
4051        let base = LinkedModule::link(program.clone(), full_surface()).expect("link base");
4052        let extra = LinkedModule::link(
4053            program.clone(),
4054            LashlangSurface::new(with_extra, LashlangAbilities::all()),
4055        )
4056        .expect("link extra");
4057        let changed_requirement = LinkedModule::link(
4058            crate::parse(
4059                "process scan(tool: Tools) { finish (await tool.echo({ value: \".\" }))? }",
4060            )
4061            .expect("parse changed resource"),
4062            full_surface(),
4063        )
4064        .expect("link changed requirement");
4065
4066        assert_eq!(base.module_ref, extra.module_ref);
4067        assert_eq!(base.required_surface_ref, extra.required_surface_ref);
4068        assert_ne!(
4069            base.required_surface_ref,
4070            changed_requirement.required_surface_ref
4071        );
4072    }
4073
4074    #[test]
4075    fn module_aliases_sharing_resource_type_route_to_distinct_host_operations() {
4076        let mut catalog = ResourceCatalog::new();
4077        catalog.add_module_operation(
4078            ["inbox", "work"],
4079            "Inbox",
4080            "send",
4081            "inbox__work__send",
4082            TypeExpr::Any,
4083            TypeExpr::Any,
4084        );
4085        catalog.add_module_operation(
4086            ["inbox", "personal"],
4087            "Inbox",
4088            "send",
4089            "inbox__personal__send",
4090            TypeExpr::Any,
4091            TypeExpr::Any,
4092        );
4093
4094        assert_eq!(
4095            catalog
4096                .resolve_module_operation("Inbox", "inbox.work", "send")
4097                .map(|binding| binding.host_operation.as_str()),
4098            Some("inbox__work__send")
4099        );
4100        assert_eq!(
4101            catalog
4102                .resolve_module_operation("Inbox", "inbox.personal", "send")
4103                .map(|binding| binding.host_operation.as_str()),
4104            Some("inbox__personal__send")
4105        );
4106    }
4107
4108    #[test]
4109    fn reusing_module_alias_for_different_resource_type_fails() {
4110        let mut catalog = ResourceCatalog::new();
4111        catalog
4112            .add_module_instance(["tools"], "Tools")
4113            .expect("initial module instance");
4114
4115        assert!(matches!(
4116            catalog.add_module_instance(["tools"], "Inbox"),
4117            Err(ResourceCatalogError::ConflictingModuleInstance {
4118                alias,
4119                existing,
4120                incoming,
4121            }) if alias == "tools" && existing == "Tools" && incoming == "Inbox"
4122        ));
4123    }
4124
4125    // --- behaviour-pinning tests for the single linking walk -------------
4126    //
4127    // These lock in the error *set*, *ordering*, and *spans* the linker
4128    // produced when validation and lowering were two separate passes, so the
4129    // fold into one walk stays behaviour-preserving.
4130
4131    #[test]
4132    fn declaration_errors_surface_before_main_errors() {
4133        // The process body references an unknown name AND the main block
4134        // references a different unknown name. The declaration error must win.
4135        let program = crate::parse(
4136            r#"
4137            process scan() { finish missing_in_body }
4138            submit missing_in_main
4139            "#,
4140        )
4141        .expect("parse");
4142        let err = LinkedModule::link(program, full_surface())
4143            .expect_err("both bodies reference unknowns");
4144        assert!(
4145            matches!(&err, LinkError::UnknownName { name, .. } if name == "missing_in_body"),
4146            "{err:?}"
4147        );
4148    }
4149
4150    #[test]
4151    fn unknown_name_in_process_body_carries_declaration_span() {
4152        let program = crate::parse("process scan() { finish missing }").expect("parse");
4153        let err = LinkedModule::link(program, full_surface()).expect_err("unknown name");
4154        let LinkError::UnknownName { name, span } = &err else {
4155            panic!("expected UnknownName, got {err:?}");
4156        };
4157        assert_eq!(name, "missing");
4158        assert!(span.is_some(), "declaration-body error should carry a span");
4159    }
4160
4161    #[test]
4162    fn linker_reproduces_full_error_set() {
4163        // One representative source per error variant that the expression walk
4164        // is responsible for raising.
4165        // Top-level scope allows unknown globals (they become runtime errors),
4166        // so unknown-name checks must be exercised inside a process body.
4167        type ErrorCase = (&'static str, fn(&LinkError) -> bool);
4168        let cases: &[ErrorCase] = &[
4169            (
4170                "process scan() { finish missing }",
4171                |err| matches!(err, LinkError::UnknownName { name, .. } if name == "missing"),
4172            ),
4173            (
4174                "process scan() { missing[0] = 1 }",
4175                |err| matches!(err, LinkError::UnknownName { name, .. } if name == "missing"),
4176            ),
4177            (
4178                "submit not_a_builtin(1)",
4179                |err| matches!(err, LinkError::UnknownBuiltin { name, .. } if name == "not_a_builtin"),
4180            ),
4181            (
4182                "x = 1\nsubmit x.read_file({})",
4183                |err| matches!(err, LinkError::UnresolvedReceiver { operation, .. } if operation == "read_file"),
4184            ),
4185            (
4186                "process scan() { finish 1 }\nstart scan(extra: 1)",
4187                |err| matches!(err, LinkError::UnexpectedProcessArgument { arg, .. } if arg == "extra"),
4188            ),
4189            (
4190                "process scan(needed: str) { finish needed }\nstart scan()",
4191                |err| matches!(err, LinkError::MissingProcessArgument { arg, .. } if arg == "needed"),
4192            ),
4193            (
4194                "start ghost()",
4195                |err| matches!(err, LinkError::UnknownProcess { name, .. } if name == "ghost"),
4196            ),
4197        ];
4198
4199        for (source, predicate) in cases {
4200            let program =
4201                crate::parse(source).unwrap_or_else(|err| panic!("parse {source:?}: {err}"));
4202            let err = LinkedModule::link(program, full_surface())
4203                .err()
4204                .unwrap_or_else(|| panic!("{source:?} should fail to link"));
4205            assert!(predicate(&err), "unexpected error for {source:?}: {err:?}");
4206        }
4207    }
4208
4209    #[test]
4210    fn unknown_resource_operation_still_rejected_after_receiver_resolves() {
4211        let program = crate::parse(
4212            r#"
4213            process scan(tool: Tools) { finish await tool.does_not_exist({})? }
4214            "#,
4215        )
4216        .expect("parse");
4217        let err = LinkedModule::link(program, full_surface()).expect_err("operation missing");
4218        assert!(
4219            matches!(&err, LinkError::UnknownResourceOperation { operation, .. } if operation == "does_not_exist"),
4220            "{err:?}"
4221        );
4222    }
4223
4224    #[tokio::test]
4225    async fn module_artifact_store_bytes_reject_corruption() {
4226        use crate::LashlangArtifactStore;
4227
4228        let linked = LinkedModule::link(
4229            crate::parse("process scan() { finish 1 }").expect("parse module"),
4230            full_surface(),
4231        )
4232        .expect("link module");
4233        let store = crate::InMemoryLashlangArtifactStore::new();
4234
4235        store
4236            .put_module_artifact(&linked.artifact)
4237            .await
4238            .expect("put artifact");
4239        assert_eq!(
4240            store
4241                .get_module_artifact(&linked.module_ref)
4242                .await
4243                .expect("get artifact")
4244                .expect("artifact exists")
4245                .module_ref,
4246            linked.module_ref
4247        );
4248
4249        assert!(ModuleArtifact::from_store_bytes(b"not json").is_err());
4250    }
4251}