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 subc_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        // A child owns the writable descriptor so other test threads cannot
596        // fork while this process holds the executable open for writing.
597        #[cfg(unix)]
598        assert!(Command::new("cp")
599            .arg(fake_aft_stub_path())
600            .arg(&path)
601            .status()
602            .expect("copy executable fixture")
603            .success());
604        #[cfg(not(unix))]
605        fs::copy(fake_aft_stub_path(), &path).unwrap();
606
607        // Per-temp-dir sidecars keep parallel tests isolated without environment
608        // variables, which are process-global in this multi-threaded test binary.
609        let mut sidecar = path.as_os_str().to_os_string();
610        sidecar.push(".fixture.json");
611        fs::write(
612            PathBuf::from(sidecar),
613            serde_json::to_vec(&fixture).unwrap(),
614        )
615        .unwrap();
616
617        #[cfg(unix)]
618        {
619            use std::os::unix::fs::PermissionsExt;
620            fs::set_permissions(
621                &path,
622                fs::Permissions::from_mode(if fixture.executable { 0o755 } else { 0o644 }),
623            )
624            .unwrap();
625        }
626        // Windows has no executable bit. The copied `.exe` is spawnable there,
627        // so this flag only changes the unix permission check.
628        #[cfg(not(unix))]
629        let _ = fixture.executable;
630        path
631    }
632
633    #[test]
634    fn fixture_sidecar_absent_preserves_existing_stub_behavior() {
635        let output = Command::new(fake_aft_stub_path())
636            .env("FAKE_AFT_EXIT_CODE", "17")
637            .output()
638            .unwrap();
639        assert_eq!(output.status.code(), Some(17));
640    }
641
642    fn manifest(module_id: &str, capabilities: Value, protocol_ver: u8) -> String {
643        json!({
644            "module_id": module_id,
645            "module_version": "0.1.0",
646            "protocol_ver": protocol_ver,
647            "trust_tier": "first_party",
648            "provides": [],
649            "consumes": [],
650            "bindings": {
651                "storage": {"kind": "sqlite", "scope": "project", "owns_schema": false},
652                "vault_grants": [],
653                "identity": {"requires": [], "optional": []}
654            },
655            "capabilities": capabilities,
656            "runtime_computed": []
657        })
658        .to_string()
659    }
660
661    fn manifest_fixture(temp: &TempDir, module_id: &str, capabilities: Value) -> PathBuf {
662        let document = manifest(module_id, capabilities, PROTOCOL_VERSION);
663        write_fixture_program(
664            temp,
665            module_id,
666            FixtureSpec {
667                stdout: document,
668                ..FixtureSpec::default()
669            },
670        )
671    }
672
673    fn write_config(
674        temp: &TempDir,
675        modules: Vec<(&str, &Path, bool)>,
676        reserved_capabilities: Value,
677    ) -> PathBuf {
678        let mut entries = Map::new();
679        for (module_id, program, enabled) in modules {
680            entries.insert(
681                module_id.to_string(),
682                json!({"program": program, "enabled": enabled}),
683            );
684        }
685        let path = temp.path().join("subc.jsonc");
686        fs::write(
687            &path,
688            json!({
689                "version": 1,
690                "modules": entries,
691                "reserved_capabilities": reserved_capabilities
692            })
693            .to_string(),
694        )
695        .unwrap();
696        path
697    }
698
699    async fn lint_config(path: &Path, verbose: bool) -> super::LintReport {
700        // Fixture processes are intentionally tiny; a long test-only budget keeps
701        // concurrent CI scheduling from masquerading as the production 10s class.
702        lint_with_timeout(path, verbose, Duration::from_secs(60))
703            .await
704            .unwrap()
705    }
706
707    #[tokio::test]
708    async fn fixture_program_missing_classifies_operational_failure() {
709        let temp = TempDir::new("program-missing");
710        let config = write_config(
711            &temp,
712            vec![("missing", &temp.path().join("missing"), true)],
713            json!({}),
714        );
715
716        let report = lint_config(&config, false).await;
717        assert_eq!(report.outcome, LintOutcome::OperationalFailure);
718        assert_failure(&report, OperationalClass::ProgramMissing, "missing");
719    }
720
721    #[tokio::test]
722    async fn fixture_program_not_executable_classifies_operational_failure() {
723        let temp = TempDir::new("program-not-executable");
724        let script = write_fixture_program(
725            &temp,
726            "not-executable",
727            FixtureSpec {
728                executable: false,
729                ..FixtureSpec::default()
730            },
731        );
732        let config = write_config(&temp, vec![("not-executable", &script, true)], json!({}));
733
734        let report = lint_config(&config, false).await;
735        assert_eq!(report.outcome, LintOutcome::OperationalFailure);
736        #[cfg(unix)]
737        assert_failure(
738            &report,
739            OperationalClass::ProgramNotExecutable,
740            "not-executable",
741        );
742        #[cfg(not(unix))]
743        {
744            // Windows has no executable permission bit, so the copied `.exe`
745            // spawns successfully and its empty stdout is classified instead.
746            assert_failure(
747                &report,
748                OperationalClass::ManifestUnparsable,
749                "not-executable",
750            );
751        }
752    }
753
754    #[tokio::test]
755    async fn fixture_manifest_timeout_classifies_operational_failure() {
756        let temp = TempDir::new("manifest-timeout");
757        let script = write_fixture_program(
758            &temp,
759            "timeout",
760            FixtureSpec {
761                sleep_ms: (MANIFEST_TIMEOUT + Duration::from_secs(1)).as_millis() as u64,
762                ..FixtureSpec::default()
763            },
764        );
765        let config = write_config(&temp, vec![("timeout", &script, true)], json!({}));
766
767        let report = lint_with_timeout(&config, false, Duration::from_millis(5))
768            .await
769            .unwrap();
770        assert_eq!(MANIFEST_TIMEOUT, Duration::from_secs(10));
771        assert_eq!(report.outcome, LintOutcome::OperationalFailure);
772        assert_failure(&report, OperationalClass::ManifestTimeout, "timeout");
773    }
774
775    #[tokio::test]
776    async fn fixture_manifest_exit_nonzero_classifies_operational_failure() {
777        let temp = TempDir::new("manifest-exit-nonzero");
778        let script = write_fixture_program(
779            &temp,
780            "nonzero",
781            FixtureSpec {
782                exit_code: 7,
783                ..FixtureSpec::default()
784            },
785        );
786        let config = write_config(&temp, vec![("nonzero", &script, true)], json!({}));
787
788        let report = lint_config(&config, false).await;
789        assert_eq!(report.outcome, LintOutcome::OperationalFailure);
790        assert_failure(&report, OperationalClass::ManifestExitNonzero, "nonzero");
791    }
792
793    #[tokio::test]
794    async fn fixture_manifest_unparsable_classifies_operational_failure() {
795        let temp = TempDir::new("manifest-unparsable");
796        let script = write_fixture_program(
797            &temp,
798            "unparsable",
799            FixtureSpec {
800                stdout: "not json\\n".to_string(),
801                ..FixtureSpec::default()
802            },
803        );
804        let config = write_config(&temp, vec![("unparsable", &script, true)], json!({}));
805
806        let report = lint_config(&config, false).await;
807        assert_eq!(report.outcome, LintOutcome::OperationalFailure);
808        assert!(
809            report.has_failure(OperationalClass::ManifestUnparsable, "unparsable"),
810            "report:\n{}",
811            report.render()
812        );
813    }
814
815    #[tokio::test]
816    async fn fixture_manifest_version_unsupported_classifies_operational_failure() {
817        let temp = TempDir::new("manifest-version-unsupported");
818        let document = manifest(
819            "unsupported",
820            json!({"provides": [], "requires": [], "must_never_reach": []}),
821            PROTOCOL_VERSION.saturating_add(1),
822        );
823        let script = write_fixture_program(
824            &temp,
825            "unsupported",
826            FixtureSpec {
827                stdout: document,
828                ..FixtureSpec::default()
829            },
830        );
831        let config = write_config(&temp, vec![("unsupported", &script, true)], json!({}));
832
833        let report = lint_config(&config, false).await;
834        assert_eq!(report.outcome, LintOutcome::OperationalFailure);
835        assert!(
836            report.has_failure(OperationalClass::ManifestVersionUnsupported, "unsupported"),
837            "report:\n{}",
838            report.render()
839        );
840    }
841
842    #[tokio::test]
843    async fn fixture_duplicate_module_id_classifies_operational_failure() {
844        let temp = TempDir::new("duplicate-module-id");
845        let script = manifest_fixture(&temp, "duplicate", Value::Null);
846        let config = temp.path().join("subc.jsonc");
847        // Hand-written JSON because serde_json cannot emit the duplicate key
848        // this test exists to exercise -- but the PATH must still be a valid
849        // JSON string: on Windows `display()` yields backslashes, which are
850        // invalid JSON escapes and fail the parse before the duplicate-id
851        // check ever runs. serde-encode the path (quotes included) instead.
852        let program = serde_json::to_string(&script.display().to_string()).unwrap();
853        fs::write(
854            &config,
855            format!(
856                r#"{{"version":1,"modules":{{"duplicate":{{"program":{program}}},"duplicate":{{"program":{program}}}}}}}"#
857            ),
858        )
859        .unwrap();
860
861        let report = lint_config(&config, false).await;
862        assert_eq!(report.outcome, LintOutcome::OperationalFailure);
863        assert_failure(&report, OperationalClass::DuplicateModuleId, "duplicate");
864    }
865
866    #[tokio::test]
867    async fn fixture_manifest_invalid_classifies_operational_failure() {
868        let temp = TempDir::new("manifest-invalid");
869        let script = manifest_fixture(
870            &temp,
871            "invalid",
872            json!({"provides": ["Not-valid/v1"], "requires": [], "must_never_reach": []}),
873        );
874        let config = write_config(&temp, vec![("invalid", &script, true)], json!({}));
875
876        let report = lint_config(&config, false).await;
877        assert_eq!(report.outcome, LintOutcome::OperationalFailure);
878        assert_failure(&report, OperationalClass::ManifestInvalid, "invalid");
879    }
880
881    #[tokio::test]
882    async fn disabled_modules_are_still_manifest_validated() {
883        let temp = TempDir::new("disabled-manifest-invalid");
884        let script = manifest_fixture(
885            &temp,
886            "disabled-invalid",
887            json!({"provides": ["Not-valid/v1"], "requires": [], "must_never_reach": []}),
888        );
889        let config = write_config(&temp, vec![("disabled-invalid", &script, false)], json!({}));
890
891        let report = lint_config(&config, false).await;
892        assert_eq!(report.outcome, LintOutcome::OperationalFailure);
893        assert_failure(
894            &report,
895            OperationalClass::ManifestInvalid,
896            "disabled-invalid",
897        );
898    }
899
900    #[tokio::test]
901    async fn golden_disabled_claimant_count_daemon_skip_and_verbose_optional_inventory() {
902        let temp = TempDir::new("disabled-claimant");
903        let consumer = manifest_fixture(
904            &temp,
905            "consumer",
906            json!({
907                "provides": [],
908                "requires": [
909                    {"capability": "credentials-provider/v1", "need": "required"},
910                    {"capability": "context-transform/v1", "need": "optional"}
911                ],
912                "must_never_reach": []
913            }),
914        );
915        let disabled = manifest_fixture(
916            &temp,
917            "disabled",
918            json!({"provides": ["credentials-provider/v1"], "requires": [], "must_never_reach": []}),
919        );
920        let daemon = temp.path().join("ck-subc");
921        let config = write_config(
922            &temp,
923            vec![
924                ("daemon", &daemon, true),
925                ("consumer", &consumer, true),
926                ("disabled", &disabled, false),
927            ],
928            json!({}),
929        );
930
931        let report = lint_config(&config, true).await;
932        assert_eq!(report.outcome, LintOutcome::SemanticViolation);
933        assert_eq!(report.examined, 2);
934        assert_eq!(report.configured, 2);
935        assert_eq!(
936            report.render(),
937            "checked 2 of 2 configured modules\n\
938verbose: skipped daemon entry daemon\n\
939deny consistency = self-contradiction check\n\
940optional context-transform/v1: no provider (consumer degrades, by declaration)\n\
941required consumer credentials-provider/v1: no enabled provider\n\
942note: disabled (disabled) claims credentials-provider/v1"
943        );
944        let default_report = lint_config(&config, false).await;
945        assert!(
946            !default_report
947                .render()
948                .contains("optional context-transform/v1"),
949            "default report must not style declared optional degradation as a warning:\n{}",
950            default_report.render()
951        );
952    }
953
954    #[tokio::test]
955    async fn golden_requirement_lines_sort_by_consumer_then_capability() {
956        let temp = TempDir::new("requirement-order");
957        let alpha = manifest_fixture(
958            &temp,
959            "alpha",
960            json!({"provides": [], "requires": [{"capability": "alpha/v1", "need": "required"}], "must_never_reach": []}),
961        );
962        let zeta = manifest_fixture(
963            &temp,
964            "zeta",
965            json!({"provides": [], "requires": [{"capability": "zeta/v1", "need": "required"}], "must_never_reach": []}),
966        );
967        let config = write_config(
968            &temp,
969            vec![("zeta", &zeta, true), ("alpha", &alpha, true)],
970            json!({}),
971        );
972
973        let report = lint_config(&config, false).await;
974        let rendered = report.render();
975        assert!(
976            rendered.find("required alpha alpha/v1").unwrap()
977                < rendered.find("required zeta zeta/v1").unwrap(),
978            "report:\n{rendered}"
979        );
980    }
981
982    #[tokio::test]
983    async fn deny_self_contradiction_mutation_proof_requires_overlap() {
984        let temp = TempDir::new("deny-self-contradiction");
985        let self_contradiction = manifest_fixture(
986            &temp,
987            "contradictory",
988            json!({
989                "provides": [],
990                "requires": [{"capability": "credentials-provider/v1", "need": "required"}],
991                "must_never_reach": ["credentials-provider/v1"]
992            }),
993        );
994        let config = write_config(
995            &temp,
996            vec![("contradictory", &self_contradiction, true)],
997            json!({}),
998        );
999
1000        let report = lint_config(&config, false).await;
1001        assert_eq!(report.outcome, LintOutcome::SemanticViolation);
1002        assert!(
1003            !report
1004                .render()
1005                .contains("deny consistency = self-contradiction check"),
1006            "internal consistency vocabulary belongs behind --verbose"
1007        );
1008        let verbose = lint_config(&config, true).await;
1009        assert!(verbose
1010            .render()
1011            .contains("deny consistency = self-contradiction check"));
1012        assert!(verbose.render().contains(
1013            "requires_deny_conflict module=contradictory capability=credentials-provider/v1"
1014        ));
1015    }
1016
1017    #[tokio::test]
1018    async fn operational_failure_overrides_semantic_exit_classification() {
1019        let temp = TempDir::new("operational-trump");
1020        let consumer = manifest_fixture(
1021            &temp,
1022            "consumer",
1023            json!({"provides": [], "requires": [{"capability": "credentials-provider/v1", "need": "required"}], "must_never_reach": []}),
1024        );
1025        let broken = write_fixture_program(
1026            &temp,
1027            "broken",
1028            FixtureSpec {
1029                exit_code: 1,
1030                ..FixtureSpec::default()
1031            },
1032        );
1033        let config = write_config(
1034            &temp,
1035            vec![("consumer", &consumer, true), ("broken", &broken, true)],
1036            json!({}),
1037        );
1038
1039        let report = lint_config(&config, false).await;
1040        assert_eq!(report.outcome, LintOutcome::OperationalFailure);
1041        assert!(
1042            report
1043                .render()
1044                .contains("checked 1 of 2 configured modules — broken do not expose a manifest"),
1045            "report:\n{}",
1046            report.render()
1047        );
1048        assert!(
1049            !report.render().contains("partial: evaluation incomplete"),
1050            "instrument detail belongs behind --verbose:\n{}",
1051            report.render()
1052        );
1053        assert!(report
1054            .render()
1055            .contains("required consumer credentials-provider/v1: no enabled provider"));
1056    }
1057
1058    #[tokio::test]
1059    async fn zero_examined_is_an_operational_failure_not_a_vacuous_pass() {
1060        let temp = TempDir::new("vacuity-floor");
1061        let config = write_config(&temp, Vec::new(), json!({}));
1062
1063        let report = lint_config(&config, false).await;
1064        assert_eq!(report.outcome, LintOutcome::OperationalFailure);
1065        assert_eq!(report.render(), "checked 0 of 0 configured modules");
1066        let verbose = lint_config(&config, true).await;
1067        assert!(verbose.render().contains("vacuity floor"));
1068        assert!(verbose
1069            .render()
1070            .contains("deny consistency = self-contradiction check"));
1071    }
1072
1073    #[tokio::test]
1074    async fn reserved_bindings_warn_when_unclaimed_and_fail_for_conflicting_claimants() {
1075        let temp = TempDir::new("reserved-bindings");
1076        let claimant = manifest_fixture(
1077            &temp,
1078            "other",
1079            json!({"provides": ["credentials-provider/v1"], "requires": [], "must_never_reach": []}),
1080        );
1081        let config = write_config(
1082            &temp,
1083            vec![("other", &claimant, true)],
1084            json!({
1085                "credentials-provider/v1": "bound",
1086                "context-transform/v1": "not-installed"
1087            }),
1088        );
1089
1090        let report = lint_config(&config, false).await;
1091        assert_eq!(report.outcome, LintOutcome::SemanticViolation);
1092        let rendered = report.render();
1093        assert!(rendered.contains(
1094            "reserved capability credentials-provider/v1: claimant other conflicts with binding bound"
1095        ));
1096        assert!(rendered.contains(
1097            "warning: reserved capability context-transform/v1 has no configured claimant for not-installed"
1098        ));
1099    }
1100}