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        .filter(|failure| failure.class != OperationalClass::DuplicateModuleId)
191        .map(|failure| failure.module.as_str())
192        .collect::<BTreeSet<_>>()
193        .into_iter()
194        .collect::<Vec<_>>();
195    let checked = format!(
196        "checked {} of {configured} configured modules",
197        examined.len()
198    );
199    let mut lines = vec![if unavailable.is_empty() {
200        checked
201    } else {
202        format!(
203            "{checked} — {} do not expose a manifest",
204            unavailable.join(", ")
205        )
206    }];
207
208    if verbose {
209        for module in &skipped_daemons {
210            lines.push(format!("verbose: skipped daemon entry {module}"));
211        }
212    }
213
214    failures.sort_by(|left, right| {
215        left.class
216            .cmp(&right.class)
217            .then_with(|| left.module.cmp(&right.module))
218    });
219    if verbose {
220        for failure in &failures {
221            lines.push(format!(
222                "partial: evaluation incomplete ({}: {})",
223                failure.class.as_str(),
224                failure.module
225            ));
226        }
227        if examined.is_empty() {
228            // An empty set must remain an operational failure, but the internal
229            // classification belongs in verbose diagnostics rather than the
230            // ordinary operator summary.
231            lines.push("operational failure: no modules examined (vacuity floor)".to_string());
232        }
233        lines.push("deny consistency = self-contradiction check".to_string());
234    }
235
236    let enabled_providers = capability_claimants(&examined, true);
237    let all_providers = capability_claimants(&examined, false);
238    let mut has_semantic_violation = false;
239    let mut deny_violations = Vec::new();
240    let mut requirement_lines = Vec::new();
241
242    for entry in &examined {
243        let Some(capabilities) = &entry.manifest.capabilities else {
244            continue;
245        };
246        if entry.enabled {
247            for requirement in &capabilities.requires {
248                let provided = enabled_providers.contains_key(&requirement.capability);
249                match requirement.need {
250                    CapabilityNeed::Required => {
251                        let text = if provided {
252                            format!(
253                                "required {} {}: provided",
254                                entry.module_id, requirement.capability
255                            )
256                        } else {
257                            let text = format!(
258                                "required {} {}: no enabled provider",
259                                entry.module_id, requirement.capability
260                            );
261                            has_semantic_violation = true;
262                            text
263                        };
264                        requirement_lines.push(RequirementLine {
265                            consumer: entry.module_id.clone(),
266                            capability: requirement.capability.clone(),
267                            text,
268                        });
269                    }
270                    CapabilityNeed::Optional if verbose && !provided => {
271                        requirement_lines.push(RequirementLine {
272                            consumer: entry.module_id.clone(),
273                            capability: requirement.capability.clone(),
274                            text: format!(
275                                "optional {}: no provider (consumer degrades, by declaration)",
276                                requirement.capability
277                            ),
278                        });
279                    }
280                    CapabilityNeed::Optional => {}
281                }
282            }
283        }
284
285        let denied = capabilities
286            .must_never_reach
287            .iter()
288            .collect::<BTreeSet<_>>();
289        for requirement in &capabilities.requires {
290            if denied.contains(&requirement.capability) {
291                has_semantic_violation = true;
292                deny_violations.push(format!(
293                    "requires_deny_conflict module={} capability={}",
294                    entry.module_id, requirement.capability
295                ));
296            }
297        }
298    }
299
300    requirement_lines.sort_by(|left, right| {
301        left.consumer
302            .cmp(&right.consumer)
303            .then_with(|| left.capability.cmp(&right.capability))
304    });
305    lines.extend(requirement_lines.into_iter().map(|line| line.text));
306
307    deny_violations.sort();
308    deny_violations.dedup();
309    lines.extend(deny_violations);
310
311    let mut reserved_lines = Vec::new();
312    let mut reserved_violation = false;
313    for (capability, bound_module) in &config.reserved_capabilities {
314        let claimants = all_providers.get(capability);
315        match claimants {
316            None => reserved_lines.push(format!(
317                "warning: reserved capability {capability} has no configured claimant for {bound_module}"
318            )),
319            Some(claimants) => {
320                for claimant in claimants {
321                    if claimant != bound_module {
322                        reserved_violation = true;
323                        reserved_lines.push(format!(
324                            "reserved capability {capability}: claimant {claimant} conflicts with binding {bound_module}"
325                        ));
326                    }
327                }
328            }
329        }
330    }
331    lines.extend(reserved_lines);
332
333    let mut disabled_notes = Vec::new();
334    for entry in &examined {
335        if entry.enabled {
336            continue;
337        }
338        let Some(capabilities) = &entry.manifest.capabilities else {
339            continue;
340        };
341        for capability in &capabilities.provides {
342            if !enabled_providers.contains_key(capability) {
343                disabled_notes.push(format!(
344                    "note: {} (disabled) claims {capability}",
345                    entry.module_id
346                ));
347            }
348        }
349    }
350    disabled_notes.sort();
351    disabled_notes.dedup();
352    lines.extend(disabled_notes);
353
354    let outcome = if !failures.is_empty() || examined.is_empty() {
355        LintOutcome::OperationalFailure
356    } else if has_semantic_violation || reserved_violation {
357        LintOutcome::SemanticViolation
358    } else {
359        LintOutcome::Clean
360    };
361
362    Ok(LintReport {
363        outcome,
364        examined: examined.len(),
365        configured,
366        lines,
367        #[cfg(test)]
368        failures,
369    })
370}
371
372fn capability_claimants(
373    examined: &[ExaminedManifest],
374    enabled_only: bool,
375) -> BTreeMap<String, BTreeSet<String>> {
376    let mut claims = BTreeMap::<String, BTreeSet<String>>::new();
377    for entry in examined {
378        if enabled_only && !entry.enabled {
379            continue;
380        }
381        let Some(capabilities) = &entry.manifest.capabilities else {
382            continue;
383        };
384        for capability in &capabilities.provides {
385            claims
386                .entry(capability.clone())
387                .or_default()
388                .insert(entry.module_id.clone());
389        }
390    }
391    claims
392}
393
394fn is_daemon_entry(module: &ConfiguredModule) -> bool {
395    module
396        .program
397        .file_name()
398        .and_then(|name| name.to_str())
399        .is_some_and(|name| matches!(name, "ck-subc" | "ck-subc.exe"))
400}
401
402async fn read_manifest(
403    module: &ConfiguredModule,
404    manifest_timeout: Duration,
405) -> Result<ModuleManifest, OperationalClass> {
406    let metadata = fs::metadata(&module.program).map_err(|error| {
407        if error.kind() == std::io::ErrorKind::NotFound {
408            OperationalClass::ProgramMissing
409        } else {
410            OperationalClass::ProgramNotExecutable
411        }
412    })?;
413    if !is_executable_file(&metadata) {
414        return Err(OperationalClass::ProgramNotExecutable);
415    }
416
417    let mut command = Command::new(&module.program);
418    // Test fixtures must not inherit the operator's fleet paths. Keep the
419    // environment on the child command, not the parallel test process.
420    #[cfg(test)]
421    let isolated = subc_test_support::TestTempDir::new("fleet-lint-manifest");
422    #[cfg(test)]
423    command
424        .env("XDG_DATA_HOME", isolated.path().join("data"))
425        .env("XDG_RUNTIME_DIR", isolated.path().join("runtime"))
426        .env("XDG_CONFIG_HOME", isolated.path().join("config"));
427    command
428        .arg("--manifest")
429        .stdin(Stdio::null())
430        .kill_on_drop(true);
431    let output = match time::timeout(manifest_timeout, command.output()).await {
432        Ok(Ok(output)) => output,
433        Ok(Err(_)) => return Err(OperationalClass::ProgramNotExecutable),
434        Err(_) => return Err(OperationalClass::ManifestTimeout),
435    };
436    if !output.status.success() {
437        return Err(OperationalClass::ManifestExitNonzero);
438    }
439
440    let value: Value =
441        serde_json::from_slice(&output.stdout).map_err(|_| OperationalClass::ManifestUnparsable)?;
442    validate_manifest_capability_grammar(&value).map_err(|_| OperationalClass::ManifestInvalid)?;
443    let manifest: ModuleManifest =
444        serde_json::from_value(value).map_err(|_| OperationalClass::ManifestUnparsable)?;
445    if manifest.protocol_ver != PROTOCOL_VERSION {
446        return Err(OperationalClass::ManifestVersionUnsupported);
447    }
448    Ok(manifest)
449}
450
451#[cfg(unix)]
452fn is_executable_file(metadata: &fs::Metadata) -> bool {
453    use std::os::unix::fs::PermissionsExt;
454
455    metadata.is_file() && metadata.permissions().mode() & 0o111 != 0
456}
457
458#[cfg(not(unix))]
459fn is_executable_file(metadata: &fs::Metadata) -> bool {
460    metadata.is_file()
461}
462
463fn duplicate_module_ids(path: &Path) -> Result<Vec<String>, LintConfigError> {
464    let document = fs::read_to_string(path)
465        .map_err(|error| LintConfigError(format!("failed to read {}: {error}", path.display())))?;
466    let json = subc_jsonc::jsonc_to_json(&document)
467        .map_err(|error| LintConfigError(format!("failed to parse {}: {error}", path.display())))?;
468    let probe: ModuleIdProbe = serde_json::from_str(&json)
469        .map_err(|error| LintConfigError(format!("failed to parse {}: {error}", path.display())))?;
470    Ok(probe.modules)
471}
472
473#[derive(Deserialize)]
474struct ModuleIdProbe {
475    #[serde(default, deserialize_with = "deserialize_module_ids")]
476    modules: Vec<String>,
477}
478
479fn deserialize_module_ids<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
480where
481    D: Deserializer<'de>,
482{
483    struct ModuleIdsVisitor;
484
485    impl<'de> Visitor<'de> for ModuleIdsVisitor {
486        type Value = Vec<String>;
487
488        fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
489            formatter.write_str("an object keyed by module id")
490        }
491
492        fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
493        where
494            M: MapAccess<'de>,
495        {
496            let mut duplicates = Vec::new();
497            let mut seen = HashSet::new();
498            while let Some(module_id) = map.next_key::<String>()? {
499                if !seen.insert(module_id.clone()) {
500                    duplicates.push(module_id);
501                }
502                map.next_value::<de::IgnoredAny>()?;
503            }
504            Ok(duplicates)
505        }
506    }
507
508    deserializer.deserialize_map(ModuleIdsVisitor)
509}
510
511#[cfg(test)]
512mod tests {
513    use std::{
514        fs,
515        path::{Path, PathBuf},
516        process::Command,
517        time::Duration,
518    };
519
520    use serde_json::{json, Map, Value};
521    use subc_protocol::PROTOCOL_VERSION;
522
523    use super::{lint_with_timeout, LintOutcome, LintReport, OperationalClass, MANIFEST_TIMEOUT};
524    use subc_test_support::TestTempDir as TempDir;
525
526    #[derive(serde::Serialize)]
527    struct FixtureSpec {
528        stdout: String,
529        exit_code: i32,
530        sleep_ms: u64,
531        #[serde(skip)]
532        executable: bool,
533    }
534
535    impl Default for FixtureSpec {
536        fn default() -> Self {
537            Self {
538                stdout: String::new(),
539                exit_code: 0,
540                sleep_ms: 0,
541                executable: true,
542            }
543        }
544    }
545
546    /// Mirrors `control.rs::fake_aft_stub_path`: library tests have no
547    /// `CARGO_BIN_EXE_*`, so the stub is the sibling two directories above the
548    /// test executable. Keep the existence panic and its remedy: `--lib` does
549    /// not build this binary, while `cargo test -p subc-core` does.
550    /// Assert one operational class for one module, NAMING WHAT WAS ACTUALLY
551    /// FOUND when it does not match.
552    ///
553    /// These were bare `assert!(report.has_failure(...))`. On 2026-09-19 the
554    /// ubuntu leg failed one of them on a SCRIPT-ONLY commit, and the whole
555    /// report was the word `false`: every sibling fixture test passed in the
556    /// same run, the preceding `outcome == OperationalFailure` assertion passed,
557    /// so the lint HAD failed operationally and classified it as something else
558    /// -- and the test could not say which. It reproduces nowhere here (4/4
559    /// alone, whole-lib green), so the next occurrence is the only evidence
560    /// available and it must carry the actual class.
561    ///
562    /// A BARE BOOLEAN ASSERTION DISCARDS THE ONE FACT THAT DISTINGUISHES A REAL
563    /// REGRESSION FROM AN ENVIRONMENTAL ONE. ManifestUnparsable or
564    /// ProgramNotExecutable here would point at the fixture copy (the stub is
565    /// copied out of a target dir a concurrent build may be rewriting);
566    /// ManifestInvalid missing with some OTHER module named would point at the
567    /// grammar validator. Same `false` for both today.
568    #[track_caller]
569    fn assert_failure(report: &LintReport, class: OperationalClass, module: &str) {
570        assert!(
571            report.has_failure(class, module),
572            "expected {class:?} for module '{module}', but the report carries {:?} \
573             (outcome {:?}, examined {} of {})",
574            report.failures,
575            report.outcome,
576            report.examined,
577            report.configured,
578        );
579    }
580
581    fn fake_aft_stub_path() -> PathBuf {
582        let mut path = std::env::current_exe().expect("current_exe available in tests");
583        path.pop(); // .../deps/
584        path.pop(); // .../<profile>/
585        path.push(if cfg!(windows) {
586            "fake-aft-stub.exe"
587        } else {
588            "fake-aft-stub"
589        });
590        assert!(
591            path.exists(),
592            "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)",
593            path.display()
594        );
595        path
596    }
597
598    fn write_fixture_program(temp: &TempDir, name: &str, fixture: FixtureSpec) -> PathBuf {
599        let filename = if cfg!(windows) {
600            format!("{name}.exe")
601        } else {
602            name.to_string()
603        };
604        let path = temp.path().join(filename);
605        // A child owns the writable descriptor so other test threads cannot
606        // fork while this process holds the executable open for writing.
607        #[cfg(unix)]
608        assert!(Command::new("cp")
609            .arg(fake_aft_stub_path())
610            .arg(&path)
611            .status()
612            .expect("copy executable fixture")
613            .success());
614        #[cfg(not(unix))]
615        fs::copy(fake_aft_stub_path(), &path).unwrap();
616
617        // Per-temp-dir sidecars keep parallel tests isolated without environment
618        // variables, which are process-global in this multi-threaded test binary.
619        let mut sidecar = path.as_os_str().to_os_string();
620        sidecar.push(".fixture.json");
621        fs::write(
622            PathBuf::from(sidecar),
623            serde_json::to_vec(&fixture).unwrap(),
624        )
625        .unwrap();
626
627        #[cfg(unix)]
628        {
629            use std::os::unix::fs::PermissionsExt;
630            fs::set_permissions(
631                &path,
632                fs::Permissions::from_mode(if fixture.executable { 0o755 } else { 0o644 }),
633            )
634            .unwrap();
635        }
636        // Windows has no executable bit. The copied `.exe` is spawnable there,
637        // so this flag only changes the unix permission check.
638        #[cfg(not(unix))]
639        let _ = fixture.executable;
640        path
641    }
642
643    #[test]
644    fn fixture_sidecar_absent_preserves_existing_stub_behavior() {
645        let output = Command::new(fake_aft_stub_path())
646            .env("FAKE_AFT_EXIT_CODE", "17")
647            .output()
648            .unwrap();
649        assert_eq!(output.status.code(), Some(17));
650    }
651
652    fn manifest(module_id: &str, capabilities: Value, protocol_ver: u8) -> String {
653        json!({
654            "module_id": module_id,
655            "module_version": "0.1.0",
656            "protocol_ver": protocol_ver,
657            "trust_tier": "first_party",
658            "provides": [],
659            "consumes": [],
660            "bindings": {
661                "storage": {"kind": "sqlite", "scope": "project", "owns_schema": false},
662                "vault_grants": [],
663                "identity": {"requires": [], "optional": []}
664            },
665            "capabilities": capabilities,
666            "runtime_computed": []
667        })
668        .to_string()
669    }
670
671    fn manifest_fixture(temp: &TempDir, module_id: &str, capabilities: Value) -> PathBuf {
672        let document = manifest(module_id, capabilities, PROTOCOL_VERSION);
673        write_fixture_program(
674            temp,
675            module_id,
676            FixtureSpec {
677                stdout: document,
678                ..FixtureSpec::default()
679            },
680        )
681    }
682
683    fn write_config(
684        temp: &TempDir,
685        modules: Vec<(&str, &Path, bool)>,
686        reserved_capabilities: Value,
687    ) -> PathBuf {
688        let mut entries = Map::new();
689        for (module_id, program, enabled) in modules {
690            entries.insert(
691                module_id.to_string(),
692                json!({"program": program, "enabled": enabled}),
693            );
694        }
695        let path = temp.path().join("subc.jsonc");
696        fs::write(
697            &path,
698            json!({
699                "version": 1,
700                "modules": entries,
701                "reserved_capabilities": reserved_capabilities
702            })
703            .to_string(),
704        )
705        .unwrap();
706        path
707    }
708
709    async fn lint_config(path: &Path, verbose: bool) -> super::LintReport {
710        // Fixture processes are intentionally tiny; a long test-only budget keeps
711        // concurrent CI scheduling from masquerading as the production 10s class.
712        lint_with_timeout(path, verbose, Duration::from_secs(60))
713            .await
714            .unwrap()
715    }
716
717    #[tokio::test]
718    async fn fixture_program_missing_classifies_operational_failure() {
719        let temp = TempDir::new("program-missing");
720        let config = write_config(
721            &temp,
722            vec![("missing", &temp.path().join("missing"), true)],
723            json!({}),
724        );
725
726        let report = lint_config(&config, false).await;
727        assert_eq!(report.outcome, LintOutcome::OperationalFailure);
728        assert_failure(&report, OperationalClass::ProgramMissing, "missing");
729    }
730
731    #[tokio::test]
732    async fn fixture_program_not_executable_classifies_operational_failure() {
733        let temp = TempDir::new("program-not-executable");
734        let script = write_fixture_program(
735            &temp,
736            "not-executable",
737            FixtureSpec {
738                executable: false,
739                ..FixtureSpec::default()
740            },
741        );
742        let config = write_config(&temp, vec![("not-executable", &script, true)], json!({}));
743
744        let report = lint_config(&config, false).await;
745        assert_eq!(report.outcome, LintOutcome::OperationalFailure);
746        #[cfg(unix)]
747        assert_failure(
748            &report,
749            OperationalClass::ProgramNotExecutable,
750            "not-executable",
751        );
752        #[cfg(not(unix))]
753        {
754            // Windows has no executable permission bit, so the copied `.exe`
755            // spawns successfully and its empty stdout is classified instead.
756            assert_failure(
757                &report,
758                OperationalClass::ManifestUnparsable,
759                "not-executable",
760            );
761        }
762    }
763
764    #[tokio::test]
765    async fn fixture_manifest_timeout_classifies_operational_failure() {
766        let temp = TempDir::new("manifest-timeout");
767        let script = write_fixture_program(
768            &temp,
769            "timeout",
770            FixtureSpec {
771                sleep_ms: (MANIFEST_TIMEOUT + Duration::from_secs(1)).as_millis() as u64,
772                ..FixtureSpec::default()
773            },
774        );
775        let config = write_config(&temp, vec![("timeout", &script, true)], json!({}));
776
777        let report = lint_with_timeout(&config, false, Duration::from_millis(5))
778            .await
779            .unwrap();
780        assert_eq!(MANIFEST_TIMEOUT, Duration::from_secs(10));
781        assert_eq!(report.outcome, LintOutcome::OperationalFailure);
782        assert_failure(&report, OperationalClass::ManifestTimeout, "timeout");
783    }
784
785    #[tokio::test]
786    async fn fixture_manifest_exit_nonzero_classifies_operational_failure() {
787        let temp = TempDir::new("manifest-exit-nonzero");
788        let script = write_fixture_program(
789            &temp,
790            "nonzero",
791            FixtureSpec {
792                exit_code: 7,
793                ..FixtureSpec::default()
794            },
795        );
796        let config = write_config(&temp, vec![("nonzero", &script, true)], json!({}));
797
798        let report = lint_config(&config, false).await;
799        assert_eq!(report.outcome, LintOutcome::OperationalFailure);
800        assert_failure(&report, OperationalClass::ManifestExitNonzero, "nonzero");
801    }
802
803    #[tokio::test]
804    async fn fixture_manifest_unparsable_classifies_operational_failure() {
805        let temp = TempDir::new("manifest-unparsable");
806        let script = write_fixture_program(
807            &temp,
808            "unparsable",
809            FixtureSpec {
810                stdout: "not json\\n".to_string(),
811                ..FixtureSpec::default()
812            },
813        );
814        let config = write_config(&temp, vec![("unparsable", &script, true)], json!({}));
815
816        let report = lint_config(&config, false).await;
817        assert_eq!(report.outcome, LintOutcome::OperationalFailure);
818        assert!(
819            report.has_failure(OperationalClass::ManifestUnparsable, "unparsable"),
820            "report:\n{}",
821            report.render()
822        );
823    }
824
825    #[tokio::test]
826    async fn fixture_manifest_version_unsupported_classifies_operational_failure() {
827        let temp = TempDir::new("manifest-version-unsupported");
828        let document = manifest(
829            "unsupported",
830            json!({"provides": [], "requires": [], "must_never_reach": []}),
831            PROTOCOL_VERSION.saturating_add(1),
832        );
833        let script = write_fixture_program(
834            &temp,
835            "unsupported",
836            FixtureSpec {
837                stdout: document,
838                ..FixtureSpec::default()
839            },
840        );
841        let config = write_config(&temp, vec![("unsupported", &script, true)], json!({}));
842
843        let report = lint_config(&config, false).await;
844        assert_eq!(report.outcome, LintOutcome::OperationalFailure);
845        assert!(
846            report.has_failure(OperationalClass::ManifestVersionUnsupported, "unsupported"),
847            "report:\n{}",
848            report.render()
849        );
850    }
851
852    #[tokio::test]
853    async fn fixture_duplicate_module_id_classifies_operational_failure() {
854        let temp = TempDir::new("duplicate-module-id");
855        let script = manifest_fixture(
856            &temp,
857            "duplicate",
858            json!({"provides": [], "requires": [], "must_never_reach": []}),
859        );
860        let config = temp.path().join("subc.jsonc");
861        // Hand-written JSON because serde_json cannot emit the duplicate key
862        // this test exists to exercise -- but the PATH must still be a valid
863        // JSON string: on Windows `display()` yields backslashes, which are
864        // invalid JSON escapes and fail the parse before the duplicate-id
865        // check ever runs. serde-encode the path (quotes included) instead.
866        let program = serde_json::to_string(&script.display().to_string()).unwrap();
867        fs::write(
868            &config,
869            format!(
870                r#"{{"version":1,"modules":{{"duplicate":{{"program":{program}}},"duplicate":{{"program":{program}}}}}}}"#
871            ),
872        )
873        .unwrap();
874
875        let report = lint_config(&config, false).await;
876        assert_eq!(report.outcome, LintOutcome::OperationalFailure);
877        assert_failure(&report, OperationalClass::DuplicateModuleId, "duplicate");
878        assert_eq!(
879            report.render().lines().next().unwrap(),
880            "checked 1 of 1 configured modules",
881            "a duplicate id is an operational error, not a missing manifest"
882        );
883    }
884
885    #[tokio::test]
886    async fn fixture_manifest_invalid_classifies_operational_failure() {
887        let temp = TempDir::new("manifest-invalid");
888        let script = manifest_fixture(
889            &temp,
890            "invalid",
891            json!({"provides": ["Not-valid/v1"], "requires": [], "must_never_reach": []}),
892        );
893        let config = write_config(&temp, vec![("invalid", &script, true)], json!({}));
894
895        let report = lint_config(&config, false).await;
896        assert_eq!(report.outcome, LintOutcome::OperationalFailure);
897        assert_failure(&report, OperationalClass::ManifestInvalid, "invalid");
898    }
899
900    #[tokio::test]
901    async fn disabled_modules_are_still_manifest_validated() {
902        let temp = TempDir::new("disabled-manifest-invalid");
903        let script = manifest_fixture(
904            &temp,
905            "disabled-invalid",
906            json!({"provides": ["Not-valid/v1"], "requires": [], "must_never_reach": []}),
907        );
908        let config = write_config(&temp, vec![("disabled-invalid", &script, false)], json!({}));
909
910        let report = lint_config(&config, false).await;
911        assert_eq!(report.outcome, LintOutcome::OperationalFailure);
912        assert_failure(
913            &report,
914            OperationalClass::ManifestInvalid,
915            "disabled-invalid",
916        );
917    }
918
919    #[tokio::test]
920    async fn golden_disabled_claimant_count_daemon_skip_and_verbose_optional_inventory() {
921        let temp = TempDir::new("disabled-claimant");
922        let consumer = manifest_fixture(
923            &temp,
924            "consumer",
925            json!({
926                "provides": [],
927                "requires": [
928                    {"capability": "credentials-provider/v1", "need": "required"},
929                    {"capability": "context-transform/v1", "need": "optional"}
930                ],
931                "must_never_reach": []
932            }),
933        );
934        let disabled = manifest_fixture(
935            &temp,
936            "disabled",
937            json!({"provides": ["credentials-provider/v1"], "requires": [], "must_never_reach": []}),
938        );
939        let daemon = temp.path().join("ck-subc");
940        let config = write_config(
941            &temp,
942            vec![
943                ("daemon", &daemon, true),
944                ("consumer", &consumer, true),
945                ("disabled", &disabled, false),
946            ],
947            json!({}),
948        );
949
950        let report = lint_config(&config, true).await;
951        assert_eq!(report.outcome, LintOutcome::SemanticViolation);
952        assert_eq!(report.examined, 2);
953        assert_eq!(report.configured, 2);
954        assert_eq!(
955            report.render(),
956            "checked 2 of 2 configured modules\n\
957verbose: skipped daemon entry daemon\n\
958deny consistency = self-contradiction check\n\
959optional context-transform/v1: no provider (consumer degrades, by declaration)\n\
960required consumer credentials-provider/v1: no enabled provider\n\
961note: disabled (disabled) claims credentials-provider/v1"
962        );
963        let default_report = lint_config(&config, false).await;
964        assert!(
965            !default_report
966                .render()
967                .contains("optional context-transform/v1"),
968            "default report must not style declared optional degradation as a warning:\n{}",
969            default_report.render()
970        );
971    }
972
973    #[tokio::test]
974    async fn golden_requirement_lines_sort_by_consumer_then_capability() {
975        let temp = TempDir::new("requirement-order");
976        let alpha = manifest_fixture(
977            &temp,
978            "alpha",
979            json!({"provides": [], "requires": [{"capability": "alpha/v1", "need": "required"}], "must_never_reach": []}),
980        );
981        let zeta = manifest_fixture(
982            &temp,
983            "zeta",
984            json!({"provides": [], "requires": [{"capability": "zeta/v1", "need": "required"}], "must_never_reach": []}),
985        );
986        let config = write_config(
987            &temp,
988            vec![("zeta", &zeta, true), ("alpha", &alpha, true)],
989            json!({}),
990        );
991
992        let report = lint_config(&config, false).await;
993        let rendered = report.render();
994        assert!(
995            rendered.find("required alpha alpha/v1").unwrap()
996                < rendered.find("required zeta zeta/v1").unwrap(),
997            "report:\n{rendered}"
998        );
999    }
1000
1001    #[tokio::test]
1002    async fn deny_self_contradiction_mutation_proof_requires_overlap() {
1003        let temp = TempDir::new("deny-self-contradiction");
1004        let self_contradiction = manifest_fixture(
1005            &temp,
1006            "contradictory",
1007            json!({
1008                "provides": [],
1009                "requires": [{"capability": "credentials-provider/v1", "need": "required"}],
1010                "must_never_reach": ["credentials-provider/v1"]
1011            }),
1012        );
1013        let config = write_config(
1014            &temp,
1015            vec![("contradictory", &self_contradiction, true)],
1016            json!({}),
1017        );
1018
1019        let report = lint_config(&config, false).await;
1020        assert_eq!(report.outcome, LintOutcome::SemanticViolation);
1021        assert!(
1022            !report
1023                .render()
1024                .contains("deny consistency = self-contradiction check"),
1025            "internal consistency vocabulary belongs behind --verbose"
1026        );
1027        let verbose = lint_config(&config, true).await;
1028        assert!(verbose
1029            .render()
1030            .contains("deny consistency = self-contradiction check"));
1031        assert!(verbose.render().contains(
1032            "requires_deny_conflict module=contradictory capability=credentials-provider/v1"
1033        ));
1034    }
1035
1036    #[tokio::test]
1037    async fn operational_failure_overrides_semantic_exit_classification() {
1038        let temp = TempDir::new("operational-trump");
1039        let consumer = manifest_fixture(
1040            &temp,
1041            "consumer",
1042            json!({"provides": [], "requires": [{"capability": "credentials-provider/v1", "need": "required"}], "must_never_reach": []}),
1043        );
1044        let broken = write_fixture_program(
1045            &temp,
1046            "broken",
1047            FixtureSpec {
1048                exit_code: 1,
1049                ..FixtureSpec::default()
1050            },
1051        );
1052        let config = write_config(
1053            &temp,
1054            vec![("consumer", &consumer, true), ("broken", &broken, true)],
1055            json!({}),
1056        );
1057
1058        let report = lint_config(&config, false).await;
1059        assert_eq!(report.outcome, LintOutcome::OperationalFailure);
1060        assert!(
1061            report
1062                .render()
1063                .contains("checked 1 of 2 configured modules — broken do not expose a manifest"),
1064            "report:\n{}",
1065            report.render()
1066        );
1067        assert!(
1068            !report.render().contains("partial: evaluation incomplete"),
1069            "instrument detail belongs behind --verbose:\n{}",
1070            report.render()
1071        );
1072        assert!(report
1073            .render()
1074            .contains("required consumer credentials-provider/v1: no enabled provider"));
1075    }
1076
1077    #[tokio::test]
1078    async fn zero_examined_is_an_operational_failure_not_a_vacuous_pass() {
1079        let temp = TempDir::new("vacuity-floor");
1080        let config = write_config(&temp, Vec::new(), json!({}));
1081
1082        let report = lint_config(&config, false).await;
1083        assert_eq!(report.outcome, LintOutcome::OperationalFailure);
1084        assert_eq!(report.render(), "checked 0 of 0 configured modules");
1085        let verbose = lint_config(&config, true).await;
1086        assert!(verbose.render().contains("vacuity floor"));
1087        assert!(verbose
1088            .render()
1089            .contains("deny consistency = self-contradiction check"));
1090    }
1091
1092    #[tokio::test]
1093    async fn reserved_bindings_warn_when_unclaimed_and_fail_for_conflicting_claimants() {
1094        let temp = TempDir::new("reserved-bindings");
1095        let claimant = manifest_fixture(
1096            &temp,
1097            "other",
1098            json!({"provides": ["credentials-provider/v1"], "requires": [], "must_never_reach": []}),
1099        );
1100        let config = write_config(
1101            &temp,
1102            vec![("other", &claimant, true)],
1103            json!({
1104                "credentials-provider/v1": "bound",
1105                "context-transform/v1": "not-installed"
1106            }),
1107        );
1108
1109        let report = lint_config(&config, false).await;
1110        assert_eq!(report.outcome, LintOutcome::SemanticViolation);
1111        let rendered = report.render();
1112        assert!(rendered.contains(
1113            "reserved capability credentials-provider/v1: claimant other conflicts with binding bound"
1114        ));
1115        assert!(rendered.contains(
1116            "warning: reserved capability context-transform/v1 has no configured claimant for not-installed"
1117        ));
1118    }
1119}