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}