speccoder-runtime 0.7.2

Application services of speccoder: the only crate that executes anything.
Documentation
//! The Spec Kit compatibility matrix: what speccoder reads and
//! runs, checked against fixtures generated by each supported release
//! (`scripts/speckit-fixture.sh <version>`).

// Test code: panicking on unexpected input is the failure report.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]

use std::path::PathBuf;

use serde_json::json;
use speccoder_core::snapshot::Severity;
use speccoder_runtime::tools::policy::{Decision, Policy};

/// The releases speccoder supports, one committed fixture each.
const SUPPORTED: [&str; 6] = ["0.13.4", "0.15.2", "0.16.5", "1.0.13", "1.1.3", "1.2.3"];

/// The supported releases, plus any in `SPECCODER_COMPAT_VERSIONS` (the
/// scheduled job's newest release, generated just before).
fn versions() -> Vec<String> {
    let mut v: Vec<String> = SUPPORTED.iter().map(|s| (*s).to_owned()).collect();
    if let Ok(extra) = std::env::var("SPECCODER_COMPAT_VERSIONS") {
        v.extend(
            extra
                .split(',')
                .map(|s| s.trim().to_owned())
                .filter(|s| !s.is_empty()),
        );
    }
    v.sort();
    v.dedup();
    v
}

fn fixture(version: &str) -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("../speccoder-speckit/tests/fixtures/compat")
        .join(version)
}

#[test]
fn every_supported_release_has_a_fixture() {
    for v in versions() {
        let v = v.as_str();
        let f = fixture(v);
        assert_eq!(
            std::fs::read_to_string(f.join("VERSION")).unwrap().trim(),
            v
        );
        let opts = std::fs::read_to_string(f.join(".specify/init-options.json")).unwrap();
        assert!(
            opts.contains(&format!("\"speckit_version\": \"{v}\"")),
            "{v}: {opts}"
        );
    }
}

#[test]
fn inspect_reads_each_releases_project_and_its_own_task_template() {
    for v in versions() {
        let v = v.as_str();
        let snap =
            speccoder_speckit::inspect(&fixture(v), None).unwrap_or_else(|e| panic!("{v}: {e:?}"));
        let errors: Vec<_> = snap
            .diagnostics
            .iter()
            .filter(|d| d.severity == Severity::Error)
            .map(|d| d.code.clone())
            .collect();
        assert!(errors.is_empty(), "{v}: {errors:?}");
        let f = snap
            .feature
            .as_ref()
            .unwrap_or_else(|| panic!("{v}: no feature selected"));
        assert_eq!(f.feature.name, "001-sample", "{v}");
        let tasks = f
            .tasks
            .as_ref()
            .unwrap_or_else(|| panic!("{v}: no tasks.md"));
        assert!(
            tasks.tasks.len() >= 5,
            "{v}: only {} tasks parsed from the template",
            tasks.tasks.len()
        );
        assert!(
            tasks.tasks.iter().any(|t| t.story.is_some()),
            "{v}: no [USn] labels parsed"
        );
        assert!(
            f.schedule.as_ref().is_some_and(|s| s
                .tasks
                .iter()
                .any(|t| t.state == speccoder_core::snapshot::TaskState::Ready)),
            "{v}: nothing ready"
        );
    }
}

#[test]
fn each_releases_skills_are_hosted_and_their_scripts_allowed() {
    let planning = Policy::new(&[], &[]).authoring();
    for v in versions() {
        let v = v.as_str();
        let root = fixture(v);
        let skills = speccoder_runtime::skills::discover(&root);
        for name in [
            "speckit-specify",
            "speckit-plan",
            "speckit-tasks",
            "speckit-clarify",
            "speckit-analyze",
        ] {
            let s = skills
                .iter()
                .find(|s| s.name == name)
                .unwrap_or_else(|| panic!("{v}: no {name}"));
            assert!(s.refused.is_none(), "{v}: {name} refused");
            let body = speccoder_runtime::skills::instruction(&root, s, "ARGS-HERE").unwrap();
            assert!(
                body.contains("ARGS-HERE") && !body.contains("$ARGUMENTS"),
                "{v}: {name} has no $ARGUMENTS slot"
            );
        }
        for name in ["speckit-implement", "speckit-converge"] {
            assert!(
                skills
                    .iter()
                    .find(|s| s.name == name)
                    .is_some_and(|s| s.refused.is_some()),
                "{v}: {name} hosted"
            );
        }
        // The scripts the planning skills call exist and are allowed.
        for script in ["check-prerequisites.sh", "setup-plan.sh", "common.sh"] {
            let rel = format!(".specify/scripts/bash/{script}");
            assert!(root.join(&rel).is_file(), "{v}: no {rel}");
            assert_eq!(
                planning.classify(
                    "run_command",
                    &json!({ "argv": ["bash", rel, "--json"] }),
                    None
                ),
                Decision::Allow,
                "{v}"
            );
        }
        // Every script a skill names is one of them, by path.
        for s in &skills {
            let text = std::fs::read_to_string(root.join(&s.path)).unwrap();
            for word in text.split(|c: char| c.is_whitespace() || c == '`' || c == '"') {
                if let Some(rel) = word
                    .strip_prefix(".specify/scripts/bash/")
                    .filter(|w| w.ends_with(".sh"))
                {
                    assert!(
                        root.join(".specify/scripts/bash").join(rel).is_file(),
                        "{v}: {} names a missing script {rel}",
                        s.name
                    );
                }
            }
        }
    }
}