|
| 1 | +//! Lint rule: rendered output is not committed (bd-9xbo5eee). |
| 2 | +//! |
| 3 | +//! Output that `q2` renders goes stale with every release, and nothing |
| 4 | +//! keeps a committed copy current. `examples/lmd/10-project/index.html` |
| 5 | +//! was committed by accident alongside its sources (the examples' |
| 6 | +//! `.gitignore` only listed `document.html` and `slides.html`) and sat |
| 7 | +//! there two minor versions out of date, rewritten by every |
| 8 | +//! `cargo xtask stage-doc-examples` run. |
| 9 | +//! |
| 10 | +//! Rendered output belongs in one of two places: |
| 11 | +//! |
| 12 | +//! - **generated and gitignored**, when it should be current — the |
| 13 | +//! `stage-doc-examples` / `build-agents-docs` pattern; |
| 14 | +//! - **frozen, under `claude-notes/plans/<name>-investigation/`**, when |
| 15 | +//! it is evidence of what a bug looked like at a given commit. Those |
| 16 | +//! files are allowed and must never be re-rendered. |
| 17 | +//! |
| 18 | +//! A tracked `.html` file is flagged when either |
| 19 | +//! |
| 20 | +//! 1. it carries q2's generator stamp |
| 21 | +//! (`<meta name="generator" content="quarto-rust-…">`), or |
| 22 | +//! 2. it sits beside a tracked source with the same stem (`slides.html` |
| 23 | +//! next to `slides.qmd`) — in-place render output. This catches |
| 24 | +//! formats whose templates carry no stamp (revealjs) and output from |
| 25 | +//! other Quarto versions. |
| 26 | +
|
| 27 | +use std::collections::HashSet; |
| 28 | +use std::path::Path; |
| 29 | +use std::process::Command; |
| 30 | + |
| 31 | +use anyhow::{Context, Result, bail}; |
| 32 | + |
| 33 | +use super::Violation; |
| 34 | + |
| 35 | +const RULE_NAME: &str = "committed-render-output"; |
| 36 | + |
| 37 | +/// q2's HTML generator stamp (`crates/quarto-core/src/template.rs`). |
| 38 | +const Q2_GENERATOR: &str = "<meta name=\"generator\" content=\"quarto-rust-"; |
| 39 | + |
| 40 | +/// Source extensions whose same-stem `.html` sibling is render output. |
| 41 | +const SOURCE_EXTENSIONS: &[&str] = &["qmd", "md", "ipynb", "Rmd"]; |
| 42 | + |
| 43 | +const SUGGESTION: &str = "untrack it with `git rm --cached <file>` and gitignore the output; \ |
| 44 | + if it should stay current, generate it (like `cargo xtask stage-doc-examples`); if it is \ |
| 45 | + a deliberately frozen repro, keep it under `claude-notes/plans/<name>-investigation/`"; |
| 46 | + |
| 47 | +/// Run the check over the files git tracks under `workspace_root`. |
| 48 | +pub fn check(workspace_root: &Path) -> Result<Vec<Violation>> { |
| 49 | + let output = Command::new("git") |
| 50 | + .args(["ls-files", "-z"]) |
| 51 | + .current_dir(workspace_root) |
| 52 | + .output() |
| 53 | + .context("running `git ls-files`")?; |
| 54 | + if !output.status.success() { |
| 55 | + bail!( |
| 56 | + "`git ls-files` failed: {}", |
| 57 | + String::from_utf8_lossy(&output.stderr) |
| 58 | + ); |
| 59 | + } |
| 60 | + let tracked: Vec<String> = output |
| 61 | + .stdout |
| 62 | + .split(|b| *b == 0) |
| 63 | + .filter(|p| !p.is_empty()) |
| 64 | + .map(|p| String::from_utf8_lossy(p).into_owned()) |
| 65 | + .collect(); |
| 66 | + check_files(workspace_root, &tracked) |
| 67 | +} |
| 68 | + |
| 69 | +/// Run the check over `tracked` (`/`-separated, root-relative paths). |
| 70 | +fn check_files(workspace_root: &Path, tracked: &[String]) -> Result<Vec<Violation>> { |
| 71 | + let tracked_set: HashSet<&str> = tracked.iter().map(String::as_str).collect(); |
| 72 | + let mut violations = Vec::new(); |
| 73 | + for rel in tracked { |
| 74 | + let Some(stem) = rel.strip_suffix(".html") else { |
| 75 | + continue; |
| 76 | + }; |
| 77 | + if is_allowed(rel) { |
| 78 | + continue; |
| 79 | + } |
| 80 | + let path = workspace_root.join(rel); |
| 81 | + // A tracked file can be missing from the working tree (deleted, |
| 82 | + // not yet committed); there is nothing to check then. |
| 83 | + let Ok(bytes) = std::fs::read(&path) else { |
| 84 | + continue; |
| 85 | + }; |
| 86 | + let text = String::from_utf8_lossy(&bytes); |
| 87 | + if let Some(line) = text.lines().position(|l| l.contains(Q2_GENERATOR)) { |
| 88 | + violations.push(Violation { |
| 89 | + file: path, |
| 90 | + line: line + 1, |
| 91 | + column: 1, |
| 92 | + rule: RULE_NAME, |
| 93 | + message: "committed file is q2 render output (it carries q2's generator stamp)" |
| 94 | + .to_string(), |
| 95 | + suggestion: Some(SUGGESTION.to_string()), |
| 96 | + }); |
| 97 | + continue; |
| 98 | + } |
| 99 | + if let Some(source) = SOURCE_EXTENSIONS |
| 100 | + .iter() |
| 101 | + .map(|ext| format!("{stem}.{ext}")) |
| 102 | + .find(|source| tracked_set.contains(source.as_str())) |
| 103 | + { |
| 104 | + violations.push(Violation { |
| 105 | + file: path, |
| 106 | + line: 1, |
| 107 | + column: 1, |
| 108 | + rule: RULE_NAME, |
| 109 | + message: format!("committed file is render output of its sibling `{source}`"), |
| 110 | + suggestion: Some(SUGGESTION.to_string()), |
| 111 | + }); |
| 112 | + } |
| 113 | + } |
| 114 | + Ok(violations) |
| 115 | +} |
| 116 | + |
| 117 | +/// Frozen repro artifacts live in investigation directories. |
| 118 | +fn is_allowed(rel: &str) -> bool { |
| 119 | + let Some(rest) = rel.strip_prefix("claude-notes/plans/") else { |
| 120 | + return false; |
| 121 | + }; |
| 122 | + rest.split('/') |
| 123 | + .rev() |
| 124 | + .skip(1) // the file name itself |
| 125 | + .any(|dir| dir.ends_with("-investigation")) |
| 126 | +} |
| 127 | + |
| 128 | +#[cfg(test)] |
| 129 | +mod tests { |
| 130 | + use super::*; |
| 131 | + |
| 132 | + fn tree(files: &[(&str, &str)]) -> (tempfile::TempDir, Vec<String>) { |
| 133 | + let tmp = tempfile::TempDir::new().unwrap(); |
| 134 | + for (rel, contents) in files { |
| 135 | + let path = tmp.path().join(rel); |
| 136 | + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); |
| 137 | + std::fs::write(path, contents).unwrap(); |
| 138 | + } |
| 139 | + let tracked = files.iter().map(|(rel, _)| rel.to_string()).collect(); |
| 140 | + (tmp, tracked) |
| 141 | + } |
| 142 | + |
| 143 | + const STAMPED: &str = "<!DOCTYPE html>\n<html>\n<head>\n\ |
| 144 | + <meta name=\"generator\" content=\"quarto-rust-0.33.0\">\n"; |
| 145 | + |
| 146 | + fn flagged(files: &[(&str, &str)]) -> Vec<(String, usize, String)> { |
| 147 | + let (tmp, tracked) = tree(files); |
| 148 | + check_files(tmp.path(), &tracked) |
| 149 | + .unwrap() |
| 150 | + .into_iter() |
| 151 | + .map(|v| { |
| 152 | + let rel = v |
| 153 | + .file |
| 154 | + .strip_prefix(tmp.path()) |
| 155 | + .unwrap() |
| 156 | + .to_string_lossy() |
| 157 | + .replace('\\', "/"); |
| 158 | + (rel, v.line, v.message) |
| 159 | + }) |
| 160 | + .collect() |
| 161 | + } |
| 162 | + |
| 163 | + #[test] |
| 164 | + fn flags_q2_stamped_html_with_its_line() { |
| 165 | + let got = flagged(&[("site/_site/page.html", STAMPED)]); |
| 166 | + assert_eq!(got.len(), 1, "{got:?}"); |
| 167 | + assert_eq!(got[0].0, "site/_site/page.html"); |
| 168 | + assert_eq!(got[0].1, 4); |
| 169 | + assert!(got[0].2.contains("generator stamp"), "{}", got[0].2); |
| 170 | + } |
| 171 | + |
| 172 | + #[test] |
| 173 | + fn flags_unstamped_html_beside_its_source() { |
| 174 | + // revealjs output carries no generator stamp. |
| 175 | + let got = flagged(&[ |
| 176 | + ("examples/p/slides.qmd", "---\nformat: revealjs\n---\n"), |
| 177 | + ("examples/p/slides.html", "<div class=\"reveal\"></div>"), |
| 178 | + ("notes/plan.md", "# Plan\n"), |
| 179 | + ( |
| 180 | + "notes/plan.html", |
| 181 | + "<meta name=\"generator\" content=\"quarto-99.9.9\">", |
| 182 | + ), |
| 183 | + ]); |
| 184 | + let files: Vec<&str> = got.iter().map(|(f, _, _)| f.as_str()).collect(); |
| 185 | + assert_eq!(files, ["examples/p/slides.html", "notes/plan.html"]); |
| 186 | + assert!(got[0].2.contains("`examples/p/slides.qmd`"), "{}", got[0].2); |
| 187 | + } |
| 188 | + |
| 189 | + #[test] |
| 190 | + fn allows_investigation_artifacts() { |
| 191 | + let got = flagged(&[ |
| 192 | + ("claude-notes/plans/foo-investigation/repro.qmd", "x"), |
| 193 | + ("claude-notes/plans/foo-investigation/repro.html", STAMPED), |
| 194 | + ( |
| 195 | + "claude-notes/plans/bar-investigation/out/observed.html", |
| 196 | + STAMPED, |
| 197 | + ), |
| 198 | + ]); |
| 199 | + assert!(got.is_empty(), "{got:?}"); |
| 200 | + } |
| 201 | + |
| 202 | + #[test] |
| 203 | + fn investigation_allowance_is_scoped_to_claude_notes_plans() { |
| 204 | + let got = flagged(&[("examples/x-investigation/page.html", STAMPED)]); |
| 205 | + assert_eq!(got.len(), 1, "{got:?}"); |
| 206 | + } |
| 207 | + |
| 208 | + #[test] |
| 209 | + fn ignores_hand_written_and_foreign_html() { |
| 210 | + let got = flagged(&[ |
| 211 | + // Quarto 1 reference fixture: other generator, no sibling source. |
| 212 | + ( |
| 213 | + "examples/site/q1-site/index.html", |
| 214 | + "<meta name=\"generator\" content=\"quarto-99.9.9\">", |
| 215 | + ), |
| 216 | + ("examples/site/index.qmd", "x"), |
| 217 | + ("resources/partials/header.html", "<header></header>"), |
| 218 | + ("notes/page.md", "x"), |
| 219 | + ]); |
| 220 | + assert!(got.is_empty(), "{got:?}"); |
| 221 | + } |
| 222 | + |
| 223 | + #[test] |
| 224 | + fn tracked_but_deleted_files_are_skipped() { |
| 225 | + let (tmp, mut tracked) = tree(&[("a.qmd", "x")]); |
| 226 | + tracked.push("a.html".to_string()); |
| 227 | + assert!(check_files(tmp.path(), &tracked).unwrap().is_empty()); |
| 228 | + } |
| 229 | +} |
0 commit comments