procyon 0.1.2

Terminal development harness for Stellar and Soroban smart contracts, driven by a language model
//! Whether the code on disk has been proven to compile since it was last changed.
//!
//! The harness could write a contract, say it was done, and deploy it, without anything in between
//! ever having compiled. Two different failures came out of that. The mild one is a report: the
//! model finishes a turn describing work it has no evidence for, because writing a file always
//! succeeds. The sharp one is `caatinga_deploy`, which deploys the wasm recorded in
//! `caatinga.artifacts.json` — the output of the *last build*. Edit a contract and deploy without
//! rebuilding and the deploy succeeds while putting the previous code on chain, which is the worst
//! shape a failure can take: everything reports fine and the contract is wrong.
//!
//! So this tracks one thing — source files written since the last successful build or test run —
//! and does two things with it. The workspace prompt names them, so "done" has to be justified.
//! And `caatinga_deploy` refuses while the list is non-empty, because there is no reading of that
//! state under which deploying stale artifacts was what anyone asked for.
//!
//! Held process-wide rather than threaded through the tools: a tool is constructed with no state,
//! and the deploy gate has to be inside `execute` where the subagent path reaches it too.

use std::collections::BTreeSet;
use std::path::Path;
use std::sync::{Mutex, OnceLock};

/// Source this project builds from. A change here invalidates the last build.
///
/// Deliberately narrow. TypeScript bindings are regenerated by the deploy itself, and markdown or
/// config changes do not make the compiled wasm stale — flagging those would train everyone to
/// ignore the flag.
fn is_buildable_source(path: &str) -> bool {
    Path::new(path)
        .extension()
        .and_then(|e| e.to_str())
        .is_some_and(|e| e == "rs")
        || path.ends_with("Cargo.toml")
}

/// The set of writes no build has covered yet.
#[derive(Debug, Default)]
pub struct Verification {
    pending: Mutex<BTreeSet<String>>,
}

impl Verification {
    /// Notes the outcome of a tool call.
    ///
    /// Reads the same `(name, input, ok)` the turn loop already has, so there is one place that
    /// decides what counts as a change and what counts as evidence.
    pub fn record(&self, tool: &str, input: &serde_json::Value, ok: bool) {
        let Ok(mut pending) = self.pending.lock() else {
            return;
        };

        match tool {
            // A failed write may still have written: `edit_file` can succeed on disk and fail
            // while reporting. Treating a failure as "nothing changed" is the assumption that
            // leaves a stale artifact behind, so the path goes in either way.
            "write_file" | "edit_file" => {
                if let Some(path) = input.get("path").and_then(|v| v.as_str()) {
                    if is_buildable_source(path) {
                        pending.insert(path.to_string());
                    }
                }
            }
            // Evidence, and only when it passed. A failed build proves the opposite.
            "caatinga_build" | "run_tests" if ok => pending.clear(),
            _ => {}
        }
    }

    /// The files written since the last successful build, for the workspace prompt.
    pub fn pending(&self) -> Vec<String> {
        self.pending
            .lock()
            .map(|set| set.iter().cloned().collect())
            .unwrap_or_default()
    }

    /// `Err` when a deploy would ship artifacts that predate the source on disk.
    ///
    /// A refusal rather than a warning: Caatinga deploys the wasm from the last build, so this
    /// deploy would put the previous version of the contract on chain and report success.
    pub fn guard_deploy(&self) -> Result<(), String> {
        let pending = self.pending();
        if pending.is_empty() {
            return Ok(());
        }
        Err(format!(
            "Refusing to deploy: {} changed since the last successful build, and a deploy ships \
             the wasm recorded by that build — this would put the previous version of the contract \
             on chain and report success. Run `caatinga_build` first. Nothing was submitted.",
            pending.join(", ")
        ))
    }
}

/// The session's tracker.
///
/// A process-wide single instance because the two ends are far apart: the turn loop records, and
/// `caatinga_deploy` reads from inside its own `execute`, which is the only point the subagent and
/// skill paths also pass through.
pub fn session() -> &'static Verification {
    static SESSION: OnceLock<Verification> = OnceLock::new();
    SESSION.get_or_init(Verification::default)
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn a_source_write_leaves_the_build_unproven() {
        let verification = Verification::default();
        verification.record(
            "write_file",
            &json!({"path": "contracts/counter/src/lib.rs"}),
            true,
        );

        assert_eq!(verification.pending(), vec!["contracts/counter/src/lib.rs"]);
        assert!(verification.guard_deploy().is_err());
    }

    #[test]
    fn a_successful_build_is_the_evidence_that_clears_it() {
        let verification = Verification::default();
        verification.record("edit_file", &json!({"path": "src/lib.rs"}), true);
        verification.record("caatinga_build", &json!({}), true);

        assert!(verification.pending().is_empty());
        assert!(verification.guard_deploy().is_ok());
    }

    // A build that failed is evidence for the opposite conclusion.
    #[test]
    fn a_failed_build_proves_nothing() {
        let verification = Verification::default();
        verification.record("write_file", &json!({"path": "src/lib.rs"}), true);
        verification.record("caatinga_build", &json!({}), false);

        assert!(verification.guard_deploy().is_err());
    }

    #[test]
    fn passing_tests_count_as_evidence_too() {
        let verification = Verification::default();
        verification.record("write_file", &json!({"path": "src/lib.rs"}), true);
        verification.record("run_tests", &json!({}), true);

        assert!(verification.guard_deploy().is_ok());
    }

    // `edit_file` can write and then fail while reporting. Reading that as "nothing changed" is
    // exactly the assumption that leaves a stale wasm on chain.
    #[test]
    fn a_write_that_reported_failure_is_still_treated_as_a_change() {
        let verification = Verification::default();
        verification.record("edit_file", &json!({"path": "src/lib.rs"}), false);

        assert!(verification.guard_deploy().is_err());
    }

    // Flagging changes that cannot make the wasm stale would train everyone to ignore the flag.
    #[test]
    fn changes_that_cannot_affect_the_build_are_not_tracked() {
        let verification = Verification::default();
        for path in ["README.md", "packages/client/index.ts", ".env"] {
            verification.record("write_file", &json!({"path": path}), true);
        }
        assert!(
            verification.pending().is_empty(),
            "{:?}",
            verification.pending()
        );
    }

    #[test]
    fn a_manifest_change_invalidates_the_build_as_much_as_a_source_change() {
        let verification = Verification::default();
        verification.record(
            "write_file",
            &json!({"path": "contracts/counter/Cargo.toml"}),
            true,
        );
        assert!(verification.guard_deploy().is_err());
    }

    #[test]
    fn a_fresh_session_has_nothing_to_prove() {
        assert!(Verification::default().guard_deploy().is_ok());
    }

    // The message has to say what to run, or the model retries the deploy and reports the refusal
    // as an environment problem.
    #[test]
    fn the_refusal_names_the_file_and_the_command_that_settles_it() {
        let verification = Verification::default();
        verification.record("write_file", &json!({"path": "src/lib.rs"}), true);

        let err = verification.guard_deploy().unwrap_err();
        assert!(err.contains("src/lib.rs"), "{}", err);
        assert!(err.contains("caatinga_build"), "{}", err);
        assert!(err.contains("Nothing was submitted"), "{}", err);
    }
}