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 body = line
94            .trim()
95            .strip_prefix("CLAIM:")
96            .ok_or(ClaimError::MissingClaim)?;
97        let fields = claim_fields(body, patterns);
98        let claim_segment = fields.first().map_or(body, |field| &body[..field.start]);
99
100        let mut verification = None;
101        let mut evidence = Vec::new();
102        let mut evidence_error = None;
103
104        for field in fields {
105            match field.kind {
106                ClaimFieldKind::Verification => {
107                    let field_value = normalize_field(field.value.to_owned());
108                    if !field_value.is_empty() && verification.is_none() {
109                        verification = Some(field_value);
110                    }
111                }
112                ClaimFieldKind::Evidence => {
113                    for item in field
114                        .value
115                        .split(',')
116                        .map(str::trim)
117                        .filter(|item| !item.is_empty())
118                    {
119                        match EvidenceRef::parse_with(item, patterns) {
120                            Ok(parsed) => evidence.push(parsed),
121                            Err(error) => {
122                                if evidence_error.is_none() {
123                                    evidence_error = Some(error);
124                                }
125                            }
126                        }
127                    }
128                }
129            }
130        }
131
132        let what = normalize_field(trim_claim_text(claim_segment).to_owned());
133        if what.is_empty() {
134            return Err(ClaimError::EmptyWhat);
135        }
136        if let Some(error) = evidence_error {
137            return Err(error);
138        }
139        if evidence.is_empty() {
140            return Err(ClaimError::MissingEvidence);
141        }
142        let verification = verification.ok_or(ClaimError::MissingVerification)?;
143        if verification.is_empty() {
144            return Err(ClaimError::MissingVerification);
145        }
146
147        Ok(Self {
148            what,
149            verification,
150            evidence,
151        })
152    }
153
154    pub fn to_line(&self) -> String {
155        let evidence = self
156            .evidence
157            .iter()
158            .map(EvidenceRef::as_str)
159            .collect::<Vec<_>>()
160            .join(", ");
161
162        format!(
163            "CLAIM: {} | verified: {} | evidence: {}",
164            self.what, self.verification, evidence
165        )
166    }
167
168    fn validate(&self) -> Result<(), ClaimError> {
169        if self.what.is_empty() {
170            return Err(ClaimError::EmptyWhat);
171        }
172
173        if self.verification.is_empty() {
174            return Err(ClaimError::MissingVerification);
175        }
176
177        if self.evidence.is_empty() {
178            return Err(ClaimError::MissingEvidence);
179        }
180
181        Ok(())
182    }
183}
184
185#[derive(Clone, Debug, Eq, PartialEq)]
186pub struct EvidenceRef(String);
187
188impl EvidenceRef {
189    pub fn parse(value: &str) -> Result<Self, ClaimError> {
190        Self::parse_with(value, DEFAULT_EVIDENCE_PATTERNS)
191    }
192
193    pub fn parse_with<S: AsRef<str>>(value: &str, patterns: &[S]) -> Result<Self, ClaimError> {
194        let value = normalize_field(value.to_owned());
195        if value.is_empty() {
196            return Err(ClaimError::MissingEvidence);
197        }
198
199        let normalized = value.to_ascii_lowercase();
200        if matches!(
201            normalized.as_str(),
202            "none" | "n/a" | "na" | "todo" | "tbd" | "later" | "missing"
203        ) || !looks_like_pointer(&value, patterns)
204        {
205            return Err(ClaimError::InvalidEvidence { value });
206        }
207
208        Ok(Self(value))
209    }
210
211    pub fn as_str(&self) -> &str {
212        &self.0
213    }
214}
215
216impl fmt::Display for EvidenceRef {
217    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
218        self.0.fmt(formatter)
219    }
220}
221
222#[derive(Clone, Debug, Eq, Error, PartialEq)]
223pub enum ClaimError {
224    #[error("missing CLAIM: line")]
225    MissingClaim,
226    #[error("CLAIM: what field is empty")]
227    EmptyWhat,
228    #[error("CLAIM: missing verified field")]
229    MissingVerification,
230    #[error("CLAIM: missing evidence pointer")]
231    MissingEvidence,
232    #[error("CLAIM: invalid evidence pointer {value:?}")]
233    InvalidEvidence { value: String },
234}
235
236#[derive(Clone, Debug, Eq, Error, PartialEq)]
237pub enum GateFailure {
238    #[error("missing CLAIM: line")]
239    MissingClaim,
240    #[error(
241        "completion wording lacks evidence pointer for word {word:?}; example: CLAIM: change behavior | verified: cargo test | evidence: tests:cargo-test"
242    )]
243    CompletionWithoutEvidence { word: String },
244    #[error("{0}")]
245    InvalidClaim(#[from] ClaimError),
246    #[error("fake marker {marker:?} found at diff line {line}")]
247    FakeMarker { marker: String, line: usize },
248}
249
250pub fn evaluate_commit_message(
251    commit_message: &str,
252    claim_file: Option<&str>,
253    diff: Option<&str>,
254    policy: &GatePolicy,
255) -> Result<Claim, GateFailure> {
256    let has_claim_line = commit_message
257        .lines()
258        .any(|line| line.trim().starts_with("CLAIM:"));
259
260    let claim_source = if has_claim_line {
261        commit_message
262    } else {
263        // No CLAIM line in the message.  Before bailing with MissingClaim, check
264        // whether body prose contains a completion word — if so, surface a more
265        // actionable CompletionWithoutEvidence hint.
266        match claim_file {
267            Some(source) => source,
268            None => {
269                return Err(match completion_word_outside_claim(commit_message) {
270                    Some(word) => GateFailure::CompletionWithoutEvidence {
271                        word: word.to_owned(),
272                    },
273                    None => GateFailure::MissingClaim,
274                });
275            }
276        }
277    };
278
279    // When a CLAIM line is structurally present, surface the underlying
280    // ClaimError directly — naming the exact problem (missing evidence, invalid
281    // pointer value, etc.) so the author can fix it without guessing.
282    //
283    // CompletionWithoutEvidence is reserved for the no-claim-line path above,
284    // where body prose with a completion word signals a forgotten CLAIM block.
285    let claim = Claim::parse_with(claim_source, &policy.evidence_patterns)
286        .map_err(GateFailure::InvalidClaim)?;
287
288    if let Some(diff) = diff
289        && let Some(marker) =
290            first_fake_marker(diff, &policy.fake_markers, &policy.marker_ignore_paths)
291    {
292        return Err(marker);
293    }
294
295    Ok(claim)
296}
297
298/// Whether a new-side diff path is excluded from the fake-marker scan.
299fn path_is_ignored<S: AsRef<str>>(path: &str, ignore_paths: &[S]) -> bool {
300    ignore_paths.iter().any(|ignore| {
301        let ignore = ignore.as_ref();
302        !ignore.is_empty() && (path.starts_with(ignore) || path.ends_with(ignore))
303    })
304}
305
306pub fn first_fake_marker<S: AsRef<str>>(
307    diff: &str,
308    fake_markers: &[String],
309    ignore_paths: &[S],
310) -> Option<GateFailure> {
311    let markers = normalized_markers(fake_markers);
312    let mut ignored_file = false;
313    for (index, line) in diff.lines().enumerate() {
314        // Track the current file from its `+++ b/<path>` header so documentation
315        // and spec files (which mention markers to describe them) are skipped.
316        if let Some(rest) = line.strip_prefix("+++ ") {
317            let path = rest.strip_prefix("b/").unwrap_or(rest);
318            ignored_file = path_is_ignored(path, ignore_paths);
319            continue;
320        }
321
322        // Only lines the commit actually INTRODUCES count. Context and removed
323        // lines are not this commit's doing — flagging them would trip on any
324        // change made near an unrelated pre-existing marker (including a source
325        // file that legitimately *defines* the marker token).
326        let Some(added) = line.strip_prefix('+') else {
327            continue;
328        };
329        if added.starts_with("++") || ignored_file {
330            continue;
331        }
332
333        let line_lower = added.to_ascii_lowercase();
334        if let Some(marker) = markers
335            .iter()
336            .find(|marker| line_lower.contains(marker.normalized.as_str()))
337        {
338            return Some(GateFailure::FakeMarker {
339                marker: marker.original.clone(),
340                line: index + 1,
341            });
342        }
343    }
344
345    None
346}
347
348struct Marker {
349    original: String,
350    normalized: String,
351}
352
353fn normalized_markers(fake_markers: &[String]) -> Vec<Marker> {
354    let source: Vec<String> = if fake_markers.is_empty() {
355        DEFAULT_FAKE_MARKERS
356            .iter()
357            .map(|marker| (*marker).to_owned())
358            .collect()
359    } else {
360        fake_markers.to_vec()
361    };
362
363    source
364        .into_iter()
365        .filter(|marker| !marker.trim().is_empty())
366        .map(|marker| Marker {
367            normalized: marker.trim().to_ascii_lowercase(),
368            original: marker.trim().to_owned(),
369        })
370        .collect()
371}
372
373/// Scan only lines that are NOT the CLAIM line itself for completion words.
374///
375/// This prevents body prose ("the fixed seed order", "verified assumptions")
376/// from triggering a gate failure when a structurally valid CLAIM is present.
377/// This function is used only when no CLAIM line was found, so all lines are
378/// scanned as a fallback signal that the author forgot to include evidence.
379fn completion_word_outside_claim(input: &str) -> Option<&'static str> {
380    const WORDS: &[&str] = &[
381        "done",
382        "complete",
383        "completed",
384        "verified",
385        "fixed",
386        "passing",
387    ];
388
389    input
390        .lines()
391        .filter(|line| !line.trim().starts_with("CLAIM:"))
392        .flat_map(|line| line.split(|character: char| !character.is_ascii_alphanumeric()))
393        .find_map(|word| {
394            let normalized = word.to_ascii_lowercase();
395            WORDS
396                .iter()
397                .copied()
398                .find(|candidate| *candidate == normalized)
399        })
400}
401
402fn looks_like_pointer<S: AsRef<str>>(value: &str, patterns: &[S]) -> bool {
403    let lower = value.to_ascii_lowercase();
404    lower.contains("://")
405        || patterns
406            .iter()
407            .any(|prefix| lower.starts_with(&prefix.as_ref().to_ascii_lowercase()))
408        || value.contains('/')
409        || value.contains('.')
410}
411
412fn normalize_field(value: String) -> String {
413    value.split_whitespace().collect::<Vec<_>>().join(" ")
414}
415
416#[derive(Clone, Copy, Debug, Eq, PartialEq)]
417enum ClaimFieldKind {
418    Verification,
419    Evidence,
420}
421
422#[derive(Clone, Copy, Debug, Eq, PartialEq)]
423struct ClaimField<'a> {
424    kind: ClaimFieldKind,
425    start: usize,
426    value: &'a str,
427}
428
429#[derive(Clone, Copy, Debug, Eq, PartialEq)]
430struct FieldTag {
431    kind: ClaimFieldKind,
432    start: usize,
433    value_start: usize,
434}
435
436fn claim_fields<'a, S: AsRef<str>>(input: &'a str, patterns: &[S]) -> Vec<ClaimField<'a>> {
437    if input.contains('|') {
438        delimited_claim_fields(input, patterns)
439    } else {
440        loose_claim_fields(input, patterns)
441    }
442}
443
444fn delimited_claim_fields<'a, S: AsRef<str>>(
445    input: &'a str,
446    patterns: &[S],
447) -> Vec<ClaimField<'a>> {
448    let mut fields = Vec::new();
449    let mut segments = Vec::new();
450    let mut segment_start = 0;
451    let mut seen_delimiter = false;
452    // The text before the first `|` is the summary (`what`); only later
453    // pipe-delimited segments are parsed for named fields.
454    for (index, character) in input.char_indices() {
455        if character != '|' {
456            continue;
457        }
458        if seen_delimiter {
459            segments.push((segment_start, index));
460        }
461        seen_delimiter = true;
462        segment_start = index + character.len_utf8();
463    }
464    if seen_delimiter {
465        segments.push((segment_start, input.len()));
466    }
467    let segment_has_evidence = segments
468        .iter()
469        .copied()
470        .map(|(start, end)| segment_starts_with_field(input, start, end, ClaimFieldKind::Evidence))
471        .collect::<Vec<_>>();
472    let any_segment_has_evidence = segment_has_evidence
473        .iter()
474        .any(|has_evidence| *has_evidence);
475
476    for (segment_index, (start, end)) in segments.iter().copied().enumerate() {
477        let other_segment_has_evidence =
478            any_segment_has_evidence && !segment_has_evidence[segment_index];
479        fields.extend(segment_claim_fields(
480            input,
481            start,
482            end,
483            other_segment_has_evidence,
484            patterns,
485        ));
486    }
487    fields
488}
489
490fn segment_claim_fields<'a, S: AsRef<str>>(
491    input: &'a str,
492    segment_start: usize,
493    segment_end: usize,
494    other_segment_has_evidence: bool,
495    patterns: &[S],
496) -> Vec<ClaimField<'a>> {
497    let Some(segment) = input.get(segment_start..segment_end) else {
498        return Vec::new();
499    };
500    if other_segment_has_evidence
501        && let Some(tag) = segment_starting_field_tag(segment, ClaimFieldKind::Verification)
502    {
503        return vec![ClaimField {
504            kind: tag.kind,
505            start: segment_start + tag.start,
506            value: &segment[tag.value_start..],
507        }];
508    }
509    loose_claim_fields(segment, patterns)
510        .into_iter()
511        .map(|field| ClaimField {
512            kind: field.kind,
513            start: segment_start + field.start,
514            value: field.value,
515        })
516        .collect()
517}
518
519fn segment_starts_with_field(
520    input: &str,
521    segment_start: usize,
522    segment_end: usize,
523    kind: ClaimFieldKind,
524) -> bool {
525    input
526        .get(segment_start..segment_end)
527        .and_then(|segment| segment_starting_field_tag(segment, kind))
528        .is_some()
529}
530
531fn segment_starting_field_tag(segment: &str, kind: ClaimFieldKind) -> Option<FieldTag> {
532    let trimmed_start = segment.len() - segment.trim_start().len();
533    field_tags(segment)
534        .into_iter()
535        .find(|tag| tag.kind == kind && tag.start == trimmed_start)
536}
537
538fn loose_claim_fields<'a, S: AsRef<str>>(input: &'a str, patterns: &[S]) -> Vec<ClaimField<'a>> {
539    let tags = field_tags(input);
540    // Loose CLAIMs may mention field-like words in the summary/prose. Prefer the
541    // latest verification/evidence pair whose evidence looks pointer-like, then
542    // anchor on the first tag's kind, then fall back to adjacent mixed tags.
543    let mut fields = complete_field_pair(input, &tags, patterns).unwrap_or_else(|| {
544        [
545            tags.iter()
546                .copied()
547                .rfind(|tag| tag.kind == ClaimFieldKind::Verification),
548            tags.iter()
549                .copied()
550                .rfind(|tag| tag.kind == ClaimFieldKind::Evidence),
551        ]
552        .into_iter()
553        .flatten()
554        .collect::<Vec<_>>()
555    });
556    fields.sort_by_key(|tag| tag.start);
557
558    fields
559        .iter()
560        .enumerate()
561        .map(|(index, tag)| {
562            let end = fields.get(index + 1).map_or(input.len(), |next| next.start);
563            ClaimField {
564                kind: tag.kind,
565                start: tag.start,
566                value: trim_field_value(&input[tag.value_start..end]),
567            }
568        })
569        .collect()
570}
571
572fn complete_field_pair<S: AsRef<str>>(
573    input: &str,
574    tags: &[FieldTag],
575    patterns: &[S],
576) -> Option<Vec<FieldTag>> {
577    if let Some(pair) = later_complete_verification_evidence_pair(input, tags, patterns) {
578        return Some(pair);
579    }
580
581    if let Some(first) = tags.first().copied() {
582        match first.kind {
583            ClaimFieldKind::Verification => {
584                if let Some(evidence) = tags
585                    .iter()
586                    .copied()
587                    .rfind(|tag| tag.kind == ClaimFieldKind::Evidence)
588                {
589                    return Some(vec![first, evidence]);
590                }
591            }
592            ClaimFieldKind::Evidence => {
593                if let Some(verification) = tags
594                    .iter()
595                    .copied()
596                    .find(|tag| tag.kind == ClaimFieldKind::Verification)
597                {
598                    if !evidence_tag_value_looks_like_pointer(
599                        input,
600                        first,
601                        verification.start,
602                        patterns,
603                    ) && let Some(evidence) = tags.iter().copied().rfind(|tag| {
604                        tag.kind == ClaimFieldKind::Evidence && tag.start > verification.start
605                    }) {
606                        return Some(vec![verification, evidence]);
607                    }
608                    return Some(vec![first, verification]);
609                }
610            }
611        }
612    }
613
614    tags.windows(2).find_map(|window| {
615        let first = window[0];
616        let second = window[1];
617        (first.kind != second.kind).then(|| vec![first, second])
618    })
619}
620
621fn later_complete_verification_evidence_pair<S: AsRef<str>>(
622    input: &str,
623    tags: &[FieldTag],
624    patterns: &[S],
625) -> Option<Vec<FieldTag>> {
626    tags.windows(2)
627        .enumerate()
628        .rev()
629        .find_map(|(index, window)| {
630            let verification = window[0];
631            let evidence = window[1];
632            let evidence_end = tags.get(index + 2).map_or(input.len(), |tag| tag.start);
633            (verification.kind == ClaimFieldKind::Verification
634                && evidence.kind == ClaimFieldKind::Evidence
635                && evidence_tag_value_looks_like_pointer(input, evidence, evidence_end, patterns))
636            .then(|| vec![verification, evidence])
637        })
638}
639
640fn evidence_tag_value_looks_like_pointer<S: AsRef<str>>(
641    input: &str,
642    tag: FieldTag,
643    end: usize,
644    patterns: &[S],
645) -> bool {
646    input
647        .get(tag.value_start..end)
648        .map(trim_field_value)
649        .is_some_and(|value| looks_like_pointer(value, patterns))
650}
651
652fn field_tags(input: &str) -> Vec<FieldTag> {
653    const FIELD_NAMES_WITH_HOW: &[(&str, ClaimFieldKind)] = &[
654        ("evidence-pointer", ClaimFieldKind::Evidence),
655        ("verification", ClaimFieldKind::Verification),
656        ("verified", ClaimFieldKind::Verification),
657        ("evidence", ClaimFieldKind::Evidence),
658        ("how", ClaimFieldKind::Verification),
659    ];
660
661    collect_field_tags(input, FIELD_NAMES_WITH_HOW)
662}
663
664fn collect_field_tags(input: &str, field_names: &[(&str, ClaimFieldKind)]) -> Vec<FieldTag> {
665    let mut tags = Vec::new();
666    for (index, _) in input.char_indices() {
667        if !field_boundary_before(input, index) {
668            continue;
669        }
670        if let Some(tag) = field_tag_at(input, index, field_names) {
671            tags.push(tag);
672        }
673    }
674    tags
675}
676
677fn field_tag_at(
678    input: &str,
679    index: usize,
680    field_names: &[(&str, ClaimFieldKind)],
681) -> Option<FieldTag> {
682    for (name, kind) in field_names {
683        let after_name = index + name.len();
684        if input
685            .get(index..after_name)
686            .is_some_and(|candidate| candidate.eq_ignore_ascii_case(name))
687            && input
688                .get(after_name..)
689                .is_some_and(|remaining| remaining.starts_with(':'))
690        {
691            return Some(FieldTag {
692                kind: *kind,
693                start: index,
694                value_start: after_name + 1,
695            });
696        }
697    }
698    None
699}
700
701fn field_boundary_before(input: &str, index: usize) -> bool {
702    index == 0
703        || input[..index]
704            .chars()
705            .next_back()
706            .is_some_and(|character| character.is_whitespace() || matches!(character, '|' | ';'))
707}
708
709fn trim_field_value(value: &str) -> &str {
710    value
711        .trim_matches(|character: char| character.is_whitespace() || matches!(character, '|' | ';'))
712}
713
714fn trim_claim_text(value: &str) -> &str {
715    value.trim_matches(|character: char| {
716        character.is_whitespace() || matches!(character, '|' | ';' | ',')
717    })
718}
719
720#[cfg(test)]
721mod tests {
722    use proptest::prelude::*;
723
724    use super::{
725        Claim, ClaimError, EvidenceRef, GateFailure, GatePolicy, evaluate_commit_message,
726        first_fake_marker,
727    };
728
729    /// No ignore paths — every added line is scanned.
730    const NO_IGNORE: &[&str] = &[];
731
732    #[test]
733    fn parses_claim_line_with_evidence() {
734        let claim = Claim::parse(
735            "feat: thing\n\nCLAIM: add parser | verified: cargo test | evidence: tests:cargo-test",
736        )
737        .unwrap();
738
739        assert_eq!(claim.what, "add parser");
740        assert_eq!(claim.verification, "cargo test");
741        assert_eq!(claim.evidence[0].as_str(), "tests:cargo-test");
742    }
743
744    #[test]
745    fn parses_named_claim_fields_without_pipe_delimiters() {
746        let claim = Claim::parse(
747            "feat: thing\n\nCLAIM: add parser verified: cargo test evidence: tests:cargo-test",
748        )
749        .unwrap();
750
751        assert_eq!(claim.what, "add parser");
752        assert_eq!(claim.verification, "cargo test");
753        assert_eq!(claim.evidence[0].as_str(), "tests:cargo-test");
754    }
755
756    #[test]
757    fn parses_how_as_verification_alias() {
758        let claim = Claim::parse(
759            "feat: thing\n\nCLAIM: add parser | how: cargo test | evidence: tests:claim",
760        )
761        .unwrap();
762
763        assert_eq!(claim.what, "add parser");
764        assert_eq!(claim.verification, "cargo test");
765        assert_eq!(claim.evidence[0].as_str(), "tests:claim");
766    }
767
768    #[test]
769    fn loose_claim_parses_how_as_verification_alias() {
770        let claim =
771            Claim::parse("feat: thing\n\nCLAIM: add parser how: cargo test evidence: tests:claim")
772                .unwrap();
773
774        assert_eq!(claim.what, "add parser");
775        assert_eq!(claim.verification, "cargo test");
776        assert_eq!(claim.evidence[0].as_str(), "tests:claim");
777    }
778
779    #[test]
780    fn loose_claim_accepts_evidence_before_verification() {
781        let claim = Claim::parse(
782            "feat: thing\n\nCLAIM: add parser evidence: tests:claim verified: cargo test",
783        )
784        .unwrap();
785
786        assert_eq!(claim.what, "add parser");
787        assert_eq!(claim.verification, "cargo test");
788        assert_eq!(claim.evidence[0].as_str(), "tests:claim");
789    }
790
791    #[test]
792    fn comma_separated_evidence_ignores_blank_items() {
793        let claim = Claim::parse(
794            "feat: thing\n\nCLAIM: add parser | verified: cargo test | evidence: tests:unit, , tests:integration,",
795        )
796        .unwrap();
797
798        assert_eq!(claim.evidence.len(), 2);
799        assert_eq!(claim.evidence[0].as_str(), "tests:unit");
800        assert_eq!(claim.evidence[1].as_str(), "tests:integration");
801    }
802
803    #[test]
804    fn loose_claim_preserves_field_like_summary_text() {
805        let claim = Claim::parse(
806            "feat: thing\n\nCLAIM: add evidence: parser verified: cargo test evidence: tests:claim",
807        )
808        .unwrap();
809
810        assert_eq!(claim.what, "add evidence: parser");
811        assert_eq!(claim.verification, "cargo test");
812        assert_eq!(claim.evidence[0].as_str(), "tests:claim");
813    }
814
815    #[test]
816    fn loose_claim_preserves_verified_label_in_summary_text() {
817        let claim = Claim::parse(
818            "feat: thing\n\nCLAIM: add verified: docs verified: cargo test evidence: tests:claim",
819        )
820        .unwrap();
821
822        assert_eq!(claim.what, "add verified: docs");
823        assert_eq!(claim.verification, "cargo test");
824        assert_eq!(claim.evidence[0].as_str(), "tests:claim");
825    }
826
827    #[test]
828    fn loose_claim_uses_custom_evidence_patterns_for_summary_disambiguation() {
829        let claim = Claim::parse_with(
830            "feat: thing\n\nCLAIM: add verified: docs verified: cargo test evidence: jira:PROJ-42",
831            &["jira:"],
832        )
833        .unwrap();
834
835        assert_eq!(claim.what, "add verified: docs");
836        assert_eq!(claim.verification, "cargo test");
837        assert_eq!(claim.evidence[0].as_str(), "jira:PROJ-42");
838    }
839
840    #[test]
841    fn loose_claim_parses_how_after_field_like_summary_text() {
842        let claim = Claim::parse(
843            "feat: thing\n\nCLAIM: add verified: docs how: cargo test evidence: tests:x",
844        )
845        .unwrap();
846
847        assert_eq!(claim.what, "add verified: docs");
848        assert_eq!(claim.verification, "cargo test");
849        assert_eq!(claim.evidence[0].as_str(), "tests:x");
850    }
851
852    #[test]
853    fn pipe_delimited_claim_accepts_mixed_named_fields_in_segment() {
854        let claim = Claim::parse(
855            "feat: thing\n\nCLAIM: add parser | verified: cargo test evidence: tests:claim",
856        )
857        .unwrap();
858
859        assert_eq!(claim.what, "add parser");
860        assert_eq!(claim.verification, "cargo test");
861        assert_eq!(claim.evidence[0].as_str(), "tests:claim");
862    }
863
864    #[test]
865    fn loose_claim_keeps_evidence_when_verification_mentions_evidence() {
866        let claim = Claim::parse(
867            "feat: thing\n\nCLAIM: add parser evidence: tests:claim verified: reviewed evidence: output",
868        )
869        .unwrap();
870
871        assert_eq!(claim.what, "add parser");
872        assert_eq!(claim.verification, "reviewed evidence: output");
873        assert_eq!(claim.evidence[0].as_str(), "tests:claim");
874    }
875
876    #[test]
877    fn pipe_delimited_claim_keeps_later_evidence_when_verification_mentions_evidence() {
878        let claim = Claim::parse(
879            "feat: thing\n\nCLAIM: add parser | verified: reviewed evidence: output | evidence: tests:claim",
880        )
881        .unwrap();
882
883        assert_eq!(claim.what, "add parser");
884        assert_eq!(claim.verification, "reviewed evidence: output");
885        assert_eq!(claim.evidence[0].as_str(), "tests:claim");
886    }
887
888    #[test]
889    fn loose_claim_keeps_later_evidence_when_verification_mentions_evidence() {
890        let claim = Claim::parse(
891            "feat: thing\n\nCLAIM: add parser verified: reviewed evidence: output evidence: tests:claim",
892        )
893        .unwrap();
894
895        assert_eq!(claim.what, "add parser");
896        assert_eq!(claim.verification, "reviewed evidence: output");
897        assert_eq!(claim.evidence[0].as_str(), "tests:claim");
898    }
899
900    #[test]
901    fn pipe_delimited_claim_keeps_earlier_evidence_when_verification_mentions_evidence() {
902        let claim = Claim::parse(
903            "feat: thing\n\nCLAIM: add parser | evidence: tests:claim | verified: reviewed evidence: output",
904        )
905        .unwrap();
906
907        assert_eq!(claim.what, "add parser");
908        assert_eq!(claim.verification, "reviewed evidence: output");
909        assert_eq!(claim.evidence[0].as_str(), "tests:claim");
910    }
911
912    #[test]
913    fn duplicate_verification_aliases_ignore_empty_duplicate_in_any_order() {
914        let messages = [
915            "feat: thing\n\nCLAIM: fix parser | verified: cargo test | verification: | evidence: tests:x",
916            "feat: thing\n\nCLAIM: fix parser | verification: | verified: cargo test | evidence: tests:x",
917        ];
918
919        for message in messages {
920            let claim = Claim::parse(message).unwrap();
921            assert_eq!(claim.verification, "cargo test");
922            assert_eq!(claim.evidence[0].as_str(), "tests:x");
923        }
924    }
925
926    #[test]
927    fn duplicate_empty_verification_aliases_still_reject_as_missing() {
928        let error = Claim::parse(
929            "feat: thing\n\nCLAIM: fix parser | verification: | verified: | evidence: tests:x",
930        )
931        .unwrap_err();
932
933        assert_eq!(error, ClaimError::MissingVerification);
934    }
935
936    #[test]
937    fn pipe_delimited_claim_keeps_field_like_words_in_summary() {
938        let claim = Claim::parse(
939            "feat: thing\n\nCLAIM: add evidence: parser | verified: cargo test | evidence: tests:cargo-test",
940        )
941        .unwrap();
942
943        assert_eq!(claim.what, "add evidence: parser");
944        assert_eq!(claim.verification, "cargo test");
945        assert_eq!(claim.evidence[0].as_str(), "tests:cargo-test");
946    }
947
948    #[test]
949    fn pipe_delimited_claim_treats_first_segment_as_summary() {
950        let claim = Claim::parse(
951            "feat: thing\n\nCLAIM: evidence: parser | verified: cargo test | evidence: tests:cargo-test",
952        )
953        .unwrap();
954
955        assert_eq!(claim.what, "evidence: parser");
956        assert_eq!(claim.verification, "cargo test");
957        assert_eq!(claim.evidence[0].as_str(), "tests:cargo-test");
958    }
959
960    #[test]
961    fn pipe_delimited_claim_preserves_semicolon_in_evidence_value() {
962        let claim = Claim::parse(
963            "feat: thing\n\nCLAIM: add parser | verified: cargo test | evidence: tests:unit; tests:integration",
964        )
965        .unwrap();
966
967        assert_eq!(claim.what, "add parser");
968        assert_eq!(claim.evidence[0].as_str(), "tests:unit; tests:integration");
969    }
970
971    #[test]
972    fn pipe_delimited_claim_keeps_field_like_words_in_verification() {
973        let claim = Claim::parse(
974            "feat: thing\n\nCLAIM: add parser | verified: describe how: it works | evidence: tests:cargo-test",
975        )
976        .unwrap();
977
978        assert_eq!(claim.what, "add parser");
979        assert_eq!(claim.verification, "describe how: it works");
980        assert_eq!(claim.evidence[0].as_str(), "tests:cargo-test");
981    }
982
983    #[test]
984    fn rejects_missing_claim() {
985        let error = Claim::parse("feat: thing").unwrap_err();
986
987        assert_eq!(error, ClaimError::MissingClaim);
988    }
989
990    #[test]
991    fn rejects_missing_evidence() {
992        let error = Claim::parse("CLAIM: complete parser | verified: cargo test").unwrap_err();
993
994        assert_eq!(error, ClaimError::MissingEvidence);
995    }
996
997    #[test]
998    fn reports_completion_word_without_evidence_when_no_claim_line() {
999        // No CLAIM line at all + body prose with completion word → CompletionWithoutEvidence.
1000        let error = evaluate_commit_message(
1001            "feat: complete the parser",
1002            None,
1003            None,
1004            &GatePolicy::default(),
1005        )
1006        .unwrap_err();
1007
1008        assert_eq!(
1009            error,
1010            GateFailure::CompletionWithoutEvidence {
1011                word: "complete".to_owned()
1012            }
1013        );
1014    }
1015
1016    #[test]
1017    fn claim_line_with_missing_evidence_reports_invalid_claim_not_completion_word() {
1018        // A CLAIM line IS present but evidence is missing → surface the underlying
1019        // ClaimError so the author knows *what* failed, not a misleading completion-word hint.
1020        let error = evaluate_commit_message(
1021            "feat: parser\n\nCLAIM: complete parser | verified: cargo test",
1022            None,
1023            None,
1024            &GatePolicy::default(),
1025        )
1026        .unwrap_err();
1027
1028        assert_eq!(
1029            error,
1030            GateFailure::InvalidClaim(ClaimError::MissingEvidence)
1031        );
1032    }
1033
1034    #[test]
1035    fn claim_line_with_non_pointer_evidence_names_offending_value() {
1036        // Finding 2 regression: InvalidEvidence carries the bad value; the error must
1037        // name it verbatim so the author can fix it without guessing.
1038        let error = evaluate_commit_message(
1039            "feat: parser\n\nCLAIM: add things | verified: manual | evidence: not-a-pointer",
1040            None,
1041            None,
1042            &GatePolicy::default(),
1043        )
1044        .unwrap_err();
1045
1046        assert_eq!(
1047            error,
1048            GateFailure::InvalidClaim(ClaimError::InvalidEvidence {
1049                value: "not-a-pointer".to_owned(),
1050            })
1051        );
1052    }
1053
1054    #[test]
1055    fn equivalent_completion_claim_shapes_get_stable_evidence_failure() {
1056        // These all have a CLAIM line present but evidence missing/invalid.
1057        // After Finding 2 fix they report InvalidClaim, not CompletionWithoutEvidence.
1058        let messages = [
1059            "feat: parser\n\nCLAIM: complete parser | verified: cargo test",
1060            "feat: parser\n\nCLAIM: complete parser verified: cargo test",
1061        ];
1062
1063        for message in messages {
1064            let error =
1065                evaluate_commit_message(message, None, None, &GatePolicy::default()).unwrap_err();
1066            assert_eq!(
1067                error,
1068                GateFailure::InvalidClaim(ClaimError::MissingEvidence),
1069                "unexpected error for message: {message:?}"
1070            );
1071        }
1072    }
1073
1074    #[test]
1075    fn equivalent_invalid_evidence_shapes_get_stable_evidence_failure() {
1076        let messages = [
1077            "feat: parser\n\nCLAIM: complete parser | evidence: missing | verified: cargo test",
1078            "feat: parser\n\nCLAIM: complete parser verified: cargo test evidence: missing",
1079            "feat: parser\n\nCLAIM: complete parser | evidence: missing",
1080        ];
1081
1082        for message in messages {
1083            let error =
1084                evaluate_commit_message(message, None, None, &GatePolicy::default()).unwrap_err();
1085            assert_eq!(
1086                error,
1087                GateFailure::InvalidClaim(ClaimError::InvalidEvidence {
1088                    value: "missing".to_owned(),
1089                })
1090            );
1091        }
1092    }
1093
1094    #[test]
1095    fn subject_completion_word_does_not_mask_claim_evidence_error() {
1096        // Finding 2: even when the subject contains a completion word ("fixed"),
1097        // a structurally-present CLAIM line means the underlying ClaimError is
1098        // surfaced directly.  CompletionWithoutEvidence is only for the no-CLAIM case.
1099        let error = evaluate_commit_message(
1100            "fix: fixed seed order\n\nCLAIM: add parser | verified: cargo test",
1101            None,
1102            None,
1103            &GatePolicy::default(),
1104        )
1105        .unwrap_err();
1106
1107        assert_eq!(
1108            error,
1109            GateFailure::InvalidClaim(ClaimError::MissingEvidence)
1110        );
1111    }
1112
1113    #[test]
1114    fn later_claim_completion_word_does_not_mask_first_claim_evidence_error() {
1115        let error = evaluate_commit_message(
1116            "feat: parser\n\nCLAIM: add parser | verified: cargo test\nCLAIM: complete parser | verified: cargo test | evidence: tests:later",
1117            None,
1118            None,
1119            &GatePolicy::default(),
1120        )
1121        .unwrap_err();
1122
1123        assert_eq!(
1124            error,
1125            GateFailure::InvalidClaim(ClaimError::MissingEvidence)
1126        );
1127    }
1128
1129    #[test]
1130    fn body_after_first_claim_does_not_mask_first_claim_evidence_error() {
1131        let error = evaluate_commit_message(
1132            "feat: parser\n\nCLAIM: add parser | verified: cargo test\n\nverified later in prose",
1133            None,
1134            None,
1135            &GatePolicy::default(),
1136        )
1137        .unwrap_err();
1138
1139        assert_eq!(
1140            error,
1141            GateFailure::InvalidClaim(ClaimError::MissingEvidence)
1142        );
1143    }
1144
1145    #[test]
1146    fn valid_claim_with_completion_words_in_body_prose_passes() {
1147        let claim = evaluate_commit_message(
1148            "fix: parser\n\nThis body says the parser is fixed and verified.\n\nCLAIM: add parser | verified: cargo test | evidence: tests:x",
1149            None,
1150            None,
1151            &GatePolicy::default(),
1152        )
1153        .unwrap();
1154
1155        assert_eq!(claim.what, "add parser");
1156        assert_eq!(claim.verification, "cargo test");
1157        assert_eq!(claim.evidence[0].as_str(), "tests:x");
1158    }
1159
1160    #[test]
1161    fn invalid_evidence_is_not_masked_by_verified_field_label() {
1162        let error = evaluate_commit_message(
1163            "fix: seed order\n\nCLAIM: add parser | verified: cargo test | evidence: Depot job 393s6c1ff6 failure log",
1164            None,
1165            None,
1166            &GatePolicy::default(),
1167        )
1168        .unwrap_err();
1169
1170        assert_eq!(
1171            error,
1172            GateFailure::InvalidClaim(ClaimError::InvalidEvidence {
1173                value: "Depot job 393s6c1ff6 failure log".to_owned(),
1174            })
1175        );
1176    }
1177
1178    #[test]
1179    fn accepts_claim_file_fallback() {
1180        let claim = evaluate_commit_message(
1181            "feat: parser",
1182            Some("CLAIM: add parser | verified: cargo test | evidence: tests:cargo-test"),
1183            None,
1184            &GatePolicy::default(),
1185        )
1186        .unwrap();
1187
1188        assert_eq!(claim.what, "add parser");
1189    }
1190
1191    #[test]
1192    fn custom_evidence_pattern_is_accepted() {
1193        let policy = GatePolicy {
1194            evidence_patterns: vec!["jira:".to_owned()],
1195            ..GatePolicy::default()
1196        };
1197        let claim = evaluate_commit_message(
1198            "chore: thing\n\nCLAIM: do thing | verified: manual | evidence: jira:PROJ-42",
1199            None,
1200            None,
1201            &policy,
1202        )
1203        .unwrap();
1204
1205        assert_eq!(claim.evidence[0].as_str(), "jira:PROJ-42");
1206    }
1207
1208    #[test]
1209    fn body_prose_with_completion_word_does_not_reject_valid_claim() {
1210        // Finding 3 regression: body text "the fixed seed order" must not trigger a
1211        // gate failure when a fully valid CLAIM line with pointer evidence is present.
1212        let claim = evaluate_commit_message(
1213            "fix: seed order\n\nThis commit fixed the seed order so tests run deterministically.\n\nCLAIM: fix seed order | verified: cargo test | evidence: tests:cargo-test",
1214            None,
1215            None,
1216            &GatePolicy::default(),
1217        )
1218        .unwrap();
1219
1220        assert_eq!(claim.what, "fix seed order");
1221    }
1222
1223    #[test]
1224    fn marker_in_ignored_doc_path_is_not_flagged() {
1225        // Same added marker line, but under a doc path in the diff → skipped.
1226        let marker = ["mock", "as", "real"].join("-");
1227        let diff = format!("diff --git a/docs/x.md b/docs/x.md\n+++ b/docs/x.md\n+ {marker}");
1228
1229        let policy = GatePolicy::default();
1230        assert!(
1231            first_fake_marker(&diff, &policy.fake_markers, &policy.marker_ignore_paths).is_none()
1232        );
1233
1234        // Under a code path, the same line IS flagged.
1235        let code_diff = format!("diff --git a/src/x.rs b/src/x.rs\n+++ b/src/x.rs\n+ {marker}");
1236        assert!(
1237            first_fake_marker(
1238                &code_diff,
1239                &policy.fake_markers,
1240                &policy.marker_ignore_paths
1241            )
1242            .is_some()
1243        );
1244    }
1245
1246    #[test]
1247    fn finds_default_fake_marker_with_location() {
1248        let done = ["TODO", "as", "done"].join("-");
1249        let diff = format!("diff --git a/x b/x\n+ {done}");
1250        let error = first_fake_marker(&diff, &[], NO_IGNORE).unwrap();
1251
1252        assert_eq!(
1253            error,
1254            GateFailure::FakeMarker {
1255                marker: "TODO-as-done".to_owned(),
1256                line: 2
1257            }
1258        );
1259    }
1260
1261    #[test]
1262    fn context_and_removed_lines_do_not_trip_fake_marker() {
1263        // Built at runtime so the literal marker token never appears in this file
1264        // (truth-mirror's own gate would otherwise flag this very line).
1265        let marker = ["mock", "as", "real"].join("-");
1266        // A context line (space prefix) and a removed line (-) that merely mention
1267        // the marker must not be flagged; only added (+) content counts.
1268        let diff = format!(
1269            "diff --git a/x b/x\n const MARKERS = [\"{marker}\"];\n- old_line_with {marker}\n+ let honest = compute();"
1270        );
1271
1272        assert!(first_fake_marker(&diff, &[], NO_IGNORE).is_none());
1273    }
1274
1275    #[test]
1276    fn added_line_with_marker_is_flagged() {
1277        let marker = ["mock", "as", "real"].join("-");
1278        let diff = format!("diff --git a/x b/x\n+ {marker} here");
1279        let error = first_fake_marker(&diff, &[], NO_IGNORE).unwrap();
1280
1281        assert!(matches!(error, GateFailure::FakeMarker { .. }));
1282    }
1283
1284    #[test]
1285    fn configured_fake_marker_overrides_defaults() {
1286        let markers = vec!["pretend-pass".to_owned()];
1287        let error = first_fake_marker("+ pretend-pass", &markers, NO_IGNORE).unwrap();
1288
1289        assert_eq!(
1290            error,
1291            GateFailure::FakeMarker {
1292                marker: "pretend-pass".to_owned(),
1293                line: 1
1294            }
1295        );
1296    }
1297
1298    proptest! {
1299        #[test]
1300        fn claim_roundtrip_preserves_semantic_fields(
1301            what in "[A-Za-z0-9][A-Za-z0-9 _./:-]{0,48}",
1302            verification in "[A-Za-z0-9][A-Za-z0-9 _./:-]{0,48}",
1303            evidence_suffix in "[a-z0-9][a-z0-9_-]{0,24}",
1304        ) {
1305            let evidence = EvidenceRef::parse(&format!("tests:{evidence_suffix}")).unwrap();
1306            let claim = Claim::new(what, verification, vec![evidence]).unwrap();
1307
1308            let parsed = Claim::parse(&claim.to_line()).unwrap();
1309
1310            prop_assert_eq!(parsed, claim);
1311        }
1312    }
1313}