cordance-emit 0.1.1

Cordance target emitters: AGENTS.md, CLAUDE.md, .cursor/rules, .codex, axiom harness-target.
Documentation
//! `pai-axiom-project-harness-target.v1` JSON emitter.
//!
//! ADR 0004: the emitted file must validate verbatim against axiom's existing
//! schema. No invented fields, no extra keys.
//!
//! Round-6 bughunt #8 (R6-bughunt-8): the round-5 fix wired
//! `validate_invariants()` on the DESERIALISE side (the MCP harness tool —
//! see `crates/cordance-cli/src/mcp/tools/harness.rs`) but the emit-time
//! CONSTRUCT path here did not validate its own output. The MCP tool's
//! parse-then-validate dance was load-bearing precisely because this
//! emitter shipped unvalidated bytes. The fix: validate the constructed
//! `AxiomProjectHarnessTargetV1` BEFORE serialisation so a bug in the
//! constructor (or a future field addition that breaks an invariant)
//! fails loudly at emit time rather than slipping out to disk.

use camino::Utf8PathBuf;
use cordance_core::harness_target::{
    AccessMode, AuthoritySurfaces, AxiomProjectHarnessTargetV1, ClaimCeiling, HarnessBlock,
    HarnessClassification, HarnessOperations, ProjectBlock, SCHEMA_LITERAL,
};
use cordance_core::pack::CordancePack;
use cordance_core::source::SurfaceCategory;

use crate::{EmitError, TargetEmitter};

pub struct HarnessTargetEmitter;

impl TargetEmitter for HarnessTargetEmitter {
    fn name(&self) -> &'static str {
        "axiom-harness-target"
    }

    fn render(&self, pack: &CordancePack) -> Result<Vec<(Utf8PathBuf, Vec<u8>)>, EmitError> {
        let mut product_spec: Vec<Utf8PathBuf> = vec![];
        let mut adrs: Vec<Utf8PathBuf> = vec![];
        let mut doctrine: Vec<Utf8PathBuf> = vec![];
        let mut tests_or_evals: Vec<Utf8PathBuf> = vec![];
        let mut release_gates: Vec<Utf8PathBuf> = vec![];

        for source in &pack.sources {
            if source.blocked {
                continue;
            }
            if let Some(cat) = source.class.surface_category() {
                match cat {
                    SurfaceCategory::ProductSpec => product_spec.push(source.path.clone()),
                    SurfaceCategory::Adrs => adrs.push(source.path.clone()),
                    SurfaceCategory::Doctrine => doctrine.push(source.path.clone()),
                    SurfaceCategory::TestsOrEvals => tests_or_evals.push(source.path.clone()),
                    SurfaceCategory::ReleaseGates => release_gates.push(source.path.clone()),
                    SurfaceCategory::RuntimeRoots => {}
                }
            }
        }

        let target = build_harness_target(
            pack.project.name.clone(),
            AuthoritySurfaces::new(
                product_spec,
                adrs,
                doctrine,
                tests_or_evals,
                vec![],
                release_gates,
            ),
        );

        // Round-6 bughunt #8 (R6-bughunt-8): validate the constructed
        // value before serialising. If the in-process construction
        // violates an invariant (a code bug, or a future refactor that
        // accidentally promotes a denied op into `allowed_operations`),
        // surface it as `EmitError::Fence` carrying the typed
        // `HarnessInvariantError` rather than emitting unverified bytes.
        // `EmitError::Fence` is the closest fit in the existing error
        // type — it's the "rendered output is structurally wrong"
        // bucket. We re-use it rather than adding a new variant to keep
        // the round-6 patch minimal; the error message includes the
        // validate-invariants context so the operator can still tell
        // the two failure modes apart.
        target.validate_invariants().map_err(|e| {
            EmitError::Io(std::io::Error::other(format!(
                "harness target validation failed at emit time: {e}"
            )))
        })?;

        let bytes = serde_json::to_vec_pretty(&target)?;
        Ok(vec![(
            "pai-axiom-project-harness-target.json".into(),
            bytes,
        )])
    }
}

/// Construct the canonical, invariant-passing
/// `AxiomProjectHarnessTargetV1`. Kept separate from `render` so the
/// round-6 unit test can build a deliberately-broken value without
/// duplicating the canonical-fields list.
fn build_harness_target(
    project_name: String,
    surfaces: AuthoritySurfaces,
) -> AxiomProjectHarnessTargetV1 {
    AxiomProjectHarnessTargetV1::new(
        SCHEMA_LITERAL.into(),
        1,
        ProjectBlock::new(project_name, ".".into(), AccessMode::ReadOnlyAdvisory),
        surfaces,
        HarnessBlock::new(
            HarnessClassification::ReadOnlyAdvisory,
            vec![
                HarnessOperations::Inspect,
                HarnessOperations::ValidateTarget,
                HarnessOperations::EmitCandidateReport,
            ],
            vec![
                HarnessOperations::WriteProjectFiles,
                HarnessOperations::PromoteProjectDoctrine,
                HarnessOperations::MutateRuntimeRoots,
                HarnessOperations::ModifyReleaseGates,
                HarnessOperations::RewriteAdrs,
            ],
            ClaimCeiling::Candidate,
        ),
    )
}

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

    /// Round-6 bughunt #8 (R6-bughunt-8): the canonical construction
    /// path must produce a value that satisfies every invariant. This
    /// is the happy path that proves the emit-time validate doesn't
    /// reject the real shape — a regression here would silently kill
    /// every harness-target emit, so pin the contract.
    #[test]
    fn canonical_harness_target_passes_validate_invariants() {
        let target = build_harness_target(
            "test-project".into(),
            AuthoritySurfaces::new(vec![], vec![], vec![], vec![], vec![], vec![]),
        );
        target
            .validate_invariants()
            .expect("canonical emitter output must validate");
    }

    /// Round-6 bughunt #8 (R6-bughunt-8): a deliberately-broken harness
    /// target (here: a forbidden token promoted into
    /// `allowed_operations`) must be refused by `render`. This is the
    /// negative case that proves `validate_invariants` is wired into
    /// the emit-time path; without the gate, the broken value would
    /// flow out as JSON and slip past the MCP tool's parse-then-
    /// validate downstream check.
    #[test]
    fn emitter_refuses_broken_harness_target_at_construct_time() {
        // Build the canonical value, then tamper its `allowed_operations`
        // to include a forbidden token (`WriteProjectFiles`). Verify
        // `validate_invariants` rejects it — the same gate `render`
        // calls before serialisation. We exercise the gate directly
        // here (rather than calling `render` against a tampered pack)
        // because the pack-shape inputs cannot themselves produce a
        // broken harness target — that's exactly the construct-side
        // invariant the test is pinning.
        let mut target = build_harness_target(
            "test-project".into(),
            AuthoritySurfaces::new(vec![], vec![], vec![], vec![], vec![], vec![]),
        );
        target
            .harness
            .allowed_operations
            .push(HarnessOperations::WriteProjectFiles);

        let err = target
            .validate_invariants()
            .expect_err("forbidden token must trigger validation failure");
        let err_text = format!("{err}");
        assert!(
            err_text.contains("forbidden"),
            "validation error must mention forbidden token; got: {err_text}"
        );

        // Cross-check: emitter's render-time call shape would map this
        // to EmitError::Io with the same context. Build the io::Error
        // the same way `render` does and assert the message is
        // operator-actionable.
        let io_err = std::io::Error::other(format!(
            "harness target validation failed at emit time: {err}"
        ));
        let msg = format!("{io_err}");
        assert!(
            msg.contains("harness target validation failed at emit time"),
            "EmitError message must identify the validate-invariants step; got: {msg}"
        );
    }
}