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