cordance-emit 0.1.1

Cordance target emitters: AGENTS.md, CLAUDE.md, .cursor/rules, .codex, axiom harness-target.
Documentation
//! `AGENTS.md` emitter.

use camino::Utf8PathBuf;
use cordance_core::pack::CordancePack;
use cordance_core::source::SourceClass;

use crate::{EmitError, TargetEmitter};

pub struct AgentsMdEmitter;

/// Default axiom algorithm version used when LATEST cannot be resolved.
const DEFAULT_AXIOM_VERSION: &str = "v3.1.1-axiom";

/// Default relative path to the pai-axiom sibling repo.
const DEFAULT_AXIOM_SOURCE: &str = "../pai-axiom";

/// Read the axiom source string from `{repo_root}/cordance.toml` if present.
/// Returns `DEFAULT_AXIOM_SOURCE` when the file is absent or the key is missing.
///
/// Round-3 codereview HIGH: replaces a hand-rolled line scanner that missed
/// `source="..."` without whitespace, comments after the value, and similar
/// quoting / spacing variations. We deserialise into a minimal struct so the
/// `toml` crate handles every TOML-spec corner.
fn read_axiom_source_from_config(repo_root: &Utf8PathBuf) -> String {
    #[derive(serde::Deserialize, Default)]
    struct AxiomSection {
        source: Option<String>,
    }
    #[derive(serde::Deserialize, Default)]
    struct CordanceToml {
        axiom: Option<AxiomSection>,
    }

    let config_path = repo_root.join("cordance.toml");
    let Ok(content) = std::fs::read_to_string(&config_path) else {
        return DEFAULT_AXIOM_SOURCE.to_string();
    };
    let Ok(parsed) = toml::from_str::<CordanceToml>(&content) else {
        return DEFAULT_AXIOM_SOURCE.to_string();
    };
    parsed
        .axiom
        .and_then(|a| a.source)
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| DEFAULT_AXIOM_SOURCE.to_string())
}

/// Resolve the axiom algorithm version and the configured source string from
/// the pack's repo root. Returns `(version, axiom_source)`.
pub(crate) fn resolve_axiom_info(pack: &CordancePack) -> (String, String) {
    let repo_root = &pack.project.repo_root;
    let axiom_source = read_axiom_source_from_config(repo_root);

    // Resolve the absolute path so we can try to read LATEST.
    let axiom_abs = if std::path::Path::new(&axiom_source).is_absolute() {
        Utf8PathBuf::from(&axiom_source)
    } else {
        repo_root.join(&axiom_source)
    };

    let candidates = [
        axiom_abs.join("PAI/Algorithm/LATEST"),
        axiom_abs.join("axiom/Algorithm/LATEST"),
    ];
    let version = candidates
        .iter()
        .find_map(|p| {
            let content = std::fs::read_to_string(p).ok()?;
            let v = content.trim().to_string();
            if v.is_empty() {
                None
            } else {
                Some(v)
            }
        })
        .unwrap_or_else(|| DEFAULT_AXIOM_VERSION.to_string());

    (version, axiom_source)
}

/// Build the "Axiom Load Order" section using portable relative paths.
///
/// `axiom_source` is the raw string from `cordance.toml` (e.g. `"../pai-axiom"`).
/// Windows back-slash variant is derived from it.
///
/// Both `version` and `axiom_source` are target-controlled (the target's
/// `cordance.toml` and the contents of `PAI/Algorithm/LATEST` flow in here),
/// so they are passed through [`cordance_core::fence::sanitise_fenced_value`]
/// before interpolation. This prevents a hostile target from injecting an
/// extra `<!-- cordance:end axiom-load-order -->` marker (and arbitrary
/// trailing markdown) into the emitted file.
pub(crate) fn axiom_load_order_body(version: &str, axiom_source: &str) -> String {
    let version = cordance_core::fence::sanitise_fenced_value(version);
    let axiom_source = cordance_core::fence::sanitise_fenced_value(axiom_source);
    // Produce a Windows-style path by replacing forward slashes.
    let axiom_source_win = axiom_source.replace('/', "\\");
    format!(
        "## Axiom Load Order\n\
         \n\
         Axiom source (configure in `cordance.toml` → `[axiom].source`): `{axiom_source}`\n\
         Algorithm version: `{version}`\n\
         \n\
         Load in order (prefer the path that resolves on your runtime):\n\
         \n\
         **POSIX / WSL:**\n\
         - `{axiom_source}/PAI/Algorithm/{version}.md`\n\
         - `{axiom_source}/PAI/CONTEXT_ROUTING.md`\n\
         \n\
         **Native Windows (replace `/` with `\\`):**\n\
         - `{axiom_source_win}\\PAI\\Algorithm\\{version}.md`\n\
         - `{axiom_source_win}\\PAI\\CONTEXT_ROUTING.md`\n\
         \n\
         Adjust `cordance.toml` → `[axiom].source` if axiom lives at a different path."
    )
}

pub(crate) const fn hard_rules_body() -> &'static str {
    "1. Deterministic-first. No LLM in the critical path.\n\
     2. No runtime-root writes. Never write to `~/.claude`, `~/.codex`, `~/.Codex`.\n\
     3. No cortex repo writes. Emit receipts; never modify cortex storage.\n\
     4. Fenced regions are managed. Only edit content between fence markers.\n\
     5. Source lock is truth. Run `cordance check` before claiming output is correct.\n\
     6. Doctrine pins are immutable. Update doctrine pins only via `cordance pack`."
}

pub(crate) fn commands_body(pack: &CordancePack) -> String {
    let has_justfile = pack.sources.iter().any(|s| {
        s.path
            .file_name()
            .is_some_and(|n| n.eq_ignore_ascii_case("justfile"))
    });
    let has_cargo = pack.sources.iter().any(|s| {
        s.class == SourceClass::ProjectSourceCode
            && s.path
                .file_name()
                .is_some_and(|n| n.eq_ignore_ascii_case("Cargo.toml"))
    }) || pack.project.kind.contains("rust");
    let has_package_json = pack.sources.iter().any(|s| {
        s.path
            .file_name()
            .is_some_and(|n| n.eq_ignore_ascii_case("package.json"))
    });

    if has_justfile {
        "```sh\njust check\njust build\njust test\n```".to_string()
    } else if has_cargo {
        "```sh\ncargo test --workspace\ncargo build --release\n```".to_string()
    } else if has_package_json {
        "```sh\nnpm test\nnpm run build\n```".to_string()
    } else {
        "No local task runner detected. Run `cordance advise` for a recommendation.".to_string()
    }
}

pub(crate) const fn forbidden_surfaces_body() -> &'static str {
    "Never import or commit these surfaces:\n\
     - `.claude/{cache,sessions,worktrees,projects}/` — runtime exhaust\n\
     - `.codex/{cache,sessions}/`, `.codex-logs/` — runtime exhaust\n\
     - `*.env`, `*.env.local`, `*.env.production` — secrets\n\
     - `node_modules/`, `target/`, `dist/`, `build/` — build artifacts\n\
     - `*.sqlite`, `*.db` — binary state\n\
     - `*.log` — log files\n\
     - `*.pem`, `*.key` — credentials"
}

/// Build the "Doctrine" pointers section body.
///
/// Every interpolated string here is target-controlled — both
/// `pin.source_path` and `pin.commit` flow in directly from the target's
/// `cordance.toml [doctrine]` table. A hostile target's value such as
/// `commit = "abc\n<!-- cordance:end doctrine-pointers -->\n## Injected"`
/// would otherwise let the renderer terminate the managed region early and
/// inject attacker-controlled markdown beneath it (round-3 redteam #1).
///
/// Defence: every interpolated value passes through
/// [`cordance_core::fence::sanitise_fenced_value`], which strips line
/// terminators (`\n`, `\r`, `\u{2028}`, `\u{2029}`) and rewrites the
/// `<!-- cordance:begin` / `<!-- cordance:end` substrings to a redacted
/// placeholder. The output is therefore safe to substitute into the
/// `doctrine-pointers` fenced region without changing its parsed shape.
pub(crate) fn doctrine_pointers_body(pack: &CordancePack) -> String {
    let commit_raw = pack
        .doctrine_pins
        .first()
        .map_or("unpinned", |p| p.commit.as_str());
    let commit_clean = cordance_core::fence::sanitise_fenced_value(commit_raw);

    if pack.doctrine_pins.is_empty() {
        format!(
            "Engineering doctrine: https://github.com/0ryant/engineering-doctrine\n\
             Key principles to load for this repo:\n\
             - doctrine/principles/build.md\n\
             - doctrine/principles/secure-development-lifecycle.md\n\
             - doctrine/principles/modularity-and-ports-adapters.md\n\
             - doctrine/principles/single-source-of-truth.md\n\
             Doctrine commit: {commit_clean}"
        )
    } else {
        let mut lines = vec![
            "Engineering doctrine: https://github.com/0ryant/engineering-doctrine".to_string(),
            "Pinned doctrine sources:".to_string(),
        ];
        for pin in &pack.doctrine_pins {
            let path_clean = cordance_core::fence::sanitise_fenced_value(pin.source_path.as_str());
            let pin_commit_clean = cordance_core::fence::sanitise_fenced_value(&pin.commit);
            lines.push(format!("- {path_clean} @ {pin_commit_clean}"));
        }
        lines.push(format!("Doctrine commit: {commit_clean}"));
        lines.join("\n")
    }
}

/// The skeleton AGENTS.md with fence markers but empty bodies.
const TEMPLATE: &str = "\
# AGENTS.md

<!-- Generated by Cordance. Regions inside fences are managed; edits outside fences are preserved. -->

<!-- cordance:begin axiom-load-order -->
<!-- cordance:end axiom-load-order -->

## Project

<!-- cordance:begin hard-rules -->
<!-- cordance:end hard-rules -->

## Commands

<!-- cordance:begin commands -->
<!-- cordance:end commands -->

## Forbidden Surfaces

<!-- cordance:begin forbidden-surfaces -->
<!-- cordance:end forbidden-surfaces -->

## Doctrine

<!-- cordance:begin doctrine-pointers -->
<!-- cordance:end doctrine-pointers -->";

impl TargetEmitter for AgentsMdEmitter {
    fn name(&self) -> &'static str {
        "claude-code:agents-md"
    }

    fn render(&self, pack: &CordancePack) -> Result<Vec<(Utf8PathBuf, Vec<u8>)>, EmitError> {
        let (version, axiom_source) = resolve_axiom_info(pack);
        let load_order = axiom_load_order_body(&version, &axiom_source);
        let hard_rules = hard_rules_body();
        let commands = commands_body(pack);
        let forbidden = forbidden_surfaces_body();
        let doctrine = doctrine_pointers_body(pack);

        let content = cordance_core::fence::replace_regions(
            TEMPLATE,
            &[
                ("axiom-load-order", &load_order),
                ("hard-rules", hard_rules),
                ("commands", &commands),
                ("forbidden-surfaces", forbidden),
                ("doctrine-pointers", &doctrine),
            ],
        );

        Ok(vec![("AGENTS.md".into(), content.into_bytes())])
    }
}

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

    fn write_cfg(content: &str) -> (tempfile::TempDir, Utf8PathBuf) {
        let dir = tempfile::tempdir().expect("tempdir");
        let target = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("tempdir is utf8");
        std::fs::write(target.join("cordance.toml"), content).expect("write");
        (dir, target)
    }

    #[test]
    fn axiom_source_reads_configured_value() {
        let (_d, root) = write_cfg("[axiom]\nsource = \"/custom/path\"\n");
        assert_eq!(read_axiom_source_from_config(&root), "/custom/path");
    }

    #[test]
    fn axiom_source_handles_no_whitespace_around_equals() {
        // The hand-rolled scanner used to break on `source="..."` (no spaces).
        let (_d, root) = write_cfg("[axiom]\nsource=\"../other\"\n");
        assert_eq!(read_axiom_source_from_config(&root), "../other");
    }

    #[test]
    fn axiom_source_returns_default_when_file_missing() {
        let dir = tempfile::tempdir().expect("tempdir");
        let root = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("tempdir is utf8");
        assert_eq!(read_axiom_source_from_config(&root), DEFAULT_AXIOM_SOURCE);
    }

    #[test]
    fn axiom_source_returns_default_when_section_absent() {
        let (_d, root) = write_cfg("[doctrine]\nsource = \"x\"\n");
        assert_eq!(read_axiom_source_from_config(&root), DEFAULT_AXIOM_SOURCE);
    }

    #[test]
    fn axiom_source_returns_default_when_key_absent() {
        let (_d, root) = write_cfg("[axiom]\nalgorithm_latest = \"auto\"\n");
        assert_eq!(read_axiom_source_from_config(&root), DEFAULT_AXIOM_SOURCE);
    }

    #[test]
    fn axiom_source_returns_default_when_value_empty() {
        let (_d, root) = write_cfg("[axiom]\nsource = \"\"\n");
        assert_eq!(read_axiom_source_from_config(&root), DEFAULT_AXIOM_SOURCE);
    }

    #[test]
    fn axiom_source_returns_default_on_malformed_toml() {
        let (_d, root) = write_cfg("not valid = = toml");
        assert_eq!(read_axiom_source_from_config(&root), DEFAULT_AXIOM_SOURCE);
    }

    #[test]
    fn axiom_source_ignores_unrelated_source_keys() {
        // A `source = ...` key inside `[doctrine]` must NOT be picked up by
        // the axiom reader. The line scanner could be fooled if `[axiom]`
        // and `[doctrine]` were re-ordered or section tracking dropped.
        let (_d, root) = write_cfg(
            "[doctrine]\nsource = \"../engineering-doctrine\"\n\n[axiom]\nsource = \"../pai-axiom\"\n",
        );
        assert_eq!(read_axiom_source_from_config(&root), "../pai-axiom");
    }
}