Skip to main content

spec_driven_docs/gates/
agents_digest_size.rs

1//! Gate: agent digests stay within their line budgets, or within the
2//! ceiling its project recorded for one.
3//!
4//! The root `AGENTS.md` is loaded into every session, so its budget is 100
5//! lines; a subtree digest gets 150. Vendored trees are pruned — a gate a
6//! consumer cannot satisfy short of deleting `node_modules/` is a gate they
7//! turn off. What a digest says is review's business; only size is judged.
8
9use crate::domain::debt::Measurement;
10use crate::domain::finding::Finding;
11use crate::domain::gate_id::GateId;
12use crate::domain::rule_id::RuleId;
13use crate::gates::budget;
14use crate::gates::{GateCtx, GateError, GateResult, Violation, line_count, read_text, walk_files};
15
16/// The rules this gate can cite.
17pub const CITES: &[RuleId] = &[RuleId::AuthorInstructionsStayWithinBudget];
18
19const RULE: RuleId = RuleId::AuthorInstructionsStayWithinBudget;
20
21/// Measure every agent digest: one `lines` count per file.
22///
23/// # Errors
24///
25/// [`GateError::Io`] when a digest cannot be read.
26pub fn measure(ctx: &GateCtx) -> Result<Vec<Measurement>, GateError> {
27    let mut measurements = Vec::new();
28    for file in walk_files(ctx) {
29        if file.file_name() != Some("AGENTS.md") {
30            continue;
31        }
32        let cap = if file == "./AGENTS.md" { 100 } else { 150 };
33        let lines = line_count(&read_text(ctx, &file)?);
34        measurements.push(Measurement::count(
35            GateId::AgentsDigestSize,
36            file.as_str(),
37            "lines",
38            lines,
39            cap,
40        ));
41    }
42    Ok(measurements)
43}
44
45/// Judge every agent digest in the repository.
46///
47/// # Errors
48///
49/// [`GateError::Io`] when a digest cannot be read, and [`GateError::Debt`]
50/// when the debt file cannot be trusted.
51pub fn run(ctx: &GateCtx, _files: &[String]) -> GateResult {
52    if !ctx.path("AGENTS.md").is_file() {
53        return Ok(vec![Violation::Layout("no root AGENTS.md".to_string())]);
54    }
55    let debt = budget::read_debt(ctx)?;
56    let measurements = measure(ctx)?;
57    Ok(budget::judge(
58        &debt,
59        GateId::AgentsDigestSize,
60        RULE,
61        &measurements,
62        |m| Violation::Finding(Finding::on_file(RULE, m.path.as_str(), "")),
63    ))
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    fn write(dir: &tempfile::TempDir, path: &str, lines: usize) {
71        let path = dir.path().join(path);
72        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
73        std::fs::write(path, "line\n".repeat(lines)).unwrap();
74    }
75
76    fn run_in(dir: &tempfile::TempDir) -> Vec<String> {
77        let ctx = GateCtx::new(dir.path().to_str().unwrap());
78        run(&ctx, &[])
79            .unwrap()
80            .iter()
81            .map(ToString::to_string)
82            .collect()
83    }
84
85    #[test]
86    fn accepts_digests_within_budget() {
87        let dir = tempfile::tempdir().unwrap();
88        write(&dir, "AGENTS.md", 100);
89        write(&dir, "method/AGENTS.md", 150);
90        assert!(run_in(&dir).is_empty());
91    }
92
93    #[test]
94    fn rejects_a_root_digest_over_its_budget() {
95        let dir = tempfile::tempdir().unwrap();
96        write(&dir, "AGENTS.md", 101);
97        let out = run_in(&dir);
98        assert_eq!(
99            out,
100            vec!["FAIL docs-format:author-instructions-stay-within-budget ./AGENTS.md".to_string()]
101        );
102    }
103
104    #[test]
105    fn ignores_vendored_digests() {
106        let dir = tempfile::tempdir().unwrap();
107        write(&dir, "AGENTS.md", 1);
108        write(&dir, "node_modules/pkg/AGENTS.md", 400);
109        assert!(run_in(&dir).is_empty());
110    }
111
112    #[test]
113    fn a_missing_root_digest_is_a_layout_failure() {
114        let dir = tempfile::tempdir().unwrap();
115        assert_eq!(run_in(&dir), vec!["FAIL no root AGENTS.md".to_string()]);
116    }
117
118    #[test]
119    fn a_recorded_ceiling_carries_an_oversize_digest() {
120        let dir = tempfile::tempdir().unwrap();
121        write(&dir, "AGENTS.md", 120);
122        std::fs::create_dir_all(dir.path().join(".spec-driven-docs")).unwrap();
123        std::fs::write(
124            dir.path().join(".spec-driven-docs/debt.yaml"),
125            "schema_version: 1\nagents-digest-size:\n  AGENTS.md:\n    lines:\n      ceiling: 120\n",
126        )
127        .unwrap();
128        assert!(run_in(&dir).is_empty());
129    }
130}