Skip to main content

koala_drift/checks/
template_placeholder.rs

1//! `wiki.template-placeholder-unfilled` — detect `<...>` placeholders
2//! that `koala-core init` left in scaffolded Tier 2/3 wiki files. If
3//! they survive into a PR, the file was never filled in.
4//!
5//! Scope:
6//!   * a curated list of files that `koala-core init` writes for the
7//!     user to fill (`CLAUDE.md`, `wiki/architecture.md`, `wiki/vision.md`,
8//!     `wiki/roadmap.md`, `wiki/runbook.md`, `wiki/testing.md`,
9//!     `wiki/tech-debt.md`, `README.md`);
10//!   * every `wiki/features/*.md` except `_template.md` / `_index.md`
11//!     (catches "copied template, forgot to fill it in").
12//!
13//! Skipped:
14//!   * Tier 1 auto-generated files (`_index.md`, `_tags/*`, `health.md`)
15//!     — `tier1.no-hand-edit` already guards them;
16//!   * lines inside fenced code blocks (`` ``` ``);
17//!   * spans inside inline code (`` `...` ``) — `<id>` in
18//!     `` `path/<id>/file.json` `` is a path parameter, not a template
19//!     placeholder;
20//!   * HTML comments (`<!-- ... -->`).
21
22use 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
30/// Hand-curated list of Tier 2/3 files that `koala-core init` writes
31/// containing `<...>` placeholders the user is expected to substitute.
32/// Keep in sync with `templates/`.
33const 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
44/// `<...>` where the inner span is 1–120 chars and contains no `<`,
45/// `>`, newline, or leading `!`. The `!` exclusion drops `<!-- ... -->`
46/// HTML comments at the regex level (those start with `<!`).
47fn 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
110/// Byte ranges (start..end, exclusive) covering inline-code spans
111/// `` `...` `` on a single line. Skips fenced delimiters (lines starting
112/// with ` ``` ` are handled at a higher level by `tagged_lines`). Treats
113/// every backtick run as an opener-or-closer pair; unmatched trailing
114/// backticks are ignored.
115fn 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                // include closing backtick in the span
128                spans.push((open, i + 1));
129                i += 1;
130            } else {
131                // unmatched — drop
132                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
145/// Filter the few legitimate inline-HTML tags we don't want to flag.
146/// Markdown templates rarely contain raw HTML, but this keeps the
147/// check honest if `<br>` etc. ever sneak in.
148fn 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        // _template.md is excluded; my-feature.md should flag.
256        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        // Real templates contain things like `<elevator pitch,<= 30 字>`.
266        // The outer `<...>` doesn't match (nested `<`), but the inner
267        // `<= 30 字>` does — that's enough to flag the line.
268        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        // No wiki/ dir at all — check should be a no-op, not panic.
284        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        // A random markdown file outside the scaffolded list should
292        // never be scanned, even if it has angle brackets.
293        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        // Real-world: `wiki/architecture.md` table row
306        //   | `storage/agents/<id>/config.json` | per-agent config |
307        // `<id>` is path notation, not a template placeholder.
308        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        // Make sure the inline-code skip doesn't over-skip: a token
324        // OUTSIDE backticks on the same line should still flag.
325        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}