Skip to main content

koala_drift/checks/
arch_claims.rs

1//! `arch.textual-claim` — `wiki/architecture.md` makes textual claims
2//! like `core 不依赖 domain` or `crates/koala-core does not depend on
3//! crates/koala-cli`. Parse those, route to the dep-direction rule.
4//! If the claim contradicts the actual `Cargo.toml` graph, fail.
5
6use crate::check::{Check, Finding, FindingKind, Severity};
7use crate::scan::tagged_lines;
8use koala_core::invariant::Context;
9use std::collections::HashMap;
10use std::fs;
11use std::path::PathBuf;
12
13const ARCH_FILE: &str = "wiki/architecture.md";
14
15pub struct ArchClaims;
16
17impl Check for ArchClaims {
18    fn id(&self) -> &'static str {
19        "arch.textual-claim"
20    }
21
22    fn intent(&self) -> &'static str {
23        "Sentences like `A 不依赖 B` / `A does not depend on B` in \
24         architecture.md must agree with the workspace's Cargo \
25         dependency graph."
26    }
27
28    fn run(&self, ctx: &Context) -> Vec<Finding> {
29        let arch_path = ctx.root().join(ARCH_FILE);
30        let Ok(text) = fs::read_to_string(&arch_path) else {
31            return Vec::new();
32        };
33        let edges = collect_dep_edges(ctx.root());
34        let mut out = Vec::new();
35        for line in tagged_lines(&text) {
36            if line.in_fence {
37                continue;
38            }
39            for claim in parse_claims(line.text) {
40                if let Some(violation) = check_claim(&claim, &edges) {
41                    out.push(Finding {
42                        check_id: self.id(),
43                        file: PathBuf::from(ARCH_FILE),
44                        line: line.line_no,
45                        claim: format!(
46                            "claim: `{a}` does not depend on `{b}`",
47                            a = claim.from,
48                            b = claim.to
49                        ),
50                        kind: FindingKind::AcceptanceTestRefMissing,
51                        severity: Severity::Hard,
52                        fix_hint: Some(violation),
53                    });
54                }
55            }
56        }
57        out
58    }
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62struct Claim {
63    from: String,
64    to: String,
65}
66
67const NEGATION_MARKERS: &[&str] = &[
68    "不依赖",
69    "禁止依赖",
70    "不可依赖",
71    "does not depend on",
72    "must not depend on",
73    "should not depend on",
74    "must never depend on",
75    "is forbidden to depend on",
76];
77
78fn parse_claims(line: &str) -> Vec<Claim> {
79    let mut out = Vec::new();
80    for marker in NEGATION_MARKERS {
81        let mut rest = line;
82        while let Some(idx) = rest.find(marker) {
83            let before = &rest[..idx];
84            let after = &rest[idx + marker.len()..];
85            if let (Some(from), Some(to)) =
86                (extract_token_before(before), extract_token_after(after))
87            {
88                out.push(Claim { from, to });
89            }
90            rest = &rest[idx + marker.len()..];
91        }
92    }
93    out
94}
95
96/// Pull the last identifier-shaped token from a string.
97fn extract_token_before(s: &str) -> Option<String> {
98    let trimmed = s.trim_end_matches([' ', '\u{3000}', '`', '*', '_', '"']);
99    let mut start = trimmed.len();
100    for (i, ch) in trimmed.char_indices().rev() {
101        if is_identifier_char(ch) {
102            start = i;
103        } else {
104            break;
105        }
106    }
107    let tok = &trimmed[start..];
108    if tok.is_empty() {
109        None
110    } else {
111        Some(tok.to_string())
112    }
113}
114
115/// Pull the first identifier-shaped token from a string.
116fn extract_token_after(s: &str) -> Option<String> {
117    let trimmed = s.trim_start_matches([' ', '\u{3000}', '`', '*', '_', '"']);
118    let mut end = 0;
119    for (i, ch) in trimmed.char_indices() {
120        if is_identifier_char(ch) {
121            end = i + ch.len_utf8();
122        } else {
123            break;
124        }
125    }
126    let tok = &trimmed[..end];
127    if tok.is_empty() {
128        None
129    } else {
130        Some(tok.to_string())
131    }
132}
133
134fn is_identifier_char(ch: char) -> bool {
135    ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' || ch == '/'
136}
137
138/// Returns Some(violation_message) if the claim is contradicted by
139/// the dep graph, None if the claim holds.
140fn check_claim(claim: &Claim, edges: &HashMap<String, Vec<String>>) -> Option<String> {
141    let from_key = normalize_crate_ref(&claim.from);
142    let to_key = normalize_crate_ref(&claim.to);
143    let neighbours = edges.get(&from_key)?;
144    if neighbours.iter().any(|n| n == &to_key) {
145        return Some(format!(
146            "Cargo.toml says `{from_key}` depends on `{to_key}` — claim contradicts the graph"
147        ));
148    }
149    None
150}
151
152fn normalize_crate_ref(s: &str) -> String {
153    let last = s
154        .rsplit(['/', ' '])
155        .next()
156        .unwrap_or(s)
157        .trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_');
158    last.to_string()
159}
160
161fn collect_dep_edges(root: &std::path::Path) -> HashMap<String, Vec<String>> {
162    let mut out: HashMap<String, Vec<String>> = HashMap::new();
163    let crates_dir = root.join("crates");
164    let Ok(read) = fs::read_dir(&crates_dir) else {
165        return out;
166    };
167    for e in read.flatten() {
168        let p = e.path();
169        let cargo = p.join("Cargo.toml");
170        let Ok(text) = fs::read_to_string(&cargo) else {
171            continue;
172        };
173        let Some(name) = p.file_name().and_then(|s| s.to_str()) else {
174            continue;
175        };
176        let mut deps = Vec::new();
177        let mut in_deps = false;
178        for line in text.lines() {
179            let trimmed = line.trim();
180            if trimmed.starts_with('[') {
181                in_deps = trimmed == "[dependencies]" || trimmed == "[dev-dependencies]";
182                continue;
183            }
184            if !in_deps {
185                continue;
186            }
187            if let Some((dep_name, _)) = trimmed.split_once('=') {
188                let dep = dep_name.trim().to_string();
189                if !dep.is_empty() {
190                    deps.push(dep);
191                }
192            }
193        }
194        out.insert(name.to_string(), deps);
195    }
196    out
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use std::fs;
203    use tempfile::TempDir;
204
205    fn write_arch(root: &std::path::Path, body: &str) {
206        let dir = root.join("wiki");
207        fs::create_dir_all(&dir).unwrap();
208        fs::write(dir.join("architecture.md"), body).unwrap();
209    }
210
211    fn write_crate(root: &std::path::Path, name: &str, deps: &[&str]) {
212        let dir = root.join("crates").join(name);
213        fs::create_dir_all(&dir).unwrap();
214        let mut body = format!("[package]\nname = \"{name}\"\n\n[dependencies]\n");
215        for d in deps {
216            body.push_str(&format!("{d} = {{ path = \"../{d}\" }}\n"));
217        }
218        fs::write(dir.join("Cargo.toml"), body).unwrap();
219    }
220
221    #[test]
222    fn parse_zh_negation() {
223        let claims = parse_claims("注意:`koala-core` 不依赖 `koala-cli`。");
224        assert_eq!(claims.len(), 1);
225        assert_eq!(claims[0].from, "koala-core");
226        assert_eq!(claims[0].to, "koala-cli");
227    }
228
229    #[test]
230    fn parse_en_negation() {
231        let claims = parse_claims("Note: `koala-core` does not depend on `koala-cli`.");
232        assert_eq!(claims.len(), 1);
233        assert_eq!(claims[0].from, "koala-core");
234        assert_eq!(claims[0].to, "koala-cli");
235    }
236
237    #[test]
238    fn parse_extended_negation_markers() {
239        for (text, _from, _to) in &[
240            ("`a` 禁止依赖 `b`", "a", "b"),
241            ("`a` 不可依赖 `b`", "a", "b"),
242            ("`a` should not depend on `b`", "a", "b"),
243            ("`a` must never depend on `b`", "a", "b"),
244            ("`a` is forbidden to depend on `b`", "a", "b"),
245        ] {
246            let claims = parse_claims(text);
247            assert_eq!(claims.len(), 1, "marker missed: {text}");
248            assert_eq!(claims[0].from, "a", "from extraction failed: {text}");
249            assert_eq!(claims[0].to, "b", "to extraction failed: {text}");
250        }
251    }
252
253    #[test]
254    fn textual_claim_routed_to_rule_passes_when_graph_agrees() {
255        let tmp = TempDir::new().unwrap();
256        write_crate(tmp.path(), "koala-core", &[]);
257        write_crate(tmp.path(), "koala-cli", &["koala-core"]);
258        write_arch(tmp.path(), "# arch\n\n`koala-core` 不依赖 `koala-cli`。\n");
259        let ctx = Context::new(tmp.path().to_path_buf());
260        let findings = ArchClaims.run(&ctx);
261        assert!(findings.is_empty(), "{findings:?}");
262    }
263
264    #[test]
265    fn textual_claim_routed_to_rule_fails_when_graph_contradicts() {
266        let tmp = TempDir::new().unwrap();
267        // koala-core actually depends on koala-cli — but architecture.md
268        // claims it doesn't. Drift.
269        write_crate(tmp.path(), "koala-core", &["koala-cli"]);
270        write_crate(tmp.path(), "koala-cli", &[]);
271        write_arch(tmp.path(), "# arch\n\n`koala-core` 不依赖 `koala-cli`。\n");
272        let ctx = Context::new(tmp.path().to_path_buf());
273        let findings = ArchClaims.run(&ctx);
274        assert_eq!(findings.len(), 1, "{findings:?}");
275        assert_eq!(findings[0].severity, Severity::Hard);
276    }
277
278    #[test]
279    fn fenced_block_skipped() {
280        let tmp = TempDir::new().unwrap();
281        write_crate(tmp.path(), "koala-core", &["koala-cli"]);
282        write_crate(tmp.path(), "koala-cli", &[]);
283        write_arch(
284            tmp.path(),
285            "# arch\n\n```\n`koala-core` 不依赖 `koala-cli`\n```\n",
286        );
287        let ctx = Context::new(tmp.path().to_path_buf());
288        let findings = ArchClaims.run(&ctx);
289        assert!(findings.is_empty());
290    }
291}