Skip to main content

git_policy_cli/markdown/
markdown_linter.rs

1//! What:
2//!  The linter the executable gives `markdown/autofix`:
3//!  `monochromatic-lint`,
4//!  linked into the executable and run in-process once per candidate,
5//!  on a guarded thread with a quiet window.
6//! Why:
7//!  The owner's rule is that the wrapper embeds the repository's own tools
8//!  and starts other programs as rarely as possible.
9//!  The linter's library entry `monochromatic_lint::run_embedded::fix_source` is the executable's
10//!  `--config --stdin --stdin-filename --fix` run without the program start
11//!  (`package/linter/monochromatic-lint/src/run_embedded.rs`),
12//!  so there is no program to find on a search path,
13//!  no configuration file to write and no stream contract to parse.
14//!  The child process used to be the wrapper's kill boundary;
15//!  `markdown_linter_guard.rs` replaces it with a thread, a quiet window and panic containment,
16//!  which is a hang and panic boundary and not a crash boundary:
17//!  a stack overflow or an abort in the linter ends the wrapper.
18//!  The size of a finished result is checked against the former child's output bounds
19//!  (`markdown_linter_output.rs`), after the linter has produced it.
20//!  The configuration is rebuilt from the policy's options for each candidate,
21//!  which costs a small JSONC document,
22//!  so no state outlives the options it was built from.
23//!
24//! In TS you'd write (pseudocode):
25//! ```ts
26//! // const run = embeddedLinter().lint({ topLevel, options, path, source });
27//! ```
28
29/// Import the guarded thread runner.
30use crate::markdown_linter_guard::{GuardFailure, GuardLimits, run_guarded};
31/// Import the one-rule configuration.
32use crate::markdown_linter_config::one_rule_configuration;
33/// Import the reading of one run.
34use crate::markdown_linter_output::{LintRun, explanation, interpret_embedded_run};
35/// Import the conversion of Git's pathname bytes to a path.
36use crate::git_metadata::path_from_git_bytes;
37/// Import the interface this linter provides.
38use crate::policy_markdown::{LintRequest, MarkdownLinter};
39/// When a long run prints its notices.
40use crate::wait_progress::Cadence;
41/// The linter library's in-process entry and its types.
42use monochromatic_lint::run_embedded::{EmbeddedFailure, EmbeddedRequest, EmbeddedRun, fix_source};
43/// The sink of those notices.
44use std::io::Write;
45/// `PathBuf` is an owned filesystem path.
46use std::path::{Path, PathBuf};
47/// `Duration` is a span of time.
48use std::time::Duration;
49
50/// What:
51///  How long the linter of one candidate may show no sign of work before the wrapper stops waiting for it.
52/// Why:
53///   A linter that works is waited for however long it takes (owner, 2026-11-03: "Stop only on no progress"; pick 9 of
54///  `doc/planning/cli-git-shipped-limits-audit.md`, the coordinator's under the owner's delegation and open to the owner's
55///  veto), and one that shows nothing is a hang.
56///  Citation: none, and **unmeasured as a quiet window**. Measured on the host with the former child process, one run takes
57///  about 11 milliseconds warm and 227 cold, and the host has run at load averages of 40 to 90; those are whole times, and a
58///  whole time is an upper bound of a quiet gap, not the gap. The longest quiet gap of every run is now kept in the ledger
59///  (`quiet_ledger.rs`) and the number waits for it.
60///
61/// In TS you'd write (pseudocode):
62/// ```ts
63/// const LINT_QUIET_WINDOW_MS = 60_000;
64/// ```
65pub const LINT_QUIET_WINDOW: Duration = Duration::from_secs(60);
66
67/// What:
68///  The signature of what fixes one source:
69///  the linter library's entry in production,
70///  a planted function in the controls of the guard.
71/// Why:
72///  A plain function pointer is `Send`, so it moves onto the guarded thread without capturing anything.
73///
74/// In TS you'd write (pseudocode):
75/// ```ts
76/// type FixFunction = (request: EmbeddedRequest) => EmbeddedRun; // throws EmbeddedFailure
77/// ```
78pub type FixFunction = fn(&EmbeddedRequest<'_>) -> Result<EmbeddedRun, EmbeddedFailure>;
79
80/// What:
81///  The linter of one invocation:
82///  what fixes a source,
83///  its quiet window,
84///  and where long-wait notices go.
85/// Why:
86///   The window and the function are fields so the controls can plant a stalled or panicking fix.
87///
88/// In TS you'd write (pseudocode):
89/// ```ts
90/// class EmbeddedLinter implements MarkdownLinter { constructor(fix, window, cadence, notices) {} }
91/// ```
92pub struct EmbeddedLinter {
93    /// What fixes one source.
94    fix: FixFunction,
95    /// How long one candidate may show no sign of work.
96    window: Duration,
97    /// The longest one candidate is waited for, whatever it shows (`wait_ceilings.rs`, `LINT_CEILING`).
98    ceiling: Duration,
99    /// When a run that takes long prints its notices.
100    cadence: Cadence,
101    /// Where those notices go.
102    notices: Box<dyn Write>,
103    /// Whether an earlier candidate of this command was abandoned for being quiet and left its thread running.
104    abandoned: bool,
105}
106
107/// What:
108///  The linter of the running executable:
109///  the linked linter, `LINT_QUIET_WINDOW`, and notices on standard error.
110/// Why:
111///   Checks built before an environment is known get one too;
112///  making it touches nothing,
113///  and a command with no Markdown candidate never runs it.
114///
115/// In TS you'd write (pseudocode):
116/// ```ts
117/// const embeddedLinter = () => new EmbeddedLinter(fixSource, LINT_QUIET_WINDOW, cadenceFromEnvironment(), stderr);
118/// ```
119pub fn embedded_linter() -> EmbeddedLinter {
120    return EmbeddedLinter {
121        fix: fix_source,
122        window: LINT_QUIET_WINDOW,
123        ceiling: crate::wait_ceilings::LINT_CEILING,
124        cadence: Cadence::from_environment(),
125        notices: Box::new(std::io::stderr()),
126        abandoned: false,
127    };
128}
129
130/// What:
131///  A linter over `fix` with this quiet window.
132/// Why:
133///   Controls plant a fix that panics or never returns, with a short window.
134///
135/// In TS you'd write (pseudocode):
136/// ```ts
137/// const embeddedLinterWith = (fix, window) => new EmbeddedLinter(fix, window, cadenceFromEnvironment(), stderr);
138/// ```
139pub fn embedded_linter_with(fix: FixFunction, window: Duration) -> EmbeddedLinter {
140    return EmbeddedLinter {
141        fix,
142        window,
143        ceiling: crate::wait_ceilings::LINT_CEILING,
144        cadence: Cadence::from_environment(),
145        notices: Box::new(std::io::stderr()),
146        abandoned: false,
147    };
148}
149
150/// What:
151///  The reason every candidate after an abandoned one gets.
152/// Why:
153///   An abandoned thread cannot be stopped and may still be working or holding memory;
154///  starting another beside it for each later candidate could pile them up, so the rest of the command
155///  leaves its Markdown candidates unchecked and says why.
156///
157/// In TS you'd write (pseudocode):
158/// ```ts
159/// const ABANDONED_REASON = 'an earlier Markdown check did not finish ...';
160/// ```
161const ABANDONED_REASON: &str = "an earlier Markdown check in this command did not finish and cli-git stopped waiting \
162     for it, so monochromatic-lint is not run again in this command; run the command again, or set the policy \
163     markdown/autofix to off to skip the check";
164
165/// What:
166///  The sentence for a guarded run that produced no result.
167/// Why:
168///   Each cause tells the person what happened and the way forward in the linter's terms.
169///
170/// In TS you'd write (pseudocode):
171/// ```ts
172/// function guardFailureReason(failure: GuardFailure): string;
173/// ```
174fn guard_failure_reason(failure: &GuardFailure) -> String {
175    match failure {
176        GuardFailure::Start(reason) => {
177            return format!("cli-git could not start the thread that runs monochromatic-lint: {reason}");
178        }
179        GuardFailure::Panicked(reason) => {
180            return format!(
181                "monochromatic-lint stopped with an internal error ({}); the file was not checked, run the \
182                 command again, run monochromatic-lint by hand on the file to see the error, or set the policy \
183                 markdown/autofix to off to skip the check",
184                explanation(reason.as_str())
185            );
186        }
187        GuardFailure::Quiet(window) => {
188            return format!(
189                "monochromatic-lint showed no sign of work for {} milliseconds and cli-git stopped waiting for it \
190                 (its thread stays in this process until the command ends); run the command again, run \
191                 monochromatic-lint by hand on the file to see why it stalls, or set the policy markdown/autofix \
192                 to off to skip the check",
193                window.as_millis()
194            );
195        }
196        GuardFailure::Ceiling(ceiling) => {
197            return format!(
198                "monochromatic-lint ran for {} seconds, the longest cli-git lets one Markdown check run, and cli-git stopped waiting \
199                 for it (its thread stays in this process until the command ends); run the command again, run \
200                 monochromatic-lint by hand on the file, or set the policy markdown/autofix to off to skip the check",
201                ceiling.as_secs()
202            );
203        }
204        GuardFailure::Vanished => {
205            return String::from(
206                "the thread that runs monochromatic-lint ended without a result; this is a defect in cli-git.",
207            );
208        }
209    }
210}
211
212/// What:
213///  `impl MarkdownLinter for EmbeddedLinter` runs the linked linter for each request.
214/// Why:
215///   See the module comment.
216///
217/// In TS you'd write (pseudocode):
218/// ```ts
219/// class EmbeddedLinter implements MarkdownLinter {}
220/// ```
221impl MarkdownLinter for EmbeddedLinter {
222    /// What:
223    ///  Lint one candidate in-process.
224    /// Why:
225    ///   The linter reads `.lfsconfig` and linked images from the worktree,
226    ///  so it is given the top level as its working directory
227    ///  and the candidate's name relative to it;
228    ///  the thread owns copies of everything it uses,
229    ///  so abandoning it when it is quiet leaves nothing the caller needs locked.
230    ///
231    /// In TS you'd write (pseudocode):
232    /// ```ts
233    /// lint(request: LintRequest): LintRun; // throws string
234    /// ```
235    fn lint(&mut self, request: &LintRequest<'_>) -> Result<LintRun, String> {
236        if self.abandoned {
237            return Err(String::from(ABANDONED_REASON));
238        }
239        let Some(name) = path_from_git_bytes(request.path) else {
240            return Err(String::from(
241                "cli-git cannot spell this candidate's pathname for monochromatic-lint on this platform",
242            ));
243        };
244        let waiting_for: String = format!("monochromatic-lint to check {}", name.display());
245        let checking: String = name.display().to_string();
246        let top_level: PathBuf = request.top_level.to_path_buf();
247        let configuration: String = one_rule_configuration(
248            request.options.rules.as_slice(),
249            request.options.exclude.as_slice(),
250        );
251        let source: Vec<u8> = request.source.to_vec();
252        let fix: FixFunction = self.fix;
253        let outcome: Result<Result<EmbeddedRun, EmbeddedFailure>, GuardFailure> = run_guarded(
254            move || {
255                return fix(&EmbeddedRequest {
256                    top_level: top_level.as_path(),
257                    configuration: configuration.as_str(),
258                    name: Path::new(name.as_os_str()),
259                    source: source.as_slice(),
260                });
261            },
262            GuardLimits { window: self.window, ceiling: self.ceiling },
263            self.cadence,
264            waiting_for.as_str(),
265            &mut self.notices,
266        );
267        match outcome {
268            Ok(finished) => {
269                return interpret_embedded_run(request.source, request.options.rules.as_slice(), finished);
270            }
271            Err(failure) => {
272                // A thread that was abandoned may still be running, so no second one starts beside it.
273                self.abandoned = matches!(failure, GuardFailure::Quiet(_) | GuardFailure::Ceiling(_));
274                // The ceiling's sentence names the file, because a ceiling is reached by a file that works for long.
275                if matches!(failure, GuardFailure::Ceiling(_)) {
276                    return Err(format!("monochromatic-lint, checking {checking}: {}", guard_failure_reason(&failure)));
277                }
278                return Err(guard_failure_reason(&failure));
279            }
280        }
281    }
282}
283
284impl EmbeddedLinter {
285    /// The same linter with another ceiling; controls plant a fix that keeps working with a short one.
286    pub fn with_ceiling(mut self, ceiling: Duration) -> Self {
287        self.ceiling = ceiling;
288        return self;
289    }
290
291    /// The same linter with another notice cadence and sink;
292    ///  tests choose a short cadence and read the notices from a buffer.
293    pub fn with_notices(mut self, cadence: Cadence, notices: Box<dyn Write>) -> Self {
294        self.cadence = cadence;
295        self.notices = notices;
296        return self;
297    }
298}
299
300/// Guard, reason and contract controls stay out of the release executable.
301#[cfg(test)]
302#[path = "markdown_linter_tests.rs"]
303mod tests;