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