1use crate::StoryDisplayDescriptor;
5use crate::admin::{
6 AdminDeclarativeComponent, AdminDeclarativeSurface, AdminEmbeddedEntry, AdminEmbeddedRuntime,
7 AdminEmbeddedSurface, AdminPermission, AdminSurface,
8};
9use crate::admin_schema::AdminSchema;
10use crate::console::{
11 ConsoleActionInputValue, ConsoleContribution, ConsoleContributionAction, ConsoleModuleManifest,
12 ConsoleModuleSurface, ConsoleSlot, ConsoleSurface, ConsoleSurfacePresentation,
13};
14use crate::events::{EventHandlerDeclaration, EventSurface};
15use crate::http::{ModuleHttpMethod, ModuleHttpRoute, lint_module_http_routes};
16use crate::lifecycle::{
17 LifecycleActivationJobDeclaration, LifecycleStartupCheckDeclaration, LifecycleStartupCheckKind,
18 LifecycleSurface,
19};
20use crate::runtime::{
21 RuntimeFunctionDeclaration, RuntimeSurface, ScheduledFunctionDeclaration,
22 WORKFLOW_DEFINITION_PROTOCOL, WorkflowDataContract, WorkflowDefinition,
23 WorkflowStepDeclaration,
24};
25use crate::validate_cron_expression;
26use schemars::JsonSchema;
27use serde::{Deserialize, Serialize};
28use serde_json::Value;
29use std::collections::HashSet;
30use utoipa::ToSchema;
31
32pub const MODULE_MANIFEST_PROTOCOL: &str = "lenso.module-manifest.v1";
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
35#[serde(deny_unknown_fields)]
36pub struct ModuleRequirement {
37 pub module_id: String,
38 pub version_requirement: String,
39 #[serde(default, skip_serializing_if = "Vec::is_empty")]
40 pub capabilities: Vec<String>,
41 #[serde(default)]
42 pub optional: bool,
43}
44
45impl ModuleRequirement {
46 pub fn new(
47 module_id: impl Into<String>,
48 version_requirement: impl AsRef<str>,
49 ) -> Result<Self, String> {
50 let version_requirement = semver::VersionReq::parse(version_requirement.as_ref())
51 .map_err(|error| format!("invalid Module version requirement: {error}"))?
52 .to_string();
53 Ok(Self {
54 module_id: module_id.into(),
55 version_requirement,
56 capabilities: Vec::new(),
57 optional: false,
58 })
59 }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
63#[serde(rename_all = "snake_case")]
64pub enum ModuleConfigFieldType {
65 String,
66 Integer,
67 Boolean,
68 Json,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
72#[serde(rename_all = "snake_case")]
73pub enum ModuleConfigScope {
74 Module,
75 Service,
76 Environment,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
80#[serde(rename_all = "snake_case")]
81pub enum ModuleConfigMutability {
82 Static,
83 Reloadable,
84 Runtime,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
88#[serde(rename_all = "snake_case")]
89pub enum ModuleConfigActivation {
90 None,
91 Build,
92 Restart,
93 ServiceRestart,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
97#[serde(deny_unknown_fields)]
98pub struct ModuleConfigValidation {
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub pattern: Option<String>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub minimum: Option<i64>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub maximum: Option<i64>,
105 #[serde(default, skip_serializing_if = "Vec::is_empty")]
106 pub allowed_values: Vec<String>,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
110#[serde(deny_unknown_fields)]
111pub struct ModuleConfigField {
112 pub key: String,
113 pub field_type: ModuleConfigFieldType,
114 pub required: bool,
115 pub scope: ModuleConfigScope,
116 pub sensitive: bool,
117 pub secret_reference: bool,
118 pub mutability: ModuleConfigMutability,
119 pub activation: ModuleConfigActivation,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub read_capability: Option<String>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub write_capability: Option<String>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub default: Option<Value>,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub validation: Option<ModuleConfigValidation>,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
136#[serde(deny_unknown_fields)]
137pub struct ModuleConfigContract {
138 #[serde(default, skip_serializing_if = "Vec::is_empty")]
139 pub fields: Vec<ModuleConfigField>,
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
143#[serde(rename_all = "snake_case")]
144pub enum ModuleMigrationActivation {
145 BeforeActivation,
146 AfterActivation,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
150#[serde(deny_unknown_fields)]
151pub struct ModuleMigrationDeclaration {
152 pub migration_id: String,
153 pub order: u32,
154 pub store: String,
155 pub destructive: bool,
156 pub reversible: bool,
157 pub activation: ModuleMigrationActivation,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
164#[serde(deny_unknown_fields)]
165#[non_exhaustive]
166pub struct ModuleManifest {
167 pub protocol: String,
168
169 pub module_id: String,
171
172 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub summary: Option<String>,
174
175 #[serde(default)]
177 pub story_display: Vec<StoryDisplayDescriptor>,
178
179 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub admin: Option<AdminSurface>,
184
185 #[serde(default)]
188 pub http_routes: Vec<ModuleHttpRoute>,
189
190 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub runtime: Option<RuntimeSurface>,
194
195 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub events: Option<EventSurface>,
199
200 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub lifecycle: Option<LifecycleSurface>,
204
205 #[serde(default)]
207 pub console: Vec<ConsoleSurface>,
208
209 #[serde(default)]
211 pub console_slots: Vec<ConsoleSlot>,
212
213 #[serde(default)]
215 pub console_contributions: Vec<ConsoleContribution>,
216
217 #[serde(default)]
219 pub capabilities: Vec<String>,
220
221 #[serde(default, skip_serializing_if = "Vec::is_empty")]
223 pub requires: Vec<ModuleRequirement>,
224
225 #[serde(default, skip_serializing_if = "ModuleConfigContract::is_empty")]
226 pub config: ModuleConfigContract,
227
228 #[serde(default, skip_serializing_if = "Vec::is_empty")]
229 pub migrations: Vec<ModuleMigrationDeclaration>,
230}
231
232impl ModuleManifest {
233 #[must_use]
235 pub fn builder(module_id: impl Into<String>) -> ModuleManifestBuilder {
236 ModuleManifestBuilder {
237 manifest: ModuleManifest {
238 protocol: MODULE_MANIFEST_PROTOCOL.to_owned(),
239 module_id: module_id.into(),
240 summary: None,
241 story_display: Vec::new(),
242 admin: None,
243 http_routes: Vec::new(),
244 runtime: None,
245 events: None,
246 lifecycle: None,
247 console: Vec::new(),
248 console_slots: Vec::new(),
249 console_contributions: Vec::new(),
250 capabilities: Vec::new(),
251 requires: Vec::new(),
252 config: ModuleConfigContract::default(),
253 migrations: Vec::new(),
254 },
255 }
256 }
257
258 #[must_use]
263 pub fn console_module_manifest(
264 &self,
265 host_api: impl Into<String>,
266 console_ui: impl Into<String>,
267 ) -> ConsoleModuleManifest {
268 let mut surfaces = self
269 .console
270 .iter()
271 .filter_map(|surface| {
272 if !matches!(surface.presentation, ConsoleSurfacePresentation::Esm { .. }) {
273 return None;
274 }
275 Some(ConsoleModuleSurface {
276 id: surface.name.clone(),
277 path: surface.route.clone(),
278 label: surface.label.clone(),
279 area: console_surface_area(&surface.route),
280 required_capabilities: surface.required_capabilities.clone(),
281 icon: surface.icon.clone(),
282 navigation: surface.navigation.clone(),
283 })
284 })
285 .collect::<Vec<_>>();
286 surfaces.sort_by(|left, right| left.id.cmp(&right.id));
287 ConsoleModuleManifest {
288 protocol: crate::CONSOLE_MODULE_PROTOCOL.to_owned(),
289 module_id: self.module_id.clone(),
290 host_api: host_api.into(),
291 console_ui: console_ui.into(),
292 surfaces,
293 }
294 }
295}
296
297impl ModuleConfigContract {
298 fn is_empty(&self) -> bool {
299 self.fields.is_empty()
300 }
301}
302
303fn console_surface_area(route: &str) -> crate::ConsoleSurfaceArea {
304 match route.split('/').nth(1) {
305 Some("operations") => crate::ConsoleSurfaceArea::Operations,
306 Some("data") => crate::ConsoleSurfaceArea::Data,
307 Some("configuration") => crate::ConsoleSurfaceArea::Configuration,
308 _ => crate::ConsoleSurfaceArea::Runtime,
309 }
310}
311
312pub fn validate_module_config_value(
316 field: &ModuleConfigField,
317 value: &Value,
318) -> Result<(), String> {
319 let type_matches = match field.field_type {
320 ModuleConfigFieldType::String => value.is_string(),
321 ModuleConfigFieldType::Integer => value.as_i64().is_some(),
322 ModuleConfigFieldType::Boolean => value.is_boolean(),
323 ModuleConfigFieldType::Json => true,
324 };
325 if !type_matches {
326 return Err(format!("value for `{}` has the wrong type", field.key));
327 }
328
329 let Some(validation) = &field.validation else {
330 return Ok(());
331 };
332 if let Some(minimum) = validation.minimum
333 && value.as_i64().is_some_and(|number| number < minimum)
334 {
335 return Err(format!("value for `{}` is below the minimum", field.key));
336 }
337 if let Some(maximum) = validation.maximum
338 && value.as_i64().is_some_and(|number| number > maximum)
339 {
340 return Err(format!("value for `{}` is above the maximum", field.key));
341 }
342 if !validation.allowed_values.is_empty()
343 && !value.as_str().is_some_and(|candidate| {
344 validation
345 .allowed_values
346 .iter()
347 .any(|allowed| allowed == candidate)
348 })
349 {
350 return Err(format!("value for `{}` is not an allowed value", field.key));
351 }
352 if let Some(pattern) = &validation.pattern {
353 let regex = regex::Regex::new(pattern)
354 .map_err(|_| format!("validation pattern for `{}` is invalid", field.key))?;
355 if !value
356 .as_str()
357 .is_some_and(|candidate| regex.is_match(candidate))
358 {
359 return Err(format!(
360 "value for `{}` does not match its pattern",
361 field.key
362 ));
363 }
364 }
365 Ok(())
366}
367
368#[derive(
369 Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema, schemars::JsonSchema,
370)]
371#[serde(rename_all = "snake_case")]
372pub enum ModuleManifestLintSeverity {
373 Ok,
374 Warning,
375 Error,
376}
377
378#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema, schemars::JsonSchema)]
379pub struct ModuleManifestLint {
380 pub severity: ModuleManifestLintSeverity,
381 pub subject: String,
382 pub message: String,
383 pub suggestion: String,
384}
385
386#[derive(Debug, Clone, PartialEq, Eq)]
387pub struct ModuleCapabilityReference {
388 pub capability: String,
389 pub subject: String,
390}
391
392pub fn lint_module_manifest(manifest: &ModuleManifest) -> Vec<ModuleManifestLint> {
393 let mut lints = lint_module_manifest_parts(
394 &manifest.module_id,
395 manifest.admin.as_ref(),
396 &manifest.http_routes,
397 manifest.runtime.as_ref(),
398 manifest.events.as_ref(),
399 manifest.lifecycle.as_ref(),
400 &manifest.console,
401 &manifest.console_slots,
402 &manifest.console_contributions,
403 &manifest.capabilities,
404 &manifest.requires,
405 );
406 lint_manifest_contract(manifest, &mut lints);
407 lints
408}
409
410fn lint_manifest_contract(manifest: &ModuleManifest, lints: &mut Vec<ModuleManifestLint>) {
411 if manifest.protocol != MODULE_MANIFEST_PROTOCOL {
412 push_contract_error(
413 lints,
414 "module.protocol",
415 format!("Protocol must be {MODULE_MANIFEST_PROTOCOL}."),
416 "Use the canonical Module Manifest protocol discriminator.",
417 );
418 }
419 if !sorted_unique(&manifest.capabilities) {
420 push_contract_error(
421 lints,
422 "module.capabilities",
423 "Capabilities must be non-empty, sorted, and unique.",
424 "Sort and deduplicate ModuleManifest.capabilities.",
425 );
426 }
427
428 let mut requirement_ids = HashSet::new();
429 for requirement in &manifest.requires {
430 if !requirement_ids.insert(requirement.module_id.as_str()) {
431 push_contract_error(
432 lints,
433 format!("requirement {}", requirement.module_id),
434 "Module requirements must have unique ModuleIds.",
435 "Merge duplicate requirements into one canonical declaration.",
436 );
437 }
438 if !matches!(
439 semver::VersionReq::parse(&requirement.version_requirement),
440 Ok(version) if version.to_string() == requirement.version_requirement
441 ) {
442 push_contract_error(
443 lints,
444 format!("requirement {}", requirement.module_id),
445 "Module version requirement must be normalized SemVer.",
446 "Parse and serialize the version requirement before publishing.",
447 );
448 }
449 if !sorted_unique(&requirement.capabilities) {
450 push_contract_error(
451 lints,
452 format!("requirement {} capabilities", requirement.module_id),
453 "Required capabilities must be non-empty, sorted, and unique.",
454 "Sort and deduplicate the requirement capabilities.",
455 );
456 }
457 }
458
459 let mut config_keys = HashSet::new();
460 for field in &manifest.config.fields {
461 if field.key.trim().is_empty() || !config_keys.insert(field.key.as_str()) {
462 push_contract_error(
463 lints,
464 format!("config {}", field.key),
465 "Config keys must be non-empty and unique.",
466 "Give each config field one stable key.",
467 );
468 }
469 let secret_named = field
470 .key
471 .split(['.', '-', '_'])
472 .any(|part| matches!(part, "secret" | "password" | "token" | "credential"));
473 if (field.sensitive || field.secret_reference || secret_named) && field.default.is_some() {
474 push_contract_error(
475 lints,
476 format!("config {} default", field.key),
477 "Secret-bearing config fields must not embed default values.",
478 "Resolve secret values outside the Module contract.",
479 );
480 }
481 if field.sensitive && !field.secret_reference {
482 push_contract_error(
483 lints,
484 format!("config {} secret_reference", field.key),
485 "Sensitive config must be declared as a secret reference.",
486 "Set secret_reference and keep the value outside the Manifest.",
487 );
488 }
489 if field.sensitive && field.read_capability.is_some() {
490 push_contract_error(
491 lints,
492 format!("config {} read_capability", field.key),
493 "Sensitive config fields are write-only and must not declare a read capability.",
494 "Remove read_capability and use a write capability for secret rotation.",
495 );
496 }
497 if !field.sensitive
498 && field
499 .read_capability
500 .as_deref()
501 .is_none_or(|value| value.trim().is_empty())
502 {
503 push_contract_error(
504 lints,
505 format!("config {} read_capability", field.key),
506 "Readable config fields require a non-empty read capability.",
507 "Declare the least-privilege capability that can read this field.",
508 );
509 }
510 if field.mutability != ModuleConfigMutability::Static
511 && field
512 .write_capability
513 .as_deref()
514 .is_none_or(|value| value.trim().is_empty())
515 {
516 push_contract_error(
517 lints,
518 format!("config {} write_capability", field.key),
519 "Mutable config fields require a non-empty write capability.",
520 "Declare the least-privilege capability that can update this field.",
521 );
522 }
523 if field
524 .read_capability
525 .as_deref()
526 .is_some_and(|value| value.trim().is_empty())
527 || field
528 .write_capability
529 .as_deref()
530 .is_some_and(|value| value.trim().is_empty())
531 {
532 push_contract_error(
533 lints,
534 format!("config {} capabilities", field.key),
535 "Config capability identifiers must be non-empty when present.",
536 "Remove empty capability values or replace them with stable identifiers.",
537 );
538 }
539 }
540
541 let mut migration_ids = HashSet::new();
542 let mut migration_orders = HashSet::new();
543 for migration in &manifest.migrations {
544 if migration.migration_id.trim().is_empty()
545 || migration.store.trim().is_empty()
546 || !migration_ids.insert(migration.migration_id.as_str())
547 || !migration_orders.insert(migration.order)
548 {
549 push_contract_error(
550 lints,
551 format!("migration {}", migration.migration_id),
552 "Migrations require non-empty identities and stores with unique order values.",
553 "Declare one deterministic order for each migration.",
554 );
555 }
556 }
557}
558
559fn sorted_unique(values: &[String]) -> bool {
560 values.iter().all(|value| !value.trim().is_empty())
561 && values.windows(2).all(|pair| pair[0] < pair[1])
562}
563
564fn push_contract_error(
565 lints: &mut Vec<ModuleManifestLint>,
566 subject: impl Into<String>,
567 message: impl Into<String>,
568 suggestion: impl Into<String>,
569) {
570 lints.push(ModuleManifestLint {
571 severity: ModuleManifestLintSeverity::Error,
572 subject: subject.into(),
573 message: message.into(),
574 suggestion: suggestion.into(),
575 });
576}
577
578pub fn lint_module_manifest_parts(
579 module_id: &str,
580 admin: Option<&AdminSurface>,
581 http_routes: &[ModuleHttpRoute],
582 runtime: Option<&RuntimeSurface>,
583 events: Option<&EventSurface>,
584 lifecycle: Option<&LifecycleSurface>,
585 console: &[ConsoleSurface],
586 console_slots: &[ConsoleSlot],
587 console_contributions: &[ConsoleContribution],
588 capabilities: &[String],
589 requirements: &[ModuleRequirement],
590) -> Vec<ModuleManifestLint> {
591 let mut lints = Vec::new();
592 let module_name = module_id.rsplit('/').next().unwrap_or(module_id);
593
594 if !valid_module_id(module_id) {
595 lints.push(ModuleManifestLint {
596 severity: ModuleManifestLintSeverity::Error,
597 subject: "module.module_id".to_owned(),
598 message: "ModuleId must use the fully qualified namespace/name form.".to_owned(),
599 suggestion: "Set ModuleManifest.module_id to a stable value such as lenso/auth."
600 .to_owned(),
601 });
602 }
603
604 for capability in capabilities {
605 if !valid_capability(capability) {
606 lints.push(ModuleManifestLint {
607 severity: ModuleManifestLintSeverity::Warning,
608 subject: format!("capability {capability}"),
609 message: "Capability name should use dot-separated lowercase identifiers."
610 .to_owned(),
611 suggestion: "Use a stable capability name such as module.entity.read.".to_owned(),
612 });
613 }
614 }
615 for requirement in requirements {
616 if !valid_module_id(&requirement.module_id)
617 || semver::VersionReq::parse(&requirement.version_requirement).is_err()
618 {
619 lints.push(ModuleManifestLint {
620 severity: ModuleManifestLintSeverity::Error,
621 subject: format!("requirement {}", requirement.module_id),
622 message: "Module requirement identity and version range must be valid.".to_owned(),
623 suggestion: "Use a fully qualified ModuleId and normalized SemVer requirement."
624 .to_owned(),
625 });
626 } else if requirement.module_id == module_id {
627 lints.push(ModuleManifestLint {
628 severity: ModuleManifestLintSeverity::Error,
629 subject: format!("requirement {}", requirement.module_id),
630 message: "Module must not depend on itself.".to_owned(),
631 suggestion: "Remove the self requirement from ModuleManifest.requires.".to_owned(),
632 });
633 }
634 }
635
636 for route_lint in lint_module_http_routes(http_routes) {
637 lints.push(ModuleManifestLint {
638 severity: match route_lint.severity {
639 crate::http::ModuleRouteLintSeverity::Ok => ModuleManifestLintSeverity::Ok,
640 crate::http::ModuleRouteLintSeverity::Warning => {
641 ModuleManifestLintSeverity::Warning
642 }
643 crate::http::ModuleRouteLintSeverity::Error => ModuleManifestLintSeverity::Error,
644 },
645 subject: route_lint.subject,
646 message: route_lint.message,
647 suggestion: route_lint.suggestion,
648 });
649 }
650 lint_capability_references(
651 admin,
652 http_routes,
653 lifecycle,
654 console,
655 console_contributions,
656 capabilities,
657 &mut lints,
658 );
659
660 if let Some(admin) = admin {
661 lint_admin_surface(admin, &mut lints);
662 }
663 let mut runtime_lints = Vec::new();
664 if let Some(runtime) = runtime {
665 lint_runtime_surface(module_name, runtime, &mut runtime_lints);
666 }
667 if let Some(events) = events {
668 lint_event_surface(events, &mut lints);
669 }
670 if let Some(lifecycle) = lifecycle {
671 lint_lifecycle_surface(lifecycle, runtime, capabilities, &mut lints);
672 }
673 lint_console_surfaces(console, &mut lints);
674 lint_console_slots(console_slots, &mut lints);
675 lint_console_contributions(console_contributions, &mut lints);
676 lints.extend(runtime_lints);
677
678 if lints.is_empty() {
679 lints.push(ModuleManifestLint {
680 severity: ModuleManifestLintSeverity::Ok,
681 subject: "manifest".to_owned(),
682 message: "Module manifest metadata is complete.".to_owned(),
683 suggestion: "No action needed.".to_owned(),
684 });
685 }
686
687 lints
688}
689
690pub fn module_capability_references(
691 admin: Option<&AdminSurface>,
692 http_routes: &[ModuleHttpRoute],
693 lifecycle: Option<&LifecycleSurface>,
694 console: &[ConsoleSurface],
695 console_contributions: &[ConsoleContribution],
696) -> Vec<ModuleCapabilityReference> {
697 let mut references = Vec::new();
698
699 for route in http_routes {
700 if let Some(capability) = route.capability.as_deref()
701 && present(capability)
702 {
703 references.push(ModuleCapabilityReference {
704 capability: capability.to_owned(),
705 subject: format!("http_route.{}", route_identity(route)),
706 });
707 }
708 }
709
710 if let Some(admin) = admin {
711 collect_admin_capability_references(admin, &mut references);
712 }
713
714 if let Some(lifecycle) = lifecycle {
715 for check in &lifecycle.startup_checks {
716 if let LifecycleStartupCheckKind::CapabilityDeclared { capability } = &check.check
717 && present(capability)
718 {
719 references.push(ModuleCapabilityReference {
720 capability: capability.to_owned(),
721 subject: format!("lifecycle.startup_check.capability.{capability}"),
722 });
723 }
724 }
725 }
726
727 for surface in console {
728 let subject = if present(&surface.name) {
729 format!("console.surface.{}", surface.name)
730 } else {
731 "console.surface".to_owned()
732 };
733 for capability in &surface.required_capabilities {
734 if present(capability) {
735 references.push(ModuleCapabilityReference {
736 capability: capability.clone(),
737 subject: subject.clone(),
738 });
739 }
740 }
741 }
742
743 for contribution in console_contributions {
744 let subject = if present(&contribution.target) {
745 format!("console.contribution.{}", contribution.target)
746 } else {
747 "console.contribution".to_owned()
748 };
749 for capability in &contribution.required_capabilities {
750 if present(capability) {
751 references.push(ModuleCapabilityReference {
752 capability: capability.clone(),
753 subject: subject.clone(),
754 });
755 }
756 }
757 }
758
759 references
760}
761
762fn lint_capability_references(
763 admin: Option<&AdminSurface>,
764 http_routes: &[ModuleHttpRoute],
765 lifecycle: Option<&LifecycleSurface>,
766 console: &[ConsoleSurface],
767 console_contributions: &[ConsoleContribution],
768 capabilities: &[String],
769 lints: &mut Vec<ModuleManifestLint>,
770) {
771 let declared = capabilities
772 .iter()
773 .map(String::as_str)
774 .collect::<HashSet<_>>();
775
776 for reference in module_capability_references(
777 admin,
778 http_routes,
779 lifecycle,
780 console,
781 console_contributions,
782 ) {
783 if reference.subject.starts_with("lifecycle.") {
786 continue;
787 }
788 if declared.contains(reference.capability.as_str()) {
789 continue;
790 }
791 lints.push(ModuleManifestLint {
792 severity: ModuleManifestLintSeverity::Warning,
793 subject: format!("capability.reference.{}", reference.subject),
794 message: "Capability reference is not declared by the module.".to_owned(),
795 suggestion: format!(
796 "Add `{}` to ModuleManifest.capabilities or update the reference.",
797 reference.capability
798 ),
799 });
800 }
801}
802
803fn collect_admin_capability_references(
804 admin: &AdminSurface,
805 references: &mut Vec<ModuleCapabilityReference>,
806) {
807 match admin {
808 AdminSurface::Schema(schema) => {
809 collect_schema_capability_references("admin.schema", schema, references);
810 }
811 AdminSurface::DeclarativeCustom(surface) => {
812 collect_declarative_query_capability_references(surface, references);
813 for action in &surface.actions {
814 if present(&action.capability) {
815 let action_subject = if present(&action.name) {
816 format!("admin.declarative.action.{}", action.name)
817 } else {
818 "admin.declarative.action".to_owned()
819 };
820 references.push(ModuleCapabilityReference {
821 capability: action.capability.clone(),
822 subject: action_subject,
823 });
824 }
825 }
826 if let Some(schema) = &surface.fallback_schema {
827 collect_schema_capability_references(
828 "admin.declarative.fallback_schema",
829 schema,
830 references,
831 );
832 }
833 }
834 AdminSurface::EmbeddedCustom(surface) => {
835 if let Some(schema) = &surface.fallback_schema {
836 collect_schema_capability_references(
837 "admin.embedded.fallback_schema",
838 schema,
839 references,
840 );
841 }
842 }
843 }
844}
845
846fn collect_schema_capability_references(
847 prefix: &str,
848 schema: &AdminSchema,
849 references: &mut Vec<ModuleCapabilityReference>,
850) {
851 for entity in &schema.entities {
852 if present(&entity.read_capability) {
853 references.push(ModuleCapabilityReference {
854 capability: entity.read_capability.clone(),
855 subject: format!("{prefix}.{}", entity.name),
856 });
857 }
858 }
859}
860
861fn lint_runtime_surface(
862 module_name: &str,
863 runtime: &RuntimeSurface,
864 lints: &mut Vec<ModuleManifestLint>,
865) {
866 if runtime.functions.is_empty() && runtime.schedules.is_empty() && runtime.workflows.is_empty()
867 {
868 lints.push(ModuleManifestLint {
869 severity: ModuleManifestLintSeverity::Warning,
870 subject: "runtime".to_owned(),
871 message: "Runtime surface declares no functions, schedules, or workflows.".to_owned(),
872 suggestion: "Add at least one runtime declaration or omit the runtime surface."
873 .to_owned(),
874 });
875 return;
876 }
877
878 let mut names = HashSet::new();
879 for function in &runtime.functions {
880 lint_runtime_function(function, &mut names, lints);
881 }
882 let function_names = runtime_function_names(Some(runtime));
883 let mut schedule_names = HashSet::new();
884 for schedule in &runtime.schedules {
885 lint_scheduled_function(schedule, &function_names, &mut schedule_names, lints);
886 }
887 let mut workflow_identities = HashSet::new();
888 for workflow in &runtime.workflows {
889 lint_workflow_definition(module_name, workflow, &mut workflow_identities, lints);
890 }
891}
892
893fn lint_workflow_definition(
894 module_name: &str,
895 workflow: &WorkflowDefinition,
896 identities: &mut HashSet<(String, String, String)>,
897 lints: &mut Vec<ModuleManifestLint>,
898) {
899 let subject = if present(&workflow.name) && present(&workflow.version) {
900 format!("runtime.workflow.{}.{}", workflow.name, workflow.version)
901 } else {
902 "runtime.workflow".to_owned()
903 };
904
905 if workflow.protocol != WORKFLOW_DEFINITION_PROTOCOL {
906 lints.push(ModuleManifestLint {
907 severity: ModuleManifestLintSeverity::Error,
908 subject: format!("{subject}.protocol"),
909 message: "Durable Workflow definition uses an unsupported protocol.".to_owned(),
910 suggestion: format!("Set protocol to {WORKFLOW_DEFINITION_PROTOCOL}."),
911 });
912 }
913 if !present(&workflow.owner) || workflow.owner != module_name {
914 lints.push(ModuleManifestLint {
915 severity: ModuleManifestLintSeverity::Error,
916 subject: format!("{subject}.owner"),
917 message: "Durable Workflow owner must match the declaring Module.".to_owned(),
918 suggestion: format!("Set owner to the Module name `{module_name}`."),
919 });
920 }
921 if !present(&workflow.name) {
922 lints.push(ModuleManifestLint {
923 severity: ModuleManifestLintSeverity::Error,
924 subject: subject.clone(),
925 message: "Durable Workflow definition is missing a stable name.".to_owned(),
926 suggestion: "Set a path-safe workflow name such as support_sla.".to_owned(),
927 });
928 } else if !valid_runtime_function_name(&workflow.name) {
929 lints.push(ModuleManifestLint {
930 severity: ModuleManifestLintSeverity::Error,
931 subject: subject.clone(),
932 message: "Durable Workflow name must be path-safe.".to_owned(),
933 suggestion: "Use ASCII letters, digits, dot, underscore, or hyphen.".to_owned(),
934 });
935 }
936 if !present(&workflow.version) {
937 lints.push(ModuleManifestLint {
938 severity: ModuleManifestLintSeverity::Error,
939 subject: format!("{subject}.version"),
940 message: "Durable Workflow definition is missing a version.".to_owned(),
941 suggestion: "Set a stable definition version such as v1.".to_owned(),
942 });
943 } else if !valid_runtime_function_name(&workflow.version) {
944 lints.push(ModuleManifestLint {
945 severity: ModuleManifestLintSeverity::Error,
946 subject: format!("{subject}.version"),
947 message: "Durable Workflow version must be path-safe.".to_owned(),
948 suggestion: "Use a stable path-safe version such as v1 or 1.0.0.".to_owned(),
949 });
950 }
951 if present(&workflow.owner)
952 && present(&workflow.name)
953 && present(&workflow.version)
954 && !identities.insert((
955 workflow.owner.clone(),
956 workflow.name.clone(),
957 workflow.version.clone(),
958 ))
959 {
960 lints.push(ModuleManifestLint {
961 severity: ModuleManifestLintSeverity::Error,
962 subject: subject.clone(),
963 message: "Duplicate Durable Workflow definition identity.".to_owned(),
964 suggestion: "Keep one declaration per owner, name, and version.".to_owned(),
965 });
966 }
967
968 lint_workflow_data_contract(
969 &format!("{subject}.input_contract"),
970 &workflow.input_contract,
971 lints,
972 );
973 lint_workflow_data_contract(
974 &format!("{subject}.result_contract"),
975 &workflow.result_contract,
976 lints,
977 );
978
979 if workflow.steps.is_empty() {
980 lints.push(ModuleManifestLint {
981 severity: ModuleManifestLintSeverity::Error,
982 subject: format!("{subject}.steps"),
983 message: "Durable Workflow definition must declare an ordered first step.".to_owned(),
984 suggestion: "Add at least one stable step declaration.".to_owned(),
985 });
986 }
987 let mut step_names = HashSet::new();
988 let mut compensation_names = HashSet::new();
989 let mut compensation_orders = HashSet::new();
990 for step in &workflow.steps {
991 let step_subject = if present(&step.name) {
992 format!("{subject}.step.{}", step.name)
993 } else {
994 format!("{subject}.step")
995 };
996 if !present(&step.name) || !valid_runtime_function_name(&step.name) {
997 lints.push(ModuleManifestLint {
998 severity: ModuleManifestLintSeverity::Error,
999 subject: step_subject.clone(),
1000 message: "Durable Workflow step name must be a non-empty path-safe identifier."
1001 .to_owned(),
1002 suggestion: "Use a stable step name such as acknowledge_ticket.".to_owned(),
1003 });
1004 } else if !step_names.insert(step.name.clone()) {
1005 lints.push(ModuleManifestLint {
1006 severity: ModuleManifestLintSeverity::Error,
1007 subject: step_subject.clone(),
1008 message: "Durable Workflow step name is declared more than once.".to_owned(),
1009 suggestion: "Keep one ordered declaration per stable step name.".to_owned(),
1010 });
1011 }
1012 lint_workflow_step_recovery(
1013 step,
1014 &step_subject,
1015 &mut compensation_names,
1016 &mut compensation_orders,
1017 lints,
1018 );
1019 }
1020}
1021
1022fn lint_workflow_step_recovery(
1023 step: &WorkflowStepDeclaration,
1024 subject: &str,
1025 compensation_names: &mut HashSet<String>,
1026 compensation_orders: &mut HashSet<u32>,
1027 lints: &mut Vec<ModuleManifestLint>,
1028) {
1029 if let Some(retry_policy) = &step.retry_policy
1030 && (retry_policy.max_attempts == 0
1031 || i32::try_from(retry_policy.max_attempts).is_err()
1032 || retry_policy.delays_ms.len()
1033 != usize::try_from(retry_policy.max_attempts.saturating_sub(1))
1034 .unwrap_or(usize::MAX)
1035 || retry_policy
1036 .delays_ms
1037 .iter()
1038 .any(|delay| i64::try_from(*delay).is_err()))
1039 {
1040 lints.push(ModuleManifestLint {
1041 severity: ModuleManifestLintSeverity::Error,
1042 subject: format!("{subject}.retry_policy"),
1043 message: "Durable Workflow retry schedule must use supported attempt and delay values."
1044 .to_owned(),
1045 suggestion: "Set maxAttempts to 1..=2147483647 and provide maxAttempts - 1 delaysMs entries within the signed 64-bit range."
1046 .to_owned(),
1047 });
1048 }
1049 if step
1050 .timeout_ms
1051 .is_some_and(|timeout| timeout == 0 || i64::try_from(timeout).is_err())
1052 {
1053 lints.push(ModuleManifestLint {
1054 severity: ModuleManifestLintSeverity::Error,
1055 subject: format!("{subject}.timeout_ms"),
1056 message: "Durable Workflow timeout must use a supported positive value.".to_owned(),
1057 suggestion: "Set timeoutMs within the positive signed 64-bit range or omit it."
1058 .to_owned(),
1059 });
1060 }
1061 if let Some(compensation) = &step.compensation {
1062 let compensation_subject = format!("{subject}.compensation");
1063 if !present(&compensation.name) || !valid_runtime_function_name(&compensation.name) {
1064 lints.push(ModuleManifestLint {
1065 severity: ModuleManifestLintSeverity::Error,
1066 subject: format!("{compensation_subject}.name"),
1067 message:
1068 "Durable Workflow compensation name must be a non-empty path-safe identifier."
1069 .to_owned(),
1070 suggestion: "Use a stable compensation name such as release_sla_reservation."
1071 .to_owned(),
1072 });
1073 } else if !compensation_names.insert(compensation.name.clone()) {
1074 lints.push(ModuleManifestLint {
1075 severity: ModuleManifestLintSeverity::Error,
1076 subject: format!("{compensation_subject}.name"),
1077 message: "Durable Workflow compensation name is declared more than once."
1078 .to_owned(),
1079 suggestion: "Keep one stable compensation name per Workflow Definition.".to_owned(),
1080 });
1081 }
1082 if compensation.order == 0 || i32::try_from(compensation.order).is_err() {
1083 lints.push(ModuleManifestLint {
1084 severity: ModuleManifestLintSeverity::Error,
1085 subject: format!("{compensation_subject}.order"),
1086 message: "Durable Workflow compensation order must use a supported positive value."
1087 .to_owned(),
1088 suggestion: "Set order to a unique value within 1..=2147483647.".to_owned(),
1089 });
1090 } else if !compensation_orders.insert(compensation.order) {
1091 lints.push(ModuleManifestLint {
1092 severity: ModuleManifestLintSeverity::Error,
1093 subject: format!("{compensation_subject}.order"),
1094 message: "Durable Workflow compensation order is declared more than once."
1095 .to_owned(),
1096 suggestion: "Assign one deterministic order to each compensation.".to_owned(),
1097 });
1098 }
1099 lint_workflow_data_contract(
1100 &format!("{compensation_subject}.contract"),
1101 &compensation.contract,
1102 lints,
1103 );
1104 lint_workflow_data_contract(
1105 &format!("{compensation_subject}.completion_contract"),
1106 &compensation.completion_contract,
1107 lints,
1108 );
1109 }
1110}
1111
1112fn lint_workflow_data_contract(
1113 subject: &str,
1114 contract: &WorkflowDataContract,
1115 lints: &mut Vec<ModuleManifestLint>,
1116) {
1117 if !present(&contract.contract_id) || !present(&contract.version) {
1118 lints.push(ModuleManifestLint {
1119 severity: ModuleManifestLintSeverity::Error,
1120 subject: subject.to_owned(),
1121 message: "Durable Workflow data contract requires a stable identity and version."
1122 .to_owned(),
1123 suggestion: "Set contractId and version to stable contract identifiers.".to_owned(),
1124 });
1125 }
1126}
1127
1128fn lint_runtime_function(
1129 function: &RuntimeFunctionDeclaration,
1130 names: &mut HashSet<String>,
1131 lints: &mut Vec<ModuleManifestLint>,
1132) {
1133 let subject = if present(&function.name) {
1134 format!("runtime.function.{}", function.name)
1135 } else {
1136 "runtime.function".to_owned()
1137 };
1138
1139 if !present(&function.name) {
1140 lints.push(ModuleManifestLint {
1141 severity: ModuleManifestLintSeverity::Error,
1142 subject: subject.clone(),
1143 message: "Runtime function declaration is missing a name.".to_owned(),
1144 suggestion: "Set a stable versioned function name such as module.action.v1.".to_owned(),
1145 });
1146 } else if !valid_runtime_function_name(&function.name) {
1147 lints.push(ModuleManifestLint {
1148 severity: ModuleManifestLintSeverity::Warning,
1149 subject: subject.clone(),
1150 message: "Runtime function name should be a stable path-safe identifier.".to_owned(),
1151 suggestion: "Use ASCII letters, digits, dot, underscore, or hyphen.".to_owned(),
1152 });
1153 } else if !names.insert(function.name.clone()) {
1154 lints.push(ModuleManifestLint {
1155 severity: ModuleManifestLintSeverity::Error,
1156 subject: subject.clone(),
1157 message: "Duplicate runtime function declaration.".to_owned(),
1158 suggestion: "Keep one declaration per runtime function name.".to_owned(),
1159 });
1160 }
1161
1162 if !present(&function.queue) {
1163 lints.push(ModuleManifestLint {
1164 severity: ModuleManifestLintSeverity::Warning,
1165 subject: subject.clone(),
1166 message: "Runtime function declaration is missing a queue.".to_owned(),
1167 suggestion: "Set the host queue used to claim this function.".to_owned(),
1168 });
1169 }
1170
1171 if let Some(input_schema) = &function.input_schema
1172 && input_schema != &function.name
1173 {
1174 lints.push(ModuleManifestLint {
1175 severity: ModuleManifestLintSeverity::Warning,
1176 subject: format!("{subject}.input_schema"),
1177 message: "Runtime function input schema does not match the function name.".to_owned(),
1178 suggestion: "Use the versioned function name as the input_schema contract identifier."
1179 .to_owned(),
1180 });
1181 }
1182
1183 if let Some(retry_policy) = &function.retry_policy
1184 && retry_policy.max_attempts == 0
1185 {
1186 lints.push(ModuleManifestLint {
1187 severity: ModuleManifestLintSeverity::Warning,
1188 subject: format!("{subject}.retry_policy"),
1189 message: "Runtime function retry policy declares zero attempts.".to_owned(),
1190 suggestion: "Set max_attempts to at least 1 or omit the retry policy.".to_owned(),
1191 });
1192 }
1193}
1194
1195fn lint_scheduled_function(
1196 schedule: &ScheduledFunctionDeclaration,
1197 runtime_functions: &HashSet<String>,
1198 names: &mut HashSet<String>,
1199 lints: &mut Vec<ModuleManifestLint>,
1200) {
1201 let subject = if present(&schedule.name) {
1202 format!("runtime.schedule.{}", schedule.name)
1203 } else {
1204 "runtime.schedule".to_owned()
1205 };
1206
1207 if !present(&schedule.name) {
1208 lints.push(ModuleManifestLint {
1209 severity: ModuleManifestLintSeverity::Error,
1210 subject: subject.clone(),
1211 message: "Scheduled runtime function is missing a name.".to_owned(),
1212 suggestion: "Set a stable schedule name such as sync_contacts_hourly.".to_owned(),
1213 });
1214 } else if !valid_runtime_function_name(&schedule.name) {
1215 lints.push(ModuleManifestLint {
1216 severity: ModuleManifestLintSeverity::Warning,
1217 subject: subject.clone(),
1218 message: "Scheduled runtime function name should be path-safe.".to_owned(),
1219 suggestion: "Use ASCII letters, digits, dot, underscore, or hyphen.".to_owned(),
1220 });
1221 } else if !names.insert(schedule.name.clone()) {
1222 lints.push(ModuleManifestLint {
1223 severity: ModuleManifestLintSeverity::Error,
1224 subject: subject.clone(),
1225 message: "Duplicate scheduled runtime function declaration.".to_owned(),
1226 suggestion: "Keep one schedule declaration per schedule name.".to_owned(),
1227 });
1228 }
1229
1230 if !present(&schedule.cron) {
1231 lints.push(ModuleManifestLint {
1232 severity: ModuleManifestLintSeverity::Error,
1233 subject: format!("{subject}.cron"),
1234 message: "Scheduled runtime function is missing a cron expression.".to_owned(),
1235 suggestion: "Set cron to a standard 5-field UTC cron expression.".to_owned(),
1236 });
1237 } else if validate_cron_expression(&schedule.cron).is_err() {
1238 lints.push(ModuleManifestLint {
1239 severity: ModuleManifestLintSeverity::Error,
1240 subject: format!("{subject}.cron"),
1241 message: "Scheduled runtime function cron expression is invalid.".to_owned(),
1242 suggestion: "Use a standard 5-field expression such as */15 * * * *.".to_owned(),
1243 });
1244 }
1245
1246 if !present(&schedule.function_name) {
1247 lints.push(ModuleManifestLint {
1248 severity: ModuleManifestLintSeverity::Error,
1249 subject,
1250 message: "Scheduled runtime function is missing a function name.".to_owned(),
1251 suggestion: "Set function_name to a declared runtime function.".to_owned(),
1252 });
1253 } else if !runtime_functions.contains(&schedule.function_name) {
1254 lints.push(ModuleManifestLint {
1255 severity: ModuleManifestLintSeverity::Error,
1256 subject,
1257 message: "Scheduled runtime function references an unknown runtime function."
1258 .to_owned(),
1259 suggestion:
1260 "Declare the function in ModuleManifest.runtime.functions or remove the schedule."
1261 .to_owned(),
1262 });
1263 }
1264}
1265
1266fn lint_event_surface(events: &EventSurface, lints: &mut Vec<ModuleManifestLint>) {
1267 if events.handlers.is_empty() {
1268 lints.push(ModuleManifestLint {
1269 severity: ModuleManifestLintSeverity::Warning,
1270 subject: "events.handlers".to_owned(),
1271 message: "Event surface declares no handlers.".to_owned(),
1272 suggestion: "Add at least one event handler declaration or omit the events surface."
1273 .to_owned(),
1274 });
1275 return;
1276 }
1277
1278 let mut names = HashSet::new();
1279 for handler in &events.handlers {
1280 lint_event_handler(handler, &mut names, lints);
1281 }
1282}
1283
1284fn lint_event_handler(
1285 handler: &EventHandlerDeclaration,
1286 names: &mut HashSet<String>,
1287 lints: &mut Vec<ModuleManifestLint>,
1288) {
1289 let subject = if present(&handler.name) {
1290 format!("events.handler.{}", handler.name)
1291 } else {
1292 "events.handler".to_owned()
1293 };
1294
1295 if !present(&handler.name) {
1296 lints.push(ModuleManifestLint {
1297 severity: ModuleManifestLintSeverity::Error,
1298 subject: subject.clone(),
1299 message: "Event handler declaration is missing a name.".to_owned(),
1300 suggestion: "Set a stable handler name such as sync_contact_on_user_registered."
1301 .to_owned(),
1302 });
1303 } else if !valid_runtime_function_name(&handler.name) {
1304 lints.push(ModuleManifestLint {
1305 severity: ModuleManifestLintSeverity::Warning,
1306 subject: subject.clone(),
1307 message: "Event handler name should be a stable path-safe identifier.".to_owned(),
1308 suggestion: "Use ASCII letters, digits, dot, underscore, or hyphen.".to_owned(),
1309 });
1310 } else if !names.insert(handler.name.clone()) {
1311 lints.push(ModuleManifestLint {
1312 severity: ModuleManifestLintSeverity::Error,
1313 subject: subject.clone(),
1314 message: "Duplicate event handler declaration.".to_owned(),
1315 suggestion: "Keep one declaration per event handler name.".to_owned(),
1316 });
1317 }
1318
1319 if !present(&handler.event_name) {
1320 lints.push(ModuleManifestLint {
1321 severity: ModuleManifestLintSeverity::Error,
1322 subject: format!("{subject}.event_name"),
1323 message: "Event handler declaration is missing an event_name.".to_owned(),
1324 suggestion: "Set the stable outbox event name this handler consumes.".to_owned(),
1325 });
1326 } else if !valid_runtime_function_name(&handler.event_name) {
1327 lints.push(ModuleManifestLint {
1328 severity: ModuleManifestLintSeverity::Warning,
1329 subject: format!("{subject}.event_name"),
1330 message: "Event name should be a stable path-safe identifier.".to_owned(),
1331 suggestion: "Use the versioned event name such as identity.user_registered.v1."
1332 .to_owned(),
1333 });
1334 }
1335}
1336
1337fn lint_lifecycle_surface(
1338 lifecycle: &LifecycleSurface,
1339 runtime: Option<&RuntimeSurface>,
1340 capabilities: &[String],
1341 lints: &mut Vec<ModuleManifestLint>,
1342) {
1343 if lifecycle.startup_checks.is_empty() && lifecycle.activation_jobs.is_empty() {
1344 lints.push(ModuleManifestLint {
1345 severity: ModuleManifestLintSeverity::Warning,
1346 subject: "lifecycle".to_owned(),
1347 message: "Lifecycle surface declares no startup checks or activation jobs.".to_owned(),
1348 suggestion: "Add lifecycle entries or omit the lifecycle surface.".to_owned(),
1349 });
1350 return;
1351 }
1352
1353 let runtime_functions = runtime_function_names(runtime);
1354 let capability_names = capabilities.iter().cloned().collect::<HashSet<_>>();
1355
1356 for check in &lifecycle.startup_checks {
1357 lint_lifecycle_startup_check(check, &runtime_functions, &capability_names, lints);
1358 }
1359
1360 for job in &lifecycle.activation_jobs {
1361 lint_lifecycle_activation_job(job, &runtime_functions, lints);
1362 }
1363}
1364
1365fn lint_lifecycle_startup_check(
1366 check: &LifecycleStartupCheckDeclaration,
1367 runtime_functions: &HashSet<String>,
1368 capabilities: &HashSet<String>,
1369 lints: &mut Vec<ModuleManifestLint>,
1370) {
1371 if !present(&check.name) {
1372 lints.push(ModuleManifestLint {
1373 severity: ModuleManifestLintSeverity::Warning,
1374 subject: "lifecycle.startup_check".to_owned(),
1375 message: "Lifecycle startup check is missing a name.".to_owned(),
1376 suggestion: "Set a short operator-facing check name.".to_owned(),
1377 });
1378 }
1379
1380 match &check.check {
1381 LifecycleStartupCheckKind::FunctionRegistered { function_name } => {
1382 if !runtime_functions.contains(function_name) {
1383 lints.push(ModuleManifestLint {
1384 severity: ModuleManifestLintSeverity::Error,
1385 subject: format!(
1386 "lifecycle.startup_check.function_registered.{function_name}"
1387 ),
1388 message: "Lifecycle startup check references an unknown runtime function."
1389 .to_owned(),
1390 suggestion:
1391 "Declare the function in ModuleManifest.runtime.functions or remove the check."
1392 .to_owned(),
1393 });
1394 }
1395 }
1396 LifecycleStartupCheckKind::CapabilityDeclared { capability } => {
1397 if !capabilities.contains(capability) {
1398 lints.push(ModuleManifestLint {
1399 severity: ModuleManifestLintSeverity::Warning,
1400 subject: format!("lifecycle.startup_check.capability.{capability}"),
1401 message: "Lifecycle startup check references an undeclared capability."
1402 .to_owned(),
1403 suggestion:
1404 "Add the capability to ModuleManifest.capabilities or update the check."
1405 .to_owned(),
1406 });
1407 }
1408 }
1409 }
1410}
1411
1412fn lint_lifecycle_activation_job(
1413 job: &LifecycleActivationJobDeclaration,
1414 runtime_functions: &HashSet<String>,
1415 lints: &mut Vec<ModuleManifestLint>,
1416) {
1417 let subject = if present(&job.name) {
1418 format!("lifecycle.activation_job.{}", job.name)
1419 } else {
1420 "lifecycle.activation_job".to_owned()
1421 };
1422
1423 if !present(&job.name) {
1424 lints.push(ModuleManifestLint {
1425 severity: ModuleManifestLintSeverity::Warning,
1426 subject: subject.clone(),
1427 message: "Lifecycle activation job is missing a name.".to_owned(),
1428 suggestion: "Set a short operator-facing activation job name.".to_owned(),
1429 });
1430 }
1431
1432 if !present(&job.function_name) {
1433 lints.push(ModuleManifestLint {
1434 severity: ModuleManifestLintSeverity::Error,
1435 subject,
1436 message: "Lifecycle activation job is missing a function name.".to_owned(),
1437 suggestion: "Set function_name to a declared runtime function.".to_owned(),
1438 });
1439 } else if !runtime_functions.contains(&job.function_name) {
1440 lints.push(ModuleManifestLint {
1441 severity: ModuleManifestLintSeverity::Error,
1442 subject,
1443 message: "Lifecycle activation job references an unknown runtime function.".to_owned(),
1444 suggestion:
1445 "Declare the function in ModuleManifest.runtime.functions or remove the activation job."
1446 .to_owned(),
1447 });
1448 }
1449}
1450
1451fn runtime_function_names(runtime: Option<&RuntimeSurface>) -> HashSet<String> {
1452 runtime
1453 .into_iter()
1454 .flat_map(|surface| surface.functions.iter())
1455 .map(|function| function.name.clone())
1456 .collect()
1457}
1458
1459fn lint_console_surfaces(console: &[ConsoleSurface], lints: &mut Vec<ModuleManifestLint>) {
1460 let mut names = HashSet::new();
1461 let mut routes = HashSet::new();
1462
1463 for surface in console {
1464 let subject = if present(&surface.name) {
1465 format!("console.surface.{}", surface.name)
1466 } else {
1467 "console.surface".to_owned()
1468 };
1469
1470 if !present(&surface.name) {
1471 lints.push(ModuleManifestLint {
1472 severity: ModuleManifestLintSeverity::Error,
1473 subject: subject.clone(),
1474 message: "Console surface is missing a name.".to_owned(),
1475 suggestion: "Set a stable surface name such as stories.".to_owned(),
1476 });
1477 } else if !valid_console_surface_name(&surface.name) {
1478 lints.push(ModuleManifestLint {
1479 severity: ModuleManifestLintSeverity::Warning,
1480 subject: subject.clone(),
1481 message: "Console surface name should be a path-safe identifier.".to_owned(),
1482 suggestion: "Use ASCII letters, digits, underscore, or hyphen.".to_owned(),
1483 });
1484 } else if !names.insert(surface.name.clone()) {
1485 lints.push(ModuleManifestLint {
1486 severity: ModuleManifestLintSeverity::Error,
1487 subject: subject.clone(),
1488 message: "Duplicate console surface declaration.".to_owned(),
1489 suggestion: "Keep one console surface per surface name.".to_owned(),
1490 });
1491 }
1492
1493 if !present(&surface.label) {
1494 lints.push(ModuleManifestLint {
1495 severity: ModuleManifestLintSeverity::Warning,
1496 subject: format!("{subject}.label"),
1497 message: "Console surface is missing an operator-facing label.".to_owned(),
1498 suggestion: "Set a short navigation label such as Stories.".to_owned(),
1499 });
1500 }
1501
1502 if !surface.route.starts_with('/') || surface.route.contains('*') {
1503 lints.push(ModuleManifestLint {
1504 severity: ModuleManifestLintSeverity::Error,
1505 subject: format!("{subject}.route"),
1506 message: "Console surface route must be an absolute static route.".to_owned(),
1507 suggestion: "Use a Console route such as /runtime/stories.".to_owned(),
1508 });
1509 } else if !routes.insert(surface.route.clone()) {
1510 lints.push(ModuleManifestLint {
1511 severity: ModuleManifestLintSeverity::Error,
1512 subject: format!("{subject}.route"),
1513 message: "Duplicate console surface route declaration.".to_owned(),
1514 suggestion: "Keep one console surface per route.".to_owned(),
1515 });
1516 }
1517
1518 match &surface.presentation {
1519 crate::ConsoleSurfacePresentation::Declarative { schema } => {
1520 if !schema.is_object() {
1521 lints.push(ModuleManifestLint {
1522 severity: ModuleManifestLintSeverity::Error,
1523 subject: format!("{subject}.presentation.schema"),
1524 message: "Declarative Console surface schema must be an object.".to_owned(),
1525 suggestion: "Provide a declarative surface schema object.".to_owned(),
1526 });
1527 }
1528 }
1529 crate::ConsoleSurfacePresentation::Esm { entry } => {
1530 if !present(entry) {
1531 lints.push(ModuleManifestLint {
1532 severity: ModuleManifestLintSeverity::Error,
1533 subject: format!("{subject}.presentation.entry"),
1534 message: "ESM Console surface entry is missing.".to_owned(),
1535 suggestion:
1536 "Reference an entry from the Module Release Console UI artifact."
1537 .to_owned(),
1538 });
1539 }
1540 }
1541 crate::ConsoleSurfacePresentation::Isolated { .. } => {
1542 lints.push(ModuleManifestLint {
1543 severity: ModuleManifestLintSeverity::Error,
1544 subject: format!("{subject}.presentation"),
1545 message: "Retired Console Bridge surfaces cannot be published.".to_owned(),
1546 suggestion: "Publish a console_ui_esm Module Release artifact instead."
1547 .to_owned(),
1548 });
1549 }
1550 }
1551
1552 if let Some(navigation) = &surface.navigation {
1553 lint_console_navigation(&subject, navigation, lints);
1554 }
1555 }
1556}
1557
1558fn lint_console_slots(console_slots: &[ConsoleSlot], lints: &mut Vec<ModuleManifestLint>) {
1559 let mut slots = HashSet::new();
1560
1561 for slot in console_slots {
1562 let subject = if present(&slot.id) {
1563 format!("console.slot.{}", slot.id)
1564 } else {
1565 "console.slot".to_owned()
1566 };
1567
1568 if !present(&slot.id) {
1569 lints.push(ModuleManifestLint {
1570 severity: ModuleManifestLintSeverity::Error,
1571 subject: subject.clone(),
1572 message: "Console slot is missing an id.".to_owned(),
1573 suggestion: "Set a stable dotted slot id such as auth.users.detail.actions."
1574 .to_owned(),
1575 });
1576 } else if !valid_console_slot_target(&slot.id) {
1577 lints.push(ModuleManifestLint {
1578 severity: ModuleManifestLintSeverity::Warning,
1579 subject: subject.clone(),
1580 message: "Console slot id should be a path-safe dotted id.".to_owned(),
1581 suggestion: "Use ASCII letters, digits, dot, underscore, or hyphen.".to_owned(),
1582 });
1583 } else if !slots.insert((slot.id.clone(), slot.version)) {
1584 lints.push(ModuleManifestLint {
1585 severity: ModuleManifestLintSeverity::Error,
1586 subject: subject.clone(),
1587 message: "Duplicate console slot declaration.".to_owned(),
1588 suggestion: "Keep one declaration per console slot id and version.".to_owned(),
1589 });
1590 }
1591
1592 if slot.version == 0 {
1593 lints.push(ModuleManifestLint {
1594 severity: ModuleManifestLintSeverity::Error,
1595 subject: format!("{subject}.version"),
1596 message: "Console slot version must be greater than zero.".to_owned(),
1597 suggestion: "Start slot contracts at version 1.".to_owned(),
1598 });
1599 }
1600
1601 if !present(&slot.label) {
1602 lints.push(ModuleManifestLint {
1603 severity: ModuleManifestLintSeverity::Warning,
1604 subject: format!("{subject}.label"),
1605 message: "Console slot is missing an operator-facing label.".to_owned(),
1606 suggestion: "Set a short label such as User detail actions.".to_owned(),
1607 });
1608 }
1609
1610 if slot.accepts.is_empty() {
1611 lints.push(ModuleManifestLint {
1612 severity: ModuleManifestLintSeverity::Warning,
1613 subject: format!("{subject}.accepts"),
1614 message: "Console slot declares no accepted contribution kinds.".to_owned(),
1615 suggestion: "Declare at least one accepted kind such as admin_action.".to_owned(),
1616 });
1617 }
1618
1619 let mut context_names = HashSet::new();
1620 for context in &slot.context {
1621 let context_subject = if present(&context.name) {
1622 format!("{subject}.context.{}", context.name)
1623 } else {
1624 format!("{subject}.context")
1625 };
1626 if !present(&context.name) {
1627 lints.push(ModuleManifestLint {
1628 severity: ModuleManifestLintSeverity::Error,
1629 subject: context_subject.clone(),
1630 message: "Console slot context is missing a name.".to_owned(),
1631 suggestion: "Set a stable context name such as selected_user.".to_owned(),
1632 });
1633 } else if !valid_slot_context_segment(&context.name) {
1634 lints.push(ModuleManifestLint {
1635 severity: ModuleManifestLintSeverity::Warning,
1636 subject: context_subject.clone(),
1637 message: "Console slot context name should be path-safe.".to_owned(),
1638 suggestion: "Use ASCII letters, digits, underscore, or hyphen.".to_owned(),
1639 });
1640 } else if !context_names.insert(context.name.clone()) {
1641 lints.push(ModuleManifestLint {
1642 severity: ModuleManifestLintSeverity::Error,
1643 subject: context_subject.clone(),
1644 message: "Duplicate console slot context declaration.".to_owned(),
1645 suggestion: "Keep one declaration per slot context name.".to_owned(),
1646 });
1647 }
1648
1649 let mut field_names = HashSet::new();
1650 for field in &context.fields {
1651 let field_subject = if present(&field.name) {
1652 format!("{context_subject}.field.{}", field.name)
1653 } else {
1654 format!("{context_subject}.field")
1655 };
1656 if !present(&field.name) {
1657 lints.push(ModuleManifestLint {
1658 severity: ModuleManifestLintSeverity::Error,
1659 subject: field_subject.clone(),
1660 message: "Console slot context field is missing a name.".to_owned(),
1661 suggestion: "Set a stable field name such as id.".to_owned(),
1662 });
1663 } else if !valid_slot_context_segment(&field.name) {
1664 lints.push(ModuleManifestLint {
1665 severity: ModuleManifestLintSeverity::Warning,
1666 subject: field_subject.clone(),
1667 message: "Console slot context field should be path-safe.".to_owned(),
1668 suggestion: "Use ASCII letters, digits, underscore, or hyphen.".to_owned(),
1669 });
1670 } else if !field_names.insert(field.name.clone()) {
1671 lints.push(ModuleManifestLint {
1672 severity: ModuleManifestLintSeverity::Error,
1673 subject: field_subject,
1674 message: "Duplicate console slot context field declaration.".to_owned(),
1675 suggestion: "Keep one declaration per context field name.".to_owned(),
1676 });
1677 }
1678 }
1679 }
1680 }
1681}
1682
1683fn lint_console_contributions(
1684 contributions: &[ConsoleContribution],
1685 lints: &mut Vec<ModuleManifestLint>,
1686) {
1687 for contribution in contributions {
1688 let subject = if present(&contribution.target) {
1689 format!("console.contribution.{}", contribution.target)
1690 } else {
1691 "console.contribution".to_owned()
1692 };
1693
1694 if !present(&contribution.target) {
1695 lints.push(ModuleManifestLint {
1696 severity: ModuleManifestLintSeverity::Error,
1697 subject: subject.clone(),
1698 message: "Console contribution is missing a target slot.".to_owned(),
1699 suggestion: "Set a stable slot target such as auth.users.detail.actions."
1700 .to_owned(),
1701 });
1702 } else if !valid_console_slot_target(&contribution.target) {
1703 lints.push(ModuleManifestLint {
1704 severity: ModuleManifestLintSeverity::Warning,
1705 subject: subject.clone(),
1706 message: "Console contribution target should be a path-safe dotted slot id."
1707 .to_owned(),
1708 suggestion: "Use ASCII letters, digits, dot, underscore, or hyphen.".to_owned(),
1709 });
1710 }
1711
1712 if contribution.target_version == 0 {
1713 lints.push(ModuleManifestLint {
1714 severity: ModuleManifestLintSeverity::Error,
1715 subject: format!("{subject}.target_version"),
1716 message: "Console contribution target version must be greater than zero."
1717 .to_owned(),
1718 suggestion: "Set target_version to the slot contract version, usually 1."
1719 .to_owned(),
1720 });
1721 }
1722
1723 if !present(&contribution.label) {
1724 lints.push(ModuleManifestLint {
1725 severity: ModuleManifestLintSeverity::Warning,
1726 subject: format!("{subject}.label"),
1727 message: "Console contribution is missing an operator-facing label.".to_owned(),
1728 suggestion: "Set a short action label such as Reset password.".to_owned(),
1729 });
1730 }
1731
1732 match &contribution.action {
1733 ConsoleContributionAction::AdminAction {
1734 module,
1735 name,
1736 input_bindings,
1737 } => {
1738 if !present(module) {
1739 lints.push(ModuleManifestLint {
1740 severity: ModuleManifestLintSeverity::Error,
1741 subject: format!("{subject}.action.module"),
1742 message: "Console contribution action is missing a module name.".to_owned(),
1743 suggestion: "Set the module that owns the admin action.".to_owned(),
1744 });
1745 }
1746 if !present(name) {
1747 lints.push(ModuleManifestLint {
1748 severity: ModuleManifestLintSeverity::Error,
1749 subject: format!("{subject}.action.name"),
1750 message: "Console contribution action is missing an action name."
1751 .to_owned(),
1752 suggestion: "Set the admin action name declared by that module.".to_owned(),
1753 });
1754 }
1755 for binding in input_bindings {
1756 if !present(&binding.input) {
1757 lints.push(ModuleManifestLint {
1758 severity: ModuleManifestLintSeverity::Error,
1759 subject: format!("{subject}.action.input_binding"),
1760 message:
1761 "Console contribution action binding is missing an input name."
1762 .to_owned(),
1763 suggestion: "Set the input field that receives the bound value."
1764 .to_owned(),
1765 });
1766 }
1767 match &binding.value {
1768 ConsoleActionInputValue::SlotContext { path } => {
1769 if !present(path) {
1770 lints.push(ModuleManifestLint {
1771 severity: ModuleManifestLintSeverity::Error,
1772 subject: format!("{subject}.action.input_binding.path"),
1773 message:
1774 "Console contribution slot-context binding is missing a path."
1775 .to_owned(),
1776 suggestion:
1777 "Set a slot context path such as selected_user.id."
1778 .to_owned(),
1779 });
1780 } else if !valid_slot_context_path(path) {
1781 lints.push(ModuleManifestLint {
1782 severity: ModuleManifestLintSeverity::Warning,
1783 subject: format!("{subject}.action.input_binding.path"),
1784 message:
1785 "Console contribution slot-context path should be path-safe."
1786 .to_owned(),
1787 suggestion:
1788 "Use dot-separated context fields such as selected_user.id."
1789 .to_owned(),
1790 });
1791 }
1792 }
1793 }
1794 }
1795 }
1796 }
1797 }
1798}
1799
1800const HOST_SYSTEM_CONSOLE_WORKSPACE_ID: &str = "system";
1801
1802fn lint_console_navigation(
1803 subject: &str,
1804 navigation: &crate::ConsoleNavigation,
1805 lints: &mut Vec<ModuleManifestLint>,
1806) {
1807 let workspace_subject = format!("{subject}.navigation.workspace");
1808 if !valid_console_navigation_id(&navigation.workspace.id) {
1809 lints.push(ModuleManifestLint {
1810 severity: ModuleManifestLintSeverity::Warning,
1811 subject: format!("{workspace_subject}.id"),
1812 message: "Console workspace id should be a path-safe identifier.".to_owned(),
1813 suggestion: "Use ASCII letters, digits, underscore, or hyphen.".to_owned(),
1814 });
1815 } else if navigation.workspace.id == HOST_SYSTEM_CONSOLE_WORKSPACE_ID {
1816 lints.push(ModuleManifestLint {
1817 severity: ModuleManifestLintSeverity::Warning,
1818 subject: format!("{workspace_subject}.id"),
1819 message: "Console workspace id system is reserved for host-owned surfaces.".to_owned(),
1820 suggestion:
1821 "Omit navigation to use the host System workspace, or use a module-owned workspace id."
1822 .to_owned(),
1823 });
1824 }
1825 if !present(&navigation.workspace.label) {
1826 lints.push(ModuleManifestLint {
1827 severity: ModuleManifestLintSeverity::Warning,
1828 subject: format!("{workspace_subject}.label"),
1829 message: "Console workspace is missing an operator-facing label.".to_owned(),
1830 suggestion: "Set a short workspace label such as CRM.".to_owned(),
1831 });
1832 }
1833 if let Some(group) = &navigation.group {
1834 let group_subject = format!("{subject}.navigation.group");
1835 if !valid_console_navigation_id(&group.id) {
1836 lints.push(ModuleManifestLint {
1837 severity: ModuleManifestLintSeverity::Warning,
1838 subject: format!("{group_subject}.id"),
1839 message: "Console navigation group id should be a path-safe identifier.".to_owned(),
1840 suggestion: "Use ASCII letters, digits, underscore, or hyphen.".to_owned(),
1841 });
1842 }
1843 if !present(&group.label) {
1844 lints.push(ModuleManifestLint {
1845 severity: ModuleManifestLintSeverity::Warning,
1846 subject: format!("{group_subject}.label"),
1847 message: "Console navigation group is missing an operator-facing label.".to_owned(),
1848 suggestion: "Set a short group label such as Customers.".to_owned(),
1849 });
1850 }
1851 }
1852}
1853
1854fn lint_admin_surface(admin: &AdminSurface, lints: &mut Vec<ModuleManifestLint>) {
1855 match admin {
1856 AdminSurface::Schema(schema) => lint_schema_entities("admin.schema", schema, lints),
1857 AdminSurface::DeclarativeCustom(surface) => {
1858 if surface.pages.is_empty() && surface.actions.is_empty() {
1859 lints.push(ModuleManifestLint {
1860 severity: ModuleManifestLintSeverity::Warning,
1861 subject: "admin.declarative.pages".to_owned(),
1862 message: "Declarative admin surface declares no pages or actions.".to_owned(),
1863 suggestion:
1864 "Add at least one page/action or omit the declarative admin surface."
1865 .to_owned(),
1866 });
1867 }
1868 if let Some(schema) = &surface.fallback_schema {
1869 lint_schema_entities("admin.declarative.fallback_schema", schema, lints);
1870 }
1871 let fallback_entities = surface
1872 .fallback_schema
1873 .as_ref()
1874 .map(schema_entity_names)
1875 .unwrap_or_default();
1876 for page in &surface.pages {
1877 for section in &page.sections {
1878 match §ion.component {
1879 AdminDeclarativeComponent::EntityTable { entity }
1880 | AdminDeclarativeComponent::EntityDetail { entity } => {
1881 if !fallback_entities.contains(entity) {
1882 lints.push(ModuleManifestLint {
1883 severity: ModuleManifestLintSeverity::Warning,
1884 subject: format!("admin.declarative.section.{}", section.name),
1885 message: format!(
1886 "Declarative section references unknown fallback entity `{entity}`."
1887 ),
1888 suggestion:
1889 "Declare the entity in fallback_schema or update the section binding."
1890 .to_owned(),
1891 });
1892 }
1893 }
1894 AdminDeclarativeComponent::QueryValue {
1895 capability,
1896 query,
1897 value_path,
1898 } => lint_query_value(
1899 section.name.as_str(),
1900 query,
1901 capability,
1902 value_path,
1903 lints,
1904 ),
1905 AdminDeclarativeComponent::MetricStrip { .. } => {}
1906 }
1907 }
1908 }
1909 }
1910 AdminSurface::EmbeddedCustom(surface) => {
1911 if surface.runtime != AdminEmbeddedRuntime::Iframe {
1912 lints.push(ModuleManifestLint {
1913 severity: ModuleManifestLintSeverity::Warning,
1914 subject: "admin.embedded.runtime".to_owned(),
1915 message: "Embedded admin runtime is reserved for a future host policy."
1916 .to_owned(),
1917 suggestion: "Use iframe for the current embedded admin slice.".to_owned(),
1918 });
1919 }
1920 match &surface.entry {
1921 AdminEmbeddedEntry::Url {
1922 url,
1923 allowed_origins,
1924 } => {
1925 if !url.starts_with("https://") && !url.starts_with("http://localhost") {
1926 lints.push(ModuleManifestLint {
1927 severity: ModuleManifestLintSeverity::Warning,
1928 subject: "admin.embedded.entry.url".to_owned(),
1929 message:
1930 "Embedded admin URL should use HTTPS outside local development."
1931 .to_owned(),
1932 suggestion: "Use an HTTPS URL and list its origin in allowed_origins."
1933 .to_owned(),
1934 });
1935 }
1936 if allowed_origins.is_empty() {
1937 lints.push(ModuleManifestLint {
1938 severity: ModuleManifestLintSeverity::Warning,
1939 subject: "admin.embedded.entry.allowed_origins".to_owned(),
1940 message: "Embedded admin surface declares no allowed origins."
1941 .to_owned(),
1942 suggestion:
1943 "Declare the iframe origin allowlist before enabling the surface."
1944 .to_owned(),
1945 });
1946 }
1947 }
1948 }
1949 if let Some(schema) = &surface.fallback_schema {
1950 lint_schema_entities("admin.embedded.fallback_schema", schema, lints);
1951 let fallback_entities = schema_entity_names(schema);
1952 for permission in &surface.permissions {
1953 if let AdminPermission::ReadEntity { entity } = permission
1954 && !fallback_entities.contains(entity)
1955 {
1956 lints.push(ModuleManifestLint {
1957 severity: ModuleManifestLintSeverity::Warning,
1958 subject: format!("admin.embedded.permission.{entity}"),
1959 message: format!(
1960 "Embedded admin permission references unknown fallback entity `{entity}`."
1961 ),
1962 suggestion:
1963 "Declare the entity in fallback_schema or remove the permission."
1964 .to_owned(),
1965 });
1966 }
1967 }
1968 }
1969 }
1970 }
1971}
1972
1973fn lint_schema_entities(prefix: &str, schema: &AdminSchema, lints: &mut Vec<ModuleManifestLint>) {
1974 if schema.entities.is_empty() {
1975 lints.push(ModuleManifestLint {
1976 severity: ModuleManifestLintSeverity::Warning,
1977 subject: prefix.to_owned(),
1978 message: "Admin schema declares no entities.".to_owned(),
1979 suggestion: "Add at least one entity or omit the admin schema surface.".to_owned(),
1980 });
1981 }
1982 for entity in &schema.entities {
1983 if !present(&entity.read_capability) {
1984 lints.push(ModuleManifestLint {
1985 severity: ModuleManifestLintSeverity::Warning,
1986 subject: format!("{prefix}.{}", entity.name),
1987 message: "Admin entity is missing read capability.".to_owned(),
1988 suggestion: "Declare the capability required to read this entity.".to_owned(),
1989 });
1990 }
1991 }
1992}
1993
1994fn collect_declarative_query_capability_references(
1995 surface: &AdminDeclarativeSurface,
1996 references: &mut Vec<ModuleCapabilityReference>,
1997) {
1998 for page in &surface.pages {
1999 for section in &page.sections {
2000 let AdminDeclarativeComponent::QueryValue {
2001 capability, query, ..
2002 } = §ion.component
2003 else {
2004 continue;
2005 };
2006 if present(capability) {
2007 let subject = if present(query) {
2008 format!("admin.declarative.query.{query}")
2009 } else {
2010 format!("admin.declarative.section.{}", section.name)
2011 };
2012 references.push(ModuleCapabilityReference {
2013 capability: capability.clone(),
2014 subject,
2015 });
2016 }
2017 }
2018 }
2019}
2020
2021fn lint_query_value(
2022 section_name: &str,
2023 query: &str,
2024 capability: &str,
2025 value_path: &str,
2026 lints: &mut Vec<ModuleManifestLint>,
2027) {
2028 let subject = if present(query) {
2029 format!("admin.declarative.query.{query}")
2030 } else {
2031 format!("admin.declarative.section.{section_name}")
2032 };
2033 if !valid_runtime_function_name(query) {
2034 lints.push(ModuleManifestLint {
2035 severity: ModuleManifestLintSeverity::Warning,
2036 subject: subject.clone(),
2037 message: "Declarative query name should be a stable path-safe identifier.".to_owned(),
2038 suggestion: "Use ASCII letters, digits, dot, underscore, or hyphen.".to_owned(),
2039 });
2040 }
2041 if !present(value_path) {
2042 lints.push(ModuleManifestLint {
2043 severity: ModuleManifestLintSeverity::Warning,
2044 subject: subject.clone(),
2045 message: "Declarative query value is missing a value path.".to_owned(),
2046 suggestion: "Set value_path to the JSON field rendered by this section.".to_owned(),
2047 });
2048 }
2049 if !present(capability) {
2050 lints.push(ModuleManifestLint {
2051 severity: ModuleManifestLintSeverity::Warning,
2052 subject,
2053 message: "Declarative query is missing a read capability.".to_owned(),
2054 suggestion: "Declare the capability required to read this query.".to_owned(),
2055 });
2056 }
2057}
2058
2059fn schema_entity_names(schema: &AdminSchema) -> HashSet<String> {
2060 schema
2061 .entities
2062 .iter()
2063 .map(|entity| entity.name.clone())
2064 .collect()
2065}
2066
2067fn present(value: &str) -> bool {
2068 !value.trim().is_empty()
2069}
2070
2071fn valid_capability(value: &str) -> bool {
2072 let mut parts = value.split('.');
2073 let Some(first) = parts.next() else {
2074 return false;
2075 };
2076 present(first)
2077 && value.contains('.')
2078 && std::iter::once(first).chain(parts).all(|part| {
2079 present(part)
2080 && part.chars().all(|character| {
2081 character.is_ascii_lowercase() || character == '_' || character.is_ascii_digit()
2082 })
2083 })
2084}
2085
2086fn valid_runtime_function_name(value: &str) -> bool {
2087 present(value)
2088 && value.chars().all(|character| {
2089 character.is_ascii_alphanumeric()
2090 || character == '.'
2091 || character == '_'
2092 || character == '-'
2093 })
2094}
2095
2096fn valid_console_surface_name(value: &str) -> bool {
2097 present(value)
2098 && value.chars().all(|character| {
2099 character.is_ascii_alphanumeric() || character == '_' || character == '-'
2100 })
2101}
2102
2103fn valid_console_slot_target(value: &str) -> bool {
2104 present(value)
2105 && value.contains('.')
2106 && value.chars().all(|character| {
2107 character.is_ascii_alphanumeric()
2108 || character == '.'
2109 || character == '_'
2110 || character == '-'
2111 })
2112}
2113
2114fn valid_slot_context_path(value: &str) -> bool {
2115 present(value) && value.split('.').all(valid_slot_context_segment)
2116}
2117
2118fn valid_slot_context_segment(value: &str) -> bool {
2119 present(value)
2120 && value.chars().all(|character| {
2121 character.is_ascii_alphanumeric() || character == '_' || character == '-'
2122 })
2123}
2124
2125fn valid_console_navigation_id(value: &str) -> bool {
2126 valid_console_surface_name(value)
2127}
2128
2129fn valid_module_id(value: &str) -> bool {
2130 let Some((namespace, name)) = value.split_once('/') else {
2131 return false;
2132 };
2133 !namespace.is_empty()
2134 && !name.is_empty()
2135 && !name.contains('/')
2136 && [namespace, name].into_iter().all(|segment| {
2137 segment.starts_with(|character: char| character.is_ascii_lowercase())
2138 && segment.chars().all(|character| {
2139 character.is_ascii_lowercase()
2140 || character.is_ascii_digit()
2141 || character == '-'
2142 || character == '_'
2143 })
2144 })
2145}
2146
2147fn route_identity(route: &ModuleHttpRoute) -> String {
2148 format!("{} {}", method_label(route.method), route.path)
2149}
2150
2151fn method_label(method: ModuleHttpMethod) -> &'static str {
2152 match method {
2153 ModuleHttpMethod::Get => "GET",
2154 ModuleHttpMethod::Post => "POST",
2155 ModuleHttpMethod::Put => "PUT",
2156 ModuleHttpMethod::Patch => "PATCH",
2157 ModuleHttpMethod::Delete => "DELETE",
2158 }
2159}
2160
2161#[derive(Debug)]
2163pub struct ModuleManifestBuilder {
2164 manifest: ModuleManifest,
2165}
2166
2167impl ModuleManifestBuilder {
2168 #[must_use]
2169 pub fn summary(mut self, summary: impl Into<String>) -> Self {
2170 self.manifest.summary = Some(summary.into());
2171 self
2172 }
2173
2174 #[must_use]
2176 pub fn story_display(mut self, story_display: Vec<StoryDisplayDescriptor>) -> Self {
2177 self.manifest.story_display = story_display;
2178 self
2179 }
2180
2181 #[must_use]
2183 pub fn capabilities(mut self, mut capabilities: Vec<String>) -> Self {
2184 capabilities.sort();
2185 self.manifest.capabilities = capabilities;
2186 self
2187 }
2188
2189 #[must_use]
2191 pub fn requires(mut self, mut requirements: Vec<ModuleRequirement>) -> Self {
2192 for requirement in &mut requirements {
2193 requirement.capabilities.sort();
2194 }
2195 requirements.sort_by(|left, right| left.module_id.cmp(&right.module_id));
2196 self.manifest.requires = requirements;
2197 self
2198 }
2199
2200 #[doc(hidden)]
2204 #[must_use]
2205 pub fn dependencies(mut self, dependencies: Vec<String>) -> Self {
2206 let mut requirements = dependencies
2207 .into_iter()
2208 .map(|dependency| ModuleRequirement {
2209 module_id: if dependency.contains('/') {
2210 dependency
2211 } else {
2212 format!("lenso/{dependency}")
2213 },
2214 version_requirement: "*".to_owned(),
2215 capabilities: Vec::new(),
2216 optional: false,
2217 })
2218 .collect::<Vec<_>>();
2219 requirements.sort_by(|left, right| left.module_id.cmp(&right.module_id));
2220 self.manifest.requires = requirements;
2221 self
2222 }
2223
2224 #[must_use]
2225 pub fn config(mut self, mut config: ModuleConfigContract) -> Self {
2226 config
2227 .fields
2228 .sort_by(|left, right| left.key.cmp(&right.key));
2229 self.manifest.config = config;
2230 self
2231 }
2232
2233 #[must_use]
2234 pub fn migrations(mut self, mut migrations: Vec<ModuleMigrationDeclaration>) -> Self {
2235 migrations.sort_by_key(|migration| migration.order);
2236 self.manifest.migrations = migrations;
2237 self
2238 }
2239
2240 #[must_use]
2242 pub fn http_routes(mut self, routes: Vec<ModuleHttpRoute>) -> Self {
2243 self.manifest.http_routes = routes;
2244 self
2245 }
2246
2247 #[must_use]
2249 pub fn runtime(mut self, runtime: RuntimeSurface) -> Self {
2250 self.manifest.runtime = Some(runtime);
2251 self
2252 }
2253
2254 #[must_use]
2256 pub fn events(mut self, events: EventSurface) -> Self {
2257 self.manifest.events = Some(events);
2258 self
2259 }
2260
2261 #[must_use]
2263 pub fn admin(mut self, schema: AdminSchema) -> Self {
2264 self.manifest.admin = Some(AdminSurface::Schema(schema));
2265 self
2266 }
2267
2268 #[must_use]
2270 pub fn declarative_admin(mut self, surface: AdminDeclarativeSurface) -> Self {
2271 self.manifest.admin = Some(AdminSurface::DeclarativeCustom(surface));
2272 self
2273 }
2274
2275 #[must_use]
2277 pub fn embedded_admin(mut self, surface: AdminEmbeddedSurface) -> Self {
2278 self.manifest.admin = Some(AdminSurface::EmbeddedCustom(surface));
2279 self
2280 }
2281
2282 #[must_use]
2284 pub fn lifecycle(mut self, lifecycle: LifecycleSurface) -> Self {
2285 self.manifest.lifecycle = Some(lifecycle);
2286 self
2287 }
2288
2289 #[must_use]
2291 pub fn console(mut self, console: Vec<ConsoleSurface>) -> Self {
2292 self.manifest.console = console;
2293 self
2294 }
2295
2296 #[must_use]
2298 pub fn console_slots(mut self, console_slots: Vec<ConsoleSlot>) -> Self {
2299 self.manifest.console_slots = console_slots;
2300 self
2301 }
2302
2303 #[must_use]
2305 pub fn console_contributions(
2306 mut self,
2307 console_contributions: Vec<ConsoleContribution>,
2308 ) -> Self {
2309 self.manifest.console_contributions = console_contributions;
2310 self
2311 }
2312
2313 #[must_use]
2315 pub fn build(mut self) -> ModuleManifest {
2316 if !self.manifest.module_id.contains('/') {
2320 self.manifest.module_id = format!("lenso/{}", self.manifest.module_id);
2321 }
2322 self.manifest
2323 }
2324}
2325
2326#[cfg(test)]
2327mod tests {
2328 use super::*;
2329 use crate::admin::{
2330 AdminDeclarativeComponent, AdminDeclarativePage, AdminDeclarativeSection,
2331 AdminDeclarativeSurface,
2332 };
2333 use crate::{
2334 AdminEmbeddedEntry, AdminEmbeddedRuntime, AdminEmbeddedSurface, AdminSandboxPolicy,
2335 ConsoleActionInputBinding, ConsoleActionInputValue, ConsoleContribution,
2336 ConsoleContributionAction, ConsoleContributionKind, ConsoleSlot, ConsoleSlotContext,
2337 ConsoleSlotContextField, ConsoleSlotContextFieldType, ConsoleSurface,
2338 ConsoleSurfacePresentation, EventHandlerDeclaration, EventSurface,
2339 };
2340 use crate::{
2341 LifecycleActivationJobDeclaration, LifecycleActivationRunPolicy,
2342 LifecycleStartupCheckDeclaration, LifecycleStartupCheckKind, LifecycleSurface,
2343 };
2344 use crate::{ModuleHttpMethod, ModuleHttpRoute};
2345 use crate::{
2346 RuntimeFunctionDeclaration, RuntimeRetryPolicyDeclaration, RuntimeSurface,
2347 WorkflowCompensationDeclaration, WorkflowDataContract, WorkflowDefinition,
2348 WorkflowRetryPolicyDeclaration, WorkflowStepDeclaration,
2349 };
2350 use crate::{StoryDisplayDescriptor, StoryDisplaySource};
2351
2352 #[test]
2353 fn manifest_round_trips_through_json() {
2354 let manifest = ModuleManifest::builder("lenso/identity")
2355 .story_display(vec![StoryDisplayDescriptor {
2356 source: StoryDisplaySource::ExecutionName {
2357 name: "identity.create_user".to_owned(),
2358 },
2359 display_name: "Create User".to_owned(),
2360 story_title: Some("User Registration".to_owned()),
2361 }])
2362 .build();
2363
2364 let json = serde_json::to_string(&manifest).expect("serialize");
2365 let back: ModuleManifest = serde_json::from_str(&json).expect("deserialize");
2366
2367 assert_eq!(manifest, back);
2368 }
2369
2370 #[test]
2371 fn manifest_with_console_surface_round_trips_through_json() {
2372 let manifest = ModuleManifest::builder("lenso/platform-story")
2373 .console(vec![ConsoleSurface {
2374 name: "stories".to_owned(),
2375 label: "Stories".to_owned(),
2376 route: "/runtime/stories".to_owned(),
2377 presentation: ConsoleSurfacePresentation::Esm {
2378 entry: "storyConsoleModule".to_owned(),
2379 },
2380 icon: Some("workflow".to_owned()),
2381 required_capabilities: vec!["runtime.stories.read".to_owned()],
2382 navigation: None,
2383 }])
2384 .capabilities(vec!["runtime.stories.read".to_owned()])
2385 .build();
2386
2387 let json = serde_json::to_string(&manifest).expect("serialize");
2388 assert!(json.contains(r#""console""#), "got {json}");
2389 assert!(json.contains(r#""kind":"esm""#), "got {json}");
2390
2391 let back: ModuleManifest = serde_json::from_str(&json).expect("deserialize");
2392
2393 assert_eq!(manifest, back);
2394 }
2395
2396 #[test]
2397 fn manifest_with_console_contribution_round_trips_through_json() {
2398 let contribution = ConsoleContribution {
2399 target: "auth.users.detail.actions".to_owned(),
2400 target_version: 1,
2401 label: "Reset password".to_owned(),
2402 action: ConsoleContributionAction::AdminAction {
2403 module: "auth-password".to_owned(),
2404 name: "reset_password".to_owned(),
2405 input_bindings: vec![ConsoleActionInputBinding {
2406 input: "user_id".to_owned(),
2407 value: ConsoleActionInputValue::SlotContext {
2408 path: "selected_user.id".to_owned(),
2409 },
2410 }],
2411 },
2412 icon: Some("key-round".to_owned()),
2413 required_capabilities: vec!["auth_password.credentials.write".to_owned()],
2414 };
2415 let manifest = ModuleManifest::builder("lenso/auth-password")
2416 .capabilities(vec!["auth_password.credentials.write".to_owned()])
2417 .console_contributions(vec![contribution.clone()])
2418 .build();
2419
2420 let json = serde_json::to_string(&manifest).expect("serialize");
2421 assert!(json.contains(r#""console_contributions""#), "got {json}");
2422 assert!(
2423 json.contains(r#""target":"auth.users.detail.actions""#),
2424 "got {json}"
2425 );
2426 assert!(json.contains(r#""target_version":1"#), "got {json}");
2427 assert!(json.contains(r#""kind":"admin_action""#), "got {json}");
2428 assert!(json.contains(r#""kind":"slot_context""#), "got {json}");
2429
2430 let back: ModuleManifest = serde_json::from_str(&json).expect("deserialize");
2431
2432 assert_eq!(back.console_contributions, vec![contribution]);
2433 }
2434
2435 #[test]
2436 fn manifest_with_console_slot_round_trips_through_json() {
2437 let slot = ConsoleSlot {
2438 id: "auth.users.detail.actions".to_owned(),
2439 version: 1,
2440 label: "User detail actions".to_owned(),
2441 accepts: vec![ConsoleContributionKind::AdminAction],
2442 context: vec![ConsoleSlotContext {
2443 name: "selected_user".to_owned(),
2444 fields: vec![ConsoleSlotContextField {
2445 name: "id".to_owned(),
2446 field_type: ConsoleSlotContextFieldType::String,
2447 required: true,
2448 }],
2449 }],
2450 };
2451 let manifest = ModuleManifest::builder("lenso/auth")
2452 .console_slots(vec![slot.clone()])
2453 .build();
2454
2455 let json = serde_json::to_string(&manifest).expect("serialize");
2456 assert!(json.contains(r#""console_slots""#), "got {json}");
2457 assert!(
2458 json.contains(r#""id":"auth.users.detail.actions""#),
2459 "got {json}"
2460 );
2461 assert!(json.contains(r#""accepts":["admin_action"]"#), "got {json}");
2462
2463 let back: ModuleManifest = serde_json::from_str(&json).expect("deserialize");
2464
2465 assert_eq!(back.console_slots, vec![slot]);
2466 }
2467
2468 #[test]
2469 fn console_contribution_capability_references_are_linted() {
2470 let manifest = ModuleManifest::builder("lenso/auth-password")
2471 .console_contributions(vec![ConsoleContribution {
2472 target: "auth.users.detail.actions".to_owned(),
2473 target_version: 1,
2474 label: "Reset password".to_owned(),
2475 action: ConsoleContributionAction::AdminAction {
2476 module: "auth-password".to_owned(),
2477 name: "reset_password".to_owned(),
2478 input_bindings: vec![ConsoleActionInputBinding {
2479 input: "user_id".to_owned(),
2480 value: ConsoleActionInputValue::SlotContext {
2481 path: "selected_user.id".to_owned(),
2482 },
2483 }],
2484 },
2485 icon: None,
2486 required_capabilities: vec!["auth_password.credentials.write".to_owned()],
2487 }])
2488 .build();
2489
2490 let lints = lint_module_manifest(&manifest);
2491
2492 assert!(lints.iter().any(|lint| {
2493 lint.subject == "capability.reference.console.contribution.auth.users.detail.actions"
2494 && lint.message == "Capability reference is not declared by the module."
2495 }));
2496 }
2497
2498 #[test]
2499 fn console_surface_navigation_round_trips() {
2500 let surface = ConsoleSurface {
2501 name: "contacts".to_owned(),
2502 label: "Contacts".to_owned(),
2503 route: "/crm/contacts".to_owned(),
2504 presentation: ConsoleSurfacePresentation::Esm {
2505 entry: "crmConsoleModule".to_owned(),
2506 },
2507 icon: Some("users".to_owned()),
2508 required_capabilities: vec!["crm.contacts.read".to_owned()],
2509 navigation: Some(crate::ConsoleNavigation {
2510 workspace: crate::ConsoleWorkspaceRef {
2511 id: "crm".to_owned(),
2512 label: "CRM".to_owned(),
2513 icon: Some("briefcase".to_owned()),
2514 },
2515 group: Some(crate::ConsoleNavigationGroup {
2516 id: "customers".to_owned(),
2517 label: "Customers".to_owned(),
2518 icon: None,
2519 order: Some(20),
2520 }),
2521 order: Some(10),
2522 }),
2523 };
2524
2525 let json = serde_json::to_string(&surface).expect("serialize");
2526 let back: ConsoleSurface = serde_json::from_str(&json).expect("deserialize");
2527
2528 assert_eq!(back, surface);
2529 }
2530
2531 #[test]
2532 fn console_navigation_lints_empty_workspace_label() {
2533 let manifest = ModuleManifest::builder("acme/crm")
2534 .capabilities(vec!["crm.contacts.read".to_owned()])
2535 .console(vec![ConsoleSurface {
2536 name: "contacts".to_owned(),
2537 label: "Contacts".to_owned(),
2538 route: "/crm/contacts".to_owned(),
2539 presentation: ConsoleSurfacePresentation::Esm {
2540 entry: "crmConsoleModule".to_owned(),
2541 },
2542 icon: None,
2543 required_capabilities: vec!["crm.contacts.read".to_owned()],
2544 navigation: Some(crate::ConsoleNavigation {
2545 workspace: crate::ConsoleWorkspaceRef {
2546 id: "crm".to_owned(),
2547 label: "".to_owned(),
2548 icon: None,
2549 },
2550 group: None,
2551 order: None,
2552 }),
2553 }])
2554 .build();
2555
2556 let subjects: Vec<_> = lint_module_manifest(&manifest)
2557 .into_iter()
2558 .map(|lint| lint.subject)
2559 .collect();
2560
2561 assert!(
2562 subjects.contains(&"console.surface.contacts.navigation.workspace.label".to_owned())
2563 );
2564 }
2565
2566 #[test]
2567 fn console_navigation_lints_reserved_system_workspace() {
2568 let manifest = ModuleManifest::builder("acme/crm")
2569 .capabilities(vec!["crm.contacts.read".to_owned()])
2570 .console(vec![ConsoleSurface {
2571 name: "contacts".to_owned(),
2572 label: "Contacts".to_owned(),
2573 route: "/crm/contacts".to_owned(),
2574 presentation: ConsoleSurfacePresentation::Esm {
2575 entry: "crmConsoleModule".to_owned(),
2576 },
2577 icon: None,
2578 required_capabilities: vec!["crm.contacts.read".to_owned()],
2579 navigation: Some(crate::ConsoleNavigation {
2580 workspace: crate::ConsoleWorkspaceRef {
2581 id: "system".to_owned(),
2582 label: "System".to_owned(),
2583 icon: Some("settings".to_owned()),
2584 },
2585 group: None,
2586 order: Some(10),
2587 }),
2588 }])
2589 .build();
2590
2591 let lints = lint_module_manifest(&manifest);
2592
2593 assert!(lints.iter().any(|lint| {
2594 lint.subject == "console.surface.contacts.navigation.workspace.id"
2595 && lint.severity == ModuleManifestLintSeverity::Warning
2596 && lint.message
2597 == "Console workspace id system is reserved for host-owned surfaces."
2598 }));
2599 }
2600
2601 #[test]
2602 fn lints_invalid_console_surface_declarations() {
2603 let manifest = ModuleManifest::builder("lenso/platform-story")
2604 .console(vec![
2605 ConsoleSurface {
2606 name: "stories".to_owned(),
2607 label: "Stories".to_owned(),
2608 route: "runtime/stories".to_owned(),
2609 presentation: ConsoleSurfacePresentation::Esm {
2610 entry: String::new(),
2611 },
2612 icon: None,
2613 required_capabilities: vec!["runtime.stories.read".to_owned()],
2614 navigation: None,
2615 },
2616 ConsoleSurface {
2617 name: "stories".to_owned(),
2618 label: "Stories duplicate".to_owned(),
2619 route: "/runtime/stories".to_owned(),
2620 presentation: ConsoleSurfacePresentation::Esm {
2621 entry: "storyConsoleModule".to_owned(),
2622 },
2623 icon: None,
2624 required_capabilities: vec![],
2625 navigation: None,
2626 },
2627 ])
2628 .build();
2629
2630 let lints = lint_module_manifest(&manifest);
2631 let subjects = lints
2632 .iter()
2633 .map(|lint| lint.subject.as_str())
2634 .collect::<Vec<_>>();
2635
2636 assert!(subjects.contains(&"console.surface.stories.route"));
2637 assert!(subjects.contains(&"console.surface.stories.presentation.entry"));
2638 assert!(!subjects.contains(&"console.surface.stories.presentation.bridge_protocol"));
2639 assert!(subjects.contains(&"capability.reference.console.surface.stories"));
2640 assert!(lints.iter().any(|lint| {
2641 lint.subject == "console.surface.stories"
2642 && lint.message == "Duplicate console surface declaration."
2643 }));
2644 }
2645
2646 #[test]
2647 fn empty_admin_is_skipped_in_json() {
2648 let manifest = ModuleManifest::builder("lenso/notifications").build();
2649 let json = serde_json::to_string(&manifest).expect("serialize");
2650 assert!(
2651 !json.contains("admin"),
2652 "admin: None must be skipped, got {json}"
2653 );
2654 }
2655
2656 #[test]
2657 fn manifest_lints_self_dependency() {
2658 let manifest = ModuleManifest::builder("lenso/auth")
2659 .requires(vec![
2660 ModuleRequirement::new("lenso/auth", "*").expect("valid requirement"),
2661 ])
2662 .build();
2663
2664 let lints = lint_module_manifest(&manifest);
2665
2666 assert!(lints.iter().any(|lint| {
2667 lint.severity == ModuleManifestLintSeverity::Error
2668 && lint.subject == "requirement lenso/auth"
2669 && lint.message == "Module must not depend on itself."
2670 }));
2671 }
2672
2673 #[test]
2674 fn manifest_with_admin_serializes_schema_kind() {
2675 use crate::admin_schema::{AdminSchema, EntitySchema, FieldSchema, FieldType};
2676 let schema = AdminSchema {
2677 entities: vec![EntitySchema {
2678 name: "users".to_owned(),
2679 label: "Users".to_owned(),
2680 read_capability: "identity.users.read".to_owned(),
2681 fields: vec![FieldSchema {
2682 name: "email".into(),
2683 label: "Email".into(),
2684 field_type: FieldType::String,
2685 nullable: false,
2686 }],
2687 }],
2688 };
2689 let manifest = ModuleManifest::builder("lenso/identity")
2690 .admin(schema)
2691 .build();
2692 let json = serde_json::to_string(&manifest).expect("serialize");
2693 assert!(json.contains(r#""kind":"schema""#), "got {json}");
2694 }
2695
2696 #[test]
2697 fn manifest_with_declarative_admin_serializes_kind() {
2698 use crate::admin::AdminDeclarativeSurface;
2699
2700 let manifest = ModuleManifest::builder("acme/remote-crm")
2701 .declarative_admin(AdminDeclarativeSurface {
2702 pages: vec![],
2703 actions: vec![],
2704 fallback_schema: None,
2705 })
2706 .build();
2707 let json = serde_json::to_string(&manifest).expect("serialize");
2708 assert!(
2709 json.contains(r#""kind":"declarative_custom""#),
2710 "got {json}"
2711 );
2712 }
2713
2714 #[test]
2715 fn manifest_with_embedded_admin_serializes_kind() {
2716 use crate::admin::{
2717 AdminEmbeddedEntry, AdminEmbeddedRuntime, AdminEmbeddedSurface, AdminSandboxPolicy,
2718 };
2719
2720 let manifest = ModuleManifest::builder("acme/remote-crm")
2721 .embedded_admin(AdminEmbeddedSurface {
2722 runtime: AdminEmbeddedRuntime::Iframe,
2723 entry: AdminEmbeddedEntry::Url {
2724 url: "https://crm.example.test/admin".to_owned(),
2725 allowed_origins: vec!["https://crm.example.test".to_owned()],
2726 },
2727 sandbox: AdminSandboxPolicy {
2728 allow_scripts: true,
2729 allow_forms: false,
2730 allow_popups: false,
2731 allow_same_origin: false,
2732 },
2733 permissions: vec![],
2734 fallback_schema: None,
2735 })
2736 .build();
2737 let json = serde_json::to_string(&manifest).expect("serialize");
2738 assert!(json.contains(r#""kind":"embedded_custom""#), "got {json}");
2739 }
2740
2741 #[test]
2742 fn manifest_with_http_routes_round_trips_through_json() {
2743 let manifest = ModuleManifest::builder("acme/remote-crm")
2744 .http_routes(vec![
2745 ModuleHttpRoute {
2746 method: ModuleHttpMethod::Get,
2747 path: "/contacts".to_owned(),
2748 capability: Some("remote_crm.contacts.read".to_owned()),
2749 display_name: Some("List Contacts".to_owned()),
2750 story_title: Some("List Contacts".to_owned()),
2751 operation: None,
2752 },
2753 ModuleHttpRoute {
2754 method: ModuleHttpMethod::Post,
2755 path: "/contacts".to_owned(),
2756 capability: Some("remote_crm.contacts.write".to_owned()),
2757 display_name: None,
2758 story_title: None,
2759 operation: None,
2760 },
2761 ])
2762 .build();
2763
2764 let json = serde_json::to_string(&manifest).expect("serialize");
2765 assert!(json.contains(r#""http_routes""#), "got {json}");
2766 assert!(json.contains(r#""method":"GET""#), "got {json}");
2767 assert!(
2768 json.contains(r#""display_name":"List Contacts""#),
2769 "got {json}"
2770 );
2771 let back: ModuleManifest = serde_json::from_str(&json).expect("deserialize");
2772 assert_eq!(manifest, back);
2773 }
2774
2775 #[test]
2776 fn manifest_with_runtime_functions_round_trips_through_json() {
2777 let manifest = ModuleManifest::builder("acme/remote-crm")
2778 .runtime(RuntimeSurface {
2779 functions: vec![RuntimeFunctionDeclaration {
2780 name: "remote_crm.sync_contact.v1".to_owned(),
2781 version: 1,
2782 queue: "remote-crm".to_owned(),
2783 input_schema: Some("remote_crm.sync_contact.v1".to_owned()),
2784 retry_policy: Some(RuntimeRetryPolicyDeclaration {
2785 max_attempts: 3,
2786 initial_delay_ms: 1000,
2787 }),
2788 operation: None,
2789 }],
2790 schedules: vec![ScheduledFunctionDeclaration {
2791 name: "sync_contacts_hourly".to_owned(),
2792 function_name: "remote_crm.sync_contact.v1".to_owned(),
2793 cron: "0 * * * *".to_owned(),
2794 input: serde_json::json!({ "reason": "schedule" }),
2795 }],
2796 workflows: vec![],
2797 })
2798 .build();
2799
2800 let json = serde_json::to_string(&manifest).expect("serialize");
2801
2802 assert!(json.contains(r#""runtime""#), "got {json}");
2803 assert!(
2804 json.contains(r#""name":"remote_crm.sync_contact.v1""#),
2805 "got {json}"
2806 );
2807 assert!(json.contains(r#""queue":"remote-crm""#), "got {json}");
2808 assert!(json.contains(r#""schedules""#), "got {json}");
2809 assert!(
2810 !json.contains(r#""workflows""#),
2811 "empty workflow declarations must not change existing Runtime Function manifests: {json}"
2812 );
2813 let back: ModuleManifest = serde_json::from_str(&json).expect("deserialize");
2814 assert_eq!(manifest, back);
2815 }
2816
2817 #[test]
2818 fn manifest_with_versioned_workflow_round_trips_and_lints_cleanly() {
2819 let manifest = ModuleManifest::builder("acme/support-sla")
2820 .runtime(RuntimeSurface {
2821 functions: vec![],
2822 schedules: vec![],
2823 workflows: vec![WorkflowDefinition::new(
2824 "support-sla",
2825 "ticket_sla",
2826 "v1",
2827 WorkflowDataContract::new("support.sla.start", "v1"),
2828 WorkflowDataContract::new("support.sla.result", "v1"),
2829 vec![
2830 WorkflowStepDeclaration::new("acknowledge_ticket")
2831 .with_display_name("Acknowledge ticket")
2832 .with_retry_policy(WorkflowRetryPolicyDeclaration::new(
2833 3,
2834 vec![1_000, 5_000],
2835 ))
2836 .with_timeout_ms(30_000)
2837 .with_compensation(
2838 WorkflowCompensationDeclaration::new(
2839 "withdraw_sla_acknowledgement",
2840 1,
2841 WorkflowDataContract::new("sla-compensation-requested", "v1"),
2842 )
2843 .with_completion_contract(
2844 WorkflowDataContract::new("sla-compensated", "v1"),
2845 ),
2846 ),
2847 WorkflowStepDeclaration::new("await_resolution"),
2848 ],
2849 )],
2850 })
2851 .build();
2852
2853 let json = serde_json::to_string(&manifest).expect("serialize");
2854 let back: ModuleManifest = serde_json::from_str(&json).expect("deserialize");
2855 let lints = lint_module_manifest(&back);
2856 let workflow_schema = crate::workflow_definition_schema();
2857 let compensation_schema = &workflow_schema["$defs"]["compensation"];
2858
2859 assert_eq!(manifest, back);
2860 assert!(json.contains(r#""protocol":"lenso.workflow-definition.v1""#));
2861 assert!(json.contains(r#""inputContract""#));
2862 assert!(json.contains(r#""maxAttempts":3"#));
2863 assert!(json.contains(r#""delaysMs":[1000,5000]"#));
2864 assert!(json.contains(r#""timeoutMs":30000"#));
2865 assert!(json.contains(r#""name":"withdraw_sla_acknowledgement""#));
2866 assert!(json.contains(r#""order":1"#));
2867 assert!(json.contains(r#""contract":{"contractId":"sla-compensation-requested""#));
2868 assert!(json.contains(r#""completionContract":{"contractId":"sla-compensated""#));
2869 assert_eq!(
2870 compensation_schema["properties"]["completionContract"]["$ref"],
2871 "#/$defs/dataContract"
2872 );
2873 assert!(
2874 compensation_schema["required"]
2875 .as_array()
2876 .unwrap()
2877 .iter()
2878 .any(|field| field == "completionContract")
2879 );
2880 assert!(lints.iter().all(|lint| {
2881 lint.severity != ModuleManifestLintSeverity::Error
2882 && !lint.subject.starts_with("runtime.workflow")
2883 }));
2884 }
2885
2886 #[test]
2887 fn manifest_lint_rejects_unowned_or_ambiguous_workflows() {
2888 let invalid = WorkflowDefinition::new(
2889 "another-module",
2890 "ticket_sla",
2891 "v1",
2892 WorkflowDataContract::new("", "v1"),
2893 WorkflowDataContract::new("support.sla.result", "v1"),
2894 vec![
2895 WorkflowStepDeclaration::new("acknowledge_ticket")
2896 .with_retry_policy(WorkflowRetryPolicyDeclaration::new(3, vec![1_000]))
2897 .with_timeout_ms(0)
2898 .with_compensation(WorkflowCompensationDeclaration::new(
2899 "invalid compensation",
2900 0,
2901 WorkflowDataContract::new("", ""),
2902 )),
2903 WorkflowStepDeclaration::new("acknowledge_ticket").with_compensation(
2904 WorkflowCompensationDeclaration::new(
2905 "invalid compensation",
2906 0,
2907 WorkflowDataContract::new("", ""),
2908 ),
2909 ),
2910 ],
2911 );
2912 let manifest = ModuleManifest::builder("acme/support-sla")
2913 .runtime(RuntimeSurface {
2914 functions: vec![],
2915 schedules: vec![],
2916 workflows: vec![invalid.clone(), invalid],
2917 })
2918 .build();
2919
2920 let lints = lint_module_manifest(&manifest);
2921
2922 assert!(
2923 lints
2924 .iter()
2925 .any(|lint| lint.subject == "runtime.workflow.ticket_sla.v1.owner")
2926 );
2927 assert!(lints.iter().any(|lint| {
2928 lint.subject == "runtime.workflow.ticket_sla.v1"
2929 && lint.message == "Duplicate Durable Workflow definition identity."
2930 }));
2931 assert!(lints.iter().any(|lint| {
2932 lint.subject == "runtime.workflow.ticket_sla.v1.step.acknowledge_ticket"
2933 && lint.message == "Durable Workflow step name is declared more than once."
2934 }));
2935 assert!(lints.iter().any(|lint| {
2936 lint.subject == "runtime.workflow.ticket_sla.v1.step.acknowledge_ticket.retry_policy"
2937 }));
2938 assert!(lints.iter().any(|lint| {
2939 lint.subject == "runtime.workflow.ticket_sla.v1.step.acknowledge_ticket.timeout_ms"
2940 }));
2941 assert!(lints.iter().any(|lint| {
2942 lint.subject
2943 == "runtime.workflow.ticket_sla.v1.step.acknowledge_ticket.compensation.name"
2944 }));
2945 assert!(lints.iter().any(|lint| {
2946 lint.subject
2947 == "runtime.workflow.ticket_sla.v1.step.acknowledge_ticket.compensation.order"
2948 }));
2949 assert!(lints.iter().any(|lint| {
2950 lint.subject
2951 == "runtime.workflow.ticket_sla.v1.step.acknowledge_ticket.compensation.contract"
2952 }));
2953 }
2954
2955 #[test]
2956 fn manifest_with_event_handlers_round_trips_through_json() {
2957 let manifest = ModuleManifest::builder("acme/remote-crm")
2958 .events(EventSurface {
2959 handlers: vec![EventHandlerDeclaration {
2960 name: "sync_contact_on_user_registered".to_owned(),
2961 event_name: "identity.user_registered.v1".to_owned(),
2962 operation: None,
2963 }],
2964 })
2965 .build();
2966
2967 let json = serde_json::to_string(&manifest).expect("serialize");
2968
2969 assert!(json.contains(r#""events""#), "got {json}");
2970 assert!(
2971 json.contains(r#""name":"sync_contact_on_user_registered""#),
2972 "got {json}"
2973 );
2974 assert!(
2975 json.contains(r#""event_name":"identity.user_registered.v1""#),
2976 "got {json}"
2977 );
2978 let back: ModuleManifest = serde_json::from_str(&json).expect("deserialize");
2979 assert_eq!(manifest, back);
2980 }
2981
2982 #[test]
2983 fn manifest_lint_warns_for_invalid_capability_names() {
2984 let manifest = ModuleManifest::builder("acme/remote-crm")
2985 .capabilities(vec!["RemoteCRM Contacts Read".to_owned()])
2986 .build();
2987
2988 assert!(
2989 lint_module_manifest(&manifest)
2990 .iter()
2991 .any(|lint| lint.subject == "capability RemoteCRM Contacts Read"
2992 && lint.severity == ModuleManifestLintSeverity::Warning)
2993 );
2994 }
2995
2996 #[test]
2997 fn manifest_lint_warns_for_unknown_declarative_fallback_entities() {
2998 let manifest = ModuleManifest::builder("acme/remote-crm")
2999 .declarative_admin(AdminDeclarativeSurface {
3000 pages: vec![AdminDeclarativePage {
3001 name: "dashboard".to_owned(),
3002 label: "Dashboard".to_owned(),
3003 sections: vec![AdminDeclarativeSection {
3004 name: "missing".to_owned(),
3005 label: "Missing".to_owned(),
3006 component: AdminDeclarativeComponent::EntityTable {
3007 entity: "contacts".to_owned(),
3008 },
3009 }],
3010 }],
3011 actions: vec![],
3012 fallback_schema: None,
3013 })
3014 .build();
3015
3016 assert!(
3017 lint_module_manifest(&manifest)
3018 .iter()
3019 .any(|lint| lint.subject == "admin.declarative.section.missing"
3020 && lint.severity == ModuleManifestLintSeverity::Warning)
3021 );
3022 }
3023
3024 #[test]
3025 fn manifest_lint_warns_for_embedded_origin_policy() {
3026 let manifest = ModuleManifest::builder("acme/remote-crm")
3027 .embedded_admin(AdminEmbeddedSurface {
3028 runtime: AdminEmbeddedRuntime::Iframe,
3029 entry: AdminEmbeddedEntry::Url {
3030 url: "http://crm.example.test/admin".to_owned(),
3031 allowed_origins: vec![],
3032 },
3033 sandbox: AdminSandboxPolicy {
3034 allow_scripts: true,
3035 allow_forms: false,
3036 allow_popups: false,
3037 allow_same_origin: false,
3038 },
3039 permissions: vec![],
3040 fallback_schema: None,
3041 })
3042 .build();
3043
3044 let lints = lint_module_manifest(&manifest);
3045
3046 assert!(
3047 lints
3048 .iter()
3049 .any(|lint| lint.subject == "admin.embedded.entry.url")
3050 );
3051 assert!(
3052 lints
3053 .iter()
3054 .any(|lint| lint.subject == "admin.embedded.entry.allowed_origins")
3055 );
3056 }
3057
3058 #[test]
3059 fn manifest_lint_warns_for_runtime_function_declarations() {
3060 let manifest = ModuleManifest::builder("acme/remote-crm")
3061 .runtime(RuntimeSurface {
3062 functions: vec![
3063 RuntimeFunctionDeclaration {
3064 name: "remote_crm/sync_contact.v1".to_owned(),
3065 version: 1,
3066 queue: "".to_owned(),
3067 input_schema: Some("remote_crm.sync_contact.v1".to_owned()),
3068 retry_policy: Some(RuntimeRetryPolicyDeclaration {
3069 max_attempts: 0,
3070 initial_delay_ms: 1000,
3071 }),
3072 operation: None,
3073 },
3074 RuntimeFunctionDeclaration {
3075 name: "remote_crm.sync_contact.v1".to_owned(),
3076 version: 1,
3077 queue: "remote-crm".to_owned(),
3078 input_schema: Some("remote_crm.sync_contact.input.v1".to_owned()),
3079 retry_policy: None,
3080 operation: None,
3081 },
3082 RuntimeFunctionDeclaration {
3083 name: "remote_crm.sync_contact.v1".to_owned(),
3084 version: 1,
3085 queue: "remote-crm".to_owned(),
3086 input_schema: Some("remote_crm.sync_contact.v1".to_owned()),
3087 retry_policy: None,
3088 operation: None,
3089 },
3090 ],
3091 schedules: vec![],
3092 workflows: vec![],
3093 })
3094 .build();
3095
3096 let lints = lint_module_manifest(&manifest);
3097
3098 assert!(lints.iter().any(|lint| {
3099 lint.subject == "runtime.function.remote_crm/sync_contact.v1"
3100 && lint.severity == ModuleManifestLintSeverity::Warning
3101 }));
3102 assert!(lints.iter().any(|lint| {
3103 lint.subject == "runtime.function.remote_crm/sync_contact.v1.retry_policy"
3104 && lint.severity == ModuleManifestLintSeverity::Warning
3105 }));
3106 assert!(lints.iter().any(|lint| {
3107 lint.subject == "runtime.function.remote_crm.sync_contact.v1.input_schema"
3108 && lint.severity == ModuleManifestLintSeverity::Warning
3109 }));
3110 assert!(lints.iter().any(|lint| {
3111 lint.subject == "runtime.function.remote_crm.sync_contact.v1"
3112 && lint.severity == ModuleManifestLintSeverity::Error
3113 }));
3114 }
3115
3116 #[test]
3117 fn manifest_with_lifecycle_round_trips_through_json() {
3118 let manifest = ModuleManifest::builder("acme/remote-crm")
3119 .runtime(RuntimeSurface {
3120 functions: vec![RuntimeFunctionDeclaration {
3121 name: "remote_crm.warm_contact_cache.v1".to_owned(),
3122 version: 1,
3123 queue: "remote-crm".to_owned(),
3124 input_schema: Some("remote_crm.warm_contact_cache.v1".to_owned()),
3125 retry_policy: Some(RuntimeRetryPolicyDeclaration {
3126 max_attempts: 2,
3127 initial_delay_ms: 500,
3128 }),
3129 operation: None,
3130 }],
3131 schedules: vec![],
3132 workflows: vec![],
3133 })
3134 .lifecycle(LifecycleSurface {
3135 startup_checks: vec![LifecycleStartupCheckDeclaration {
3136 name: "warm cache function is registered".to_owned(),
3137 required: true,
3138 check: LifecycleStartupCheckKind::FunctionRegistered {
3139 function_name: "remote_crm.warm_contact_cache.v1".to_owned(),
3140 },
3141 }],
3142 activation_jobs: vec![LifecycleActivationJobDeclaration {
3143 name: "warm contact cache".to_owned(),
3144 function_name: "remote_crm.warm_contact_cache.v1".to_owned(),
3145 run_policy: LifecycleActivationRunPolicy::EveryStartup,
3146 input: serde_json::json!({ "reason": "worker_startup" }),
3147 required: true,
3148 }],
3149 })
3150 .build();
3151
3152 let json = serde_json::to_string(&manifest).expect("serialize");
3153
3154 assert!(json.contains(r#""lifecycle""#), "got {json}");
3155 assert!(
3156 json.contains(r#""kind":"function_registered""#),
3157 "got {json}"
3158 );
3159 assert!(
3160 json.contains(r#""run_policy":"every_startup""#),
3161 "got {json}"
3162 );
3163 let back: ModuleManifest = serde_json::from_str(&json).expect("deserialize");
3164 assert_eq!(manifest, back);
3165 }
3166
3167 #[test]
3168 fn manifest_lint_flags_lifecycle_declarations_that_cannot_run() {
3169 let manifest = ModuleManifest::builder("acme/remote-crm")
3170 .runtime(RuntimeSurface {
3171 functions: vec![],
3172 schedules: vec![],
3173 workflows: vec![],
3174 })
3175 .lifecycle(LifecycleSurface {
3176 startup_checks: vec![
3177 LifecycleStartupCheckDeclaration {
3178 name: "".to_owned(),
3179 required: true,
3180 check: LifecycleStartupCheckKind::FunctionRegistered {
3181 function_name: "remote_crm.missing.v1".to_owned(),
3182 },
3183 },
3184 LifecycleStartupCheckDeclaration {
3185 name: "missing capability".to_owned(),
3186 required: true,
3187 check: LifecycleStartupCheckKind::CapabilityDeclared {
3188 capability: "remote_crm.contacts.read".to_owned(),
3189 },
3190 },
3191 ],
3192 activation_jobs: vec![LifecycleActivationJobDeclaration {
3193 name: "warm contact cache".to_owned(),
3194 function_name: "remote_crm.warm_contact_cache.v1".to_owned(),
3195 run_policy: LifecycleActivationRunPolicy::EveryStartup,
3196 input: serde_json::json!({}),
3197 required: true,
3198 }],
3199 })
3200 .build();
3201
3202 let lints = lint_module_manifest(&manifest);
3203
3204 assert!(lints.iter().any(|lint| {
3205 lint.subject == "lifecycle.startup_check"
3206 && lint.severity == ModuleManifestLintSeverity::Warning
3207 && lint.message == "Lifecycle startup check is missing a name."
3208 }));
3209 assert!(lints.iter().any(|lint| {
3210 lint.subject == "lifecycle.startup_check.function_registered.remote_crm.missing.v1"
3211 && lint.severity == ModuleManifestLintSeverity::Error
3212 }));
3213 assert!(lints.iter().any(|lint| {
3214 lint.subject == "lifecycle.startup_check.capability.remote_crm.contacts.read"
3215 && lint.severity == ModuleManifestLintSeverity::Warning
3216 }));
3217 assert!(lints.iter().any(|lint| {
3218 lint.subject == "lifecycle.activation_job.warm contact cache"
3219 && lint.severity == ModuleManifestLintSeverity::Error
3220 }));
3221 }
3222
3223 #[test]
3224 fn manifest_lint_warns_for_empty_lifecycle_surface() {
3225 let manifest = ModuleManifest::builder("acme/remote-crm")
3226 .lifecycle(LifecycleSurface {
3227 startup_checks: vec![],
3228 activation_jobs: vec![],
3229 })
3230 .build();
3231
3232 let lints = lint_module_manifest(&manifest);
3233
3234 assert!(lints.iter().any(|lint| {
3235 lint.subject == "lifecycle"
3236 && lint.severity == ModuleManifestLintSeverity::Warning
3237 && lint.message
3238 == "Lifecycle surface declares no startup checks or activation jobs."
3239 }));
3240 }
3241
3242 #[test]
3243 fn manifest_lint_warns_for_activation_job_missing_name() {
3244 let manifest = ModuleManifest::builder("acme/remote-crm")
3245 .runtime(RuntimeSurface {
3246 functions: vec![RuntimeFunctionDeclaration {
3247 name: "remote_crm.warm_contact_cache.v1".to_owned(),
3248 version: 1,
3249 queue: "remote-crm".to_owned(),
3250 input_schema: Some("remote_crm.warm_contact_cache.v1".to_owned()),
3251 retry_policy: None,
3252 operation: None,
3253 }],
3254 schedules: vec![],
3255 workflows: vec![],
3256 })
3257 .lifecycle(LifecycleSurface {
3258 startup_checks: vec![],
3259 activation_jobs: vec![LifecycleActivationJobDeclaration {
3260 name: "".to_owned(),
3261 function_name: "remote_crm.warm_contact_cache.v1".to_owned(),
3262 run_policy: LifecycleActivationRunPolicy::EveryStartup,
3263 input: serde_json::json!({}),
3264 required: true,
3265 }],
3266 })
3267 .build();
3268
3269 let lints = lint_module_manifest(&manifest);
3270
3271 assert!(lints.iter().any(|lint| {
3272 lint.subject == "lifecycle.activation_job"
3273 && lint.severity == ModuleManifestLintSeverity::Warning
3274 && lint.message == "Lifecycle activation job is missing a name."
3275 }));
3276 }
3277
3278 #[test]
3279 fn manifest_lint_errors_for_activation_job_missing_function_name() {
3280 let manifest = ModuleManifest::builder("acme/remote-crm")
3281 .lifecycle(LifecycleSurface {
3282 startup_checks: vec![],
3283 activation_jobs: vec![LifecycleActivationJobDeclaration {
3284 name: "".to_owned(),
3285 function_name: "".to_owned(),
3286 run_policy: LifecycleActivationRunPolicy::EveryStartup,
3287 input: serde_json::json!({}),
3288 required: true,
3289 }],
3290 })
3291 .build();
3292
3293 let lints = lint_module_manifest(&manifest);
3294
3295 assert!(lints.iter().any(|lint| {
3296 lint.subject == "lifecycle.activation_job"
3297 && lint.severity == ModuleManifestLintSeverity::Error
3298 && lint.message == "Lifecycle activation job is missing a function name."
3299 }));
3300 }
3301
3302 #[test]
3303 fn manifest_lint_warns_for_undeclared_capability_references() {
3304 use crate::admin::{AdminAction, AdminActionDangerLevel};
3305
3306 let manifest = ModuleManifest::builder("acme/remote-crm")
3307 .capabilities(vec!["remote_crm.contacts.write".to_owned()])
3308 .http_routes(vec![ModuleHttpRoute {
3309 method: ModuleHttpMethod::Get,
3310 path: "/contacts/{id}".to_owned(),
3311 capability: Some("remote_crm.contacts.read".to_owned()),
3312 display_name: Some("Fetch Contact".to_owned()),
3313 story_title: Some("Fetch Contact".to_owned()),
3314 operation: None,
3315 }])
3316 .declarative_admin(AdminDeclarativeSurface {
3317 pages: vec![AdminDeclarativePage {
3318 name: "contacts".to_owned(),
3319 label: "Contacts".to_owned(),
3320 sections: vec![AdminDeclarativeSection {
3321 name: "contacts".to_owned(),
3322 label: "Contacts".to_owned(),
3323 component: AdminDeclarativeComponent::EntityTable {
3324 entity: "contacts".to_owned(),
3325 },
3326 }],
3327 }],
3328 actions: vec![AdminAction {
3329 name: "sync_contacts".to_owned(),
3330 label: "Sync Contacts".to_owned(),
3331 capability: "remote_crm.contacts.sync".to_owned(),
3332 input_schema: None,
3333 confirmation: None,
3334 danger_level: AdminActionDangerLevel::Low,
3335 operation: None,
3336 }],
3337 fallback_schema: Some(AdminSchema {
3338 entities: vec![crate::EntitySchema {
3339 name: "contacts".to_owned(),
3340 label: "Contacts".to_owned(),
3341 fields: vec![],
3342 read_capability: "remote_crm.contacts.read".to_owned(),
3343 }],
3344 }),
3345 })
3346 .build();
3347
3348 let lints = lint_module_manifest(&manifest);
3349
3350 assert!(lints.iter().any(|lint| {
3351 lint.severity == ModuleManifestLintSeverity::Warning
3352 && lint.subject == "capability.reference.http_route.GET /contacts/{id}"
3353 && lint.message == "Capability reference is not declared by the module."
3354 }));
3355 assert!(lints.iter().any(|lint| {
3356 lint.severity == ModuleManifestLintSeverity::Warning
3357 && lint.subject == "capability.reference.admin.declarative.action.sync_contacts"
3358 && lint.message == "Capability reference is not declared by the module."
3359 }));
3360 assert!(lints.iter().any(|lint| {
3361 lint.severity == ModuleManifestLintSeverity::Warning
3362 && lint.subject == "capability.reference.admin.declarative.fallback_schema.contacts"
3363 && lint.message == "Capability reference is not declared by the module."
3364 }));
3365 }
3366
3367 #[test]
3368 fn manifest_lint_catalog_covers_current_subjects() {
3369 let schema = AdminSchema {
3370 entities: vec![crate::EntitySchema {
3371 name: "contacts".to_owned(),
3372 label: "Contacts".to_owned(),
3373 fields: vec![],
3374 read_capability: "".to_owned(),
3375 }],
3376 };
3377 let manifest = ModuleManifest::builder("")
3378 .capabilities(vec!["RemoteCRM Contacts Read".to_owned()])
3379 .http_routes(vec![
3380 ModuleHttpRoute {
3381 method: ModuleHttpMethod::Get,
3382 path: "/contacts/{id}".to_owned(),
3383 capability: None,
3384 display_name: None,
3385 story_title: None,
3386 operation: None,
3387 },
3388 ModuleHttpRoute {
3389 method: ModuleHttpMethod::Get,
3390 path: "/contacts/{id}".to_owned(),
3391 capability: None,
3392 display_name: None,
3393 story_title: None,
3394 operation: None,
3395 },
3396 ])
3397 .embedded_admin(AdminEmbeddedSurface {
3398 runtime: AdminEmbeddedRuntime::Wasm,
3399 entry: AdminEmbeddedEntry::Url {
3400 url: "http://crm.example.test/admin".to_owned(),
3401 allowed_origins: vec![],
3402 },
3403 sandbox: AdminSandboxPolicy {
3404 allow_scripts: true,
3405 allow_forms: false,
3406 allow_popups: false,
3407 allow_same_origin: false,
3408 },
3409 permissions: vec![AdminPermission::ReadEntity {
3410 entity: "missing".to_owned(),
3411 }],
3412 fallback_schema: Some(schema),
3413 })
3414 .runtime(RuntimeSurface {
3415 functions: vec![RuntimeFunctionDeclaration {
3416 name: "remote_crm.sync_contact.v1".to_owned(),
3417 version: 1,
3418 queue: "".to_owned(),
3419 input_schema: Some("remote_crm.sync_contact.input.v1".to_owned()),
3420 retry_policy: Some(RuntimeRetryPolicyDeclaration {
3421 max_attempts: 0,
3422 initial_delay_ms: 1000,
3423 }),
3424 operation: None,
3425 }],
3426 schedules: vec![ScheduledFunctionDeclaration {
3427 name: "sync_contacts_hourly".to_owned(),
3428 function_name: "remote_crm.missing.v1".to_owned(),
3429 cron: "bad cron".to_owned(),
3430 input: serde_json::json!({}),
3431 }],
3432 workflows: vec![],
3433 })
3434 .lifecycle(LifecycleSurface {
3435 startup_checks: vec![LifecycleStartupCheckDeclaration {
3436 name: "missing function".to_owned(),
3437 required: true,
3438 check: LifecycleStartupCheckKind::FunctionRegistered {
3439 function_name: "remote_crm.missing.v1".to_owned(),
3440 },
3441 }],
3442 activation_jobs: vec![LifecycleActivationJobDeclaration {
3443 name: "missing activation".to_owned(),
3444 function_name: "remote_crm.missing.v1".to_owned(),
3445 run_policy: LifecycleActivationRunPolicy::EveryStartup,
3446 input: serde_json::json!({}),
3447 required: true,
3448 }],
3449 })
3450 .console(vec![ConsoleSurface {
3451 name: "contacts".to_owned(),
3452 label: "Contacts".to_owned(),
3453 route: "/remote-crm/contacts".to_owned(),
3454 presentation: ConsoleSurfacePresentation::Esm {
3455 entry: "remoteCrmConsoleModule".to_owned(),
3456 },
3457 icon: None,
3458 required_capabilities: Vec::new(),
3459 navigation: Some(crate::ConsoleNavigation {
3460 workspace: crate::ConsoleWorkspaceRef {
3461 id: "system".to_owned(),
3462 label: "System".to_owned(),
3463 icon: None,
3464 },
3465 group: None,
3466 order: None,
3467 }),
3468 }])
3469 .build();
3470
3471 let catalog: Vec<_> = lint_module_manifest(&manifest)
3472 .into_iter()
3473 .map(|lint| (lint.severity, lint.subject))
3474 .collect();
3475
3476 assert_eq!(
3477 catalog,
3478 vec![
3479 (
3480 ModuleManifestLintSeverity::Error,
3481 "module.module_id".to_owned(),
3482 ),
3483 (
3484 ModuleManifestLintSeverity::Warning,
3485 "capability RemoteCRM Contacts Read".to_owned(),
3486 ),
3487 (
3488 ModuleManifestLintSeverity::Error,
3489 "GET /contacts/{id}".to_owned(),
3490 ),
3491 (
3492 ModuleManifestLintSeverity::Warning,
3493 "GET /contacts/{id}".to_owned(),
3494 ),
3495 (
3496 ModuleManifestLintSeverity::Warning,
3497 "GET /contacts/{id}".to_owned(),
3498 ),
3499 (
3500 ModuleManifestLintSeverity::Warning,
3501 "GET /contacts/{id}".to_owned(),
3502 ),
3503 (
3504 ModuleManifestLintSeverity::Warning,
3505 "GET /contacts/{id}".to_owned(),
3506 ),
3507 (
3508 ModuleManifestLintSeverity::Warning,
3509 "admin.embedded.runtime".to_owned(),
3510 ),
3511 (
3512 ModuleManifestLintSeverity::Warning,
3513 "admin.embedded.entry.url".to_owned(),
3514 ),
3515 (
3516 ModuleManifestLintSeverity::Warning,
3517 "admin.embedded.entry.allowed_origins".to_owned(),
3518 ),
3519 (
3520 ModuleManifestLintSeverity::Warning,
3521 "admin.embedded.fallback_schema.contacts".to_owned(),
3522 ),
3523 (
3524 ModuleManifestLintSeverity::Warning,
3525 "admin.embedded.permission.missing".to_owned(),
3526 ),
3527 (
3528 ModuleManifestLintSeverity::Error,
3529 "lifecycle.startup_check.function_registered.remote_crm.missing.v1".to_owned(),
3530 ),
3531 (
3532 ModuleManifestLintSeverity::Error,
3533 "lifecycle.activation_job.missing activation".to_owned(),
3534 ),
3535 (
3536 ModuleManifestLintSeverity::Warning,
3537 "console.surface.contacts.navigation.workspace.id".to_owned(),
3538 ),
3539 (
3540 ModuleManifestLintSeverity::Warning,
3541 "runtime.function.remote_crm.sync_contact.v1".to_owned(),
3542 ),
3543 (
3544 ModuleManifestLintSeverity::Warning,
3545 "runtime.function.remote_crm.sync_contact.v1.input_schema".to_owned(),
3546 ),
3547 (
3548 ModuleManifestLintSeverity::Warning,
3549 "runtime.function.remote_crm.sync_contact.v1.retry_policy".to_owned(),
3550 ),
3551 (
3552 ModuleManifestLintSeverity::Error,
3553 "runtime.schedule.sync_contacts_hourly.cron".to_owned(),
3554 ),
3555 (
3556 ModuleManifestLintSeverity::Error,
3557 "runtime.schedule.sync_contacts_hourly".to_owned(),
3558 ),
3559 ],
3560 );
3561 }
3562}