Skip to main content

amont_agent_process/
plan_text.rs

1//! A plan's text as reviews bind to it: the canonical body the panel's sha
2//! is taken over, its fences, and what a repository's `package.json` says
3//! about having an interface. Shared by the plan-review panel, preview
4//! approvals and `plan-apply`, so none of them reaches into another for it.
5
6use amont_agent_guard::sha256::{hex, sha256};
7
8pub(crate) const REVIEW_SECTION: &str = "Review panel";
9const FULL_REVIEWS: &str = "Full reviews";
10
11#[cfg(test)]
12thread_local! {
13    /// Set by a test to make [`body_sha`] panic, as a parser bug would.
14    pub(crate) static PARSER_PANICS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
15}
16
17/// The plan as reviewed: CRLF → LF; YAML front matter (a `---` first line
18/// through the next `---`) and the blank lines after it removed; the
19/// `## Review panel` section, the `## Full reviews …` section to the end,
20/// and a machine comment on the last non-empty line removed; trailing space
21/// and tab trimmed per line; one final newline. Headings inside code fences
22/// are text. Idempotent.
23///
24/// Front matter is skipped because a plan gains it when it lands in a
25/// repository (`docs/plans/`, ADR-0022). The landed copy then hashes like
26/// the approved one the reviewers read, so landing can be checked on the
27/// file it writes.
28pub fn canonical(text: &str) -> String {
29    let text = text.replace("\r\n", "\n");
30    let lines: Vec<&str> = text.split('\n').collect();
31    let comment = lines
32        .iter()
33        .rposition(|l| !l.trim().is_empty())
34        .filter(|&i| is_machine_comment(lines[i]));
35    let mut start = front_matter_end(&lines);
36    while start < lines.len() && lines[start].trim().is_empty() {
37        start += 1;
38    }
39    let mut out: Vec<&str> = Vec::new();
40    let mut fence = Fence::default();
41    let mut dropping = false;
42    for (i, line) in lines.iter().enumerate().skip(start) {
43        if Some(i) == comment {
44            continue;
45        }
46        if let Some(title) = fence.h2(line) {
47            if title.starts_with(FULL_REVIEWS) {
48                break;
49            }
50            dropping = title == REVIEW_SECTION;
51        }
52        if !dropping {
53            out.push(line.trim_end_matches([' ', '\t']));
54        }
55    }
56    let mut body = out.join("\n");
57    body.truncate(body.trim_end_matches(['\n', ' ', '\t']).len());
58    body.push('\n');
59    body
60}
61
62/// Whether the byte at `offset` of `text` lies in a region [`canonical`]
63/// strips: front matter, `## Review panel`, `## Full reviews` and after, or
64/// the machine comment. An edit there does not change the plan's sha.
65pub(crate) fn outside_body(text: &str, offset: usize) -> bool {
66    let lines: Vec<&str> = text.split('\n').collect();
67    let comment = lines
68        .iter()
69        .rposition(|l| !l.trim().is_empty())
70        .filter(|&i| is_machine_comment(lines[i]));
71    let front = front_matter_end(&lines);
72    let mut fence = Fence::default();
73    let (mut dropping, mut reviews) = (false, false);
74    let mut start = 0;
75    for (i, line) in lines.iter().enumerate() {
76        let end = start + line.len() + 1;
77        if i >= front {
78            if let Some(title) = fence.h2(line) {
79                reviews |= title.starts_with(FULL_REVIEWS);
80                dropping = title == REVIEW_SECTION;
81            }
82        }
83        if offset < end {
84            return i < front || Some(i) == comment || reviews || dropping;
85        }
86        start = end;
87    }
88    false
89}
90
91/// The index of the first line after YAML front matter, or 0 when there is
92/// none: a `---` first line with no closing `---` is text, not front matter.
93fn front_matter_end(lines: &[&str]) -> usize {
94    if lines.first().map(|l| l.trim_end()) != Some("---") {
95        return 0;
96    }
97    lines
98        .iter()
99        .enumerate()
100        .skip(1)
101        .find(|(_, l)| l.trim_end() == "---")
102        .map_or(0, |(i, _)| i + 1)
103}
104
105/// sha256 of what the canonical body says, read as CommonMark
106/// ([`crate::plan_words::words`]), 64 lowercase hex: a formatter that only
107/// moves blank lines, list markers or table padding keeps it.
108pub fn body_sha(text: &str) -> String {
109    #[cfg(test)]
110    if PARSER_PANICS.with(|p| p.get()) {
111        panic!("injected parser panic");
112    }
113    hex(&sha256(
114        crate::plan_words::words(&canonical(text)).as_bytes(),
115    ))
116}
117
118// LEGACY: the byte sha every review was bound to before 2.25. Accepted
119// alongside `body_sha` until the journal shows no `match=legacy` for 14 days.
120/// sha256 of the canonical body's bytes, 64 lowercase hex.
121pub fn legacy_sha(text: &str) -> String {
122    hex(&sha256(canonical(text).as_bytes()))
123}
124
125/// Both shas of a plan.
126pub struct Shas {
127    pub sha: String,
128    pub legacy: String,
129}
130
131/// [`body_sha`] and [`legacy_sha`], with a panic in the Markdown parser
132/// caught here: the hook's own guard turns a panic into silence, which for
133/// this rule would let a plan through unreviewed.
134pub fn shas(text: &str) -> Result<Shas, ShaError> {
135    guarded(|| body_sha(text)).map(|sha| Shas {
136        sha,
137        legacy: legacy_sha(text),
138    })
139}
140
141pub(crate) fn guarded(f: impl FnOnce() -> String) -> Result<String, ShaError> {
142    std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).map_err(|_| ShaError::ParserPanicked)
143}
144
145/// Why a plan's sha could not be computed.
146#[derive(Debug, PartialEq)]
147pub enum ShaError {
148    ParserPanicked,
149}
150
151impl std::fmt::Display for ShaError {
152    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153        match self {
154            ShaError::ParserPanicked => {
155                f.write_str("the plan's sha cannot be computed (the Markdown parser panicked)")
156            }
157        }
158    }
159}
160
161pub(crate) fn is_machine_comment(line: &str) -> bool {
162    let t = line.trim();
163    t.starts_with("<!-- panel:") && t.ends_with("-->")
164}
165
166/// Tracks fenced code so that a `## Review panel` inside an example is text.
167#[derive(Default)]
168pub(crate) struct Fence {
169    pub(crate) open: Option<(char, usize)>,
170}
171
172impl Fence {
173    /// The line's text when it is prose outside any fence, indented at most
174    /// 3; updates the fence state. A fence line, a line inside code, and an
175    /// indented line are `None`.
176    pub(crate) fn prose<'a>(&mut self, line: &'a str) -> Option<&'a str> {
177        let indent = line.len() - line.trim_start_matches(' ').len();
178        let t = line.trim_start_matches(' ');
179        if indent <= 3 {
180            for c in ['`', '~'] {
181                let run = t.len() - t.trim_start_matches(c).len();
182                if run >= 3 {
183                    match self.open {
184                        None => self.open = Some((c, run)),
185                        Some((o, n)) if o == c && run >= n && t[run..].trim().is_empty() => {
186                            self.open = None
187                        }
188                        _ => {}
189                    }
190                    return None;
191                }
192            }
193        }
194        if self.open.is_some() || indent > 3 {
195            return None;
196        }
197        Some(t)
198    }
199
200    /// The title of an ATX H2 outside any fence; updates the fence state.
201    pub(crate) fn h2<'a>(&mut self, line: &'a str) -> Option<&'a str> {
202        let title = self.prose(line)?.strip_prefix("## ")?;
203        Some(title.trim().trim_end_matches('#').trim_end())
204    }
205
206    /// The title of an ATX heading of any level outside any fence; updates
207    /// the fence state.
208    pub(crate) fn heading<'a>(&mut self, line: &'a str) -> Option<&'a str> {
209        let t = self.prose(line)?;
210        let hashes = t.len() - t.trim_start_matches('#').len();
211        if hashes == 0 || hashes > 6 {
212            return None;
213        }
214        let rest = t[hashes..].strip_prefix(' ')?;
215        Some(rest.trim().trim_end_matches('#').trim_end())
216    }
217}
218
219/// Packages whose presence says "this renders something".
220const UI_PACKAGES: &[&str] = &[
221    "react",
222    "react-dom",
223    "react-native",
224    "react-strict-dom",
225    "@duro-app/ui",
226];
227
228/// A `package.json` that looks like an interface: a `dev` script, or a UI
229/// package among its `dependencies` or its non-optional `peerDependencies`.
230///
231/// `devDependencies` and optional peers do not count: a CLI that reads the
232/// design system's metadata (duro-design-system's `packages/cli`) carries
233/// `@duro-app/ui` exactly there, and renders nothing. Unparseable counts.
234pub fn package_is_ui(text: &str) -> bool {
235    let Ok(v) = serde_json::from_str::<serde_json::Value>(text) else {
236        return true;
237    };
238    let dev = v
239        .get("scripts")
240        .and_then(|s| s.get("dev"))
241        .and_then(|d| d.as_str())
242        .is_some_and(|s| !s.trim().is_empty());
243    if dev {
244        return true;
245    }
246    let has = |section: &str, name: &str| {
247        v.get(section)
248            .and_then(|d| d.as_object())
249            .is_some_and(|d| d.contains_key(name))
250    };
251    let optional = |name: &str| {
252        v.get("peerDependenciesMeta")
253            .and_then(|m| m.get(name))
254            .and_then(|m| m.get("optional"))
255            .and_then(|o| o.as_bool())
256            .unwrap_or(false)
257    };
258    UI_PACKAGES
259        .iter()
260        .any(|name| has("dependencies", name) || (has("peerDependencies", name) && !optional(name)))
261}