1use std::{
8 collections::{BTreeMap, BTreeSet, HashSet},
9 fmt, fs,
10 path::Path,
11 process::Stdio,
12 time::Duration,
13};
14
15use serde::{
16 de::{self, MapAccess, Visitor},
17 Deserialize, Deserializer,
18};
19use serde_json::Value;
20use subc_protocol::{
21 manifest::{validate_manifest_capability_grammar, CapabilityNeed, ModuleManifest},
22 PROTOCOL_VERSION,
23};
24use tokio::{process::Command, time};
25
26use crate::daemon_config::{self, ConfiguredModule};
27
28pub const MANIFEST_TIMEOUT: Duration = Duration::from_secs(10);
31
32#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
34pub enum OperationalClass {
35 ProgramMissing,
36 ProgramNotExecutable,
37 ManifestTimeout,
38 ManifestExitNonzero,
39 ManifestUnparsable,
40 ManifestVersionUnsupported,
41 DuplicateModuleId,
42 ManifestInvalid,
43}
44
45impl OperationalClass {
46 pub const fn as_str(self) -> &'static str {
47 match self {
48 Self::ProgramMissing => "program_missing",
49 Self::ProgramNotExecutable => "program_not_executable",
50 Self::ManifestTimeout => "manifest_timeout",
51 Self::ManifestExitNonzero => "manifest_exit_nonzero",
52 Self::ManifestUnparsable => "manifest_unparsable",
53 Self::ManifestVersionUnsupported => "manifest_version_unsupported",
54 Self::DuplicateModuleId => "duplicate_module_id",
55 Self::ManifestInvalid => "manifest_invalid",
56 }
57 }
58}
59
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62pub enum LintOutcome {
63 Clean,
64 SemanticViolation,
65 OperationalFailure,
66}
67
68impl LintOutcome {
69 pub const fn exit_code(self) -> i32 {
70 match self {
71 Self::Clean => 0,
72 Self::SemanticViolation => 1,
73 Self::OperationalFailure => 2,
74 }
75 }
76}
77
78#[derive(Debug)]
80pub struct LintReport {
81 pub outcome: LintOutcome,
82 pub examined: usize,
83 pub configured: usize,
84 lines: Vec<String>,
85 #[cfg(test)]
86 failures: Vec<OperationalFailure>,
87}
88
89impl LintReport {
90 pub fn render(&self) -> String {
93 self.lines.join("\n")
94 }
95
96 #[cfg(test)]
97 fn has_failure(&self, class: OperationalClass, module: &str) -> bool {
98 self.failures
99 .iter()
100 .any(|failure| failure.class == class && failure.module == module)
101 }
102}
103
104#[derive(Debug)]
105pub struct LintConfigError(String);
106
107impl fmt::Display for LintConfigError {
108 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
109 formatter.write_str(&self.0)
110 }
111}
112
113impl std::error::Error for LintConfigError {}
114
115#[derive(Debug)]
116struct OperationalFailure {
117 class: OperationalClass,
118 module: String,
119}
120
121#[derive(Debug)]
122struct ExaminedManifest {
123 module_id: String,
124 enabled: bool,
125 manifest: ModuleManifest,
126}
127
128#[derive(Debug)]
129struct RequirementLine {
130 consumer: String,
131 capability: String,
132 text: String,
133}
134
135pub async fn lint(path: impl AsRef<Path>, verbose: bool) -> Result<LintReport, LintConfigError> {
137 lint_with_timeout(path.as_ref(), verbose, MANIFEST_TIMEOUT).await
138}
139
140async fn lint_with_timeout(
141 path: &Path,
142 verbose: bool,
143 manifest_timeout: Duration,
144) -> Result<LintReport, LintConfigError> {
145 let duplicate_module_ids = duplicate_module_ids(path)?;
146 let config = daemon_config::load(path)
147 .map_err(|error| LintConfigError(format!("failed to parse {}: {error}", path.display())))?
148 .ok_or_else(|| {
149 LintConfigError(format!("daemon config {} does not exist", path.display()))
150 })?;
151
152 let mut modules = config.modules.iter().collect::<Vec<_>>();
153 modules.sort_by(|left, right| left.module_id.cmp(&right.module_id));
154 let mut failures = duplicate_module_ids
155 .into_iter()
156 .map(|module| OperationalFailure {
157 class: OperationalClass::DuplicateModuleId,
158 module,
159 })
160 .collect::<Vec<_>>();
161 let mut skipped_daemons = Vec::new();
162 let mut examined = Vec::new();
163
164 for module in modules {
165 if is_daemon_entry(module) {
166 skipped_daemons.push(module.module_id.clone());
167 continue;
168 }
169
170 match read_manifest(module, manifest_timeout).await {
171 Ok(manifest) => examined.push(ExaminedManifest {
172 module_id: module.module_id.clone(),
173 enabled: module.enabled,
174 manifest,
175 }),
176 Err(class) => failures.push(OperationalFailure {
177 class,
178 module: module.module_id.clone(),
179 }),
180 }
181 }
182
183 let configured = config
184 .modules
185 .iter()
186 .filter(|module| !is_daemon_entry(module))
187 .count();
188 let unavailable = failures
189 .iter()
190 .map(|failure| failure.module.as_str())
191 .collect::<BTreeSet<_>>()
192 .into_iter()
193 .collect::<Vec<_>>();
194 let checked = format!(
195 "checked {} of {configured} configured modules",
196 examined.len()
197 );
198 let mut lines = vec![if unavailable.is_empty() {
199 checked
200 } else {
201 format!(
202 "{checked} — {} do not expose a manifest",
203 unavailable.join(", ")
204 )
205 }];
206
207 if verbose {
208 for module in &skipped_daemons {
209 lines.push(format!("verbose: skipped daemon entry {module}"));
210 }
211 }
212
213 failures.sort_by(|left, right| {
214 left.class
215 .cmp(&right.class)
216 .then_with(|| left.module.cmp(&right.module))
217 });
218 if verbose {
219 for failure in &failures {
220 lines.push(format!(
221 "partial: evaluation incomplete ({}: {})",
222 failure.class.as_str(),
223 failure.module
224 ));
225 }
226 if examined.is_empty() {
227 lines.push("operational failure: no modules examined (vacuity floor)".to_string());
231 }
232 lines.push("deny consistency = self-contradiction check".to_string());
233 }
234
235 let enabled_providers = capability_claimants(&examined, true);
236 let all_providers = capability_claimants(&examined, false);
237 let mut has_semantic_violation = false;
238 let mut deny_violations = Vec::new();
239 let mut requirement_lines = Vec::new();
240
241 for entry in &examined {
242 let Some(capabilities) = &entry.manifest.capabilities else {
243 continue;
244 };
245 if entry.enabled {
246 for requirement in &capabilities.requires {
247 let provided = enabled_providers.contains_key(&requirement.capability);
248 match requirement.need {
249 CapabilityNeed::Required => {
250 let text = if provided {
251 format!(
252 "required {} {}: provided",
253 entry.module_id, requirement.capability
254 )
255 } else {
256 let text = format!(
257 "required {} {}: no enabled provider",
258 entry.module_id, requirement.capability
259 );
260 has_semantic_violation = true;
261 text
262 };
263 requirement_lines.push(RequirementLine {
264 consumer: entry.module_id.clone(),
265 capability: requirement.capability.clone(),
266 text,
267 });
268 }
269 CapabilityNeed::Optional if verbose && !provided => {
270 requirement_lines.push(RequirementLine {
271 consumer: entry.module_id.clone(),
272 capability: requirement.capability.clone(),
273 text: format!(
274 "optional {}: no provider (consumer degrades, by declaration)",
275 requirement.capability
276 ),
277 });
278 }
279 CapabilityNeed::Optional => {}
280 }
281 }
282 }
283
284 let denied = capabilities
285 .must_never_reach
286 .iter()
287 .collect::<BTreeSet<_>>();
288 for requirement in &capabilities.requires {
289 if denied.contains(&requirement.capability) {
290 has_semantic_violation = true;
291 deny_violations.push(format!(
292 "requires_deny_conflict module={} capability={}",
293 entry.module_id, requirement.capability
294 ));
295 }
296 }
297 }
298
299 requirement_lines.sort_by(|left, right| {
300 left.consumer
301 .cmp(&right.consumer)
302 .then_with(|| left.capability.cmp(&right.capability))
303 });
304 lines.extend(requirement_lines.into_iter().map(|line| line.text));
305
306 deny_violations.sort();
307 deny_violations.dedup();
308 lines.extend(deny_violations);
309
310 let mut reserved_lines = Vec::new();
311 let mut reserved_violation = false;
312 for (capability, bound_module) in &config.reserved_capabilities {
313 let claimants = all_providers.get(capability);
314 match claimants {
315 None => reserved_lines.push(format!(
316 "warning: reserved capability {capability} has no configured claimant for {bound_module}"
317 )),
318 Some(claimants) => {
319 for claimant in claimants {
320 if claimant != bound_module {
321 reserved_violation = true;
322 reserved_lines.push(format!(
323 "reserved capability {capability}: claimant {claimant} conflicts with binding {bound_module}"
324 ));
325 }
326 }
327 }
328 }
329 }
330 lines.extend(reserved_lines);
331
332 let mut disabled_notes = Vec::new();
333 for entry in &examined {
334 if entry.enabled {
335 continue;
336 }
337 let Some(capabilities) = &entry.manifest.capabilities else {
338 continue;
339 };
340 for capability in &capabilities.provides {
341 if !enabled_providers.contains_key(capability) {
342 disabled_notes.push(format!(
343 "note: {} (disabled) claims {capability}",
344 entry.module_id
345 ));
346 }
347 }
348 }
349 disabled_notes.sort();
350 disabled_notes.dedup();
351 lines.extend(disabled_notes);
352
353 let outcome = if !failures.is_empty() || examined.is_empty() {
354 LintOutcome::OperationalFailure
355 } else if has_semantic_violation || reserved_violation {
356 LintOutcome::SemanticViolation
357 } else {
358 LintOutcome::Clean
359 };
360
361 Ok(LintReport {
362 outcome,
363 examined: examined.len(),
364 configured,
365 lines,
366 #[cfg(test)]
367 failures,
368 })
369}
370
371fn capability_claimants(
372 examined: &[ExaminedManifest],
373 enabled_only: bool,
374) -> BTreeMap<String, BTreeSet<String>> {
375 let mut claims = BTreeMap::<String, BTreeSet<String>>::new();
376 for entry in examined {
377 if enabled_only && !entry.enabled {
378 continue;
379 }
380 let Some(capabilities) = &entry.manifest.capabilities else {
381 continue;
382 };
383 for capability in &capabilities.provides {
384 claims
385 .entry(capability.clone())
386 .or_default()
387 .insert(entry.module_id.clone());
388 }
389 }
390 claims
391}
392
393fn is_daemon_entry(module: &ConfiguredModule) -> bool {
394 module
395 .program
396 .file_name()
397 .and_then(|name| name.to_str())
398 .is_some_and(|name| matches!(name, "ck-subc" | "ck-subc.exe"))
399}
400
401async fn read_manifest(
402 module: &ConfiguredModule,
403 manifest_timeout: Duration,
404) -> Result<ModuleManifest, OperationalClass> {
405 let metadata = fs::metadata(&module.program).map_err(|error| {
406 if error.kind() == std::io::ErrorKind::NotFound {
407 OperationalClass::ProgramMissing
408 } else {
409 OperationalClass::ProgramNotExecutable
410 }
411 })?;
412 if !is_executable_file(&metadata) {
413 return Err(OperationalClass::ProgramNotExecutable);
414 }
415
416 let mut command = Command::new(&module.program);
417 command
418 .arg("--manifest")
419 .stdin(Stdio::null())
420 .kill_on_drop(true);
421 let output = match time::timeout(manifest_timeout, command.output()).await {
422 Ok(Ok(output)) => output,
423 Ok(Err(_)) => return Err(OperationalClass::ProgramNotExecutable),
424 Err(_) => return Err(OperationalClass::ManifestTimeout),
425 };
426 if !output.status.success() {
427 return Err(OperationalClass::ManifestExitNonzero);
428 }
429
430 let value: Value =
431 serde_json::from_slice(&output.stdout).map_err(|_| OperationalClass::ManifestUnparsable)?;
432 validate_manifest_capability_grammar(&value).map_err(|_| OperationalClass::ManifestInvalid)?;
433 let manifest: ModuleManifest =
434 serde_json::from_value(value).map_err(|_| OperationalClass::ManifestUnparsable)?;
435 if manifest.protocol_ver != PROTOCOL_VERSION {
436 return Err(OperationalClass::ManifestVersionUnsupported);
437 }
438 Ok(manifest)
439}
440
441#[cfg(unix)]
442fn is_executable_file(metadata: &fs::Metadata) -> bool {
443 use std::os::unix::fs::PermissionsExt;
444
445 metadata.is_file() && metadata.permissions().mode() & 0o111 != 0
446}
447
448#[cfg(not(unix))]
449fn is_executable_file(metadata: &fs::Metadata) -> bool {
450 metadata.is_file()
451}
452
453fn duplicate_module_ids(path: &Path) -> Result<Vec<String>, LintConfigError> {
454 let document = fs::read_to_string(path)
455 .map_err(|error| LintConfigError(format!("failed to read {}: {error}", path.display())))?;
456 let json = subc_jsonc::jsonc_to_json(&document)
457 .map_err(|error| LintConfigError(format!("failed to parse {}: {error}", path.display())))?;
458 let probe: ModuleIdProbe = serde_json::from_str(&json)
459 .map_err(|error| LintConfigError(format!("failed to parse {}: {error}", path.display())))?;
460 Ok(probe.modules)
461}
462
463#[derive(Deserialize)]
464struct ModuleIdProbe {
465 #[serde(default, deserialize_with = "deserialize_module_ids")]
466 modules: Vec<String>,
467}
468
469fn deserialize_module_ids<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
470where
471 D: Deserializer<'de>,
472{
473 struct ModuleIdsVisitor;
474
475 impl<'de> Visitor<'de> for ModuleIdsVisitor {
476 type Value = Vec<String>;
477
478 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
479 formatter.write_str("an object keyed by module id")
480 }
481
482 fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
483 where
484 M: MapAccess<'de>,
485 {
486 let mut duplicates = Vec::new();
487 let mut seen = HashSet::new();
488 while let Some(module_id) = map.next_key::<String>()? {
489 if !seen.insert(module_id.clone()) {
490 duplicates.push(module_id);
491 }
492 map.next_value::<de::IgnoredAny>()?;
493 }
494 Ok(duplicates)
495 }
496 }
497
498 deserializer.deserialize_map(ModuleIdsVisitor)
499}
500
501#[cfg(test)]
502mod tests {
503 use std::{
504 fs,
505 path::{Path, PathBuf},
506 process::Command,
507 time::Duration,
508 };
509
510 use serde_json::{json, Map, Value};
511 use subc_protocol::PROTOCOL_VERSION;
512
513 use super::{lint_with_timeout, LintOutcome, LintReport, OperationalClass, MANIFEST_TIMEOUT};
514 use crate::test_support::TestTempDir as TempDir;
515
516 #[derive(serde::Serialize)]
517 struct FixtureSpec {
518 stdout: String,
519 exit_code: i32,
520 sleep_ms: u64,
521 #[serde(skip)]
522 executable: bool,
523 }
524
525 impl Default for FixtureSpec {
526 fn default() -> Self {
527 Self {
528 stdout: String::new(),
529 exit_code: 0,
530 sleep_ms: 0,
531 executable: true,
532 }
533 }
534 }
535
536 #[track_caller]
559 fn assert_failure(report: &LintReport, class: OperationalClass, module: &str) {
560 assert!(
561 report.has_failure(class, module),
562 "expected {class:?} for module '{module}', but the report carries {:?} \
563 (outcome {:?}, examined {} of {})",
564 report.failures,
565 report.outcome,
566 report.examined,
567 report.configured,
568 );
569 }
570
571 fn fake_aft_stub_path() -> PathBuf {
572 let mut path = std::env::current_exe().expect("current_exe available in tests");
573 path.pop(); path.pop(); path.push(if cfg!(windows) {
576 "fake-aft-stub.exe"
577 } else {
578 "fake-aft-stub"
579 });
580 assert!(
581 path.exists(),
582 "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
583 path.display()
584 );
585 path
586 }
587
588 fn write_fixture_program(temp: &TempDir, name: &str, fixture: FixtureSpec) -> PathBuf {
589 let filename = if cfg!(windows) {
590 format!("{name}.exe")
591 } else {
592 name.to_string()
593 };
594 let path = temp.path().join(filename);
595 fs::copy(fake_aft_stub_path(), &path).unwrap();
596
597 let mut sidecar = path.as_os_str().to_os_string();
600 sidecar.push(".fixture.json");
601 fs::write(
602 PathBuf::from(sidecar),
603 serde_json::to_vec(&fixture).unwrap(),
604 )
605 .unwrap();
606
607 #[cfg(unix)]
608 {
609 use std::os::unix::fs::PermissionsExt;
610 fs::set_permissions(
611 &path,
612 fs::Permissions::from_mode(if fixture.executable { 0o755 } else { 0o644 }),
613 )
614 .unwrap();
615 }
616 #[cfg(not(unix))]
619 let _ = fixture.executable;
620 path
621 }
622
623 #[test]
624 fn fixture_sidecar_absent_preserves_existing_stub_behavior() {
625 let output = Command::new(fake_aft_stub_path())
626 .env("FAKE_AFT_EXIT_CODE", "17")
627 .output()
628 .unwrap();
629 assert_eq!(output.status.code(), Some(17));
630 }
631
632 fn manifest(module_id: &str, capabilities: Value, protocol_ver: u8) -> String {
633 json!({
634 "module_id": module_id,
635 "module_version": "0.1.0",
636 "protocol_ver": protocol_ver,
637 "trust_tier": "first_party",
638 "provides": [],
639 "consumes": [],
640 "bindings": {
641 "storage": {"kind": "sqlite", "scope": "project", "owns_schema": false},
642 "vault_grants": [],
643 "identity": {"requires": [], "optional": []}
644 },
645 "capabilities": capabilities,
646 "runtime_computed": []
647 })
648 .to_string()
649 }
650
651 fn manifest_fixture(temp: &TempDir, module_id: &str, capabilities: Value) -> PathBuf {
652 let document = manifest(module_id, capabilities, PROTOCOL_VERSION);
653 write_fixture_program(
654 temp,
655 module_id,
656 FixtureSpec {
657 stdout: document,
658 ..FixtureSpec::default()
659 },
660 )
661 }
662
663 fn write_config(
664 temp: &TempDir,
665 modules: Vec<(&str, &Path, bool)>,
666 reserved_capabilities: Value,
667 ) -> PathBuf {
668 let mut entries = Map::new();
669 for (module_id, program, enabled) in modules {
670 entries.insert(
671 module_id.to_string(),
672 json!({"program": program, "enabled": enabled}),
673 );
674 }
675 let path = temp.path().join("subc.jsonc");
676 fs::write(
677 &path,
678 json!({
679 "version": 1,
680 "modules": entries,
681 "reserved_capabilities": reserved_capabilities
682 })
683 .to_string(),
684 )
685 .unwrap();
686 path
687 }
688
689 async fn lint_config(path: &Path, verbose: bool) -> super::LintReport {
690 lint_with_timeout(path, verbose, Duration::from_secs(60))
693 .await
694 .unwrap()
695 }
696
697 #[tokio::test]
698 async fn fixture_program_missing_classifies_operational_failure() {
699 let temp = TempDir::new("program-missing");
700 let config = write_config(
701 &temp,
702 vec![("missing", &temp.path().join("missing"), true)],
703 json!({}),
704 );
705
706 let report = lint_config(&config, false).await;
707 assert_eq!(report.outcome, LintOutcome::OperationalFailure);
708 assert_failure(&report, OperationalClass::ProgramMissing, "missing");
709 }
710
711 #[tokio::test]
712 async fn fixture_program_not_executable_classifies_operational_failure() {
713 let temp = TempDir::new("program-not-executable");
714 let script = write_fixture_program(
715 &temp,
716 "not-executable",
717 FixtureSpec {
718 executable: false,
719 ..FixtureSpec::default()
720 },
721 );
722 let config = write_config(&temp, vec![("not-executable", &script, true)], json!({}));
723
724 let report = lint_config(&config, false).await;
725 assert_eq!(report.outcome, LintOutcome::OperationalFailure);
726 #[cfg(unix)]
727 assert_failure(
728 &report,
729 OperationalClass::ProgramNotExecutable,
730 "not-executable",
731 );
732 #[cfg(not(unix))]
733 {
734 assert_failure(
737 &report,
738 OperationalClass::ManifestUnparsable,
739 "not-executable",
740 );
741 }
742 }
743
744 #[tokio::test]
745 async fn fixture_manifest_timeout_classifies_operational_failure() {
746 let temp = TempDir::new("manifest-timeout");
747 let script = write_fixture_program(
748 &temp,
749 "timeout",
750 FixtureSpec {
751 sleep_ms: (MANIFEST_TIMEOUT + Duration::from_secs(1)).as_millis() as u64,
752 ..FixtureSpec::default()
753 },
754 );
755 let config = write_config(&temp, vec![("timeout", &script, true)], json!({}));
756
757 let report = lint_with_timeout(&config, false, Duration::from_millis(5))
758 .await
759 .unwrap();
760 assert_eq!(MANIFEST_TIMEOUT, Duration::from_secs(10));
761 assert_eq!(report.outcome, LintOutcome::OperationalFailure);
762 assert_failure(&report, OperationalClass::ManifestTimeout, "timeout");
763 }
764
765 #[tokio::test]
766 async fn fixture_manifest_exit_nonzero_classifies_operational_failure() {
767 let temp = TempDir::new("manifest-exit-nonzero");
768 let script = write_fixture_program(
769 &temp,
770 "nonzero",
771 FixtureSpec {
772 exit_code: 7,
773 ..FixtureSpec::default()
774 },
775 );
776 let config = write_config(&temp, vec![("nonzero", &script, true)], json!({}));
777
778 let report = lint_config(&config, false).await;
779 assert_eq!(report.outcome, LintOutcome::OperationalFailure);
780 assert_failure(&report, OperationalClass::ManifestExitNonzero, "nonzero");
781 }
782
783 #[tokio::test]
784 async fn fixture_manifest_unparsable_classifies_operational_failure() {
785 let temp = TempDir::new("manifest-unparsable");
786 let script = write_fixture_program(
787 &temp,
788 "unparsable",
789 FixtureSpec {
790 stdout: "not json\\n".to_string(),
791 ..FixtureSpec::default()
792 },
793 );
794 let config = write_config(&temp, vec![("unparsable", &script, true)], json!({}));
795
796 let report = lint_config(&config, false).await;
797 assert_eq!(report.outcome, LintOutcome::OperationalFailure);
798 assert!(
799 report.has_failure(OperationalClass::ManifestUnparsable, "unparsable"),
800 "report:\n{}",
801 report.render()
802 );
803 }
804
805 #[tokio::test]
806 async fn fixture_manifest_version_unsupported_classifies_operational_failure() {
807 let temp = TempDir::new("manifest-version-unsupported");
808 let document = manifest(
809 "unsupported",
810 json!({"provides": [], "requires": [], "must_never_reach": []}),
811 PROTOCOL_VERSION.saturating_add(1),
812 );
813 let script = write_fixture_program(
814 &temp,
815 "unsupported",
816 FixtureSpec {
817 stdout: document,
818 ..FixtureSpec::default()
819 },
820 );
821 let config = write_config(&temp, vec![("unsupported", &script, true)], json!({}));
822
823 let report = lint_config(&config, false).await;
824 assert_eq!(report.outcome, LintOutcome::OperationalFailure);
825 assert!(
826 report.has_failure(OperationalClass::ManifestVersionUnsupported, "unsupported"),
827 "report:\n{}",
828 report.render()
829 );
830 }
831
832 #[tokio::test]
833 async fn fixture_duplicate_module_id_classifies_operational_failure() {
834 let temp = TempDir::new("duplicate-module-id");
835 let script = manifest_fixture(&temp, "duplicate", Value::Null);
836 let config = temp.path().join("subc.jsonc");
837 let program = serde_json::to_string(&script.display().to_string()).unwrap();
843 fs::write(
844 &config,
845 format!(
846 r#"{{"version":1,"modules":{{"duplicate":{{"program":{program}}},"duplicate":{{"program":{program}}}}}}}"#
847 ),
848 )
849 .unwrap();
850
851 let report = lint_config(&config, false).await;
852 assert_eq!(report.outcome, LintOutcome::OperationalFailure);
853 assert_failure(&report, OperationalClass::DuplicateModuleId, "duplicate");
854 }
855
856 #[tokio::test]
857 async fn fixture_manifest_invalid_classifies_operational_failure() {
858 let temp = TempDir::new("manifest-invalid");
859 let script = manifest_fixture(
860 &temp,
861 "invalid",
862 json!({"provides": ["Not-valid/v1"], "requires": [], "must_never_reach": []}),
863 );
864 let config = write_config(&temp, vec![("invalid", &script, true)], json!({}));
865
866 let report = lint_config(&config, false).await;
867 assert_eq!(report.outcome, LintOutcome::OperationalFailure);
868 assert_failure(&report, OperationalClass::ManifestInvalid, "invalid");
869 }
870
871 #[tokio::test]
872 async fn disabled_modules_are_still_manifest_validated() {
873 let temp = TempDir::new("disabled-manifest-invalid");
874 let script = manifest_fixture(
875 &temp,
876 "disabled-invalid",
877 json!({"provides": ["Not-valid/v1"], "requires": [], "must_never_reach": []}),
878 );
879 let config = write_config(&temp, vec![("disabled-invalid", &script, false)], json!({}));
880
881 let report = lint_config(&config, false).await;
882 assert_eq!(report.outcome, LintOutcome::OperationalFailure);
883 assert_failure(
884 &report,
885 OperationalClass::ManifestInvalid,
886 "disabled-invalid",
887 );
888 }
889
890 #[tokio::test]
891 async fn golden_disabled_claimant_count_daemon_skip_and_verbose_optional_inventory() {
892 let temp = TempDir::new("disabled-claimant");
893 let consumer = manifest_fixture(
894 &temp,
895 "consumer",
896 json!({
897 "provides": [],
898 "requires": [
899 {"capability": "credentials-provider/v1", "need": "required"},
900 {"capability": "context-transform/v1", "need": "optional"}
901 ],
902 "must_never_reach": []
903 }),
904 );
905 let disabled = manifest_fixture(
906 &temp,
907 "disabled",
908 json!({"provides": ["credentials-provider/v1"], "requires": [], "must_never_reach": []}),
909 );
910 let daemon = temp.path().join("ck-subc");
911 let config = write_config(
912 &temp,
913 vec![
914 ("daemon", &daemon, true),
915 ("consumer", &consumer, true),
916 ("disabled", &disabled, false),
917 ],
918 json!({}),
919 );
920
921 let report = lint_config(&config, true).await;
922 assert_eq!(report.outcome, LintOutcome::SemanticViolation);
923 assert_eq!(report.examined, 2);
924 assert_eq!(report.configured, 2);
925 assert_eq!(
926 report.render(),
927 "checked 2 of 2 configured modules\n\
928verbose: skipped daemon entry daemon\n\
929deny consistency = self-contradiction check\n\
930optional context-transform/v1: no provider (consumer degrades, by declaration)\n\
931required consumer credentials-provider/v1: no enabled provider\n\
932note: disabled (disabled) claims credentials-provider/v1"
933 );
934 let default_report = lint_config(&config, false).await;
935 assert!(
936 !default_report
937 .render()
938 .contains("optional context-transform/v1"),
939 "default report must not style declared optional degradation as a warning:\n{}",
940 default_report.render()
941 );
942 }
943
944 #[tokio::test]
945 async fn golden_requirement_lines_sort_by_consumer_then_capability() {
946 let temp = TempDir::new("requirement-order");
947 let alpha = manifest_fixture(
948 &temp,
949 "alpha",
950 json!({"provides": [], "requires": [{"capability": "alpha/v1", "need": "required"}], "must_never_reach": []}),
951 );
952 let zeta = manifest_fixture(
953 &temp,
954 "zeta",
955 json!({"provides": [], "requires": [{"capability": "zeta/v1", "need": "required"}], "must_never_reach": []}),
956 );
957 let config = write_config(
958 &temp,
959 vec![("zeta", &zeta, true), ("alpha", &alpha, true)],
960 json!({}),
961 );
962
963 let report = lint_config(&config, false).await;
964 let rendered = report.render();
965 assert!(
966 rendered.find("required alpha alpha/v1").unwrap()
967 < rendered.find("required zeta zeta/v1").unwrap(),
968 "report:\n{rendered}"
969 );
970 }
971
972 #[tokio::test]
973 async fn deny_self_contradiction_mutation_proof_requires_overlap() {
974 let temp = TempDir::new("deny-self-contradiction");
975 let self_contradiction = manifest_fixture(
976 &temp,
977 "contradictory",
978 json!({
979 "provides": [],
980 "requires": [{"capability": "credentials-provider/v1", "need": "required"}],
981 "must_never_reach": ["credentials-provider/v1"]
982 }),
983 );
984 let config = write_config(
985 &temp,
986 vec![("contradictory", &self_contradiction, true)],
987 json!({}),
988 );
989
990 let report = lint_config(&config, false).await;
991 assert_eq!(report.outcome, LintOutcome::SemanticViolation);
992 assert!(
993 !report
994 .render()
995 .contains("deny consistency = self-contradiction check"),
996 "internal consistency vocabulary belongs behind --verbose"
997 );
998 let verbose = lint_config(&config, true).await;
999 assert!(verbose
1000 .render()
1001 .contains("deny consistency = self-contradiction check"));
1002 assert!(verbose.render().contains(
1003 "requires_deny_conflict module=contradictory capability=credentials-provider/v1"
1004 ));
1005 }
1006
1007 #[tokio::test]
1008 async fn operational_failure_overrides_semantic_exit_classification() {
1009 let temp = TempDir::new("operational-trump");
1010 let consumer = manifest_fixture(
1011 &temp,
1012 "consumer",
1013 json!({"provides": [], "requires": [{"capability": "credentials-provider/v1", "need": "required"}], "must_never_reach": []}),
1014 );
1015 let broken = write_fixture_program(
1016 &temp,
1017 "broken",
1018 FixtureSpec {
1019 exit_code: 1,
1020 ..FixtureSpec::default()
1021 },
1022 );
1023 let config = write_config(
1024 &temp,
1025 vec![("consumer", &consumer, true), ("broken", &broken, true)],
1026 json!({}),
1027 );
1028
1029 let report = lint_config(&config, false).await;
1030 assert_eq!(report.outcome, LintOutcome::OperationalFailure);
1031 assert!(
1032 report
1033 .render()
1034 .contains("checked 1 of 2 configured modules — broken do not expose a manifest"),
1035 "report:\n{}",
1036 report.render()
1037 );
1038 assert!(
1039 !report.render().contains("partial: evaluation incomplete"),
1040 "instrument detail belongs behind --verbose:\n{}",
1041 report.render()
1042 );
1043 assert!(report
1044 .render()
1045 .contains("required consumer credentials-provider/v1: no enabled provider"));
1046 }
1047
1048 #[tokio::test]
1049 async fn zero_examined_is_an_operational_failure_not_a_vacuous_pass() {
1050 let temp = TempDir::new("vacuity-floor");
1051 let config = write_config(&temp, Vec::new(), json!({}));
1052
1053 let report = lint_config(&config, false).await;
1054 assert_eq!(report.outcome, LintOutcome::OperationalFailure);
1055 assert_eq!(report.render(), "checked 0 of 0 configured modules");
1056 let verbose = lint_config(&config, true).await;
1057 assert!(verbose.render().contains("vacuity floor"));
1058 assert!(verbose
1059 .render()
1060 .contains("deny consistency = self-contradiction check"));
1061 }
1062
1063 #[tokio::test]
1064 async fn reserved_bindings_warn_when_unclaimed_and_fail_for_conflicting_claimants() {
1065 let temp = TempDir::new("reserved-bindings");
1066 let claimant = manifest_fixture(
1067 &temp,
1068 "other",
1069 json!({"provides": ["credentials-provider/v1"], "requires": [], "must_never_reach": []}),
1070 );
1071 let config = write_config(
1072 &temp,
1073 vec![("other", &claimant, true)],
1074 json!({
1075 "credentials-provider/v1": "bound",
1076 "context-transform/v1": "not-installed"
1077 }),
1078 );
1079
1080 let report = lint_config(&config, false).await;
1081 assert_eq!(report.outcome, LintOutcome::SemanticViolation);
1082 let rendered = report.render();
1083 assert!(rendered.contains(
1084 "reserved capability credentials-provider/v1: claimant other conflicts with binding bound"
1085 ));
1086 assert!(rendered.contains(
1087 "warning: reserved capability context-transform/v1 has no configured claimant for not-installed"
1088 ));
1089 }
1090}