1use crate::check::{Check, Finding, FindingKind, Severity};
23use crate::scan::{list_feature_files, rel, tagged_lines};
24use koala_core::invariant::Context;
25use regex::Regex;
26use std::fs;
27use std::path::PathBuf;
28use std::sync::OnceLock;
29
30const SCAFFOLDED_FILES: &[&str] = &[
34 "CLAUDE.md",
35 "README.md",
36 "wiki/architecture.md",
37 "wiki/vision.md",
38 "wiki/roadmap.md",
39 "wiki/runbook.md",
40 "wiki/testing.md",
41 "wiki/tech-debt.md",
42];
43
44fn placeholder_re() -> &'static Regex {
48 static RE: OnceLock<Regex> = OnceLock::new();
49 RE.get_or_init(|| Regex::new(r"<([^<>!\n][^<>\n]{0,119})>").unwrap())
50}
51
52pub struct TemplatePlaceholder;
53
54impl Check for TemplatePlaceholder {
55 fn id(&self) -> &'static str {
56 "wiki.template-placeholder-unfilled"
57 }
58
59 fn intent(&self) -> &'static str {
60 "Tier 2/3 wiki files scaffolded by `koala-core init` must have \
61 their `<...>` placeholders replaced with real content — leaving \
62 them in means the file was never filled."
63 }
64
65 fn run(&self, ctx: &Context) -> Vec<Finding> {
66 let mut out = Vec::new();
67 let mut targets: Vec<PathBuf> = SCAFFOLDED_FILES
68 .iter()
69 .map(|p| ctx.root().join(p))
70 .collect();
71 targets.extend(list_feature_files(ctx.root()));
72
73 for path in &targets {
74 let Ok(content) = fs::read_to_string(path) else {
75 continue;
76 };
77 let display = rel(path, ctx.root());
78 for line in tagged_lines(&content) {
79 if line.in_fence {
80 continue;
81 }
82 let inline_code = inline_code_spans(line.text);
83 for m in placeholder_re().find_iter(line.text) {
84 let token = m.as_str();
85 if looks_like_html_tag(token) {
86 continue;
87 }
88 if in_any_span(m.start(), &inline_code) {
89 continue;
90 }
91 out.push(Finding {
92 check_id: self.id(),
93 file: display.clone(),
94 line: line.line_no,
95 claim: token.to_string(),
96 kind: FindingKind::TemplatePlaceholderUnfilled,
97 severity: Severity::Hard,
98 fix_hint: Some(format!(
99 "replace `{token}` with real project content; \
100 `koala-core init` left this placeholder for you to fill"
101 )),
102 });
103 }
104 }
105 }
106 out
107 }
108}
109
110fn inline_code_spans(line: &str) -> Vec<(usize, usize)> {
116 let bytes = line.as_bytes();
117 let mut spans = Vec::new();
118 let mut i = 0;
119 while i < bytes.len() {
120 if bytes[i] == b'`' {
121 let open = i;
122 i += 1;
123 while i < bytes.len() && bytes[i] != b'`' {
124 i += 1;
125 }
126 if i < bytes.len() {
127 spans.push((open, i + 1));
129 i += 1;
130 } else {
131 break;
133 }
134 } else {
135 i += 1;
136 }
137 }
138 spans
139}
140
141fn in_any_span(pos: usize, spans: &[(usize, usize)]) -> bool {
142 spans.iter().any(|&(s, e)| pos >= s && pos < e)
143}
144
145fn looks_like_html_tag(token: &str) -> bool {
149 let inner = token
150 .strip_prefix('<')
151 .and_then(|s| s.strip_suffix('>'))
152 .unwrap_or(token);
153 matches!(
154 inner.trim().to_ascii_lowercase().as_str(),
155 "br" | "hr" | "p" | "li" | "ul" | "ol" | "td" | "tr" | "th" | "table" | "sub" | "sup"
156 )
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162 use std::fs;
163 use tempfile::TempDir;
164
165 fn write(dir: &std::path::Path, rel: &str, body: &str) {
166 let p = dir.join(rel);
167 fs::create_dir_all(p.parent().unwrap()).unwrap();
168 fs::write(p, body).unwrap();
169 }
170
171 #[test]
172 fn flags_unfilled_architecture_md() {
173 let tmp = TempDir::new().unwrap();
174 write(
175 tmp.path(),
176 "wiki/architecture.md",
177 "# Architecture\n\n## 模块清单\n\n| <module-1> | <职责> | <依赖> |\n",
178 );
179 let ctx = Context::new(tmp.path().to_path_buf());
180 let findings = TemplatePlaceholder.run(&ctx);
181 assert_eq!(findings.len(), 3, "{findings:#?}");
182 assert!(findings
183 .iter()
184 .all(|f| matches!(f.kind, FindingKind::TemplatePlaceholderUnfilled)));
185 assert!(findings.iter().any(|f| f.claim == "<module-1>"));
186 assert!(findings.iter().any(|f| f.claim == "<职责>"));
187 }
188
189 #[test]
190 fn ignores_filled_in_file() {
191 let tmp = TempDir::new().unwrap();
192 write(
193 tmp.path(),
194 "wiki/architecture.md",
195 "# Architecture\n\n## 模块清单\n\n| world | 状态容器 | core |\n",
196 );
197 let ctx = Context::new(tmp.path().to_path_buf());
198 assert!(TemplatePlaceholder.run(&ctx).is_empty());
199 }
200
201 #[test]
202 fn ignores_html_comments() {
203 let tmp = TempDir::new().unwrap();
204 write(
205 tmp.path(),
206 "wiki/architecture.md",
207 "# Architecture\n\n<!-- AUTO-GENERATED -->\n\nfilled.\n",
208 );
209 let ctx = Context::new(tmp.path().to_path_buf());
210 assert!(TemplatePlaceholder.run(&ctx).is_empty());
211 }
212
213 #[test]
214 fn ignores_fenced_code_block() {
215 let tmp = TempDir::new().unwrap();
216 write(
217 tmp.path(),
218 "wiki/architecture.md",
219 "# Architecture\n\n```\nfn foo<T>(x: T) {}\n```\n\nfilled.\n",
220 );
221 let ctx = Context::new(tmp.path().to_path_buf());
222 assert!(
223 TemplatePlaceholder.run(&ctx).is_empty(),
224 "generic <T> inside code fence should not flag"
225 );
226 }
227
228 #[test]
229 fn ignores_html_inline_tag_allowlist() {
230 let tmp = TempDir::new().unwrap();
231 write(
232 tmp.path(),
233 "wiki/architecture.md",
234 "# Architecture\n\nLine one.<br>\nLine two.\n",
235 );
236 let ctx = Context::new(tmp.path().to_path_buf());
237 assert!(TemplatePlaceholder.run(&ctx).is_empty());
238 }
239
240 #[test]
241 fn scans_user_feature_files_not_template() {
242 let tmp = TempDir::new().unwrap();
243 write(
244 tmp.path(),
245 "wiki/features/_template.md",
246 "# Feature\n\n<elevator pitch>\n",
247 );
248 write(
249 tmp.path(),
250 "wiki/features/my-feature.md",
251 "# Feature\n\n<elevator pitch>\n",
252 );
253 let ctx = Context::new(tmp.path().to_path_buf());
254 let findings = TemplatePlaceholder.run(&ctx);
255 assert_eq!(findings.len(), 1, "{findings:#?}");
257 assert!(findings[0]
258 .file
259 .to_string_lossy()
260 .ends_with("my-feature.md"));
261 }
262
263 #[test]
264 fn nested_placeholder_still_flags_inner_token() {
265 let tmp = TempDir::new().unwrap();
269 write(
270 tmp.path(),
271 "wiki/architecture.md",
272 "# Architecture\n\n<elevator pitch,<= 30 字>\n",
273 );
274 let ctx = Context::new(tmp.path().to_path_buf());
275 let findings = TemplatePlaceholder.run(&ctx);
276 assert_eq!(findings.len(), 1, "{findings:#?}");
277 assert_eq!(findings[0].claim, "<= 30 字>");
278 assert_eq!(findings[0].line, 3);
279 }
280
281 #[test]
282 fn missing_target_file_is_silent() {
283 let tmp = TempDir::new().unwrap();
285 let ctx = Context::new(tmp.path().to_path_buf());
286 assert!(TemplatePlaceholder.run(&ctx).is_empty());
287 }
288
289 #[test]
290 fn skips_non_scaffolded_files() {
291 let tmp = TempDir::new().unwrap();
294 write(
295 tmp.path(),
296 "wiki/other.md",
297 "# Other\n\n<would-flag-if-scanned>\n",
298 );
299 let ctx = Context::new(tmp.path().to_path_buf());
300 assert!(TemplatePlaceholder.run(&ctx).is_empty());
301 }
302
303 #[test]
304 fn ignores_path_parameters_inside_inline_code() {
305 let tmp = TempDir::new().unwrap();
309 write(
310 tmp.path(),
311 "wiki/architecture.md",
312 "# Architecture\n\n| `storage/<id>/config.json` | filled in |\n",
313 );
314 let ctx = Context::new(tmp.path().to_path_buf());
315 assert!(
316 TemplatePlaceholder.run(&ctx).is_empty(),
317 "<id> inside backticks must not flag"
318 );
319 }
320
321 #[test]
322 fn flags_token_outside_backticks_on_same_line() {
323 let tmp = TempDir::new().unwrap();
326 write(
327 tmp.path(),
328 "wiki/architecture.md",
329 "# Architecture\n\nReal `<safe>` content but <unfilled> here.\n",
330 );
331 let ctx = Context::new(tmp.path().to_path_buf());
332 let findings = TemplatePlaceholder.run(&ctx);
333 assert_eq!(findings.len(), 1, "{findings:#?}");
334 assert_eq!(findings[0].claim, "<unfilled>");
335 }
336
337 #[test]
338 fn fix_hint_quotes_the_token() {
339 let tmp = TempDir::new().unwrap();
340 write(
341 tmp.path(),
342 "wiki/vision.md",
343 "# Vision\n\n<one-liner project pitch>\n",
344 );
345 let ctx = Context::new(tmp.path().to_path_buf());
346 let findings = TemplatePlaceholder.run(&ctx);
347 assert_eq!(findings.len(), 1);
348 let hint = findings[0].fix_hint.as_deref().unwrap_or("");
349 assert!(hint.contains("<one-liner project pitch>"), "{hint}");
350 assert!(hint.contains("koala-core init"), "{hint}");
351 }
352}