use std::fmt;
use thiserror::Error;
pub const DEFAULT_FAKE_MARKERS: &[&str] = &["mock-as-real", "TODO-as-done"];
pub const DEFAULT_EVIDENCE_PATTERNS: &[&str] = &[
"file:",
"path:",
"log:",
"test:",
"tests:",
"screenshot:",
"artifact:",
"ci:",
"bead:",
"openspec:",
"commit:",
];
pub const DEFAULT_MARKER_IGNORE_PATHS: &[&str] = &[".md", "openspec/", "docs/"];
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GatePolicy {
pub fake_markers: Vec<String>,
pub evidence_patterns: Vec<String>,
pub marker_ignore_paths: Vec<String>,
}
impl Default for GatePolicy {
fn default() -> Self {
Self {
fake_markers: owned(DEFAULT_FAKE_MARKERS),
evidence_patterns: owned(DEFAULT_EVIDENCE_PATTERNS),
marker_ignore_paths: owned(DEFAULT_MARKER_IGNORE_PATHS),
}
}
}
fn owned(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_owned()).collect()
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Claim {
pub what: String,
pub verification: String,
pub evidence: Vec<EvidenceRef>,
}
impl Claim {
pub fn new(
what: impl Into<String>,
verification: impl Into<String>,
evidence: Vec<EvidenceRef>,
) -> Result<Self, ClaimError> {
let claim = Self {
what: normalize_field(what.into()),
verification: normalize_field(verification.into()),
evidence,
};
claim.validate()?;
Ok(claim)
}
pub fn parse(input: &str) -> Result<Self, ClaimError> {
Self::parse_with(input, DEFAULT_EVIDENCE_PATTERNS)
}
pub fn parse_with<S: AsRef<str>>(input: &str, patterns: &[S]) -> Result<Self, ClaimError> {
let paragraph = claim_paragraph(input).ok_or(ClaimError::MissingClaim)?;
Self::parse_line_with(¶graph, patterns)
}
pub fn parse_line(line: &str) -> Result<Self, ClaimError> {
Self::parse_line_with(line, DEFAULT_EVIDENCE_PATTERNS)
}
pub fn parse_line_with<S: AsRef<str>>(line: &str, patterns: &[S]) -> Result<Self, ClaimError> {
let body = line
.trim()
.strip_prefix("CLAIM:")
.ok_or(ClaimError::MissingClaim)?;
let fields = claim_fields(body, patterns);
let claim_segment = fields.first().map_or(body, |field| &body[..field.start]);
let mut verification = None;
let mut evidence = Vec::new();
let mut evidence_error = None;
for field in fields {
match field.kind {
ClaimFieldKind::Verification => {
let field_value = normalize_field(field.value.to_owned());
if !field_value.is_empty() && verification.is_none() {
verification = Some(field_value);
}
}
ClaimFieldKind::Evidence => {
for item in field
.value
.split(',')
.map(str::trim)
.filter(|item| !item.is_empty())
{
match EvidenceRef::parse_with(item, patterns) {
Ok(parsed) => evidence.push(parsed),
Err(error) => {
if evidence_error.is_none() {
evidence_error = Some(error);
}
}
}
}
}
}
}
let what = normalize_field(trim_claim_text(claim_segment).to_owned());
if what.is_empty() {
return Err(ClaimError::EmptyWhat);
}
if let Some(error) = evidence_error {
return Err(error);
}
if evidence.is_empty() {
return Err(ClaimError::MissingEvidence);
}
let verification = verification.ok_or(ClaimError::MissingVerification)?;
if verification.is_empty() {
return Err(ClaimError::MissingVerification);
}
Ok(Self {
what,
verification,
evidence,
})
}
pub fn to_line(&self) -> String {
let evidence = self
.evidence
.iter()
.map(EvidenceRef::as_str)
.collect::<Vec<_>>()
.join(", ");
format!(
"CLAIM: {} | verified: {} | evidence: {}",
self.what, self.verification, evidence
)
}
fn validate(&self) -> Result<(), ClaimError> {
if self.what.is_empty() {
return Err(ClaimError::EmptyWhat);
}
if self.verification.is_empty() {
return Err(ClaimError::MissingVerification);
}
if self.evidence.is_empty() {
return Err(ClaimError::MissingEvidence);
}
Ok(())
}
}
fn claim_paragraph(input: &str) -> Option<String> {
let mut lines = input.lines();
let first = lines.find(|line| line.trim_start().starts_with("CLAIM:"))?;
let mut paragraph = first.trim().to_owned();
for line in lines {
let line = line.trim();
if line.is_empty() || starts_next_trailer(line) {
break;
}
paragraph.push(' ');
paragraph.push_str(line);
}
Some(paragraph)
}
fn starts_next_trailer(line: &str) -> bool {
let Some((key, _)) = line.split_once(':') else {
return false;
};
let key = key.trim();
if [
"evidence-pointer",
"verification",
"verified",
"evidence",
"how",
]
.iter()
.any(|field| key.eq_ignore_ascii_case(field))
{
return false;
}
!key.is_empty()
&& key
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b' '))
&& key.bytes().any(|byte| byte.is_ascii_uppercase())
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct EvidenceRef(String);
impl EvidenceRef {
pub fn parse(value: &str) -> Result<Self, ClaimError> {
Self::parse_with(value, DEFAULT_EVIDENCE_PATTERNS)
}
pub fn parse_with<S: AsRef<str>>(value: &str, patterns: &[S]) -> Result<Self, ClaimError> {
let value = normalize_field(value.to_owned());
if value.is_empty() {
return Err(ClaimError::MissingEvidence);
}
let normalized = value.to_ascii_lowercase();
if matches!(
normalized.as_str(),
"none" | "n/a" | "na" | "todo" | "tbd" | "later" | "missing"
) || !looks_like_pointer(&value, patterns)
{
return Err(ClaimError::InvalidEvidence { value });
}
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for EvidenceRef {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(formatter)
}
}
#[derive(Clone, Debug, Eq, Error, PartialEq)]
pub enum ClaimError {
#[error("missing CLAIM: line")]
MissingClaim,
#[error("CLAIM: what field is empty")]
EmptyWhat,
#[error("CLAIM: missing verified field")]
MissingVerification,
#[error("CLAIM: missing evidence pointer")]
MissingEvidence,
#[error("CLAIM: invalid evidence pointer {value:?}")]
InvalidEvidence { value: String },
}
#[derive(Clone, Debug, Eq, Error, PartialEq)]
pub enum GateFailure {
#[error("missing CLAIM: line")]
MissingClaim,
#[error(
"completion wording lacks evidence pointer for word {word:?}; example: CLAIM: change behavior | verified: cargo test | evidence: tests:cargo-test"
)]
CompletionWithoutEvidence { word: String },
#[error("{0}")]
InvalidClaim(#[from] ClaimError),
#[error("fake marker {marker:?} found at diff line {line}")]
FakeMarker { marker: String, line: usize },
}
pub fn evaluate_commit_message(
commit_message: &str,
claim_file: Option<&str>,
diff: Option<&str>,
policy: &GatePolicy,
) -> Result<Claim, GateFailure> {
let has_claim_line = commit_message
.lines()
.any(|line| line.trim().starts_with("CLAIM:"));
let claim_source = if has_claim_line {
commit_message
} else {
match claim_file {
Some(source) => source,
None => {
return Err(match completion_word_outside_claim(commit_message) {
Some(word) => GateFailure::CompletionWithoutEvidence {
word: word.to_owned(),
},
None => GateFailure::MissingClaim,
});
}
}
};
let claim = Claim::parse_with(claim_source, &policy.evidence_patterns)
.map_err(GateFailure::InvalidClaim)?;
if let Some(diff) = diff
&& let Some(marker) =
first_fake_marker(diff, &policy.fake_markers, &policy.marker_ignore_paths)
{
return Err(marker);
}
Ok(claim)
}
fn path_is_ignored<S: AsRef<str>>(path: &str, ignore_paths: &[S]) -> bool {
ignore_paths.iter().any(|ignore| {
let ignore = ignore.as_ref();
!ignore.is_empty() && (path.starts_with(ignore) || path.ends_with(ignore))
})
}
pub fn first_fake_marker<S: AsRef<str>>(
diff: &str,
fake_markers: &[String],
ignore_paths: &[S],
) -> Option<GateFailure> {
let markers = normalized_markers(fake_markers);
let mut ignored_file = false;
for (index, line) in diff.lines().enumerate() {
if let Some(rest) = line.strip_prefix("+++ ") {
let path = rest.strip_prefix("b/").unwrap_or(rest);
ignored_file = path_is_ignored(path, ignore_paths);
continue;
}
let Some(added) = line.strip_prefix('+') else {
continue;
};
if added.starts_with("++") || ignored_file {
continue;
}
let line_lower = added.to_ascii_lowercase();
if let Some(marker) = markers
.iter()
.find(|marker| line_lower.contains(marker.normalized.as_str()))
{
return Some(GateFailure::FakeMarker {
marker: marker.original.clone(),
line: index + 1,
});
}
}
None
}
struct Marker {
original: String,
normalized: String,
}
fn normalized_markers(fake_markers: &[String]) -> Vec<Marker> {
let source: Vec<String> = if fake_markers.is_empty() {
DEFAULT_FAKE_MARKERS
.iter()
.map(|marker| (*marker).to_owned())
.collect()
} else {
fake_markers.to_vec()
};
source
.into_iter()
.filter(|marker| !marker.trim().is_empty())
.map(|marker| Marker {
normalized: marker.trim().to_ascii_lowercase(),
original: marker.trim().to_owned(),
})
.collect()
}
fn completion_word_outside_claim(input: &str) -> Option<&'static str> {
const WORDS: &[&str] = &[
"done",
"complete",
"completed",
"verified",
"fixed",
"passing",
];
input
.lines()
.filter(|line| !line.trim().starts_with("CLAIM:"))
.flat_map(|line| line.split(|character: char| !character.is_ascii_alphanumeric()))
.find_map(|word| {
let normalized = word.to_ascii_lowercase();
WORDS
.iter()
.copied()
.find(|candidate| *candidate == normalized)
})
}
fn looks_like_pointer<S: AsRef<str>>(value: &str, patterns: &[S]) -> bool {
let lower = value.to_ascii_lowercase();
lower.contains("://")
|| patterns
.iter()
.any(|prefix| lower.starts_with(&prefix.as_ref().to_ascii_lowercase()))
|| value.contains('/')
|| value.contains('.')
}
fn normalize_field(value: String) -> String {
value.split_whitespace().collect::<Vec<_>>().join(" ")
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ClaimFieldKind {
Verification,
Evidence,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct ClaimField<'a> {
kind: ClaimFieldKind,
start: usize,
value: &'a str,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct FieldTag {
kind: ClaimFieldKind,
start: usize,
value_start: usize,
}
fn claim_fields<'a, S: AsRef<str>>(input: &'a str, patterns: &[S]) -> Vec<ClaimField<'a>> {
if input.contains('|') {
delimited_claim_fields(input, patterns)
} else {
loose_claim_fields(input, patterns)
}
}
fn delimited_claim_fields<'a, S: AsRef<str>>(
input: &'a str,
patterns: &[S],
) -> Vec<ClaimField<'a>> {
let mut fields = Vec::new();
let mut segments = Vec::new();
let mut segment_start = 0;
let mut seen_delimiter = false;
for (index, character) in input.char_indices() {
if character != '|' {
continue;
}
if seen_delimiter {
segments.push((segment_start, index));
}
seen_delimiter = true;
segment_start = index + character.len_utf8();
}
if seen_delimiter {
segments.push((segment_start, input.len()));
}
let segment_has_evidence = segments
.iter()
.copied()
.map(|(start, end)| segment_starts_with_field(input, start, end, ClaimFieldKind::Evidence))
.collect::<Vec<_>>();
let any_segment_has_evidence = segment_has_evidence
.iter()
.any(|has_evidence| *has_evidence);
for (segment_index, (start, end)) in segments.iter().copied().enumerate() {
let other_segment_has_evidence =
any_segment_has_evidence && !segment_has_evidence[segment_index];
fields.extend(segment_claim_fields(
input,
start,
end,
other_segment_has_evidence,
patterns,
));
}
fields
}
fn segment_claim_fields<'a, S: AsRef<str>>(
input: &'a str,
segment_start: usize,
segment_end: usize,
other_segment_has_evidence: bool,
patterns: &[S],
) -> Vec<ClaimField<'a>> {
let Some(segment) = input.get(segment_start..segment_end) else {
return Vec::new();
};
if other_segment_has_evidence
&& let Some(tag) = segment_starting_field_tag(segment, ClaimFieldKind::Verification)
{
return vec![ClaimField {
kind: tag.kind,
start: segment_start + tag.start,
value: &segment[tag.value_start..],
}];
}
loose_claim_fields(segment, patterns)
.into_iter()
.map(|field| ClaimField {
kind: field.kind,
start: segment_start + field.start,
value: field.value,
})
.collect()
}
fn segment_starts_with_field(
input: &str,
segment_start: usize,
segment_end: usize,
kind: ClaimFieldKind,
) -> bool {
input
.get(segment_start..segment_end)
.and_then(|segment| segment_starting_field_tag(segment, kind))
.is_some()
}
fn segment_starting_field_tag(segment: &str, kind: ClaimFieldKind) -> Option<FieldTag> {
let trimmed_start = segment.len() - segment.trim_start().len();
field_tags(segment)
.into_iter()
.find(|tag| tag.kind == kind && tag.start == trimmed_start)
}
fn loose_claim_fields<'a, S: AsRef<str>>(input: &'a str, patterns: &[S]) -> Vec<ClaimField<'a>> {
let tags = field_tags(input);
let mut fields = complete_field_pair(input, &tags, patterns).unwrap_or_else(|| {
[
tags.iter()
.copied()
.rfind(|tag| tag.kind == ClaimFieldKind::Verification),
tags.iter()
.copied()
.rfind(|tag| tag.kind == ClaimFieldKind::Evidence),
]
.into_iter()
.flatten()
.collect::<Vec<_>>()
});
fields.sort_by_key(|tag| tag.start);
fields
.iter()
.enumerate()
.map(|(index, tag)| {
let end = fields.get(index + 1).map_or(input.len(), |next| next.start);
ClaimField {
kind: tag.kind,
start: tag.start,
value: trim_field_value(&input[tag.value_start..end]),
}
})
.collect()
}
fn complete_field_pair<S: AsRef<str>>(
input: &str,
tags: &[FieldTag],
patterns: &[S],
) -> Option<Vec<FieldTag>> {
if let Some(pair) = later_complete_verification_evidence_pair(input, tags, patterns) {
return Some(pair);
}
if let Some(first) = tags.first().copied() {
match first.kind {
ClaimFieldKind::Verification => {
if let Some(evidence) = tags
.iter()
.copied()
.rfind(|tag| tag.kind == ClaimFieldKind::Evidence)
{
return Some(vec![first, evidence]);
}
}
ClaimFieldKind::Evidence => {
if let Some(verification) = tags
.iter()
.copied()
.find(|tag| tag.kind == ClaimFieldKind::Verification)
{
if !evidence_tag_value_looks_like_pointer(
input,
first,
verification.start,
patterns,
) && let Some(evidence) = tags.iter().copied().rfind(|tag| {
tag.kind == ClaimFieldKind::Evidence && tag.start > verification.start
}) {
return Some(vec![verification, evidence]);
}
return Some(vec![first, verification]);
}
}
}
}
tags.windows(2).find_map(|window| {
let first = window[0];
let second = window[1];
(first.kind != second.kind).then(|| vec![first, second])
})
}
fn later_complete_verification_evidence_pair<S: AsRef<str>>(
input: &str,
tags: &[FieldTag],
patterns: &[S],
) -> Option<Vec<FieldTag>> {
tags.windows(2)
.enumerate()
.rev()
.find_map(|(index, window)| {
let verification = window[0];
let evidence = window[1];
let evidence_end = tags.get(index + 2).map_or(input.len(), |tag| tag.start);
(verification.kind == ClaimFieldKind::Verification
&& evidence.kind == ClaimFieldKind::Evidence
&& evidence_tag_value_looks_like_pointer(input, evidence, evidence_end, patterns))
.then(|| vec![verification, evidence])
})
}
fn evidence_tag_value_looks_like_pointer<S: AsRef<str>>(
input: &str,
tag: FieldTag,
end: usize,
patterns: &[S],
) -> bool {
input
.get(tag.value_start..end)
.map(trim_field_value)
.is_some_and(|value| looks_like_pointer(value, patterns))
}
fn field_tags(input: &str) -> Vec<FieldTag> {
const FIELD_NAMES_WITH_HOW: &[(&str, ClaimFieldKind)] = &[
("evidence-pointer", ClaimFieldKind::Evidence),
("verification", ClaimFieldKind::Verification),
("verified", ClaimFieldKind::Verification),
("evidence", ClaimFieldKind::Evidence),
("how", ClaimFieldKind::Verification),
];
collect_field_tags(input, FIELD_NAMES_WITH_HOW)
}
fn collect_field_tags(input: &str, field_names: &[(&str, ClaimFieldKind)]) -> Vec<FieldTag> {
let mut tags = Vec::new();
for (index, _) in input.char_indices() {
if !field_boundary_before(input, index) {
continue;
}
if let Some(tag) = field_tag_at(input, index, field_names) {
tags.push(tag);
}
}
tags
}
fn field_tag_at(
input: &str,
index: usize,
field_names: &[(&str, ClaimFieldKind)],
) -> Option<FieldTag> {
for (name, kind) in field_names {
let after_name = index + name.len();
if input
.get(index..after_name)
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(name))
&& input
.get(after_name..)
.is_some_and(|remaining| remaining.starts_with(':'))
{
return Some(FieldTag {
kind: *kind,
start: index,
value_start: after_name + 1,
});
}
}
None
}
fn field_boundary_before(input: &str, index: usize) -> bool {
index == 0
|| input[..index]
.chars()
.next_back()
.is_some_and(|character| character.is_whitespace() || matches!(character, '|' | ';'))
}
fn trim_field_value(value: &str) -> &str {
value
.trim_matches(|character: char| character.is_whitespace() || matches!(character, '|' | ';'))
}
fn trim_claim_text(value: &str) -> &str {
value.trim_matches(|character: char| {
character.is_whitespace() || matches!(character, '|' | ';' | ',')
})
}
#[cfg(test)]
mod tests {
use proptest::prelude::*;
use super::{
Claim, ClaimError, EvidenceRef, GateFailure, GatePolicy, evaluate_commit_message,
first_fake_marker,
};
const NO_IGNORE: &[&str] = &[];
#[test]
fn parses_claim_line_with_evidence() {
let claim = Claim::parse(
"feat: thing\n\nCLAIM: add parser | verified: cargo test | evidence: tests:cargo-test",
)
.unwrap();
assert_eq!(claim.what, "add parser");
assert_eq!(claim.verification, "cargo test");
assert_eq!(claim.evidence[0].as_str(), "tests:cargo-test");
}
#[test]
fn parses_wrapped_claim_paragraph_until_the_next_trailer() {
let claim = Claim::parse(
"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>",
)
.unwrap();
assert_eq!(claim.what, "continue draining after a poisoned review item");
assert_eq!(claim.verification, "cargo test --workspace");
assert_eq!(claim.evidence.len(), 2);
assert_eq!(claim.evidence[0].as_str(), "tests:reviewer-drain");
assert_eq!(
claim.evidence[1].as_str(),
"logs:target/test-results/reviewer.log"
);
}
#[test]
fn parses_named_claim_fields_without_pipe_delimiters() {
let claim = Claim::parse(
"feat: thing\n\nCLAIM: add parser verified: cargo test evidence: tests:cargo-test",
)
.unwrap();
assert_eq!(claim.what, "add parser");
assert_eq!(claim.verification, "cargo test");
assert_eq!(claim.evidence[0].as_str(), "tests:cargo-test");
}
#[test]
fn parses_how_as_verification_alias() {
let claim = Claim::parse(
"feat: thing\n\nCLAIM: add parser | how: cargo test | evidence: tests:claim",
)
.unwrap();
assert_eq!(claim.what, "add parser");
assert_eq!(claim.verification, "cargo test");
assert_eq!(claim.evidence[0].as_str(), "tests:claim");
}
#[test]
fn loose_claim_parses_how_as_verification_alias() {
let claim =
Claim::parse("feat: thing\n\nCLAIM: add parser how: cargo test evidence: tests:claim")
.unwrap();
assert_eq!(claim.what, "add parser");
assert_eq!(claim.verification, "cargo test");
assert_eq!(claim.evidence[0].as_str(), "tests:claim");
}
#[test]
fn loose_claim_accepts_evidence_before_verification() {
let claim = Claim::parse(
"feat: thing\n\nCLAIM: add parser evidence: tests:claim verified: cargo test",
)
.unwrap();
assert_eq!(claim.what, "add parser");
assert_eq!(claim.verification, "cargo test");
assert_eq!(claim.evidence[0].as_str(), "tests:claim");
}
#[test]
fn comma_separated_evidence_ignores_blank_items() {
let claim = Claim::parse(
"feat: thing\n\nCLAIM: add parser | verified: cargo test | evidence: tests:unit, , tests:integration,",
)
.unwrap();
assert_eq!(claim.evidence.len(), 2);
assert_eq!(claim.evidence[0].as_str(), "tests:unit");
assert_eq!(claim.evidence[1].as_str(), "tests:integration");
}
#[test]
fn loose_claim_preserves_field_like_summary_text() {
let claim = Claim::parse(
"feat: thing\n\nCLAIM: add evidence: parser verified: cargo test evidence: tests:claim",
)
.unwrap();
assert_eq!(claim.what, "add evidence: parser");
assert_eq!(claim.verification, "cargo test");
assert_eq!(claim.evidence[0].as_str(), "tests:claim");
}
#[test]
fn loose_claim_preserves_verified_label_in_summary_text() {
let claim = Claim::parse(
"feat: thing\n\nCLAIM: add verified: docs verified: cargo test evidence: tests:claim",
)
.unwrap();
assert_eq!(claim.what, "add verified: docs");
assert_eq!(claim.verification, "cargo test");
assert_eq!(claim.evidence[0].as_str(), "tests:claim");
}
#[test]
fn loose_claim_uses_custom_evidence_patterns_for_summary_disambiguation() {
let claim = Claim::parse_with(
"feat: thing\n\nCLAIM: add verified: docs verified: cargo test evidence: jira:PROJ-42",
&["jira:"],
)
.unwrap();
assert_eq!(claim.what, "add verified: docs");
assert_eq!(claim.verification, "cargo test");
assert_eq!(claim.evidence[0].as_str(), "jira:PROJ-42");
}
#[test]
fn loose_claim_parses_how_after_field_like_summary_text() {
let claim = Claim::parse(
"feat: thing\n\nCLAIM: add verified: docs how: cargo test evidence: tests:x",
)
.unwrap();
assert_eq!(claim.what, "add verified: docs");
assert_eq!(claim.verification, "cargo test");
assert_eq!(claim.evidence[0].as_str(), "tests:x");
}
#[test]
fn pipe_delimited_claim_accepts_mixed_named_fields_in_segment() {
let claim = Claim::parse(
"feat: thing\n\nCLAIM: add parser | verified: cargo test evidence: tests:claim",
)
.unwrap();
assert_eq!(claim.what, "add parser");
assert_eq!(claim.verification, "cargo test");
assert_eq!(claim.evidence[0].as_str(), "tests:claim");
}
#[test]
fn loose_claim_keeps_evidence_when_verification_mentions_evidence() {
let claim = Claim::parse(
"feat: thing\n\nCLAIM: add parser evidence: tests:claim verified: reviewed evidence: output",
)
.unwrap();
assert_eq!(claim.what, "add parser");
assert_eq!(claim.verification, "reviewed evidence: output");
assert_eq!(claim.evidence[0].as_str(), "tests:claim");
}
#[test]
fn pipe_delimited_claim_keeps_later_evidence_when_verification_mentions_evidence() {
let claim = Claim::parse(
"feat: thing\n\nCLAIM: add parser | verified: reviewed evidence: output | evidence: tests:claim",
)
.unwrap();
assert_eq!(claim.what, "add parser");
assert_eq!(claim.verification, "reviewed evidence: output");
assert_eq!(claim.evidence[0].as_str(), "tests:claim");
}
#[test]
fn loose_claim_keeps_later_evidence_when_verification_mentions_evidence() {
let claim = Claim::parse(
"feat: thing\n\nCLAIM: add parser verified: reviewed evidence: output evidence: tests:claim",
)
.unwrap();
assert_eq!(claim.what, "add parser");
assert_eq!(claim.verification, "reviewed evidence: output");
assert_eq!(claim.evidence[0].as_str(), "tests:claim");
}
#[test]
fn pipe_delimited_claim_keeps_earlier_evidence_when_verification_mentions_evidence() {
let claim = Claim::parse(
"feat: thing\n\nCLAIM: add parser | evidence: tests:claim | verified: reviewed evidence: output",
)
.unwrap();
assert_eq!(claim.what, "add parser");
assert_eq!(claim.verification, "reviewed evidence: output");
assert_eq!(claim.evidence[0].as_str(), "tests:claim");
}
#[test]
fn duplicate_verification_aliases_ignore_empty_duplicate_in_any_order() {
let messages = [
"feat: thing\n\nCLAIM: fix parser | verified: cargo test | verification: | evidence: tests:x",
"feat: thing\n\nCLAIM: fix parser | verification: | verified: cargo test | evidence: tests:x",
];
for message in messages {
let claim = Claim::parse(message).unwrap();
assert_eq!(claim.verification, "cargo test");
assert_eq!(claim.evidence[0].as_str(), "tests:x");
}
}
#[test]
fn duplicate_empty_verification_aliases_still_reject_as_missing() {
let error = Claim::parse(
"feat: thing\n\nCLAIM: fix parser | verification: | verified: | evidence: tests:x",
)
.unwrap_err();
assert_eq!(error, ClaimError::MissingVerification);
}
#[test]
fn pipe_delimited_claim_keeps_field_like_words_in_summary() {
let claim = Claim::parse(
"feat: thing\n\nCLAIM: add evidence: parser | verified: cargo test | evidence: tests:cargo-test",
)
.unwrap();
assert_eq!(claim.what, "add evidence: parser");
assert_eq!(claim.verification, "cargo test");
assert_eq!(claim.evidence[0].as_str(), "tests:cargo-test");
}
#[test]
fn pipe_delimited_claim_treats_first_segment_as_summary() {
let claim = Claim::parse(
"feat: thing\n\nCLAIM: evidence: parser | verified: cargo test | evidence: tests:cargo-test",
)
.unwrap();
assert_eq!(claim.what, "evidence: parser");
assert_eq!(claim.verification, "cargo test");
assert_eq!(claim.evidence[0].as_str(), "tests:cargo-test");
}
#[test]
fn pipe_delimited_claim_preserves_semicolon_in_evidence_value() {
let claim = Claim::parse(
"feat: thing\n\nCLAIM: add parser | verified: cargo test | evidence: tests:unit; tests:integration",
)
.unwrap();
assert_eq!(claim.what, "add parser");
assert_eq!(claim.evidence[0].as_str(), "tests:unit; tests:integration");
}
#[test]
fn pipe_delimited_claim_keeps_field_like_words_in_verification() {
let claim = Claim::parse(
"feat: thing\n\nCLAIM: add parser | verified: describe how: it works | evidence: tests:cargo-test",
)
.unwrap();
assert_eq!(claim.what, "add parser");
assert_eq!(claim.verification, "describe how: it works");
assert_eq!(claim.evidence[0].as_str(), "tests:cargo-test");
}
#[test]
fn rejects_missing_claim() {
let error = Claim::parse("feat: thing").unwrap_err();
assert_eq!(error, ClaimError::MissingClaim);
}
#[test]
fn rejects_missing_evidence() {
let error = Claim::parse("CLAIM: complete parser | verified: cargo test").unwrap_err();
assert_eq!(error, ClaimError::MissingEvidence);
}
#[test]
fn reports_completion_word_without_evidence_when_no_claim_line() {
let error = evaluate_commit_message(
"feat: complete the parser",
None,
None,
&GatePolicy::default(),
)
.unwrap_err();
assert_eq!(
error,
GateFailure::CompletionWithoutEvidence {
word: "complete".to_owned()
}
);
}
#[test]
fn claim_line_with_missing_evidence_reports_invalid_claim_not_completion_word() {
let error = evaluate_commit_message(
"feat: parser\n\nCLAIM: complete parser | verified: cargo test",
None,
None,
&GatePolicy::default(),
)
.unwrap_err();
assert_eq!(
error,
GateFailure::InvalidClaim(ClaimError::MissingEvidence)
);
}
#[test]
fn claim_line_with_non_pointer_evidence_names_offending_value() {
let error = evaluate_commit_message(
"feat: parser\n\nCLAIM: add things | verified: manual | evidence: not-a-pointer",
None,
None,
&GatePolicy::default(),
)
.unwrap_err();
assert_eq!(
error,
GateFailure::InvalidClaim(ClaimError::InvalidEvidence {
value: "not-a-pointer".to_owned(),
})
);
}
#[test]
fn equivalent_completion_claim_shapes_get_stable_evidence_failure() {
let messages = [
"feat: parser\n\nCLAIM: complete parser | verified: cargo test",
"feat: parser\n\nCLAIM: complete parser verified: cargo test",
];
for message in messages {
let error =
evaluate_commit_message(message, None, None, &GatePolicy::default()).unwrap_err();
assert_eq!(
error,
GateFailure::InvalidClaim(ClaimError::MissingEvidence),
"unexpected error for message: {message:?}"
);
}
}
#[test]
fn equivalent_invalid_evidence_shapes_get_stable_evidence_failure() {
let messages = [
"feat: parser\n\nCLAIM: complete parser | evidence: missing | verified: cargo test",
"feat: parser\n\nCLAIM: complete parser verified: cargo test evidence: missing",
"feat: parser\n\nCLAIM: complete parser | evidence: missing",
];
for message in messages {
let error =
evaluate_commit_message(message, None, None, &GatePolicy::default()).unwrap_err();
assert_eq!(
error,
GateFailure::InvalidClaim(ClaimError::InvalidEvidence {
value: "missing".to_owned(),
})
);
}
}
#[test]
fn subject_completion_word_does_not_mask_claim_evidence_error() {
let error = evaluate_commit_message(
"fix: fixed seed order\n\nCLAIM: add parser | verified: cargo test",
None,
None,
&GatePolicy::default(),
)
.unwrap_err();
assert_eq!(
error,
GateFailure::InvalidClaim(ClaimError::MissingEvidence)
);
}
#[test]
fn later_claim_completion_word_does_not_mask_first_claim_evidence_error() {
let error = evaluate_commit_message(
"feat: parser\n\nCLAIM: add parser | verified: cargo test\nCLAIM: complete parser | verified: cargo test | evidence: tests:later",
None,
None,
&GatePolicy::default(),
)
.unwrap_err();
assert_eq!(
error,
GateFailure::InvalidClaim(ClaimError::MissingEvidence)
);
}
#[test]
fn body_after_first_claim_does_not_mask_first_claim_evidence_error() {
let error = evaluate_commit_message(
"feat: parser\n\nCLAIM: add parser | verified: cargo test\n\nverified later in prose",
None,
None,
&GatePolicy::default(),
)
.unwrap_err();
assert_eq!(
error,
GateFailure::InvalidClaim(ClaimError::MissingEvidence)
);
}
#[test]
fn valid_claim_with_completion_words_in_body_prose_passes() {
let claim = evaluate_commit_message(
"fix: parser\n\nThis body says the parser is fixed and verified.\n\nCLAIM: add parser | verified: cargo test | evidence: tests:x",
None,
None,
&GatePolicy::default(),
)
.unwrap();
assert_eq!(claim.what, "add parser");
assert_eq!(claim.verification, "cargo test");
assert_eq!(claim.evidence[0].as_str(), "tests:x");
}
#[test]
fn invalid_evidence_is_not_masked_by_verified_field_label() {
let error = evaluate_commit_message(
"fix: seed order\n\nCLAIM: add parser | verified: cargo test | evidence: Depot job 393s6c1ff6 failure log",
None,
None,
&GatePolicy::default(),
)
.unwrap_err();
assert_eq!(
error,
GateFailure::InvalidClaim(ClaimError::InvalidEvidence {
value: "Depot job 393s6c1ff6 failure log".to_owned(),
})
);
}
#[test]
fn accepts_claim_file_fallback() {
let claim = evaluate_commit_message(
"feat: parser",
Some("CLAIM: add parser | verified: cargo test | evidence: tests:cargo-test"),
None,
&GatePolicy::default(),
)
.unwrap();
assert_eq!(claim.what, "add parser");
}
#[test]
fn custom_evidence_pattern_is_accepted() {
let policy = GatePolicy {
evidence_patterns: vec!["jira:".to_owned()],
..GatePolicy::default()
};
let claim = evaluate_commit_message(
"chore: thing\n\nCLAIM: do thing | verified: manual | evidence: jira:PROJ-42",
None,
None,
&policy,
)
.unwrap();
assert_eq!(claim.evidence[0].as_str(), "jira:PROJ-42");
}
#[test]
fn body_prose_with_completion_word_does_not_reject_valid_claim() {
let claim = evaluate_commit_message(
"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",
None,
None,
&GatePolicy::default(),
)
.unwrap();
assert_eq!(claim.what, "fix seed order");
}
#[test]
fn marker_in_ignored_doc_path_is_not_flagged() {
let marker = ["mock", "as", "real"].join("-");
let diff = format!("diff --git a/docs/x.md b/docs/x.md\n+++ b/docs/x.md\n+ {marker}");
let policy = GatePolicy::default();
assert!(
first_fake_marker(&diff, &policy.fake_markers, &policy.marker_ignore_paths).is_none()
);
let code_diff = format!("diff --git a/src/x.rs b/src/x.rs\n+++ b/src/x.rs\n+ {marker}");
assert!(
first_fake_marker(
&code_diff,
&policy.fake_markers,
&policy.marker_ignore_paths
)
.is_some()
);
}
#[test]
fn finds_default_fake_marker_with_location() {
let done = ["TODO", "as", "done"].join("-");
let diff = format!("diff --git a/x b/x\n+ {done}");
let error = first_fake_marker(&diff, &[], NO_IGNORE).unwrap();
assert_eq!(
error,
GateFailure::FakeMarker {
marker: "TODO-as-done".to_owned(),
line: 2
}
);
}
#[test]
fn context_and_removed_lines_do_not_trip_fake_marker() {
let marker = ["mock", "as", "real"].join("-");
let diff = format!(
"diff --git a/x b/x\n const MARKERS = [\"{marker}\"];\n- old_line_with {marker}\n+ let honest = compute();"
);
assert!(first_fake_marker(&diff, &[], NO_IGNORE).is_none());
}
#[test]
fn added_line_with_marker_is_flagged() {
let marker = ["mock", "as", "real"].join("-");
let diff = format!("diff --git a/x b/x\n+ {marker} here");
let error = first_fake_marker(&diff, &[], NO_IGNORE).unwrap();
assert!(matches!(error, GateFailure::FakeMarker { .. }));
}
#[test]
fn configured_fake_marker_overrides_defaults() {
let markers = vec!["pretend-pass".to_owned()];
let error = first_fake_marker("+ pretend-pass", &markers, NO_IGNORE).unwrap();
assert_eq!(
error,
GateFailure::FakeMarker {
marker: "pretend-pass".to_owned(),
line: 1
}
);
}
proptest! {
#[test]
fn claim_roundtrip_preserves_semantic_fields(
what in "[A-Za-z0-9][A-Za-z0-9 _./:-]{0,48}",
verification in "[A-Za-z0-9][A-Za-z0-9 _./:-]{0,48}",
evidence_suffix in "[a-z0-9][a-z0-9_-]{0,24}",
) {
let evidence = EvidenceRef::parse(&format!("tests:{evidence_suffix}")).unwrap();
let claim = Claim::new(what, verification, vec![evidence]).unwrap();
let parsed = Claim::parse(&claim.to_line()).unwrap();
prop_assert_eq!(parsed, claim);
}
}
}