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