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 mut segments = line.split('|').map(str::trim);
94 let claim_segment = segments
95 .next()
96 .and_then(|segment| segment.strip_prefix("CLAIM:"))
97 .ok_or(ClaimError::MissingClaim)?;
98
99 let mut verification = None;
100 let mut evidence = Vec::new();
101
102 for segment in segments {
103 if let Some(value) = field_value(segment, &["verified", "verification", "how"]) {
104 verification = Some(normalize_field(value.to_owned()));
105 continue;
106 }
107
108 if let Some(value) = field_value(segment, &["evidence", "evidence-pointer"]) {
109 for item in value.split(',') {
110 evidence.push(EvidenceRef::parse_with(item, patterns)?);
111 }
112 }
113 }
114
115 Self::new(
116 claim_segment,
117 verification.ok_or(ClaimError::MissingVerification)?,
118 evidence,
119 )
120 }
121
122 pub fn to_line(&self) -> String {
123 let evidence = self
124 .evidence
125 .iter()
126 .map(EvidenceRef::as_str)
127 .collect::<Vec<_>>()
128 .join(", ");
129
130 format!(
131 "CLAIM: {} | verified: {} | evidence: {}",
132 self.what, self.verification, evidence
133 )
134 }
135
136 fn validate(&self) -> Result<(), ClaimError> {
137 if self.what.is_empty() {
138 return Err(ClaimError::EmptyWhat);
139 }
140
141 if self.verification.is_empty() {
142 return Err(ClaimError::MissingVerification);
143 }
144
145 if self.evidence.is_empty() {
146 return Err(ClaimError::MissingEvidence);
147 }
148
149 Ok(())
150 }
151}
152
153#[derive(Clone, Debug, Eq, PartialEq)]
154pub struct EvidenceRef(String);
155
156impl EvidenceRef {
157 pub fn parse(value: &str) -> Result<Self, ClaimError> {
158 Self::parse_with(value, DEFAULT_EVIDENCE_PATTERNS)
159 }
160
161 pub fn parse_with<S: AsRef<str>>(value: &str, patterns: &[S]) -> Result<Self, ClaimError> {
162 let value = normalize_field(value.to_owned());
163 if value.is_empty() {
164 return Err(ClaimError::MissingEvidence);
165 }
166
167 let normalized = value.to_ascii_lowercase();
168 if matches!(
169 normalized.as_str(),
170 "none" | "n/a" | "na" | "todo" | "tbd" | "later" | "missing"
171 ) || !looks_like_pointer(&value, patterns)
172 {
173 return Err(ClaimError::InvalidEvidence { value });
174 }
175
176 Ok(Self(value))
177 }
178
179 pub fn as_str(&self) -> &str {
180 &self.0
181 }
182}
183
184impl fmt::Display for EvidenceRef {
185 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
186 self.0.fmt(formatter)
187 }
188}
189
190#[derive(Clone, Debug, Eq, Error, PartialEq)]
191pub enum ClaimError {
192 #[error("missing CLAIM: line")]
193 MissingClaim,
194 #[error("CLAIM: what field is empty")]
195 EmptyWhat,
196 #[error("CLAIM: missing verified field")]
197 MissingVerification,
198 #[error("CLAIM: missing evidence pointer")]
199 MissingEvidence,
200 #[error("CLAIM: invalid evidence pointer {value:?}")]
201 InvalidEvidence { value: String },
202}
203
204#[derive(Clone, Debug, Eq, Error, PartialEq)]
205pub enum GateFailure {
206 #[error("missing CLAIM: line")]
207 MissingClaim,
208 #[error("completion wording lacks evidence pointer for word {word:?}")]
209 CompletionWithoutEvidence { word: String },
210 #[error("{0}")]
211 InvalidClaim(#[from] ClaimError),
212 #[error("fake marker {marker:?} found at diff line {line}")]
213 FakeMarker { marker: String, line: usize },
214}
215
216pub fn evaluate_commit_message(
217 commit_message: &str,
218 claim_file: Option<&str>,
219 diff: Option<&str>,
220 policy: &GatePolicy,
221) -> Result<Claim, GateFailure> {
222 let claim_source = if commit_message
223 .lines()
224 .any(|line| line.trim().starts_with("CLAIM:"))
225 {
226 commit_message
227 } else {
228 claim_file.ok_or(GateFailure::MissingClaim)?
229 };
230
231 let completion_word = completion_word(commit_message);
232 let claim =
233 Claim::parse_with(claim_source, &policy.evidence_patterns).map_err(|error| {
234 match (&error, completion_word) {
235 (ClaimError::MissingEvidence | ClaimError::InvalidEvidence { .. }, Some(word)) => {
236 GateFailure::CompletionWithoutEvidence {
237 word: word.to_owned(),
238 }
239 }
240 _ => GateFailure::InvalidClaim(error),
241 }
242 })?;
243
244 if let Some(diff) = diff
245 && let Some(marker) =
246 first_fake_marker(diff, &policy.fake_markers, &policy.marker_ignore_paths)
247 {
248 return Err(marker);
249 }
250
251 Ok(claim)
252}
253
254fn path_is_ignored<S: AsRef<str>>(path: &str, ignore_paths: &[S]) -> bool {
256 ignore_paths.iter().any(|ignore| {
257 let ignore = ignore.as_ref();
258 !ignore.is_empty() && (path.starts_with(ignore) || path.ends_with(ignore))
259 })
260}
261
262pub fn first_fake_marker<S: AsRef<str>>(
263 diff: &str,
264 fake_markers: &[String],
265 ignore_paths: &[S],
266) -> Option<GateFailure> {
267 let markers = normalized_markers(fake_markers);
268 let mut ignored_file = false;
269 for (index, line) in diff.lines().enumerate() {
270 if let Some(rest) = line.strip_prefix("+++ ") {
273 let path = rest.strip_prefix("b/").unwrap_or(rest);
274 ignored_file = path_is_ignored(path, ignore_paths);
275 continue;
276 }
277
278 let Some(added) = line.strip_prefix('+') else {
283 continue;
284 };
285 if added.starts_with("++") || ignored_file {
286 continue;
287 }
288
289 let line_lower = added.to_ascii_lowercase();
290 if let Some(marker) = markers
291 .iter()
292 .find(|marker| line_lower.contains(marker.normalized.as_str()))
293 {
294 return Some(GateFailure::FakeMarker {
295 marker: marker.original.clone(),
296 line: index + 1,
297 });
298 }
299 }
300
301 None
302}
303
304struct Marker {
305 original: String,
306 normalized: String,
307}
308
309fn normalized_markers(fake_markers: &[String]) -> Vec<Marker> {
310 let source: Vec<String> = if fake_markers.is_empty() {
311 DEFAULT_FAKE_MARKERS
312 .iter()
313 .map(|marker| (*marker).to_owned())
314 .collect()
315 } else {
316 fake_markers.to_vec()
317 };
318
319 source
320 .into_iter()
321 .filter(|marker| !marker.trim().is_empty())
322 .map(|marker| Marker {
323 normalized: marker.trim().to_ascii_lowercase(),
324 original: marker.trim().to_owned(),
325 })
326 .collect()
327}
328
329fn completion_word(input: &str) -> Option<&'static str> {
330 const WORDS: &[&str] = &[
331 "done",
332 "complete",
333 "completed",
334 "verified",
335 "fixed",
336 "passing",
337 ];
338
339 input
340 .split(|character: char| !character.is_ascii_alphanumeric())
341 .find_map(|word| {
342 let normalized = word.to_ascii_lowercase();
343 WORDS
344 .iter()
345 .copied()
346 .find(|candidate| *candidate == normalized)
347 })
348}
349
350fn field_value<'a>(segment: &'a str, names: &[&str]) -> Option<&'a str> {
351 let (name, value) = segment.split_once(':')?;
352 names
353 .iter()
354 .any(|candidate| name.trim().eq_ignore_ascii_case(candidate))
355 .then_some(value.trim())
356}
357
358fn looks_like_pointer<S: AsRef<str>>(value: &str, patterns: &[S]) -> bool {
359 let lower = value.to_ascii_lowercase();
360 lower.contains("://")
361 || patterns
362 .iter()
363 .any(|prefix| lower.starts_with(&prefix.as_ref().to_ascii_lowercase()))
364 || value.contains('/')
365 || value.contains('.')
366}
367
368fn normalize_field(value: String) -> String {
369 value.split_whitespace().collect::<Vec<_>>().join(" ")
370}
371
372#[cfg(test)]
373mod tests {
374 use proptest::prelude::*;
375
376 use super::{
377 Claim, ClaimError, EvidenceRef, GateFailure, GatePolicy, evaluate_commit_message,
378 first_fake_marker,
379 };
380
381 const NO_IGNORE: &[&str] = &[];
383
384 #[test]
385 fn parses_claim_line_with_evidence() {
386 let claim = Claim::parse(
387 "feat: thing\n\nCLAIM: add parser | verified: cargo test | evidence: tests:cargo-test",
388 )
389 .unwrap();
390
391 assert_eq!(claim.what, "add parser");
392 assert_eq!(claim.verification, "cargo test");
393 assert_eq!(claim.evidence[0].as_str(), "tests:cargo-test");
394 }
395
396 #[test]
397 fn rejects_missing_claim() {
398 let error = Claim::parse("feat: thing").unwrap_err();
399
400 assert_eq!(error, ClaimError::MissingClaim);
401 }
402
403 #[test]
404 fn rejects_missing_evidence() {
405 let error = Claim::parse("CLAIM: complete parser | verified: cargo test").unwrap_err();
406
407 assert_eq!(error, ClaimError::MissingEvidence);
408 }
409
410 #[test]
411 fn reports_completion_word_without_evidence() {
412 let error = evaluate_commit_message(
413 "feat: parser\n\nCLAIM: complete parser | verified: cargo test",
414 None,
415 None,
416 &GatePolicy::default(),
417 )
418 .unwrap_err();
419
420 assert_eq!(
421 error,
422 GateFailure::CompletionWithoutEvidence {
423 word: "complete".to_owned()
424 }
425 );
426 }
427
428 #[test]
429 fn accepts_claim_file_fallback() {
430 let claim = evaluate_commit_message(
431 "feat: parser",
432 Some("CLAIM: add parser | verified: cargo test | evidence: tests:cargo-test"),
433 None,
434 &GatePolicy::default(),
435 )
436 .unwrap();
437
438 assert_eq!(claim.what, "add parser");
439 }
440
441 #[test]
442 fn custom_evidence_pattern_is_accepted() {
443 let policy = GatePolicy {
444 evidence_patterns: vec!["jira:".to_owned()],
445 ..GatePolicy::default()
446 };
447 let claim = evaluate_commit_message(
448 "chore: thing\n\nCLAIM: do thing | verified: manual | evidence: jira:PROJ-42",
449 None,
450 None,
451 &policy,
452 )
453 .unwrap();
454
455 assert_eq!(claim.evidence[0].as_str(), "jira:PROJ-42");
456 }
457
458 #[test]
459 fn marker_in_ignored_doc_path_is_not_flagged() {
460 let marker = ["mock", "as", "real"].join("-");
462 let diff = format!("diff --git a/docs/x.md b/docs/x.md\n+++ b/docs/x.md\n+ {marker}");
463
464 let policy = GatePolicy::default();
465 assert!(
466 first_fake_marker(&diff, &policy.fake_markers, &policy.marker_ignore_paths).is_none()
467 );
468
469 let code_diff = format!("diff --git a/src/x.rs b/src/x.rs\n+++ b/src/x.rs\n+ {marker}");
471 assert!(
472 first_fake_marker(
473 &code_diff,
474 &policy.fake_markers,
475 &policy.marker_ignore_paths
476 )
477 .is_some()
478 );
479 }
480
481 #[test]
482 fn finds_default_fake_marker_with_location() {
483 let done = ["TODO", "as", "done"].join("-");
484 let diff = format!("diff --git a/x b/x\n+ {done}");
485 let error = first_fake_marker(&diff, &[], NO_IGNORE).unwrap();
486
487 assert_eq!(
488 error,
489 GateFailure::FakeMarker {
490 marker: "TODO-as-done".to_owned(),
491 line: 2
492 }
493 );
494 }
495
496 #[test]
497 fn context_and_removed_lines_do_not_trip_fake_marker() {
498 let marker = ["mock", "as", "real"].join("-");
501 let diff = format!(
504 "diff --git a/x b/x\n const MARKERS = [\"{marker}\"];\n- old_line_with {marker}\n+ let honest = compute();"
505 );
506
507 assert!(first_fake_marker(&diff, &[], NO_IGNORE).is_none());
508 }
509
510 #[test]
511 fn added_line_with_marker_is_flagged() {
512 let marker = ["mock", "as", "real"].join("-");
513 let diff = format!("diff --git a/x b/x\n+ {marker} here");
514 let error = first_fake_marker(&diff, &[], NO_IGNORE).unwrap();
515
516 assert!(matches!(error, GateFailure::FakeMarker { .. }));
517 }
518
519 #[test]
520 fn configured_fake_marker_overrides_defaults() {
521 let markers = vec!["pretend-pass".to_owned()];
522 let error = first_fake_marker("+ pretend-pass", &markers, NO_IGNORE).unwrap();
523
524 assert_eq!(
525 error,
526 GateFailure::FakeMarker {
527 marker: "pretend-pass".to_owned(),
528 line: 1
529 }
530 );
531 }
532
533 proptest! {
534 #[test]
535 fn claim_roundtrip_preserves_semantic_fields(
536 what in "[A-Za-z0-9][A-Za-z0-9 _./:-]{0,48}",
537 verification in "[A-Za-z0-9][A-Za-z0-9 _./:-]{0,48}",
538 evidence_suffix in "[a-z0-9][a-z0-9_-]{0,24}",
539 ) {
540 let evidence = EvidenceRef::parse(&format!("tests:{evidence_suffix}")).unwrap();
541 let claim = Claim::new(what, verification, vec![evidence]).unwrap();
542
543 let parsed = Claim::parse(&claim.to_line()).unwrap();
544
545 prop_assert_eq!(parsed, claim);
546 }
547 }
548}