Skip to main content

truth_mirror/
claim.rs

1//! CLAIM parsing and deterministic pre-commit gate.
2
3use std::fmt;
4
5use thiserror::Error;
6
7pub const DEFAULT_FAKE_MARKERS: &[&str] = &["mock-as-real", "TODO-as-done"];
8
9/// Default evidence-pointer prefixes that make a CLAIM's evidence look real.
10pub const DEFAULT_EVIDENCE_PATTERNS: &[&str] = &[
11    "file:",
12    "path:",
13    "log:",
14    "test:",
15    "tests:",
16    "screenshot:",
17    "artifact:",
18    "ci:",
19    "bead:",
20    "openspec:",
21    "commit:",
22];
23
24/// Diff paths excluded from the fake-marker scan: documentation and specs mention
25/// markers to *describe* them, not to fake behavior. Matched by prefix or suffix.
26pub const DEFAULT_MARKER_IGNORE_PATHS: &[&str] = &[".md", "openspec/", "docs/"];
27
28/// Resolved deterministic-gate policy (markers, evidence patterns, ignore paths).
29#[derive(Clone, Debug, Eq, PartialEq)]
30pub struct GatePolicy {
31    pub fake_markers: Vec<String>,
32    pub evidence_patterns: Vec<String>,
33    pub marker_ignore_paths: Vec<String>,
34}
35
36impl Default for GatePolicy {
37    fn default() -> Self {
38        Self {
39            fake_markers: owned(DEFAULT_FAKE_MARKERS),
40            evidence_patterns: owned(DEFAULT_EVIDENCE_PATTERNS),
41            marker_ignore_paths: owned(DEFAULT_MARKER_IGNORE_PATHS),
42        }
43    }
44}
45
46fn owned(values: &[&str]) -> Vec<String> {
47    values.iter().map(|value| (*value).to_owned()).collect()
48}
49
50#[derive(Clone, Debug, Eq, PartialEq)]
51pub struct Claim {
52    pub what: String,
53    pub verification: String,
54    pub evidence: Vec<EvidenceRef>,
55}
56
57impl Claim {
58    pub fn new(
59        what: impl Into<String>,
60        verification: impl Into<String>,
61        evidence: Vec<EvidenceRef>,
62    ) -> Result<Self, ClaimError> {
63        let claim = Self {
64            what: normalize_field(what.into()),
65            verification: normalize_field(verification.into()),
66            evidence,
67        };
68        claim.validate()?;
69        Ok(claim)
70    }
71
72    pub fn parse(input: &str) -> Result<Self, ClaimError> {
73        Self::parse_with(input, DEFAULT_EVIDENCE_PATTERNS)
74    }
75
76    /// Parse a CLAIM, accepting the given evidence-pointer patterns (in addition
77    /// to the built-in heuristics).
78    pub fn parse_with<S: AsRef<str>>(input: &str, patterns: &[S]) -> Result<Self, ClaimError> {
79        let line = input
80            .lines()
81            .map(str::trim)
82            .find(|line| line.starts_with("CLAIM:"))
83            .ok_or(ClaimError::MissingClaim)?;
84
85        Self::parse_line_with(line, patterns)
86    }
87
88    pub fn parse_line(line: &str) -> Result<Self, ClaimError> {
89        Self::parse_line_with(line, DEFAULT_EVIDENCE_PATTERNS)
90    }
91
92    pub fn parse_line_with<S: AsRef<str>>(line: &str, patterns: &[S]) -> Result<Self, ClaimError> {
93        let mut segments = line.split('|').map(str::trim);
94        let claim_segment = segments
95            .next()
96            .and_then(|segment| segment.strip_prefix("CLAIM:"))
97            .ok_or(ClaimError::MissingClaim)?;
98
99        let mut verification = None;
100        let mut evidence = Vec::new();
101
102        for segment in segments {
103            if let Some(value) = field_value(segment, &["verified", "verification", "how"]) {
104                verification = Some(normalize_field(value.to_owned()));
105                continue;
106            }
107
108            if let Some(value) = field_value(segment, &["evidence", "evidence-pointer"]) {
109                for item in value.split(',') {
110                    evidence.push(EvidenceRef::parse_with(item, patterns)?);
111                }
112            }
113        }
114
115        Self::new(
116            claim_segment,
117            verification.ok_or(ClaimError::MissingVerification)?,
118            evidence,
119        )
120    }
121
122    pub fn to_line(&self) -> String {
123        let evidence = self
124            .evidence
125            .iter()
126            .map(EvidenceRef::as_str)
127            .collect::<Vec<_>>()
128            .join(", ");
129
130        format!(
131            "CLAIM: {} | verified: {} | evidence: {}",
132            self.what, self.verification, evidence
133        )
134    }
135
136    fn validate(&self) -> Result<(), ClaimError> {
137        if self.what.is_empty() {
138            return Err(ClaimError::EmptyWhat);
139        }
140
141        if self.verification.is_empty() {
142            return Err(ClaimError::MissingVerification);
143        }
144
145        if self.evidence.is_empty() {
146            return Err(ClaimError::MissingEvidence);
147        }
148
149        Ok(())
150    }
151}
152
153#[derive(Clone, Debug, Eq, PartialEq)]
154pub struct EvidenceRef(String);
155
156impl EvidenceRef {
157    pub fn parse(value: &str) -> Result<Self, ClaimError> {
158        Self::parse_with(value, DEFAULT_EVIDENCE_PATTERNS)
159    }
160
161    pub fn parse_with<S: AsRef<str>>(value: &str, patterns: &[S]) -> Result<Self, ClaimError> {
162        let value = normalize_field(value.to_owned());
163        if value.is_empty() {
164            return Err(ClaimError::MissingEvidence);
165        }
166
167        let normalized = value.to_ascii_lowercase();
168        if matches!(
169            normalized.as_str(),
170            "none" | "n/a" | "na" | "todo" | "tbd" | "later" | "missing"
171        ) || !looks_like_pointer(&value, patterns)
172        {
173            return Err(ClaimError::InvalidEvidence { value });
174        }
175
176        Ok(Self(value))
177    }
178
179    pub fn as_str(&self) -> &str {
180        &self.0
181    }
182}
183
184impl fmt::Display for EvidenceRef {
185    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
186        self.0.fmt(formatter)
187    }
188}
189
190#[derive(Clone, Debug, Eq, Error, PartialEq)]
191pub enum ClaimError {
192    #[error("missing CLAIM: line")]
193    MissingClaim,
194    #[error("CLAIM: what field is empty")]
195    EmptyWhat,
196    #[error("CLAIM: missing verified field")]
197    MissingVerification,
198    #[error("CLAIM: missing evidence pointer")]
199    MissingEvidence,
200    #[error("CLAIM: invalid evidence pointer {value:?}")]
201    InvalidEvidence { value: String },
202}
203
204#[derive(Clone, Debug, Eq, Error, PartialEq)]
205pub enum GateFailure {
206    #[error("missing CLAIM: line")]
207    MissingClaim,
208    #[error("completion wording lacks evidence pointer for word {word:?}")]
209    CompletionWithoutEvidence { word: String },
210    #[error("{0}")]
211    InvalidClaim(#[from] ClaimError),
212    #[error("fake marker {marker:?} found at diff line {line}")]
213    FakeMarker { marker: String, line: usize },
214}
215
216pub fn evaluate_commit_message(
217    commit_message: &str,
218    claim_file: Option<&str>,
219    diff: Option<&str>,
220    policy: &GatePolicy,
221) -> Result<Claim, GateFailure> {
222    let claim_source = if commit_message
223        .lines()
224        .any(|line| line.trim().starts_with("CLAIM:"))
225    {
226        commit_message
227    } else {
228        claim_file.ok_or(GateFailure::MissingClaim)?
229    };
230
231    let completion_word = completion_word(commit_message);
232    let claim =
233        Claim::parse_with(claim_source, &policy.evidence_patterns).map_err(|error| {
234            match (&error, completion_word) {
235                (ClaimError::MissingEvidence | ClaimError::InvalidEvidence { .. }, Some(word)) => {
236                    GateFailure::CompletionWithoutEvidence {
237                        word: word.to_owned(),
238                    }
239                }
240                _ => GateFailure::InvalidClaim(error),
241            }
242        })?;
243
244    if let Some(diff) = diff
245        && let Some(marker) =
246            first_fake_marker(diff, &policy.fake_markers, &policy.marker_ignore_paths)
247    {
248        return Err(marker);
249    }
250
251    Ok(claim)
252}
253
254/// Whether a new-side diff path is excluded from the fake-marker scan.
255fn path_is_ignored<S: AsRef<str>>(path: &str, ignore_paths: &[S]) -> bool {
256    ignore_paths.iter().any(|ignore| {
257        let ignore = ignore.as_ref();
258        !ignore.is_empty() && (path.starts_with(ignore) || path.ends_with(ignore))
259    })
260}
261
262pub fn first_fake_marker<S: AsRef<str>>(
263    diff: &str,
264    fake_markers: &[String],
265    ignore_paths: &[S],
266) -> Option<GateFailure> {
267    let markers = normalized_markers(fake_markers);
268    let mut ignored_file = false;
269    for (index, line) in diff.lines().enumerate() {
270        // Track the current file from its `+++ b/<path>` header so documentation
271        // and spec files (which mention markers to describe them) are skipped.
272        if let Some(rest) = line.strip_prefix("+++ ") {
273            let path = rest.strip_prefix("b/").unwrap_or(rest);
274            ignored_file = path_is_ignored(path, ignore_paths);
275            continue;
276        }
277
278        // Only lines the commit actually INTRODUCES count. Context and removed
279        // lines are not this commit's doing — flagging them would trip on any
280        // change made near an unrelated pre-existing marker (including a source
281        // file that legitimately *defines* the marker token).
282        let Some(added) = line.strip_prefix('+') else {
283            continue;
284        };
285        if added.starts_with("++") || ignored_file {
286            continue;
287        }
288
289        let line_lower = added.to_ascii_lowercase();
290        if let Some(marker) = markers
291            .iter()
292            .find(|marker| line_lower.contains(marker.normalized.as_str()))
293        {
294            return Some(GateFailure::FakeMarker {
295                marker: marker.original.clone(),
296                line: index + 1,
297            });
298        }
299    }
300
301    None
302}
303
304struct Marker {
305    original: String,
306    normalized: String,
307}
308
309fn normalized_markers(fake_markers: &[String]) -> Vec<Marker> {
310    let source: Vec<String> = if fake_markers.is_empty() {
311        DEFAULT_FAKE_MARKERS
312            .iter()
313            .map(|marker| (*marker).to_owned())
314            .collect()
315    } else {
316        fake_markers.to_vec()
317    };
318
319    source
320        .into_iter()
321        .filter(|marker| !marker.trim().is_empty())
322        .map(|marker| Marker {
323            normalized: marker.trim().to_ascii_lowercase(),
324            original: marker.trim().to_owned(),
325        })
326        .collect()
327}
328
329fn completion_word(input: &str) -> Option<&'static str> {
330    const WORDS: &[&str] = &[
331        "done",
332        "complete",
333        "completed",
334        "verified",
335        "fixed",
336        "passing",
337    ];
338
339    input
340        .split(|character: char| !character.is_ascii_alphanumeric())
341        .find_map(|word| {
342            let normalized = word.to_ascii_lowercase();
343            WORDS
344                .iter()
345                .copied()
346                .find(|candidate| *candidate == normalized)
347        })
348}
349
350fn field_value<'a>(segment: &'a str, names: &[&str]) -> Option<&'a str> {
351    let (name, value) = segment.split_once(':')?;
352    names
353        .iter()
354        .any(|candidate| name.trim().eq_ignore_ascii_case(candidate))
355        .then_some(value.trim())
356}
357
358fn looks_like_pointer<S: AsRef<str>>(value: &str, patterns: &[S]) -> bool {
359    let lower = value.to_ascii_lowercase();
360    lower.contains("://")
361        || patterns
362            .iter()
363            .any(|prefix| lower.starts_with(&prefix.as_ref().to_ascii_lowercase()))
364        || value.contains('/')
365        || value.contains('.')
366}
367
368fn normalize_field(value: String) -> String {
369    value.split_whitespace().collect::<Vec<_>>().join(" ")
370}
371
372#[cfg(test)]
373mod tests {
374    use proptest::prelude::*;
375
376    use super::{
377        Claim, ClaimError, EvidenceRef, GateFailure, GatePolicy, evaluate_commit_message,
378        first_fake_marker,
379    };
380
381    /// No ignore paths — every added line is scanned.
382    const NO_IGNORE: &[&str] = &[];
383
384    #[test]
385    fn parses_claim_line_with_evidence() {
386        let claim = Claim::parse(
387            "feat: thing\n\nCLAIM: add parser | verified: cargo test | evidence: tests:cargo-test",
388        )
389        .unwrap();
390
391        assert_eq!(claim.what, "add parser");
392        assert_eq!(claim.verification, "cargo test");
393        assert_eq!(claim.evidence[0].as_str(), "tests:cargo-test");
394    }
395
396    #[test]
397    fn rejects_missing_claim() {
398        let error = Claim::parse("feat: thing").unwrap_err();
399
400        assert_eq!(error, ClaimError::MissingClaim);
401    }
402
403    #[test]
404    fn rejects_missing_evidence() {
405        let error = Claim::parse("CLAIM: complete parser | verified: cargo test").unwrap_err();
406
407        assert_eq!(error, ClaimError::MissingEvidence);
408    }
409
410    #[test]
411    fn reports_completion_word_without_evidence() {
412        let error = evaluate_commit_message(
413            "feat: parser\n\nCLAIM: complete parser | verified: cargo test",
414            None,
415            None,
416            &GatePolicy::default(),
417        )
418        .unwrap_err();
419
420        assert_eq!(
421            error,
422            GateFailure::CompletionWithoutEvidence {
423                word: "complete".to_owned()
424            }
425        );
426    }
427
428    #[test]
429    fn accepts_claim_file_fallback() {
430        let claim = evaluate_commit_message(
431            "feat: parser",
432            Some("CLAIM: add parser | verified: cargo test | evidence: tests:cargo-test"),
433            None,
434            &GatePolicy::default(),
435        )
436        .unwrap();
437
438        assert_eq!(claim.what, "add parser");
439    }
440
441    #[test]
442    fn custom_evidence_pattern_is_accepted() {
443        let policy = GatePolicy {
444            evidence_patterns: vec!["jira:".to_owned()],
445            ..GatePolicy::default()
446        };
447        let claim = evaluate_commit_message(
448            "chore: thing\n\nCLAIM: do thing | verified: manual | evidence: jira:PROJ-42",
449            None,
450            None,
451            &policy,
452        )
453        .unwrap();
454
455        assert_eq!(claim.evidence[0].as_str(), "jira:PROJ-42");
456    }
457
458    #[test]
459    fn marker_in_ignored_doc_path_is_not_flagged() {
460        // Same added marker line, but under a doc path in the diff → skipped.
461        let marker = ["mock", "as", "real"].join("-");
462        let diff = format!("diff --git a/docs/x.md b/docs/x.md\n+++ b/docs/x.md\n+ {marker}");
463
464        let policy = GatePolicy::default();
465        assert!(
466            first_fake_marker(&diff, &policy.fake_markers, &policy.marker_ignore_paths).is_none()
467        );
468
469        // Under a code path, the same line IS flagged.
470        let code_diff = format!("diff --git a/src/x.rs b/src/x.rs\n+++ b/src/x.rs\n+ {marker}");
471        assert!(
472            first_fake_marker(
473                &code_diff,
474                &policy.fake_markers,
475                &policy.marker_ignore_paths
476            )
477            .is_some()
478        );
479    }
480
481    #[test]
482    fn finds_default_fake_marker_with_location() {
483        let done = ["TODO", "as", "done"].join("-");
484        let diff = format!("diff --git a/x b/x\n+ {done}");
485        let error = first_fake_marker(&diff, &[], NO_IGNORE).unwrap();
486
487        assert_eq!(
488            error,
489            GateFailure::FakeMarker {
490                marker: "TODO-as-done".to_owned(),
491                line: 2
492            }
493        );
494    }
495
496    #[test]
497    fn context_and_removed_lines_do_not_trip_fake_marker() {
498        // Built at runtime so the literal marker token never appears in this file
499        // (truth-mirror's own gate would otherwise flag this very line).
500        let marker = ["mock", "as", "real"].join("-");
501        // A context line (space prefix) and a removed line (-) that merely mention
502        // the marker must not be flagged; only added (+) content counts.
503        let diff = format!(
504            "diff --git a/x b/x\n const MARKERS = [\"{marker}\"];\n- old_line_with {marker}\n+ let honest = compute();"
505        );
506
507        assert!(first_fake_marker(&diff, &[], NO_IGNORE).is_none());
508    }
509
510    #[test]
511    fn added_line_with_marker_is_flagged() {
512        let marker = ["mock", "as", "real"].join("-");
513        let diff = format!("diff --git a/x b/x\n+ {marker} here");
514        let error = first_fake_marker(&diff, &[], NO_IGNORE).unwrap();
515
516        assert!(matches!(error, GateFailure::FakeMarker { .. }));
517    }
518
519    #[test]
520    fn configured_fake_marker_overrides_defaults() {
521        let markers = vec!["pretend-pass".to_owned()];
522        let error = first_fake_marker("+ pretend-pass", &markers, NO_IGNORE).unwrap();
523
524        assert_eq!(
525            error,
526            GateFailure::FakeMarker {
527                marker: "pretend-pass".to_owned(),
528                line: 1
529            }
530        );
531    }
532
533    proptest! {
534        #[test]
535        fn claim_roundtrip_preserves_semantic_fields(
536            what in "[A-Za-z0-9][A-Za-z0-9 _./:-]{0,48}",
537            verification in "[A-Za-z0-9][A-Za-z0-9 _./:-]{0,48}",
538            evidence_suffix in "[a-z0-9][a-z0-9_-]{0,24}",
539        ) {
540            let evidence = EvidenceRef::parse(&format!("tests:{evidence_suffix}")).unwrap();
541            let claim = Claim::new(what, verification, vec![evidence]).unwrap();
542
543            let parsed = Claim::parse(&claim.to_line()).unwrap();
544
545            prop_assert_eq!(parsed, claim);
546        }
547    }
548}