1use std::fmt;
4
5use thiserror::Error;
6
7pub const DEFAULT_FAKE_MARKERS: &[&str] = &["mock-as-real", "TODO-as-done"];
8
9pub 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
24pub const DEFAULT_MARKER_IGNORE_PATHS: &[&str] = &[".md", "openspec/", "docs/"];
27
28#[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 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 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 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
298fn 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 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 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
373fn 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 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 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 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 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 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 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 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 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 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 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 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 let marker = ["mock", "as", "real"].join("-");
1266 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}