1use 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
96fn 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
115fn 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
138fn 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 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}