Skip to main content

cordance_core/
pack.rs

1//! The `CordancePack` IR — the canonical in-repo description of a target project.
2//!
3//! A pack is produced by `cordance pack` and is the deterministic compiled view
4//! of: project identity + classified sources + selected doctrine entries +
5//! generated outputs + lock. All emitters read this.
6//!
7//! ## Determinism
8//!
9//! Two `cordance pack` runs against the same commit on the same machine must
10//! produce byte-identical `pack.json` / `sources.lock`. Round-4 bughunt #1
11//! discovered five `Utc::now()` sites that broke this — `CordancePack`,
12//! `SourceLock`, `AdviseReport`, `EvidenceMap` all carried a `generated_at`
13//! timestamp that drifted by milliseconds run-to-run. All four fields are now
14//! removed from the on-disk shape. If a wall-clock is ever needed for audit,
15//! it must be threaded through from a deterministic source (commit date,
16//! operator-supplied flag, etc.), not pulled from the system clock at
17//! serialisation time.
18
19use camino::Utf8PathBuf;
20use serde::{Deserialize, Serialize};
21
22use crate::advise::AdviseReport;
23use crate::lock::SourceLock;
24use crate::source::SourceRecord;
25
26#[derive(Clone, Debug, Serialize, Deserialize)]
27pub struct CordancePack {
28    /// `cordance-pack.v1`.
29    pub schema: String,
30
31    pub project: ProjectIdentity,
32    pub sources: Vec<SourceRecord>,
33    pub doctrine_pins: Vec<DoctrinePin>,
34    pub targets: PackTargets,
35
36    pub outputs: Vec<PackOutput>,
37    pub source_lock: SourceLock,
38    pub advise: AdviseReport,
39
40    /// Records of all decisions made during compilation. Never empty.
41    pub residual_risk: Vec<String>,
42}
43
44#[derive(Clone, Debug, Default, Serialize, Deserialize)]
45pub struct ProjectIdentity {
46    pub name: String,
47    pub repo_root: Utf8PathBuf,
48    /// Free-form classifier ("rust-workspace", "ts-bun", "polyglot"…). Set by
49    /// the scanner, not the spec.
50    pub kind: String,
51    /// Operating-system host that produced the pack ("windows", "linux", "macos").
52    pub host_os: String,
53    /// Axiom algorithm pin (e.g. `"v3.1.1-axiom"`), if known. `None` when
54    /// axiom is unconfigured or the `LATEST` file isn't readable.
55    ///
56    /// `#[serde(default)]` keeps backward compatibility with pack.json files
57    /// produced before this field existed.
58    #[serde(default)]
59    pub axiom_pin: Option<String>,
60}
61
62#[derive(Clone, Debug, Serialize, Deserialize)]
63pub struct DoctrinePin {
64    /// `0ryant/engineering-doctrine` (or fork).
65    pub repo: String,
66    pub commit: String,
67    pub source_path: Utf8PathBuf,
68}
69
70#[derive(Clone, Debug, Serialize, Deserialize, Default)]
71#[allow(clippy::struct_excessive_bools)]
72pub struct PackTargets {
73    pub claude_code: bool,
74    pub cursor: bool,
75    pub codex: bool,
76    pub axiom_harness_target: bool,
77    pub cortex_receipt: bool,
78}
79
80/// Errors from [`PackTargets::from_csv`].
81#[derive(Debug, thiserror::Error)]
82pub enum ParseTargetsError {
83    /// A token was not one of the recognised target names.
84    ///
85    /// Round-4 codereview #4 / bughunt #9: the legacy `s.contains("cursor")`
86    /// parser silently accepted `no-cursor`, `supercursor`, `--targets foo`,
87    /// and any other string containing one of the target substrings. This
88    /// error surfaces the user's mistake instead of guessing.
89    #[error(
90        "unknown pack target {0:?} — expected one of \
91             claude-code, cursor, codex, axiom-harness-target, cortex-receipt"
92    )]
93    UnknownTarget(String),
94}
95
96impl PackTargets {
97    /// Enable every target. Used as the default when no `--targets` argument is
98    /// supplied on the CLI or in an MCP tool call.
99    #[must_use]
100    pub const fn all() -> Self {
101        Self {
102            claude_code: true,
103            cursor: true,
104            codex: true,
105            axiom_harness_target: true,
106            cortex_receipt: true,
107        }
108    }
109
110    /// Parse a comma-separated list of target names into a [`PackTargets`].
111    ///
112    /// Each token is trimmed and exact-matched against the closed set of
113    /// known targets. Unknown tokens return [`ParseTargetsError::UnknownTarget`]
114    /// — the previous substring scan happily accepted `no-cursor` as "cursor".
115    /// Passing `None` (no `--targets` argument supplied at all) or an empty
116    /// / whitespace-only string defaults to [`Self::all`] for backward
117    /// compatibility with the historical CLI default.
118    ///
119    /// # Errors
120    ///
121    /// Returns [`ParseTargetsError::UnknownTarget`] for any token that is not
122    /// one of: `"claude-code"`, `"cursor"`, `"codex"`, `"axiom-harness-target"`,
123    /// `"cortex-receipt"`.
124    pub fn from_csv(s: Option<&str>) -> Result<Self, ParseTargetsError> {
125        let Some(s) = s else {
126            return Ok(Self::all());
127        };
128        if s.trim().is_empty() {
129            return Ok(Self::all());
130        }
131
132        let mut targets = Self::default();
133        let mut saw_non_empty_token = false;
134        for raw in s.split(',') {
135            let token = raw.trim();
136            if token.is_empty() {
137                continue;
138            }
139            saw_non_empty_token = true;
140            match token {
141                "claude-code" => targets.claude_code = true,
142                "cursor" => targets.cursor = true,
143                "codex" => targets.codex = true,
144                "axiom-harness-target" => targets.axiom_harness_target = true,
145                "cortex-receipt" => targets.cortex_receipt = true,
146                other => return Err(ParseTargetsError::UnknownTarget(other.into())),
147            }
148        }
149        // Round-5 bughunt #2 (R5-bughunt-2): inputs that are only commas /
150        // whitespace ("`,`", "` , , `") pass the upper `is_empty` guard but
151        // contribute zero recognised tokens, leaving `targets` as the
152        // derived `Default` (all-FALSE). The empty-string contract above is
153        // "no signal → enable every target"; comma-only soup is the same
154        // shape of "no signal" and must follow the same rule rather than
155        // silently dropping every emitter. The `saw_non_empty_token` guard
156        // distinguishes this from a successful parse that legitimately
157        // enables no fields — which is *impossible* today (every recognised
158        // token sets exactly one bool to `true`), so the only path that
159        // reaches `!saw_non_empty_token` is comma/whitespace-only input.
160        if !saw_non_empty_token {
161            return Ok(Self::all());
162        }
163        Ok(targets)
164    }
165}
166
167#[derive(Clone, Debug, Serialize, Deserialize)]
168pub struct PackOutput {
169    pub path: Utf8PathBuf,
170    pub target: String,
171    pub sha256: String,
172    pub bytes: u64,
173    pub managed: bool,
174    /// IDs of sources this output cites.
175    pub source_anchors: Vec<String>,
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::schema;
182
183    #[test]
184    fn empty_pack_serialises() {
185        let pack = CordancePack {
186            schema: schema::CORDANCE_PACK_V1.into(),
187            project: ProjectIdentity {
188                name: "fixture".into(),
189                repo_root: ".".into(),
190                kind: "rust-workspace".into(),
191                host_os: "linux".into(),
192                axiom_pin: None,
193            },
194            sources: vec![],
195            doctrine_pins: vec![],
196            targets: PackTargets::default(),
197            outputs: vec![],
198            source_lock: SourceLock::empty(),
199            advise: AdviseReport::empty(),
200            residual_risk: vec!["v0 pack — claim_ceiling=candidate".into()],
201        };
202        let s = serde_json::to_string(&pack).expect("ser");
203        assert!(s.contains("cordance-pack.v1"));
204    }
205
206    /// Round-4 bughunt #1: the on-disk shape must not embed wall-clock time.
207    /// Two byte-identical packs must serialise to the same bytes regardless of
208    /// when they were constructed. The previous `generated_at: DateTime<Utc>`
209    /// field made this impossible.
210    #[test]
211    fn pack_json_contains_no_generated_at_field() {
212        let pack = CordancePack {
213            schema: schema::CORDANCE_PACK_V1.into(),
214            project: ProjectIdentity::default(),
215            sources: vec![],
216            doctrine_pins: vec![],
217            targets: PackTargets::default(),
218            outputs: vec![],
219            source_lock: SourceLock::empty(),
220            advise: AdviseReport::empty(),
221            residual_risk: vec![],
222        };
223        let s = serde_json::to_string(&pack).expect("ser");
224        assert!(
225            !s.contains("generated_at"),
226            "pack.json must not embed a wall-clock timestamp: {s}"
227        );
228    }
229
230    #[test]
231    fn parse_targets_default_when_none() {
232        let t = PackTargets::from_csv(None).expect("ok");
233        assert!(t.claude_code && t.cursor && t.codex && t.axiom_harness_target && t.cortex_receipt);
234    }
235
236    #[test]
237    fn parse_targets_default_when_empty() {
238        let t = PackTargets::from_csv(Some("")).expect("ok");
239        assert!(t.claude_code && t.cursor);
240    }
241
242    #[test]
243    fn parse_targets_single_token() {
244        let t = PackTargets::from_csv(Some("cursor")).expect("ok");
245        assert!(t.cursor);
246        assert!(!t.claude_code && !t.codex && !t.axiom_harness_target && !t.cortex_receipt);
247    }
248
249    #[test]
250    fn parse_targets_multiple_tokens() {
251        let t = PackTargets::from_csv(Some("claude-code,codex")).expect("ok");
252        assert!(t.claude_code && t.codex);
253        assert!(!t.cursor && !t.axiom_harness_target);
254    }
255
256    #[test]
257    fn parse_targets_trims_whitespace() {
258        let t = PackTargets::from_csv(Some("  claude-code , cursor  ")).expect("ok");
259        assert!(t.claude_code && t.cursor);
260    }
261
262    /// Round-4 codereview #4 / bughunt #9: the legacy substring scan accepted
263    /// `no-cursor` as enabling `cursor`. `from_csv` must reject unknown tokens
264    /// with a typed error so the user sees what they actually asked for.
265    #[test]
266    fn parse_targets_rejects_unknown_token() {
267        let err = PackTargets::from_csv(Some("no-cursor")).expect_err("unknown token must fail");
268        match err {
269            ParseTargetsError::UnknownTarget(got) => assert_eq!(got, "no-cursor"),
270        }
271    }
272
273    /// The substring scan also accepted `--targets supercursor` as enabling
274    /// `cursor`. Same expectation: typed error.
275    #[test]
276    fn parse_targets_rejects_super_prefix() {
277        let err = PackTargets::from_csv(Some("supercursor")).expect_err("supercursor must fail");
278        assert!(matches!(err, ParseTargetsError::UnknownTarget(_)));
279    }
280
281    #[test]
282    fn parse_targets_skips_empty_tokens() {
283        // Trailing commas and double commas must not produce empty-token
284        // errors — they're a benign formatting variant.
285        let t = PackTargets::from_csv(Some("claude-code,,cursor,")).expect("ok");
286        assert!(t.claude_code && t.cursor);
287    }
288
289    /// Round-5 bughunt #2 (R5-bughunt-2): a single comma is "no signal" the
290    /// same way an empty string is, so the parser must default to enabling
291    /// every target — not silently fall through to the all-FALSE
292    /// `Self::default()` shape that would suppress every emitter except the
293    /// always-on `pack_json`. A scripted `--targets "$selection"` invocation
294    /// with an empty `$selection` after filtering is the realistic case.
295    #[test]
296    fn parse_targets_comma_only_defaults_to_all() {
297        let t = PackTargets::from_csv(Some(",")).expect("ok");
298        assert!(
299            t.claude_code && t.cursor && t.codex && t.axiom_harness_target && t.cortex_receipt,
300            "comma-only input must default to all targets enabled (got {t:?})"
301        );
302    }
303
304    /// Round-5 bughunt #2 (R5-bughunt-2): whitespace-and-comma soup follows
305    /// the same rule. The previous shape returned the all-FALSE `Default`
306    /// because every token trimmed to empty and was silently skipped, leaving
307    /// the operator with one-output packs and no error to chase.
308    #[test]
309    fn parse_targets_whitespace_and_commas_defaults_to_all() {
310        let t = PackTargets::from_csv(Some(" , , ")).expect("ok");
311        assert!(
312            t.claude_code && t.cursor && t.codex && t.axiom_harness_target && t.cortex_receipt,
313            "whitespace+comma soup must default to all targets enabled (got {t:?})"
314        );
315    }
316}