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