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, 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 /// When a run that takes long prints its notices.
98 cadence: Cadence,
99 /// Where those notices go.
100 notices: Box<dyn Write>,
101 /// Whether an earlier candidate of this command was abandoned for being quiet and left its thread running.
102 abandoned: bool,
103}
104
105/// What:
106/// The linter of the running executable:
107/// the linked linter, `LINT_QUIET_WINDOW`, and notices on standard error.
108/// Why:
109/// Checks built before an environment is known get one too;
110/// making it touches nothing,
111/// and a command with no Markdown candidate never runs it.
112///
113/// In TS you'd write (pseudocode):
114/// ```ts
115/// const embeddedLinter = () => new EmbeddedLinter(fixSource, LINT_QUIET_WINDOW, cadenceFromEnvironment(), stderr);
116/// ```
117pub fn embedded_linter() -> EmbeddedLinter {
118 return EmbeddedLinter {
119 fix: fix_source,
120 window: LINT_QUIET_WINDOW,
121 cadence: Cadence::from_environment(),
122 notices: Box::new(std::io::stderr()),
123 abandoned: false,
124 };
125}
126
127/// What:
128/// A linter over `fix` with this quiet window.
129/// Why:
130/// Controls plant a fix that panics or never returns, with a short window.
131///
132/// In TS you'd write (pseudocode):
133/// ```ts
134/// const embeddedLinterWith = (fix, window) => new EmbeddedLinter(fix, window, cadenceFromEnvironment(), stderr);
135/// ```
136pub fn embedded_linter_with(fix: FixFunction, window: Duration) -> EmbeddedLinter {
137 return EmbeddedLinter {
138 fix,
139 window,
140 cadence: Cadence::from_environment(),
141 notices: Box::new(std::io::stderr()),
142 abandoned: false,
143 };
144}
145
146/// What:
147/// The reason every candidate after an abandoned one gets.
148/// Why:
149/// An abandoned thread cannot be stopped and may still be working or holding memory;
150/// starting another beside it for each later candidate could pile them up, so the rest of the command
151/// leaves its Markdown candidates unchecked and says why.
152///
153/// In TS you'd write (pseudocode):
154/// ```ts
155/// const ABANDONED_REASON = 'an earlier Markdown check did not finish ...';
156/// ```
157const ABANDONED_REASON: &str = "an earlier Markdown check in this command did not finish and cli-git stopped waiting \
158 for it, so monochromatic-lint is not run again in this command; run the command again, or set the policy \
159 markdown/autofix to off to skip the check";
160
161/// What:
162/// The sentence for a guarded run that produced no result.
163/// Why:
164/// Each cause tells the person what happened and the way forward in the linter's terms.
165///
166/// In TS you'd write (pseudocode):
167/// ```ts
168/// function guardFailureReason(failure: GuardFailure): string;
169/// ```
170fn guard_failure_reason(failure: &GuardFailure) -> String {
171 match failure {
172 GuardFailure::Start(reason) => {
173 return format!("cli-git could not start the thread that runs monochromatic-lint: {reason}");
174 }
175 GuardFailure::Panicked(reason) => {
176 return format!(
177 "monochromatic-lint stopped with an internal error ({}); the file was not checked, run the \
178 command again, run monochromatic-lint by hand on the file to see the error, or set the policy \
179 markdown/autofix to off to skip the check",
180 explanation(reason.as_str())
181 );
182 }
183 GuardFailure::Quiet(window) => {
184 return format!(
185 "monochromatic-lint showed no sign of work for {} milliseconds and cli-git stopped waiting for it \
186 (its thread stays in this process until the command ends); run the command again, run \
187 monochromatic-lint by hand on the file to see why it stalls, or set the policy markdown/autofix \
188 to off to skip the check",
189 window.as_millis()
190 );
191 }
192 GuardFailure::Vanished => {
193 return String::from(
194 "the thread that runs monochromatic-lint ended without a result; this is a defect in cli-git.",
195 );
196 }
197 }
198}
199
200/// What:
201/// `impl MarkdownLinter for EmbeddedLinter` runs the linked linter for each request.
202/// Why:
203/// See the module comment.
204///
205/// In TS you'd write (pseudocode):
206/// ```ts
207/// class EmbeddedLinter implements MarkdownLinter {}
208/// ```
209impl MarkdownLinter for EmbeddedLinter {
210 /// What:
211 /// Lint one candidate in-process.
212 /// Why:
213 /// The linter reads `.lfsconfig` and linked images from the worktree,
214 /// so it is given the top level as its working directory
215 /// and the candidate's name relative to it;
216 /// the thread owns copies of everything it uses,
217 /// so abandoning it when it is quiet leaves nothing the caller needs locked.
218 ///
219 /// In TS you'd write (pseudocode):
220 /// ```ts
221 /// lint(request: LintRequest): LintRun; // throws string
222 /// ```
223 fn lint(&mut self, request: &LintRequest<'_>) -> Result<LintRun, String> {
224 if self.abandoned {
225 return Err(String::from(ABANDONED_REASON));
226 }
227 let Some(name) = path_from_git_bytes(request.path) else {
228 return Err(String::from(
229 "cli-git cannot spell this candidate's pathname for monochromatic-lint on this platform",
230 ));
231 };
232 let waiting_for: String = format!("monochromatic-lint to check {}", name.display());
233 let top_level: PathBuf = request.top_level.to_path_buf();
234 let configuration: String = one_rule_configuration(
235 request.options.rules.as_slice(),
236 request.options.exclude.as_slice(),
237 );
238 let source: Vec<u8> = request.source.to_vec();
239 let fix: FixFunction = self.fix;
240 let outcome: Result<Result<EmbeddedRun, EmbeddedFailure>, GuardFailure> = run_guarded(
241 move || {
242 return fix(&EmbeddedRequest {
243 top_level: top_level.as_path(),
244 configuration: configuration.as_str(),
245 name: Path::new(name.as_os_str()),
246 source: source.as_slice(),
247 });
248 },
249 self.window,
250 self.cadence,
251 waiting_for.as_str(),
252 &mut self.notices,
253 );
254 match outcome {
255 Ok(finished) => {
256 return interpret_embedded_run(request.source, request.options.rules.as_slice(), finished);
257 }
258 Err(failure) => {
259 // A thread that was abandoned may still be running, so no second one starts beside it.
260 self.abandoned = matches!(failure, GuardFailure::Quiet(_));
261 return Err(guard_failure_reason(&failure));
262 }
263 }
264 }
265}
266
267impl EmbeddedLinter {
268 /// The same linter with another notice cadence and sink;
269 /// tests choose a short cadence and read the notices from a buffer.
270 pub fn with_notices(mut self, cadence: Cadence, notices: Box<dyn Write>) -> Self {
271 self.cadence = cadence;
272 self.notices = notices;
273 return self;
274 }
275}
276
277/// Guard, reason and contract controls stay out of the release executable.
278#[cfg(test)]
279#[path = "markdown_linter_tests.rs"]
280mod tests;