Skip to main content

subc_daemon/
fleet_lint.rs

1//! Offline capability-manifest evaluation for `ck daemon lint`.
2//!
3//! The evaluator deliberately starts only each configured program's `--manifest`
4//! mode. It never contacts the daemon, so its findings describe static assembly
5//! coherence rather than runtime availability.
6
7use 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
28/// Each manifest probe gets a bounded, non-configurable budget so a broken
29/// module cannot make an offline fleet inspection wait forever.
30pub const MANIFEST_TIMEOUT: Duration = Duration::from_secs(10);
31
32/// The only per-program operational failures that lint classifies.
33#[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/// Lint's externally meaningful process status.
61#[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/// A deterministic, line-oriented lint report.
79#[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    /// Render the operator-facing report. Newlines are deliberately stable so
91    /// callers can use the output in package assembly logs and golden tests.
92    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
135/// Evaluate the configured module set without connecting to the daemon.
136pub 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            // An empty set must remain an operational failure, but the internal
228            // classification belongs in verbose diagnostics rather than the
229            // ordinary operator summary.
230            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    /// Mirrors `control.rs::fake_aft_stub_path`: library tests have no
537    /// `CARGO_BIN_EXE_*`, so the stub is the sibling two directories above the
538    /// test executable. Keep the existence panic and its remedy: `--lib` does
539    /// not build this binary, while `cargo test -p subc-core` does.
540    /// Assert one operational class for one module, NAMING WHAT WAS ACTUALLY
541    /// FOUND when it does not match.
542    ///
543    /// These were bare `assert!(report.has_failure(...))`. On 2026-09-19 the
544    /// ubuntu leg failed one of them on a SCRIPT-ONLY commit, and the whole
545    /// report was the word `false`: every sibling fixture test passed in the
546    /// same run, the preceding `outcome == OperationalFailure` assertion passed,
547    /// so the lint HAD failed operationally and classified it as something else
548    /// -- and the test could not say which. It reproduces nowhere here (4/4
549    /// alone, whole-lib green), so the next occurrence is the only evidence
550    /// available and it must carry the actual class.
551    ///
552    /// A BARE BOOLEAN ASSERTION DISCARDS THE ONE FACT THAT DISTINGUISHES A REAL
553    /// REGRESSION FROM AN ENVIRONMENTAL ONE. ManifestUnparsable or
554    /// ProgramNotExecutable here would point at the fixture copy (the stub is
555    /// copied out of a target dir a concurrent build may be rewriting);
556    /// ManifestInvalid missing with some OTHER module named would point at the
557    /// grammar validator. Same `false` for both today.
558    #[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(); // .../deps/
574        path.pop(); // .../<profile>/
575        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        // Per-temp-dir sidecars keep parallel tests isolated without environment
598        // variables, which are process-global in this multi-threaded test binary.
599        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        // Windows has no executable bit. The copied `.exe` is spawnable there,
617        // so this flag only changes the unix permission check.
618        #[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        // Fixture processes are intentionally tiny; a long test-only budget keeps
691        // concurrent CI scheduling from masquerading as the production 10s class.
692        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            // Windows has no executable permission bit, so the copied `.exe`
735            // spawns successfully and its empty stdout is classified instead.
736            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        // Hand-written JSON because serde_json cannot emit the duplicate key
838        // this test exists to exercise -- but the PATH must still be a valid
839        // JSON string: on Windows `display()` yields backslashes, which are
840        // invalid JSON escapes and fail the parse before the duplicate-id
841        // check ever runs. serde-encode the path (quotes included) instead.
842        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}