Skip to main content

git_policy_cli/
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 deadline.
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 deadline 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 super::markdown_linter_guard::{GuardFailure, run_guarded};
31/// Import the one-rule configuration.
32use super::markdown_linter_config::one_rule_configuration;
33/// Import the reading of one run.
34use super::markdown_linter_output::{LintRun, explanation, interpret_embedded_run};
35/// Import the conversion of Git's pathname bytes to a path.
36use super::git_metadata::path_from_git_bytes;
37/// Import the interface this linter provides.
38use super::policy_markdown::{LintRequest, MarkdownLinter};
39/// When a long run prints its notices.
40use super::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 one candidate may take before the wrapper stops waiting for it.
52/// Why:
53///   Measured on the host with the former child process,
54///  one run takes about 11 milliseconds warm and 227 cold;
55///  the LFS rule also hashes every image a file links,
56///  which for a large image takes seconds,
57///  and the host has run at load averages of 40 to 90.
58///  A minute leaves room for all of that and still ends a hung linter.
59///
60/// In TS you'd write (pseudocode):
61/// ```ts
62/// const LINT_DEADLINE_MS = 60_000;
63/// ```
64pub const LINT_DEADLINE: Duration = Duration::from_secs(60);
65
66/// What:
67///  The signature of what fixes one source:
68///  the linter library's entry in production,
69///  a planted function in the controls of the guard.
70/// Why:
71///  A plain function pointer is `Send`, so it moves onto the guarded thread without capturing anything.
72///
73/// In TS you'd write (pseudocode):
74/// ```ts
75/// type FixFunction = (request: EmbeddedRequest) => EmbeddedRun; // throws EmbeddedFailure
76/// ```
77pub type FixFunction = fn(&EmbeddedRequest<'_>) -> Result<EmbeddedRun, EmbeddedFailure>;
78
79/// What:
80///  The linter of one invocation:
81///  what fixes a source,
82///  its deadline,
83///  and where long-wait notices go.
84/// Why:
85///   The deadline and the function are fields so the controls can plant a stalled or panicking fix.
86///
87/// In TS you'd write (pseudocode):
88/// ```ts
89/// class EmbeddedLinter implements MarkdownLinter { constructor(fix, deadline, cadence, notices) {} }
90/// ```
91pub struct EmbeddedLinter {
92    /// What fixes one source.
93    fix: FixFunction,
94    /// How long one candidate may take.
95    deadline: Duration,
96    /// When a run that takes long prints its notices.
97    cadence: Cadence,
98    /// Where those notices go.
99    notices: Box<dyn Write>,
100    /// Whether an earlier candidate of this command ran past its deadline and left its thread running.
101    abandoned: bool,
102}
103
104/// What:
105///  The linter of the running executable:
106///  the linked linter, `LINT_DEADLINE`, and notices on standard error.
107/// Why:
108///   Checks built before an environment is known get one too;
109///  making it touches nothing,
110///  and a command with no Markdown candidate never runs it.
111///
112/// In TS you'd write (pseudocode):
113/// ```ts
114/// const embeddedLinter = () => new EmbeddedLinter(fixSource, LINT_DEADLINE, cadenceFromEnvironment(), stderr);
115/// ```
116pub fn embedded_linter() -> EmbeddedLinter {
117    return EmbeddedLinter {
118        fix: fix_source,
119        deadline: LINT_DEADLINE,
120        cadence: Cadence::from_environment(),
121        notices: Box::new(std::io::stderr()),
122        abandoned: false,
123    };
124}
125
126/// What:
127///  A linter over `fix` with this deadline.
128/// Why:
129///   Controls plant a fix that panics or never returns, with a short deadline.
130///
131/// In TS you'd write (pseudocode):
132/// ```ts
133/// const embeddedLinterWith = (fix, deadline) => new EmbeddedLinter(fix, deadline, cadenceFromEnvironment(), stderr);
134/// ```
135pub fn embedded_linter_with(fix: FixFunction, deadline: Duration) -> EmbeddedLinter {
136    return EmbeddedLinter {
137        fix,
138        deadline,
139        cadence: Cadence::from_environment(),
140        notices: Box::new(std::io::stderr()),
141        abandoned: false,
142    };
143}
144
145/// What:
146///  The reason every candidate after an abandoned one gets.
147/// Why:
148///   An abandoned thread cannot be stopped and may still be working or holding memory;
149///  starting another beside it for each later candidate could pile them up, so the rest of the command
150///  leaves its Markdown candidates unchecked and says why.
151///
152/// In TS you'd write (pseudocode):
153/// ```ts
154/// const ABANDONED_REASON = 'an earlier Markdown check did not finish ...';
155/// ```
156const ABANDONED_REASON: &str = "an earlier Markdown check in this command did not finish and cli-git stopped waiting \
157     for it, so monochromatic-lint is not run again in this command; run the command again, or set the policy \
158     markdown/autofix to off to skip the check";
159
160/// What:
161///  The sentence for a guarded run that produced no result.
162/// Why:
163///   Each cause tells the person what happened and the way forward in the linter's terms.
164///
165/// In TS you'd write (pseudocode):
166/// ```ts
167/// function guardFailureReason(failure: GuardFailure): string;
168/// ```
169fn guard_failure_reason(failure: &GuardFailure) -> String {
170    match failure {
171        GuardFailure::Start(reason) => {
172            return format!("cli-git could not start the thread that runs monochromatic-lint: {reason}");
173        }
174        GuardFailure::Panicked(reason) => {
175            return format!(
176                "monochromatic-lint stopped with an internal error ({}); the file was not checked, run the \
177                 command again, run monochromatic-lint by hand on the file to see the error, or set the policy \
178                 markdown/autofix to off to skip the check",
179                explanation(reason.as_str())
180            );
181        }
182        GuardFailure::TimedOut(deadline) => {
183            return format!(
184                "monochromatic-lint did not finish within {} milliseconds and cli-git stopped waiting for it \
185                 (its thread stays in this process until the command ends); run the command again, run \
186                 monochromatic-lint by hand on the file to see why it stalls, or set the policy markdown/autofix \
187                 to off to skip the check",
188                deadline.as_millis()
189            );
190        }
191        GuardFailure::Vanished => {
192            return String::from(
193                "the thread that runs monochromatic-lint ended without a result; this is a defect in cli-git.",
194            );
195        }
196    }
197}
198
199/// What:
200///  `impl MarkdownLinter for EmbeddedLinter` runs the linked linter for each request.
201/// Why:
202///   See the module comment.
203///
204/// In TS you'd write (pseudocode):
205/// ```ts
206/// class EmbeddedLinter implements MarkdownLinter {}
207/// ```
208impl MarkdownLinter for EmbeddedLinter {
209    /// What:
210    ///  Lint one candidate in-process.
211    /// Why:
212    ///   The linter reads `.lfsconfig` and linked images from the worktree,
213    ///  so it is given the top level as its working directory
214    ///  and the candidate's name relative to it;
215    ///  the thread owns copies of everything it uses,
216    ///  so abandoning it after the deadline leaves nothing the caller needs locked.
217    ///
218    /// In TS you'd write (pseudocode):
219    /// ```ts
220    /// lint(request: LintRequest): LintRun; // throws string
221    /// ```
222    fn lint(&mut self, request: &LintRequest<'_>) -> Result<LintRun, String> {
223        if self.abandoned {
224            return Err(String::from(ABANDONED_REASON));
225        }
226        let Some(name) = path_from_git_bytes(request.path) else {
227            return Err(String::from(
228                "cli-git cannot spell this candidate's pathname for monochromatic-lint on this platform",
229            ));
230        };
231        let waiting_for: String = format!("monochromatic-lint to check {}", name.display());
232        let top_level: PathBuf = request.top_level.to_path_buf();
233        let configuration: String = one_rule_configuration(
234            request.options.rules.as_slice(),
235            request.options.exclude.as_slice(),
236        );
237        let source: Vec<u8> = request.source.to_vec();
238        let fix: FixFunction = self.fix;
239        let outcome: Result<Result<EmbeddedRun, EmbeddedFailure>, GuardFailure> = run_guarded(
240            move || {
241                return fix(&EmbeddedRequest {
242                    top_level: top_level.as_path(),
243                    configuration: configuration.as_str(),
244                    name: Path::new(name.as_os_str()),
245                    source: source.as_slice(),
246                });
247            },
248            self.deadline,
249            self.cadence,
250            waiting_for.as_str(),
251            &mut self.notices,
252        );
253        match outcome {
254            Ok(finished) => {
255                return interpret_embedded_run(request.source, request.options.rules.as_slice(), finished);
256            }
257            Err(failure) => {
258                // A thread that outlived its deadline may still be running, so no second one starts beside it.
259                self.abandoned = matches!(failure, GuardFailure::TimedOut(_));
260                return Err(guard_failure_reason(&failure));
261            }
262        }
263    }
264}
265
266impl EmbeddedLinter {
267    /// The same linter with another notice cadence and sink;
268    ///  tests choose a short cadence and read the notices from a buffer.
269    pub fn with_notices(mut self, cadence: Cadence, notices: Box<dyn Write>) -> Self {
270        self.cadence = cadence;
271        self.notices = notices;
272        return self;
273    }
274}
275
276/// Guard, reason and contract controls stay out of the release executable.
277#[cfg(test)]
278#[path = "markdown_linter_tests.rs"]
279mod tests;