koala-drift 1.0.4

Wiki ↔ code drift detector.
Documentation
//! `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
    }
}