Skip to content

Commit d49ad93

Browse files
authored
Merge pull request #920 from quarto-dev/chore/bd-9xbo5eee-lint-reject-committed-q2
lint: reject committed render output; untrack two stale renders
2 parents c7d299a + f4f3a17 commit d49ad93

6 files changed

Lines changed: 249 additions & 1316 deletions

File tree

‎AGENTS.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,6 +650,11 @@ do when authoring code:
650650
- **vendored-pandoc-filters / pandoc-pin-agreement**: keep the vendored
651651
filters' "Ours vs. pinned" section and the pandoc version pin in sync
652652
across CI / dev-setup / README.
653+
- **committed-render-output**: never commit rendered HTML (q2's
654+
generator stamp, or `X.html` beside a tracked `X.qmd`/`X.md`). Output
655+
that should stay current is generated and gitignored (`cargo xtask
656+
stage-doc-examples`); a deliberately frozen repro lives under
657+
`claude-notes/plans/<name>-investigation/`, the only allowed place.
653658

654659
### Adding New Lint Rules
655660

‎claude-notes/plans/2026-04-16-ts-engine-extensions-subprocess.html‎

Lines changed: 0 additions & 1260 deletions
This file was deleted.
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
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+
}

‎crates/xtask/src/lint/mod.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
66
mod add_file_with_id;
77
mod ci_test_wiring;
8+
mod committed_render_output;
89
mod error_docs;
910
mod error_docs_sidebar;
1011
mod external_sources;
@@ -124,6 +125,11 @@ pub fn run_check(config: &LintConfig) -> Result<()> {
124125
}
125126
all_violations.extend(vendored_registry::check(&workspace_root)?);
126127

128+
if config.verbose {
129+
eprintln!("Checking tracked files for committed render output");
130+
}
131+
all_violations.extend(committed_render_output::check(&workspace_root)?);
132+
127133
// Report results
128134
if all_violations.is_empty() {
129135
if !config.quiet {

‎examples/.gitignore‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Example sources are .qmd; `type: default` projects render their HTML in
2+
# place beside the source, and website projects into _site/. All of it is
3+
# output: regenerate it (`cargo xtask stage-doc-examples`), never commit
4+
# it. The `committed-render-output` lint (crates/xtask/src/lint/) fails on
5+
# any that slips through (bd-9xbo5eee).
6+
*.html
7+
8+
# Quarto 1 reference output, kept on purpose as a comparison fixture.
9+
!/websites/06-site-metadata/q1-site/**

‎examples/lmd/10-project/index.html‎

Lines changed: 0 additions & 56 deletions
This file was deleted.

0 commit comments

Comments
 (0)