Skip to main content

rto_exec/
adapter.rs

1//! Per-analyzer adapters: native analyzer output in, a [`NormalizedReport`] out.
2//!
3//! An adapter is the **only** analyzer-specific code in this crate. It knows one
4//! tool's native JSON, how to name that tool's findings so they are recognisable
5//! across runs, and which argv produces that JSON. Everything downstream — the
6//! validation, the identity keys, the ordering, the store — is shared.
7//!
8//! # Why this is the seam, and not the runner
9//!
10//! ADR-0012 requires that "a finding is the same artifact whether it was produced
11//! locally in a sandbox or ingested from a CI report". The cheap way to satisfy
12//! that is to write the conversion twice and add a test comparing the two. This
13//! crate does the other thing: **there is one conversion**, and both paths call
14//! it. A subprocess run captures the analyzer's stdout and hands those bytes to
15//! the adapter; `roteiro security ingest` reads a file of the same native bytes
16//! and hands them to the same adapter. Equality of the resulting [`Finding`]s is
17//! therefore a property of the code, and the tests that assert it are guarding
18//! against a future refactor rather than establishing the invariant.
19//!
20//! [`Finding`]: rto_graph::Finding
21//!
22//! # Adding an analyzer needs no migration
23//!
24//! [`rto_graph::FindingKey`] is `finding:<analyzer>:<that analyzer's own ordered
25//! identity components>`. An adapter chooses the recipe; the schema never learns
26//! what the components mean. So a new analyzer is a new file in `adapters/`, an
27//! entry in [`ADAPTERS`], and nothing else — no schema change, no migration.
28//!
29//! @rto:0012
30//! @rto:0014
31//! @rto:0018
32
33use rto_graph::SourceIdentity;
34
35use crate::guidance::{Guidance, Line};
36use crate::ingest::NormalizedReport;
37use crate::runner::ExecError;
38use crate::snippet::SnippetSource;
39
40pub mod cargo_audit;
41pub mod clippy;
42pub mod osv_scanner;
43pub mod semgrep;
44
45/// Everything an adapter may need that is *not* in the analyzer's own output.
46///
47/// Native analyzer output is missing things the evidence chain requires — no
48/// mainstream analyzer stamps its report with the wall-clock window it ran in,
49/// and `cargo audit` does not even record its own version. Rather than let an
50/// adapter invent them, the caller supplies what it actually knows, and an
51/// adapter that has nothing better says so ([`UNKNOWN_VERSION`]).
52#[derive(Clone)]
53pub struct NativeContext<'a> {
54    /// When the run started, RFC 3339 UTC. A subprocess run measures it; an
55    /// ingest of a report file uses the file's modification time, which is the
56    /// only timestamp evidence a bare report carries.
57    pub started_at: String,
58    /// When the run ended, RFC 3339 UTC.
59    pub ended_at: String,
60    /// The analyzer's version, where the caller learned it out of band (a
61    /// subprocess run asks the binary). `None` leaves the adapter to use
62    /// whatever the report itself carries.
63    pub analyzer_version: Option<String>,
64    /// The analyzer's process exit status, where the caller observed it.
65    pub exit_status: i32,
66    /// The source identity the run was against. Some identity recipes need it —
67    /// `cargo-audit` keys findings by lockfile blob, so a finding stays distinct
68    /// when the lockfile changes underneath the same advisory.
69    pub source: &'a SourceIdentity,
70    /// Digest of the rule set the analyzer ran with, where one applies.
71    pub rules_digest: Option<String>,
72    /// The pinned advisory database the caller provisioned, where one applies.
73    ///
74    /// A fallback, not an override: an adapter prefers what the analyzer's own
75    /// report says about the database it consulted, and uses this only when the
76    /// report says nothing. `cargo audit` says nothing whenever it is pointed at
77    /// a database with `--db`, which is every pinned run — so without this, the
78    /// reproducible configuration would be the one with no staleness evidence.
79    pub advisory_db: Option<rto_graph::AdvisoryDb>,
80    /// The checkout the report describes, where the caller knows it.
81    ///
82    /// Only an adapter whose analyzer reports **absolute** paths needs this, and
83    /// `osv-scanner` is that adapter: it returns a full filesystem path for every
84    /// manifest even when it is told to scan `.`. Without the worktree there is
85    /// nothing to relativise against, so an absolute path would be stored
86    /// verbatim — user-identifying data in a persisted finding key, and a key
87    /// that differs between two machines running the identical scan.
88    ///
89    /// `None` is the honest answer for a report about a tree this checkout does
90    /// not have; an adapter must then say the location is unknown rather than
91    /// guess at one.
92    pub worktree: Option<&'a std::path::Path>,
93    /// Where to read the source a finding points at, for identity recipes that
94    /// include a snippet hash.
95    ///
96    /// It is here rather than inside an adapter because the *caller* knows which
97    /// checkout the report describes, and because both execution paths must read
98    /// the same one — that is what makes a subprocess run and an ingest of its
99    /// output produce identical finding keys.
100    pub snippets: &'a dyn SnippetSource,
101}
102
103// Hand-written because `&dyn SnippetSource` is not `Debug` and does not need to
104// be: what a debug print of a context should show is the evidence it carries,
105// not the identity of the thing that reads files.
106impl std::fmt::Debug for NativeContext<'_> {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        f.debug_struct("NativeContext")
109            .field("started_at", &self.started_at)
110            .field("ended_at", &self.ended_at)
111            .field("analyzer_version", &self.analyzer_version)
112            .field("exit_status", &self.exit_status)
113            .field("source", self.source)
114            .field("rules_digest", &self.rules_digest)
115            .field("advisory_db", &self.advisory_db)
116            .field("worktree", &self.worktree)
117            .finish_non_exhaustive()
118    }
119}
120
121impl NativeContext<'_> {
122    /// The version to record: what the caller learned, else what the report
123    /// carried, else [`UNKNOWN_VERSION`].
124    ///
125    /// Never empty — [`crate::IngestRunner`] refuses a report that cannot say
126    /// what version produced it, and "unknown" is a truthful answer where an
127    /// empty string is a missing one.
128    #[must_use]
129    pub fn version_or(&self, from_report: Option<&str>) -> String {
130        self.analyzer_version
131            .as_deref()
132            .or(from_report)
133            .map(str::trim)
134            .filter(|v| !v.is_empty())
135            .unwrap_or(UNKNOWN_VERSION)
136            .to_owned()
137    }
138}
139
140/// Recorded as an analyzer's version when neither the caller nor the report
141/// knows it — which is the ordinary case for a `cargo audit` report ingested
142/// from CI, since its JSON has no version field.
143pub const UNKNOWN_VERSION: &str = "unknown";
144
145/// How an analyzer is invoked as a child process.
146///
147/// Returned by [`Adapter::command`] and consumed by the subprocess runner, so
148/// the argv lives beside the parser that understands its output rather than in
149/// the runner, which knows no analyzer.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct Invocation {
152    /// The program to execute, looked up on `PATH` unless it is a path.
153    pub program: String,
154    /// Its arguments, in order.
155    pub args: Vec<String>,
156    /// Exit statuses that mean "the analyzer ran and produced a report".
157    ///
158    /// Analyzers overload the exit status: `semgrep` exits `1` when it found
159    /// something, `cargo audit` exits `1` on a vulnerability. Treating non-zero
160    /// as failure would discard exactly the runs that matter, so each adapter
161    /// declares which statuses carry a usable report and every other status is a
162    /// hard failure.
163    pub success_statuses: Vec<i32>,
164}
165
166/// How to obtain one program from [`Adapter::host_programs`], for the refusal
167/// that discovers it is absent.
168///
169/// The counterpart of the refusal rule in `docs/REVIEW_CHECKLIST.md`: a refusal
170/// names the way forward, and *this* is the way forward for the one obstacle
171/// Roteiro will never clear on the reader's behalf. It is keyed **by program and
172/// not by analyzer** because a single analyzer's programs are obtained
173/// differently — `cargo-audit` needs `cargo` from rustup *and* `cargo-audit`
174/// from crates.io, and one hint covering both would be right about at most one
175/// of them. That is the "right *kind* of way forward" check, which is the one
176/// that has shipped wrong here before.
177///
178/// # What may go in one, and what may not
179///
180/// - **The command is upstream's, verbatim, or there is none.** Every command
181///   below was read off the tool's own install page at the time it was written,
182///   not recalled. Where upstream documents no single command — `osv-scanner`
183///   offers eight platform-specific ones and ranks none — the hint says so and
184///   gives the page. A plausible command that fails is worse than a URL.
185/// - **Never the reader's package manager.** No `brew`, no `apt`, unless
186///   upstream itself names one as *the* way. A canonical ecosystem command is
187///   portable and checkable; a package-manager guess is wrong for most readers.
188/// - **Always the upstream page.** A URL ages better than a command line, so
189///   even a hint with a good command carries the page that would correct it.
190/// - **Saying how is not doing it.** Nothing reads a hint and runs it. Roteiro
191///   installs no analyzer (ADR-0014), and a refusal that quietly installed one
192///   is the silent downgrade ADR-0019 §6 and ADR-0020 §6 forbid.
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub struct InstallHint {
195    /// The program this obtains — an entry in the same adapter's
196    /// [`Adapter::host_programs`], which is what
197    /// `tests::every_host_program_has_an_install_hint` pairs them by.
198    pub program: &'static str,
199    /// What to tell the reader, rendered by [`Guidance`] so a message built from
200    /// it cannot lose its own indentation. See [`crate::guidance`].
201    pub guidance: Guidance,
202}
203
204/// Obtaining `cargo` or `rustc`: the toolchain itself, not a tool installed with
205/// it.
206///
207/// Shared by the two adapters that shell out to cargo, so the answer to "how do
208/// I get cargo" cannot drift into two answers. **Deliberately no command:**
209/// rustup's installer is a different shell line on every host, and printing one
210/// of them is exactly the platform guess the refusals checklist forbids. Its
211/// front page picks the right one, which is why the page *is* the answer here.
212///
213/// So this hint has a `Note` and no [`Line::Command`], which is what "no
214/// command" has to mean if the sentence above is to be true of the code under
215/// it. It read `Line::Command("https://rustup.rs")` for one revision — a URL
216/// promoted into the slot a command would have occupied, three lines under a
217/// comment denying there was one. See [`URL_PREFIX`].
218pub const RUST_TOOLCHAIN: Guidance = Guidance::new(&[
219    Line::Note(&[
220        "Roteiro does not install toolchains. Install Rust — rustup's front page",
221        "selects the right installer for this host, so there is nothing to paste",
222        "here.",
223    ]),
224    Line::Note(&["Upstream: https://rustup.rs"]),
225]);
226
227/// How every install hint introduces its upstream page.
228///
229/// One convention, named once, because there were two. Three hints carried the
230/// URL as a `Note` reading `Upstream: …` and two promoted it into a
231/// [`Line::Command`] — and both of the two were the hints whose prose said they
232/// had *no* command, so the odd rendering and the contradicted comment were the
233/// same mistake seen from either end.
234///
235/// The `Note` is the right side of that split. [`Line::Command`] renders one
236/// step further in, as the thing to copy and run; a page is a thing to *read*,
237/// and the label is what says which of the two a reader is looking at. Reserving
238/// the command slot for commands is also what lets a hint say "there is nothing
239/// to paste here" and be visibly telling the truth.
240///
241/// `tests::every_hint_renders_its_upstream_page_the_same_way` holds it, so a
242/// fifth adapter cannot introduce a third convention.
243pub const URL_PREFIX: &str = "Upstream: ";
244
245/// One analyzer's native output format and invocation.
246pub trait Adapter: Sync + std::fmt::Debug {
247    /// The analyzer id — the value that appears in every layer key and finding
248    /// key this adapter produces.
249    fn analyzer(&self) -> &'static str;
250
251    /// A one-line description of what it looks for, for `roteiro security
252    /// status` and `--help`.
253    fn summary(&self) -> &'static str;
254
255    /// The languages this adapter produces findings for, as the coverage matrix
256    /// in ADR-0018 states them. Reported by the CLI so the claim is inspectable
257    /// rather than only documented.
258    fn languages(&self) -> &'static [&'static str];
259
260    /// Which pinned assets the analyzer needs before it can run offline (see
261    /// [`crate::assets`]). An empty slice means it needs none.
262    fn asset_ids(&self) -> &'static [&'static str];
263
264    /// Which programs must be on `PATH` for this analyzer to run **on this host**,
265    /// in the order a reader would install them.
266    ///
267    /// The counterpart of [`Adapter::asset_ids`], and the reason both exist:
268    /// asset ids are what Roteiro *provisions*, and these are what it
269    /// deliberately **never installs** (ADR-0014). `roteiro security status`
270    /// reports the two separately because their remedies differ — `prefetch` for
271    /// the first, an install the host owner performs for the second — and
272    /// collapsing them into one word is issue #464.
273    ///
274    /// # Why this is declared and not read off [`Adapter::command`]
275    ///
276    /// Because [`Invocation::program`] is not always the thing to look for.
277    /// `cargo-audit`'s program is `cargo`, and `cargo audit` dispatches to a
278    /// separate `cargo-audit` binary on `PATH` — so probing `Invocation::program`
279    /// would find `cargo` on any Rust developer's machine and report *ready* in
280    /// precisely the commonest failure, `cargo` installed and `cargo-audit` not.
281    /// That is the defect #464 is about, reintroduced one level down. An adapter
282    /// therefore states its own requirement.
283    ///
284    /// Empty means the analyzer needs nothing on `PATH`.
285    fn host_programs(&self) -> &'static [&'static str];
286
287    /// How to obtain each of [`Adapter::host_programs`], one hint per program.
288    ///
289    /// Required rather than defaulted, and that is the whole point of it being
290    /// on the trait: a fifth analyzer cannot compile without answering, so it
291    /// cannot ship a refusal that names the obstacle and trails off. The
292    /// *pairing* is what
293    /// `tests::every_host_program_has_an_install_hint` checks — a hint for
294    /// some other program would satisfy the compiler and not the reader.
295    ///
296    /// Order and duplication do not matter; [`install_hint`] looks up by
297    /// program. Empty is correct only for an analyzer that needs nothing on
298    /// `PATH`, which no shipped adapter is.
299    fn install_hints(&self) -> &'static [InstallHint];
300
301    /// The argv that makes the analyzer emit the native format
302    /// [`Adapter::normalize`] parses, with egress configured off.
303    ///
304    /// `assets` maps an id from [`Adapter::asset_ids`] to the verified local
305    /// path it was provisioned to.
306    fn command(&self, assets: &AssetPaths<'_>) -> Invocation;
307
308    /// Parse native output into a normalized report.
309    ///
310    /// # Errors
311    /// Returns [`ExecError::MalformedReport`] when the bytes are not this
312    /// analyzer's format, or [`ExecError::Json`] when they are not JSON at all.
313    /// A partially-parsed report is never returned: either the whole thing
314    /// converts or the run fails.
315    fn normalize(
316        &self,
317        native: &[u8],
318        ctx: &NativeContext<'_>,
319    ) -> Result<NormalizedReport, ExecError>;
320}
321
322/// Verified local paths of an analyzer's provisioned assets, keyed by asset id.
323#[derive(Debug, Clone, Copy, Default)]
324pub struct AssetPaths<'a> {
325    entries: &'a [(&'a str, std::path::PathBuf)],
326}
327
328impl<'a> AssetPaths<'a> {
329    /// Wrap a resolved id → path list.
330    #[must_use]
331    pub fn new(entries: &'a [(&'a str, std::path::PathBuf)]) -> Self {
332        Self { entries }
333    }
334
335    /// The path provisioned for `id`, or `None` if it was not resolved.
336    ///
337    /// An adapter that asked for an asset in [`Adapter::asset_ids`] will always
338    /// find it here, because the runner refuses to start otherwise — see
339    /// [`ExecError::AssetsUnavailableOffline`].
340    #[must_use]
341    pub fn get(&self, id: &str) -> Option<&std::path::Path> {
342        self.entries
343            .iter()
344            .find(|(key, _)| *key == id)
345            .map(|(_, path)| path.as_path())
346    }
347
348    /// The path provisioned for `id` as a string, or an empty string. Adapters
349    /// build argv from this; an unresolved asset cannot reach here.
350    #[must_use]
351    pub fn arg(&self, id: &str) -> String {
352        self.get(id)
353            .map(|p| p.to_string_lossy().into_owned())
354            .unwrap_or_default()
355    }
356}
357
358/// Every analyzer whose findings this build can **store**.
359///
360/// Ingest consults this table, so a report from any of them can be read in from
361/// CI whether or not this build can *execute* the analyzer — which is the whole
362/// point of ADR-0014's "ingest is always available".
363///
364/// # `clippy` is an adapter and is deliberately not here
365///
366/// [`clippy::Clippy`] implements the trait above and is reached only by
367/// `roteiro lint`, which reports and stores nothing. Membership of this table is
368/// what makes an analyzer storable — it is how `ingest` resolves `--analyzer`,
369/// and everything it resolves ends at
370/// [`rto_graph::Store::replace_findings_layer`]. Leaving a linter out is
371/// therefore the mechanism, not a note: there is no `--analyzer clippy` to
372/// accept and no layer key for two runs at different toolchains to collide over.
373/// ADR-0020 v1.1 is the decision, and [`clippy`]'s module documentation is the
374/// reasoning. **Adding it here would silently make lint output an artifact.**
375pub static ADAPTERS: &[&dyn Adapter] = &[
376    &semgrep::Semgrep,
377    &cargo_audit::CargoAudit,
378    &osv_scanner::OsvScanner,
379];
380
381/// Every analyzer `roteiro lint` can run.
382///
383/// A separate list from [`known_analyzers`], which answers a different question
384/// — *what can be stored* — and would name `semgrep` and `cargo-audit` here,
385/// sending a caller off to ask for a lint from an analyzer that files layers.
386/// It sits beside [`ADAPTERS`] rather than in `crate::lint` so that the two
387/// lists are read together: they are the same shape and deliberately disjoint,
388/// and a name that drifted into both would make a lint storable by accident.
389///
390/// Ungated, unlike the linter itself, for [`crate::lint_grant`]'s reason: what
391/// `roteiro lint` *could* run is a question a build that cannot run it still has
392/// to answer, and `roteiro security prefetch --analyzer clippy` is one of the
393/// callers that asks. That is also why `crate::lint` above is named and not
394/// linked: a link would be unresolved in precisely the builds this sentence is
395/// about.
396pub const LINT_ANALYZERS: &[&str] = &[clippy::ANALYZER];
397
398/// The adapters behind [`LINT_ANALYZERS`].
399///
400/// [`LINT_ANALYZERS`] names them and this one *is* them, because the callers
401/// differ: `prefetch` and the CLI want ids, and anything asking how to obtain a
402/// linter's binary wants the adapter. Kept in step by
403/// `tests::the_lint_tables_name_the_same_analyzers` rather than derived from
404/// each other, since neither can be `const`-derived from the other and a silent
405/// divergence would cost a linter its install hint.
406static LINT_ADAPTERS: &[&dyn Adapter] = &[&clippy::Clippy];
407
408/// The adapter for `analyzer`, or `None` if this build has none.
409///
410/// Storable analyzers only, deliberately: this is what `ingest` resolves
411/// `--analyzer` through, so answering for `clippy` here would make lint output
412/// storable — see [`ADAPTERS`]. Use [`every_adapter`] to ask a question that is
413/// about the tool rather than about the store.
414#[must_use]
415pub fn adapter_for(analyzer: &str) -> Option<&'static dyn Adapter> {
416    ADAPTERS.iter().copied().find(|a| a.analyzer() == analyzer)
417}
418
419/// Every adapter this build has, storable or not.
420///
421/// The set an install hint has to exist for, which is a wider set than
422/// [`ADAPTERS`]: `roteiro lint` reaches the same subprocess machinery, so a
423/// missing `cargo-clippy` produces the same refusal as a missing `semgrep` and
424/// deserves the same answer. Kept distinct from [`adapter_for`] so that widening
425/// *this* can never widen what `ingest` accepts.
426pub fn every_adapter() -> impl Iterator<Item = &'static dyn Adapter> {
427    ADAPTERS.iter().chain(LINT_ADAPTERS).copied()
428}
429
430/// How to obtain `program`, as declared by the adapter that needs it.
431///
432/// `None` for a program no adapter declares — which a refusal must then print
433/// without an install clause rather than with a guessed one. It cannot happen
434/// for a program reached through [`Adapter::command`], because
435/// `tests::every_host_program_has_an_install_hint` pairs the two lists and
436/// `tests::every_invoked_program_is_declared_on_path` ties the invocation to
437/// them.
438#[must_use]
439pub fn install_hint(program: &str) -> Option<Guidance> {
440    every_adapter()
441        .flat_map(Adapter::install_hints)
442        .find(|hint| hint.program == program)
443        .map(|hint| hint.guidance)
444}
445
446/// Every analyzer id this build can normalise, sorted — for error messages that
447/// tell a caller what it *could* have asked for.
448#[must_use]
449pub fn known_analyzers() -> Vec<&'static str> {
450    let mut ids: Vec<&'static str> = ADAPTERS.iter().map(|a| a.analyzer()).collect();
451    ids.sort_unstable();
452    ids
453}
454
455/// Recorded in place of a snippet hash when the source could not be read — an
456/// ingested report about a tree this checkout does not have.
457///
458/// A named marker rather than a hash of the empty string, so a reader of a
459/// finding key can tell "the code was empty" from "the code was unavailable".
460pub const NO_SNIPPET: &str = "no-snippet";
461
462/// Short SHA-256 prefix of a snippet, used by identity recipes that need to
463/// notice that the *code* at a location changed even though the location did
464/// not.
465///
466/// Sixteen hex characters is 64 bits — far more than enough to keep two
467/// snippets at the same rule and offset distinct, and short enough that a
468/// rendered key stays readable in a terminal. Leading and trailing whitespace is
469/// stripped first, so a reformat that only moved indentation is not a new
470/// finding.
471#[must_use]
472pub fn snippet_hash(snippet: &str) -> String {
473    crate::sha256_hex(snippet.trim().as_bytes())[..16].to_owned()
474}
475
476/// [`snippet_hash`] of what `snippets` holds for the span, or [`NO_SNIPPET`].
477#[must_use]
478pub fn snippet_hash_at(snippets: &dyn SnippetSource, path: &str, start: u32, end: u32) -> String {
479    snippets
480        .snippet(path, start, end)
481        .map_or_else(|| NO_SNIPPET.to_owned(), |text| snippet_hash(&text))
482}
483
484#[cfg(test)]
485mod tests {
486    use super::{
487        Adapter as _, AssetPaths, Guidance, LINT_ANALYZERS, Line, NO_SNIPPET, NativeContext,
488        UNKNOWN_VERSION, URL_PREFIX, adapter_for, every_adapter, install_hint, known_analyzers,
489        snippet_hash, snippet_hash_at,
490    };
491    use rto_graph::SourceIdentity;
492
493    fn ctx(version: Option<&str>) -> NativeContext<'static> {
494        static SOURCE: std::sync::LazyLock<SourceIdentity> =
495            std::sync::LazyLock::new(SourceIdentity::default);
496        NativeContext {
497            started_at: "2026-08-15T09:00:00Z".to_owned(),
498            ended_at: "2026-08-15T09:00:04Z".to_owned(),
499            analyzer_version: version.map(str::to_owned),
500            exit_status: 0,
501            source: &SOURCE,
502            rules_digest: None,
503            advisory_db: None,
504            worktree: None,
505            snippets: &crate::snippet::NoSnippets,
506        }
507    }
508
509    #[test]
510    fn the_registry_answers_for_every_analyzer_it_lists() {
511        for id in known_analyzers() {
512            assert_eq!(adapter_for(id).expect("registered").analyzer(), id);
513        }
514        assert!(adapter_for("no-such-analyzer").is_none());
515    }
516
517    /// The guard issue #430 asks for, and the reason the hint lives on the trait
518    /// rather than in a table beside it.
519    ///
520    /// What cannot be tested offline is that a command *works* — this machine
521    /// may have no network and certainly should not install anything to find
522    /// out. What can be tested is that none is **missing**, and missing is the
523    /// failure that shipped: a refusal that names the obstacle and stops. So a
524    /// fifth analyzer that declares a program and no hint for it fails here,
525    /// rather than reaching a reader as a message that trails off.
526    ///
527    /// Paired **by program**, not counted: an adapter with two programs and two
528    /// hints for one of them would satisfy a count and leave the other reader
529    /// with nothing.
530    #[test]
531    fn every_host_program_has_an_install_hint() {
532        for adapter in every_adapter() {
533            for program in adapter.host_programs() {
534                let hint = adapter
535                    .install_hints()
536                    .iter()
537                    .find(|hint| hint.program == *program);
538                assert!(
539                    hint.is_some(),
540                    "{} needs `{program}` on PATH and says nothing about how to get it — \
541                     see `Adapter::install_hints`",
542                    adapter.analyzer()
543                );
544            }
545            for hint in adapter.install_hints() {
546                assert!(
547                    adapter.host_programs().contains(&hint.program),
548                    "{} hints at installing `{}`, which it does not need on PATH — a hint \
549                     for a program no refusal names is one nobody reads",
550                    adapter.analyzer(),
551                    hint.program
552                );
553            }
554        }
555    }
556
557    /// A hint is a *way forward*, so this asserts the shape that makes it one.
558    ///
559    /// `Guidance` checks its own prose whenever it renders ([`crate::guidance`]),
560    /// which covers the collapsed-continuation defect. What it cannot know is
561    /// that a hint about obtaining a program must carry the upstream page —
562    /// #430's durability rule, because a URL ages better than a command line —
563    /// and must not print a package manager that upstream did not name, which is
564    /// the platform guess `docs/REVIEW_CHECKLIST.md` forbids.
565    #[test]
566    fn every_install_hint_carries_upstream_and_guesses_no_package_manager() {
567        for adapter in every_adapter() {
568            for hint in adapter.install_hints() {
569                let rendered = hint.guidance.to_string();
570                assert!(
571                    rendered.contains("https://"),
572                    "the hint for `{}` names no upstream page",
573                    hint.program
574                );
575                // Not a blanket ban on the words: an analyzer whose upstream
576                // *does* name one as canonical would state so here and this
577                // would have to be argued with. None of the shipped four does,
578                // and every one of them has an ecosystem command or a page
579                // instead.
580                for guess in ["brew ", "apt ", "apt-get ", "yum ", "dnf ", "choco "] {
581                    assert!(
582                        !rendered.contains(guess),
583                        "the hint for `{}` reaches for `{guess}`, which guesses the \
584                         reader's platform",
585                        hint.program
586                    );
587                }
588            }
589        }
590    }
591
592    /// One convention for the upstream page, asserted on the [`Line`]s rather
593    /// than on rendered text.
594    ///
595    /// Two of the five hints used to promote the URL into a [`Line::Command`],
596    /// and both were the hints whose prose said they had *no* command — so the
597    /// inconsistent rendering and the contradicted comment were one mistake, and
598    /// a reviewer met it as the comment. The convention is now: the page is a
599    /// `Note` beginning [`URL_PREFIX`], and the command slot holds commands.
600    ///
601    /// Structural, because that is what a fifth adapter would evade. A test on
602    /// rendered text would pass on a hint that put the URL anywhere at all — it
603    /// is the *shape* that has drifted here, not the presence of the string, and
604    /// `every_install_hint_carries_upstream_and_guesses_no_package_manager`
605    /// already checks the presence.
606    #[test]
607    fn every_hint_renders_its_upstream_page_the_same_way() {
608        for adapter in every_adapter() {
609            for hint in adapter.install_hints() {
610                let pages: Vec<&str> = hint
611                    .guidance
612                    .lines()
613                    .iter()
614                    .filter_map(|line| match line {
615                        Line::Note(fragments) => fragments
616                            .iter()
617                            .copied()
618                            .find(|f| f.starts_with(URL_PREFIX)),
619                        Line::Command(_) => None,
620                    })
621                    .collect();
622                assert_eq!(
623                    pages.len(),
624                    1,
625                    "the hint for `{}` must introduce its upstream page exactly once, as a \
626                     note beginning {URL_PREFIX:?} — found {pages:?}",
627                    hint.program
628                );
629
630                // The other half, and the one that caught the real defect: a URL
631                // in the command slot. `Line::Command` renders one step further
632                // in as the thing to copy and run, so a page there reads as a
633                // command — and in both hints where it happened, the prose three
634                // lines above said there was no command at all.
635                for line in hint.guidance.lines() {
636                    if let Line::Command(command) = line {
637                        assert!(
638                            !command.contains("://"),
639                            "the hint for `{}` puts a URL in the command slot ({command:?}) — \
640                             a page is read, not run; introduce it with {URL_PREFIX:?}",
641                            hint.program
642                        );
643                    }
644                }
645            }
646        }
647    }
648
649    /// Two adapters needing the same program must answer the same way.
650    ///
651    /// `cargo` is needed by `cargo-audit` and by `clippy`, and
652    /// [`install_hint`] resolves by program alone — so it returns whichever is
653    /// found first, and the two disagreeing would make the message depend on
654    /// table order. They share [`RUST_TOOLCHAIN`] for that reason, and this is
655    /// what says so.
656    #[test]
657    fn a_program_two_adapters_need_is_obtained_one_way() {
658        let mut seen: Vec<(&str, Guidance)> = Vec::new();
659        for adapter in every_adapter() {
660            for hint in adapter.install_hints() {
661                if let Some((_, first)) = seen.iter().find(|(name, _)| *name == hint.program) {
662                    assert_eq!(
663                        *first, hint.guidance,
664                        "`{}` is obtained two different ways depending on which adapter \
665                         asked — `install_hint` resolves by program, so one of them would \
666                         never be printed",
667                        hint.program
668                    );
669                } else {
670                    seen.push((hint.program, hint.guidance));
671                }
672            }
673        }
674    }
675
676    /// The program a refusal actually names is the one an invocation runs, so
677    /// that is the one that must resolve to a hint.
678    ///
679    /// [`every_host_program_has_an_install_hint`] pairs the two *declared*
680    /// lists; this ties them to the third thing, which is what
681    /// `SubprocessError::BinaryNotFound` looks up. `cargo-audit` is why it is a
682    /// separate assertion: its invocation is `cargo`, so a hint table covering
683    /// only `cargo-audit` would pass the pairing and still leave the commonest
684    /// refusal without an answer.
685    #[test]
686    fn every_invoked_program_is_declared_on_path() {
687        let empty = AssetPaths::default();
688        for adapter in every_adapter() {
689            let program = adapter.command(&empty).program;
690            assert!(
691                adapter.host_programs().contains(&program.as_str()),
692                "{} invokes `{program}`, which it does not declare in `host_programs` — \
693                 a refusal naming it would find no install hint",
694                adapter.analyzer()
695            );
696            assert!(
697                install_hint(&program).is_some(),
698                "`{program}` is invoked and has no install hint"
699            );
700        }
701    }
702
703    /// [`LINT_ANALYZERS`] and `LINT_ADAPTERS` are two spellings of one list, and
704    /// this is what keeps them one. A linter present in the first and absent
705    /// from the second would lose its install hint silently — the refusal would
706    /// still print, just without the half that says what to do.
707    #[test]
708    fn the_lint_tables_name_the_same_analyzers() {
709        let from_adapters: Vec<&str> = super::LINT_ADAPTERS.iter().map(|a| a.analyzer()).collect();
710        assert_eq!(from_adapters, LINT_ANALYZERS.to_vec());
711    }
712
713    /// The lookup is by program and answers for every shipped one, including the
714    /// two that are toolchain components rather than analyzers.
715    #[test]
716    fn the_lookup_answers_for_a_known_program_and_not_an_unknown_one() {
717        for program in [
718            "semgrep",
719            "cargo-audit",
720            "osv-scanner",
721            "cargo",
722            "cargo-clippy",
723        ] {
724            assert!(install_hint(program).is_some(), "no hint for `{program}`");
725        }
726        assert!(install_hint("no-such-binary").is_none());
727    }
728
729    /// The two hints that could most easily become the same wrong answer.
730    ///
731    /// A reader missing `cargo-audit` has cargo already; a reader missing
732    /// `cargo` has neither. Collapsing them onto one hint would send the first
733    /// to an installer they do not need, which is the "wrong *kind* of way
734    /// forward" the refusals checklist says has cost an hour here before. This
735    /// asserts the distinction survives, and asserts it on rendered text because
736    /// that is what the reader gets.
737    #[test]
738    fn the_toolchain_and_the_subcommand_are_obtained_differently() {
739        let toolchain = install_hint("cargo").expect("cargo").to_string();
740        let subcommand = install_hint("cargo-audit")
741            .expect("cargo-audit")
742            .to_string();
743        assert!(toolchain.contains("https://rustup.rs"), "{toolchain}");
744        assert!(
745            !toolchain.contains("cargo install cargo-audit"),
746            "{toolchain}"
747        );
748        assert!(
749            subcommand.contains("cargo install cargo-audit"),
750            "{subcommand}"
751        );
752        assert!(!subcommand.contains("https://rustup.rs"), "{subcommand}");
753        // Verified against upstream's README, which documents no `--locked`.
754        assert!(!subcommand.contains("--locked"), "{subcommand}");
755    }
756
757    /// The analyzer with no honest single command says so, rather than reaching
758    /// for one of the eight platform-specific ones upstream lists.
759    #[test]
760    fn an_analyzer_without_one_canonical_command_says_so() {
761        let hint = install_hint("osv-scanner")
762            .expect("osv-scanner")
763            .to_string();
764        assert!(
765            hint.contains("https://google.github.io/osv-scanner/installation/"),
766            "{hint}"
767        );
768        assert!(hint.contains("no single install command"), "{hint}");
769    }
770
771    /// Every registered analyzer is one whose findings are **stored**, so each
772    /// one must have a pinned rule set or database to decide the answer. A
773    /// linter has neither — its rules are the toolchain — which is why clippy
774    /// has an adapter and no registry entry, and why this asserts the property
775    /// rather than the name: a future storable analyzer with no asset would fail
776    /// here and have to argue its case.
777    #[test]
778    fn every_storable_analyzer_pins_what_decides_its_answer() {
779        for id in known_analyzers() {
780            let adapter = adapter_for(id).expect("registered");
781            assert!(
782                !adapter.asset_ids().is_empty(),
783                "{id} is stored but pins nothing that decides its findings"
784            );
785        }
786        assert!(
787            super::clippy::Clippy.asset_ids().is_empty(),
788            "a linter has no pinned rule set — that is why it is not stored"
789        );
790    }
791
792    /// Every shipped adapter claims at least one language and a summary, because
793    /// `roteiro security status` prints the coverage matrix from this table —
794    /// an adapter that claims nothing would silently shrink the reported
795    /// coverage.
796    #[test]
797    fn every_adapter_states_its_coverage() {
798        for id in known_analyzers() {
799            let adapter = adapter_for(id).expect("registered");
800            assert!(!adapter.languages().is_empty(), "{id} claims no language");
801            assert!(!adapter.summary().is_empty(), "{id} has no summary");
802        }
803    }
804
805    #[test]
806    fn a_version_is_taken_from_the_caller_then_the_report_then_unknown() {
807        assert_eq!(ctx(Some("1.2.3")).version_or(Some("0.0.1")), "1.2.3");
808        assert_eq!(ctx(None).version_or(Some("0.0.1")), "0.0.1");
809        assert_eq!(ctx(None).version_or(None), UNKNOWN_VERSION);
810        // Whitespace is not a version: an all-blank field would be refused
811        // downstream as missing evidence, so it is treated as absent here.
812        assert_eq!(ctx(Some("  ")).version_or(None), UNKNOWN_VERSION);
813    }
814
815    #[test]
816    fn snippet_hashes_are_short_stable_and_whitespace_insensitive() {
817        let hash = snippet_hash("eval(user_input)");
818        assert_eq!(hash.len(), 16);
819        assert_eq!(hash, snippet_hash("  eval(user_input)\n"));
820        assert_ne!(hash, snippet_hash("eval(other_input)"));
821    }
822
823    /// A report about a tree this checkout does not have still yields a
824    /// well-formed identity, and one that says why it is weaker.
825    #[test]
826    fn an_unavailable_snippet_is_named_not_hashed_as_empty() {
827        let hash = snippet_hash_at(&crate::snippet::NoSnippets, "a.py", 0, 4);
828        assert_eq!(hash, NO_SNIPPET);
829        assert_ne!(hash, snippet_hash(""));
830    }
831
832    #[test]
833    fn asset_paths_resolve_only_what_was_provisioned() {
834        let entries = [("semgrep-rules", std::path::PathBuf::from("/cache/r.yaml"))];
835        let paths = AssetPaths::new(&entries);
836        assert_eq!(paths.arg("semgrep-rules"), "/cache/r.yaml");
837        assert!(paths.get("advisory-db").is_none());
838        assert!(paths.arg("advisory-db").is_empty());
839    }
840}