Skip to main content

koala_drift/checks/
feature_acceptance.rs

1//! `feature.acceptance-test-ref` — every `→ <path>` line under a feature's
2//! `## Acceptance criteria` section must point to a real file in the repo,
3//! unless the line is explicitly marked `(待创建)` (forecast / not-yet-built).
4
5use crate::check::{Check, Finding, FindingKind, Severity};
6use crate::scan::{fuzzy_suggest, list_feature_files, rel, tagged_lines};
7use koala_core::invariant::Context;
8use std::fs;
9use std::path::Path;
10
11const FORECAST_MARKER: &str = "(待创建)";
12const ACCEPTANCE_HEADING_PREFIX: &str = "Acceptance criteria";
13
14pub struct FeatureAcceptanceTestRef;
15
16impl Check for FeatureAcceptanceTestRef {
17    fn id(&self) -> &'static str {
18        "feature.acceptance-test-ref"
19    }
20
21    fn intent(&self) -> &'static str {
22        "Acceptance criteria reference paths that exist in the repo \
23         (unless explicitly marked (待创建) as a forecast)."
24    }
25
26    fn run(&self, ctx: &Context) -> Vec<Finding> {
27        let mut out = Vec::new();
28        for feature_path in list_feature_files(ctx.root()) {
29            let Ok(content) = fs::read_to_string(&feature_path) else {
30                continue;
31            };
32            let display = rel(&feature_path, ctx.root());
33            for line in tagged_lines(&content) {
34                if line.in_fence {
35                    continue;
36                }
37                if !line
38                    .section
39                    .map(|s| s.starts_with(ACCEPTANCE_HEADING_PREFIX))
40                    .unwrap_or(false)
41                {
42                    continue;
43                }
44                // Only treat the line as an acceptance ref when `→ ` is the
45                // first non-whitespace token. Body prose like `commit → verify`
46                // is not a claim about a path.
47                let stripped = line.text.trim_start();
48                let Some(after) = stripped.strip_prefix("→ ") else {
49                    continue;
50                };
51                let raw = after.trim();
52                if raw.is_empty() {
53                    continue;
54                }
55                if raw.contains(FORECAST_MARKER) {
56                    // Forecast tests are intentionally allowed during the
57                    // bootstrap stages (see drift-detector.md `## Acceptance`).
58                    continue;
59                }
60
61                // The path is the prefix up to the first whitespace or `(` /
62                // `(`. Strip a trailing `#anchor` for file-existence purposes —
63                // symbol matching is Stage 4 work.
64                let token = raw
65                    .split(|c: char| c.is_whitespace() || c == '(' || c == '(')
66                    .next()
67                    .unwrap_or("")
68                    .trim();
69                let path_str = token.split('#').next().unwrap_or("");
70                if path_str.is_empty() || path_str.contains('<') {
71                    // `<placeholder>` tokens come from `_template.md`; we
72                    // already skip underscore files but also skip in-prose
73                    // angle-bracket placeholders defensively.
74                    continue;
75                }
76
77                let target = ctx.root().join(path_str);
78                if target.exists() {
79                    continue;
80                }
81                let suggestion = fuzzy_suggest(Path::new(path_str), ctx.root(), 3);
82                let mut hint = format!(
83                    "create `{path_str}` or mark this acceptance line as \
84                     {FORECAST_MARKER} until the test exists"
85                );
86                if let Some(s) = &suggestion {
87                    hint.push_str(&format!("; did you mean `{}` ?", s.display()));
88                }
89                out.push(Finding {
90                    check_id: self.id(),
91                    file: display.clone(),
92                    line: line.line_no,
93                    claim: raw.to_string(),
94                    kind: FindingKind::AcceptanceTestRefMissing,
95                    severity: Severity::Hard,
96                    fix_hint: Some(hint),
97                });
98            }
99        }
100        out
101    }
102}