Skip to main content

judge/
finding.rs

1//! The common output unit for detectors: a `Finding`. Findings can reference
2//! each other (`caused_by`/`causes`) so a single root cause — e.g. a missed
3//! entry point — doesn't present as dozens of unrelated findings (see
4//! todo.md §7 "Kausale Finding-Gruppen", §14.2 P0#1). Causal edges are owned
5//! exclusively by [`FindingGraph`] (todo.md §15.2): detectors emit findings
6//! without edges, the graph stores each edge once, and the per-finding
7//! `caused_by`/`causes` fields are derived from that single edge set on
8//! export.
9
10use std::collections::{BTreeSet, HashMap};
11use std::path::{Path, PathBuf};
12
13use serde::{Deserialize, Serialize};
14
15/// Stable identifier for a finding, referenced by `caused_by`/`causes` links.
16/// A newtype around the existing id string (e.g. `hotspot:src/lib.rs`) — the
17/// id computation is unchanged, the type only makes graph indexing explicit.
18/// Serializes transparently as that string, so the JSON schema is unaffected.
19#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
20#[serde(transparent)]
21pub struct FindingId(String);
22
23impl FindingId {
24    pub fn as_str(&self) -> &str {
25        &self.0
26    }
27}
28
29impl From<String> for FindingId {
30    fn from(value: String) -> Self {
31        Self(value)
32    }
33}
34
35impl From<&str> for FindingId {
36    fn from(value: &str) -> Self {
37        Self(value.to_string())
38    }
39}
40
41impl std::fmt::Display for FindingId {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        f.write_str(&self.0)
44    }
45}
46
47impl PartialEq<str> for FindingId {
48    fn eq(&self, other: &str) -> bool {
49        self.0 == other
50    }
51}
52
53impl PartialEq<&str> for FindingId {
54    fn eq(&self, other: &&str) -> bool {
55        self.0 == *other
56    }
57}
58
59/// Deterministic, version-independent 64-bit FNV-1a hash, hex-encoded.
60/// Used to compute stable, deterministic identifiers for candidates and heuristics.
61pub(crate) fn fnv1a_hex(input: &str) -> String {
62    const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
63    const PRIME: u64 = 0x0000_0100_0000_01b3;
64    let mut hash = OFFSET_BASIS;
65    for byte in input.bytes() {
66        hash ^= u64::from(byte);
67        hash = hash.wrapping_mul(PRIME);
68    }
69    format!("{hash:016x}")
70}
71
72/// Stable identifier of a rule (e.g. `duplicate-code`). A newtype around the
73/// rule-id string each detector exposes as a `&'static str` constant — the
74/// ids themselves are unchanged, and `#[serde(transparent)]` keeps the JSON
75/// schema (and every baseline) byte-identical to the former plain string.
76#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
77#[serde(transparent)]
78pub struct RuleId(String);
79
80impl RuleId {
81    pub fn as_str(&self) -> &str {
82        &self.0
83    }
84}
85
86impl From<String> for RuleId {
87    fn from(value: String) -> Self {
88        Self(value)
89    }
90}
91
92impl From<&str> for RuleId {
93    fn from(value: &str) -> Self {
94        Self(value.to_string())
95    }
96}
97
98impl std::fmt::Display for RuleId {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        f.write_str(&self.0)
101    }
102}
103
104impl PartialEq<str> for RuleId {
105    fn eq(&self, other: &str) -> bool {
106        self.0 == other
107    }
108}
109
110impl PartialEq<&str> for RuleId {
111    fn eq(&self, other: &&str) -> bool {
112        self.0 == *other
113    }
114}
115
116/// Ordered `Info < Warn < Fail` (derive order follows declaration order) so
117/// findings can be sorted worst-first across detectors — see
118/// [`sort_by_severity_desc`].
119#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
120#[serde(rename_all = "snake_case")]
121pub enum Severity {
122    Info,
123    Warn,
124    Fail,
125}
126
127/// How a finding's claim is backed — the categorical replacement for the
128/// former numeric `confidence` score (todo.md §17.2, §17.5: numbers like
129/// `0.95` suggest a calibrated probability that never existed).
130///
131/// Rule → class mapping (see [`evidence_class_for_rule`], todo.md §17.3):
132///
133/// | Rule | Class |
134/// |---|---|
135/// | `swallowed-result`, `empty-error-arm`, `catch-all-error`, `suppression-debt`, `merged-stub`, `empty-impl`, `assertion-free-test`, `tautological-test`, `ignored-test-accumulation`, `conversational-artifact`, `restating-comment`, `step-comment-inflation`, `generic-naming`, `doc-restates-signature` | `derived_fact` (G1–G3: the reported pattern is a syntax fact) |
136/// | `undocumented-public-item` | `derived_fact` (the absence of a `#[doc = ...]` attribute on a `pub` item is an exact syntax fact — see `crate::rules::api_surface`) |
137/// | `semver-hazard` | `derived_fact` for the two Fast-Tier sub-cases (the absence of a `#[non_exhaustive]` attribute on a `pub enum`/`pub struct` is an exact syntax fact — see `crate::rules::api_surface`); `bounded_semantic` for the Deep-Tier `leaked_dependency_type` sub-case, overridden at its own creation site (see `crate::rules::api_surface_deep`) |
138/// | `duplicate-code` | `derived_fact` for `Strict`/`Mild` token equality; `heuristic` for `Weak`/`Semantic` normalization (see [`crate::rules::duplication::CloneMember::to_finding`]) |
139/// | `duplicate-crate-versions`, `msrv-drift`, `workspace-dep-drift` | `derived_fact` (manifest/resolve-graph facts read directly from `cargo_metadata`) |
140/// | `dep-without-repo` | `derived_fact` (a manifest fact — the dependency's own `repository` field — read directly from a full `cargo_metadata` resolve, same class as `duplicate-crate-versions`/`msrv-drift`/`workspace-dep-drift`) |
141/// | `unsafe-surface` | `derived_fact` (a syntax-fact pairing: an `unsafe { .. }` block's span plus the absence of an adjacent `// SAFETY:` comment, both read directly from the parsed source — see `crate::rules::security`) |
142/// | `panic-in-lib` | `derived_fact` (a `.unwrap()`/`.expect(..)`/`panic!(..)`/indexing construct's span, plus the enclosing function's `pub` visibility, both read directly from the parsed source — same class as `unsafe-surface`, not a claim that it panics at runtime — see `crate::rules::security`) |
143/// | `unused-feature-flag` | `derived_fact` (the feature is declared in the manifest, and zero usage of the dependency was found anywhere in the examined view — both read directly from the declared inputs, see [`crate::rules::deps`] module docs "Feature-only evidence") |
144/// | `default-features-unused` | `derived_fact` (the manifest text explicitly sets `default-features = true`, and zero usage of the dependency was found anywhere in the examined view — see [`crate::rules::deps`] module docs "Feature-only evidence") |
145/// | `unused-feature` | `derived_fact` (a feature this crate itself declares, with no implied features/deps and no `cfg(feature = ...)`/`cfg!(feature = ...)` reference found anywhere in its own authored source — see [`crate::rules::deps`] module docs "`unused-feature`") |
146/// | `feature-graph-cycle` | `derived_fact` (a cyclic implication chain within one crate's own, fully self-contained `[features]` table — no partial-view caveat, unlike `dependency-cycle`'s workspace-scoped crate graph — see [`crate::rules::boundaries`] module docs "`feature-graph-cycle`") |
147/// | `unused-pub-workspace`, `crate-boundary-violation`, `dependency-cycle` | `bounded_semantic` (proven only within the loaded workspace / configured crate graph) |
148/// | `dead-enum-variant`, `test-only-pub` | `bounded_semantic` (Deep Tier; same "every workspace crate is workspace-internal" simplification as `unused-pub-workspace` — see `crate::rules::dead_code` module docs) |
149/// | `unreachable-from-entry` | `bounded_semantic` (Deep Tier; entry-point reachability only, scoped to non-`pub` items — see `crate::rules::dead_code`'s `UNREACHABLE_FROM_ENTRY_RULE` doc comment) |
150/// | `dead-trait-impl` | `bounded_semantic` (Deep Tier; a real `Semantics::resolve_method_call`-backed call-site resolution, scoped to traits defined within the analyzed workspace only — see `crate::rules::dead_trait_impl` module docs) |
151/// | `unused-pub-api` | `heuristic` (Deep Tier; a published crate's public surface is expected to have zero *internal* reference — see `crate::rules::dead_code`'s `UNUSED_PUB_API_RULE` doc comment) |
152/// | `unlinked-file`, `orphan-module` | `bounded_semantic` (proven only within the crate's own resolved `mod` tree / the loaded workspace's cross-file reference scan — see `crate::rules::module_graph`) |
153/// | `module-boundary-violation` | `bounded_semantic` (an explicitly configured edge over a heuristically derived, directory-convention module view — see [`crate::rules::boundaries`] module docs "Module-level boundaries") |
154/// | `module-boundary-violation-deep` | `bounded_semantic` (Deep Tier; the reference edge itself is a real symbol fact, but the `from`/`forbidden` scoping is still the same directory-convention heuristic as the Fast-Tier rule above — see `crate::rules::boundaries_deep` module docs) |
155/// | `internal-leak` | `bounded_semantic` (Deep Tier; an explicitly configured `internal_crates` edge over the same semantically resolved type reference `leaked_dependency_type`'s `bounded_semantic` override already relies on — see `crate::rules::api_surface_deep`, [`crate::rules::boundaries::BoundaryConfig::internal_crates`]) |
156/// | `unused-dev-dependency` | `bounded_semantic` (no usage found in the examined view — tests/examples/benches and `#[cfg(test)]` modules of the declaring package; doctests are not scanned) |
157/// | `unused-dependency` | `bounded_semantic` (rustc's own `unused_crate_dependencies` lint result, narrowed to the intersection across every target compiled for the package — see [`crate::rules::deps`] module docs "Importing rustc's `unused_crate_dependencies` lint") |
158/// | `phantom-crate`, `phantom-version`, `fresh-low-reputation-dep`, `yanked-dependency`, `dep-single-maintainer` | `external_measurement` (a crates.io lookup snapshot) |
159/// | `known-vulnerability` | `external_measurement` (an imported `cargo audit --json` snapshot — see `crate::advisory::advisories`) |
160/// | `untested-hotspot` | `external_measurement` (complexity and churn are `derived_fact`/`heuristic` in isolation, but the imported `cargo-llvm-cov` coverage snapshot is the rarest, least locally-verifiable ingredient in the combination, so it sets the class — see `crate::advisory::coverage::untested_hotspots`) |
161/// | `mutation-survivor` | `external_measurement` (an imported `cargo-mutants` `outcomes.json` snapshot — same class as `untested-hotspot`, judge's other external-tool-derived test-strength signal — see `crate::advisory::mutants`) |
162/// | `hotspot`, `churn-hotspot`, `low-bus-factor`, `ownership-fragmentation`, `abstraction-inflation`, `complexity-inflation`, `duplicative-reinvention`, `connectivity-drop`, `name-collision-risk`, `misplaced-dependency-kind`, `heavy-dependency`, `provenance-churn`, `provenance-duplication-rate`, `provenance-suppression-debt`, `dep-added-by-agent`, `integer-cast-risk`, `fragile-substring-classification`, `size-distribution`, `complexity-concentration`, `re-export-chain`, `hardcoded-secret`, `change-coupling-signal`, `signature-complexity` | `heuristic` (reproducible interpretation, not proof) |
163/// | `crate-coupling` | `heuristic` (Deep Tier; Robert C. Martin's Instability metric `Ce / (Ca + Ce)` over the same cross-crate reference edges `unused-pub-workspace`'s `check_item` already resolves — a purely descriptive distributional signal, never a claim that a crate is 'too coupled' — see `crate::rules::dead_code` module docs) |
164/// | `feature-gated-dead-code` | `heuristic` (Deep Tier; reachability re-checked once per user-configured `judge.toml` `[feature_matrix]` combination, but correctness depends entirely on that matrix being representative of real downstream usage — a combination the config omits is never checked, so this stays advisory rather than `unreachable-from-entry`'s `bounded_semantic` — see `crate::rules::feature_matrix` module docs) |
165/// | `silent-default`, `context-free-propagation`, `debug-format-leak` | `heuristic` (narrow syntax-only proxies for signals that would need real type/taint information for a complete check — see `crate::rules::slop` module docs) |
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(rename_all = "snake_case")]
168pub enum EvidenceClass {
169    /// Exactly derived from the declared inputs — syntax facts, strict/mild
170    /// token duplicates, manifest facts, suppressions (todo.md §17.2).
171    DerivedFact,
172    /// Semantically backed, but only within a fully described analysis view —
173    /// e.g. "no reference found in the loaded workspace for the searched
174    /// crates/entry points" (todo.md §17.2).
175    BoundedSemantic,
176    /// The result of a concrete external run/snapshot — e.g. a crates.io
177    /// index/API query. Valid for that snapshot, not a timeless truth
178    /// (todo.md §17.2).
179    ExternalMeasurement,
180    /// A reproducible interpretation of facts/measurements — a hint by
181    /// default, never proof (todo.md §17.2).
182    Heuristic,
183}
184
185impl EvidenceClass {
186    /// Whether findings of this class may affect a verdict/exit code or the
187    /// health score. [`EvidenceClass::Heuristic`] findings are advisory by
188    /// default — reported, but never gating (todo.md §17.2: "standardmäßig
189    /// nur Hinweis, kein Exitcode 1"). The single place this policy lives;
190    /// every verdict/score consumer goes through it.
191    pub const fn is_gating(self) -> bool {
192        !matches!(self, Self::Heuristic)
193    }
194}
195
196/// The single authoritative rule-id → [`EvidenceClass`] mapping (see the
197/// table on [`EvidenceClass`]). Used by detectors whose constructors take a
198/// rule id, and by the baseline v1→v2 migration, which must derive a class
199/// from nothing but the stored rule id.
200///
201/// `duplicate-code` maps to its `Strict`/`Mild` (default-mode, fact-backed)
202/// class here; `Weak`/`Semantic` creation sites override to `Heuristic` at
203/// the source (see [`crate::rules::duplication::CloneMember::to_finding`]) — a
204/// migrated v1 baseline entry can't recover the mode, and baseline entries
205/// only serve identity matching. Unknown rule ids (e.g. from a v1 baseline
206/// written by a different judge) conservatively map to `Heuristic`.
207/// `pub(crate)`: only detectors and the baseline migration consult the
208/// mapping — consumers read the materialized `Finding.evidence_class`.
209pub(crate) fn evidence_class_for_rule(rule: &RuleId) -> EvidenceClass {
210    match rule.as_str() {
211        "swallowed-result"
212        | "empty-error-arm"
213        | "catch-all-error"
214        | "suppression-debt"
215        | "merged-stub"
216        | "empty-impl"
217        | "assertion-free-test"
218        | "tautological-test"
219        | "ignored-test-accumulation"
220        | "conversational-artifact"
221        | "restating-comment"
222        | "step-comment-inflation"
223        | "generic-naming"
224        | "doc-restates-signature"
225        | "undocumented-public-item"
226        | "semver-hazard"
227        | "duplicate-code"
228        | "duplicate-crate-versions"
229        | "msrv-drift"
230        | "workspace-dep-drift"
231        | "dep-without-repo"
232        | "unsafe-surface"
233        | "panic-in-lib" => EvidenceClass::DerivedFact,
234        "unused-feature-flag" => EvidenceClass::DerivedFact,
235        "default-features-unused" => EvidenceClass::DerivedFact,
236        "unused-feature" => EvidenceClass::DerivedFact,
237        "feature-graph-cycle" => EvidenceClass::DerivedFact,
238        "unused-pub-workspace"
239        | "crate-boundary-violation"
240        | "dependency-cycle"
241        | "module-boundary-violation"
242        | "module-boundary-violation-deep"
243        | "internal-leak"
244        | "unused-dev-dependency"
245        | "unused-dependency"
246        | "unlinked-file"
247        | "orphan-module"
248        | "dead-enum-variant"
249        | "test-only-pub"
250        | "unreachable-from-entry"
251        | "dead-trait-impl" => EvidenceClass::BoundedSemantic,
252        "phantom-crate"
253        | "phantom-version"
254        | "fresh-low-reputation-dep"
255        | "yanked-dependency"
256        | "dep-single-maintainer"
257        | "known-vulnerability"
258        | "untested-hotspot"
259        | "mutation-survivor" => EvidenceClass::ExternalMeasurement,
260        "unused-pub-api" => EvidenceClass::Heuristic,
261        "crate-coupling" => EvidenceClass::Heuristic,
262        "size-distribution" => EvidenceClass::Heuristic,
263        "complexity-concentration" => EvidenceClass::Heuristic,
264        "re-export-chain" => EvidenceClass::Heuristic,
265        "feature-gated-dead-code" => EvidenceClass::Heuristic,
266        "silent-default" | "context-free-propagation" | "debug-format-leak" => {
267            EvidenceClass::Heuristic
268        }
269        _ => EvidenceClass::Heuristic,
270    }
271}
272
273/// Where a finding comes from. Distinguishes an actual code issue from a
274/// finding about judge's own configuration or analyzer state, which must
275/// not be suppressed or baselined the same way (see todo.md §14.2 P0#1).
276#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
277#[serde(rename_all = "snake_case")]
278pub enum Origin {
279    Code,
280    Config,
281    Analyzer,
282}
283
284/// A 1-based source line number. Line 0 is unrepresentable: every producer
285/// (syn/proc-macro2 spans, git blame, manual "whole file" anchors) counts
286/// from 1, and the fallible constructor makes that invariant a type instead
287/// of a convention. Serializes as the bare number — the JSON schema is
288/// unchanged — while deserialization validates via `TryFrom` and rejects 0.
289#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
290#[serde(into = "usize", try_from = "usize")]
291pub struct OneBasedLine(usize);
292
293impl OneBasedLine {
294    /// The first line of a file — the anchor for findings about a file (or
295    /// manifest) as a whole rather than a specific line.
296    pub const FIRST: Self = Self(1);
297
298    pub fn new(line: usize) -> Option<Self> {
299        (line != 0).then_some(Self(line))
300    }
301
302    pub fn get(self) -> usize {
303        self.0
304    }
305}
306
307impl From<OneBasedLine> for usize {
308    fn from(value: OneBasedLine) -> Self {
309        value.0
310    }
311}
312
313impl TryFrom<usize> for OneBasedLine {
314    type Error = &'static str;
315
316    fn try_from(value: usize) -> Result<Self, Self::Error> {
317        Self::new(value).ok_or("line numbers are 1-based; 0 is not a valid line")
318    }
319}
320
321impl PartialEq<usize> for OneBasedLine {
322    fn eq(&self, other: &usize) -> bool {
323        self.0 == *other
324    }
325}
326
327impl std::fmt::Display for OneBasedLine {
328    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
329        self.0.fmt(f)
330    }
331}
332
333#[derive(Debug, Clone, Serialize)]
334pub struct Location {
335    pub file: PathBuf,
336    pub line: OneBasedLine,
337    pub item_path: String,
338}
339
340#[derive(Debug, Clone, Serialize)]
341pub struct Finding {
342    /// Stable identifier (e.g. `hotspot:src/lib.rs`) — the same [`FindingId`]
343    /// the causal edges reference. Detectors build the underlying string
344    /// inline; [`relativize_paths`] rebases it together with `location`.
345    pub id: FindingId,
346    pub rule: RuleId,
347    pub severity: Severity,
348    pub location: Location,
349    pub evidence_class: EvidenceClass,
350    pub origin: Origin,
351    /// Free-form, rule-specific proof for why this finding fired — e.g. how
352    /// many crates/entry points were searched, and what backs
353    /// `evidence_class` (see todo.md §7). `None` where a detector doesn't
354    /// yet populate it; not every rule does.
355    pub evidence: Option<serde_json::Value>,
356    /// Known, structural gaps in what this specific finding's analysis could
357    /// observe — e.g. `["proc_macro_expansion_disabled"]` when the Deep Tier
358    /// ran without proc-macro expansion, so some references may be invisible
359    /// to it. Distinct from `evidence`, which carries the finding's own
360    /// supporting data rather than a caveat about that data's completeness.
361    /// `None` where a detector has no such gap to disclose.
362    #[serde(skip_serializing_if = "Option::is_none")]
363    pub limitations: Option<Vec<String>>,
364    /// Findings that caused this one to appear (the root-cause direction).
365    /// Derived from [`FindingGraph`]'s edge set on export — `pub(crate)` so
366    /// the public API cannot set it independently of `causes` (todo.md
367    /// §15.2: one stored truth, the reverse direction is derived).
368    pub(crate) caused_by: Vec<FindingId>,
369    /// Findings this one caused to appear (the cascade direction). Same
370    /// ownership rule as `caused_by`: only [`FindingGraph`] populates it.
371    pub(crate) causes: Vec<FindingId>,
372}
373
374impl Finding {
375    /// Constructs a finding without causal edges — the only way to attach
376    /// edges is [`FindingGraph::add_edge`], which validates ids, duplicates,
377    /// and cycles.
378    pub fn new(
379        id: impl Into<FindingId>,
380        rule: impl Into<RuleId>,
381        severity: Severity,
382        location: Location,
383        evidence_class: EvidenceClass,
384        origin: Origin,
385        evidence: Option<serde_json::Value>,
386    ) -> Self {
387        Self {
388            id: id.into(),
389            rule: rule.into(),
390            severity,
391            location,
392            evidence_class,
393            origin,
394            evidence,
395            limitations: None,
396            caused_by: Vec::new(),
397            causes: Vec::new(),
398        }
399    }
400
401    /// Constructs a finding anchored to a `syn`/`proc_macro2` span — the
402    /// shape shared by every syntax-derived finding that reports a precise
403    /// token location: an id of `{rule}:{file}:{line}:{col}`, and a
404    /// `Location` at the span's start line. Callers only supply what makes
405    /// their rule's claim distinct: severity, evidence class, and evidence.
406    pub fn at_span(
407        rule: &str,
408        file: &Path,
409        span: proc_macro2::Span,
410        item_path: &str,
411        severity: Severity,
412        evidence_class: EvidenceClass,
413        evidence: Option<serde_json::Value>,
414    ) -> Self {
415        let start = span.start();
416        Self::new(
417            format!("{rule}:{}:{}:{}", file.display(), start.line, start.column),
418            rule,
419            severity,
420            Location {
421                file: file.to_path_buf(),
422                line: OneBasedLine::new(start.line).expect("proc-macro2 span lines are 1-based"),
423                item_path: item_path.to_string(),
424            },
425            evidence_class,
426            Origin::Code,
427            evidence,
428        )
429    }
430
431    /// See [`EvidenceClass::is_gating`] — `false` means this finding is
432    /// advisory: shown, but with no effect on verdicts or the health score.
433    pub fn is_gating(&self) -> bool {
434        self.evidence_class.is_gating()
435    }
436
437    /// Findings that caused this one (read-only; derived from
438    /// [`FindingGraph`]'s edge set on export).
439    pub fn caused_by(&self) -> &[FindingId] {
440        &self.caused_by
441    }
442
443    /// Findings this one caused (read-only; derived from
444    /// [`FindingGraph`]'s edge set on export).
445    pub fn causes(&self) -> &[FindingId] {
446        &self.causes
447    }
448}
449
450/// Current version of the JSON report schema (see todo.md §7). Bump whenever
451/// a field is removed or changes meaning; additive fields don't require it.
452/// v2: `Finding.confidence: f32` replaced by `Finding.evidence_class`
453/// (todo.md §17.5).
454pub const SCHEMA_VERSION: u32 = 2;
455
456/// Tri-state status of an analysis capability, so a tier where a capability
457/// has no meaning at all doesn't have to report a false `true`/`false`: the
458/// Fast Tier is a syntactic pass that never expands anything, and
459/// `proc_macro_expansion: false` there would read like a Deep-Tier-style
460/// fidelity trade-off instead of "no such dimension exists here"
461/// (todo.md §17.5).
462#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
463#[serde(rename_all = "snake_case")]
464pub enum FidelityStatus {
465    /// The capability was active during analysis.
466    Enabled,
467    /// The capability exists for this tier but was deliberately off — a
468    /// real, documented fidelity trade-off (e.g. the Deep Tier loads without
469    /// a proc-macro server and without running build scripts; see
470    /// `judge::deep`'s `DeepContext::load`).
471    Disabled,
472    /// The capability has no meaning for this tier — nothing was traded off.
473    NotApplicable,
474}
475
476/// What the analysis actually looked at — the report-level answer to "what
477/// is this report even a claim about?" (todo.md §17.5, §0): source snapshot,
478/// examined targets, feature selection, platform, entry-point model, test
479/// and generated-code policy, expansion fidelity, and the judge version.
480///
481/// Lives on [`Report`], not on each [`Finding`]: one report is one analysis
482/// view, and repeating the universe per finding (as todo.md §7's schema
483/// sketch shows) would be massively redundant. Should a future detector ever
484/// produce findings under a *different* view than the enclosing report's
485/// (none does today), a per-finding override would be follow-up work — not
486/// part of this type.
487#[derive(Debug, Clone, Serialize)]
488pub struct AnalysisUniverse {
489    /// Reserved source-snapshot identity. judge's current-state analysis
490    /// does not inspect Git, so this is always `None`.
491    pub commit: Option<String>,
492    /// Cargo target kinds discovered in the workspace, deduped and sorted —
493    /// the ingest layer's labels: `lib`, `bin`, `example`, `test`, `bench`,
494    /// `build-script` (see `crate::ingest::EntryPointKind::label`).
495    pub targets: Vec<String>,
496    /// The cargo feature selection the analyzed view was resolved under.
497    /// Deep Tier: `["all"]` — `DeepContext::load` (`crate::deep`) loads the
498    /// workspace with every feature active (`CargoFeatures::All`,
499    /// `--all-features`-equivalent), not just `default`. Fast Tier: empty —
500    /// the syntactic pass is feature-blind and parses every `cfg`-gated line
501    /// regardless of any selection.
502    pub features: Vec<String>,
503    /// Host platform the analysis ran on, as `<arch>-<os>` from
504    /// `std::env::consts`.
505    pub platform: String,
506    /// Entry-point kinds the reachability root set recognizes (see
507    /// `crate::reachability`): `fn-main-bin`, `fn-main-example`, `test` and
508    /// `bench` only with `--include-tests`, plus `no-mangle`, `export-name`,
509    /// `wasm-bindgen` unconditionally. Empty at the Fast Tier, which makes
510    /// no reachability claims and therefore has no entry-point model.
511    pub entry_points: Vec<String>,
512    /// Whether test code counted as usage/entry points (`--include-tests`).
513    /// Always `true` at the Fast Tier: its test-focused rules
514    /// (assertion-free-test, tautological-test, …) require parsing test
515    /// code, so tests are always part of the examined universe there.
516    pub include_tests: bool,
517    /// Whether generated files were analyzed as finding targets
518    /// (`--include-generated`). Generated code stays part of the graph
519    /// either way (see `crate::ingest::SourceKind`) — this only says whether
520    /// findings were reported *about* it.
521    pub include_generated: bool,
522    /// Whether proc-macros were expanded. [`FidelityStatus::Disabled`] at
523    /// the Deep Tier — it deliberately loads without a proc-macro server, so
524    /// macro-generated references are invisible (the documented trade-off on
525    /// `judge::deep`'s `DeepContext::load`). [`FidelityStatus::NotApplicable`]
526    /// at the Fast Tier.
527    pub proc_macro_expansion: FidelityStatus,
528    /// Whether build scripts ran (`OUT_DIR` code visible). Same tier logic
529    /// as `proc_macro_expansion`: [`FidelityStatus::Disabled`] at the Deep
530    /// Tier, [`FidelityStatus::NotApplicable`] at the Fast Tier.
531    pub build_scripts: FidelityStatus,
532    /// The judge version that produced this report (`CARGO_PKG_VERSION`).
533    pub judge_version: String,
534    /// Which tier produced this view: `"fast"` or `"deep"` (see
535    /// `crate::AnalysisTier`).
536    pub tier: String,
537}
538
539impl AnalysisUniverse {
540    /// The Deep Tier view (`dead-code`, `explain --why-live`): a semantic
541    /// workspace load under every Cargo feature, deliberately without a
542    /// proc-macro server and without build scripts (see `judge::deep`).
543    /// `include_generated` is fixed to `false`: generated files stay in the
544    /// semantic graph but are never finding targets at the Deep Tier (see
545    /// `crate::rules::dead_code`).
546    pub fn deep(workspace: &crate::ingest::Workspace, include_tests: bool) -> Self {
547        let mut entry_points = vec!["fn-main-bin".to_string(), "fn-main-example".to_string()];
548        if include_tests {
549            entry_points.push("test".to_string());
550            entry_points.push("bench".to_string());
551        }
552        entry_points.extend(
553            ["no-mangle", "export-name", "wasm-bindgen"]
554                .iter()
555                .map(ToString::to_string),
556        );
557        Self {
558            features: vec!["all".to_string()],
559            entry_points,
560            include_tests,
561            proc_macro_expansion: FidelityStatus::Disabled,
562            build_scripts: FidelityStatus::Disabled,
563            tier: "deep".to_string(),
564            ..Self::base(workspace)
565        }
566    }
567
568    /// The Fast Tier view (`health`, bare `cargo judge`): a feature-blind
569    /// syntactic pass over every discovered source file — no reachability
570    /// (empty `entry_points`), tests always parsed, and nothing that could
571    /// be expanded (both fidelity fields [`FidelityStatus::NotApplicable`]).
572    pub fn fast(workspace: &crate::ingest::Workspace, include_generated: bool) -> Self {
573        Self {
574            include_generated,
575            ..Self::base(workspace)
576        }
577    }
578
579    /// The fields shared by both tiers, with Fast Tier defaults.
580    fn base(workspace: &crate::ingest::Workspace) -> Self {
581        let mut targets: Vec<String> = workspace
582            .crates
583            .iter()
584            .flat_map(|krate| &krate.entry_points)
585            .map(|entry| entry.kind.label().to_string())
586            .collect();
587        targets.sort();
588        targets.dedup();
589        Self {
590            commit: None,
591            targets,
592            features: Vec::new(),
593            platform: format!("{}-{}", std::env::consts::ARCH, std::env::consts::OS),
594            entry_points: Vec::new(),
595            include_tests: true,
596            include_generated: false,
597            proc_macro_expansion: FidelityStatus::NotApplicable,
598            build_scripts: FidelityStatus::NotApplicable,
599            judge_version: env!("CARGO_PKG_VERSION").to_string(),
600            tier: "fast".to_string(),
601        }
602    }
603}
604
605/// How many of a report's findings can affect a verdict vs. how many are
606/// purely advisory (see [`EvidenceClass::is_gating`]). Additive report field
607/// — per-finding classification is already carried by
608/// `Finding.evidence_class`, this is just the pre-computed split so
609/// consumers don't have to re-derive the gating policy.
610#[derive(Debug, Clone, Serialize)]
611pub struct VerdictEffectCounts {
612    pub gating: usize,
613    pub advisory: usize,
614}
615
616/// The versioned, agent-readable output envelope (see todo.md §7). Always
617/// carries the full finding graph — TTY/Markdown reduce to root findings by
618/// default, JSON never does. `findings` includes advisory (heuristic)
619/// findings; `counts` records the gating/advisory split, and any verdict or
620/// exit code derived from a report reflects only the gating findings
621/// (todo.md §17.2, §17.5).
622#[derive(Debug, Clone, Serialize)]
623pub struct Report {
624    pub schema_version: u32,
625    /// The analysis view this report's findings are claims about (see
626    /// [`AnalysisUniverse`]). Additive in schema v2 — no version bump; the
627    /// field is omitted from JSON when absent, so universe-less v2 reports
628    /// stay valid. The Deep Tier always fills it (todo.md §0); Fast Tier
629    /// commands may.
630    #[serde(skip_serializing_if = "Option::is_none")]
631    pub analysis_universe: Option<AnalysisUniverse>,
632    /// Gating vs. advisory finding counts (additive in schema v2 — no
633    /// version bump; see [`VerdictEffectCounts`]).
634    pub counts: VerdictEffectCounts,
635    pub findings: Vec<Finding>,
636    /// Analyzer failures that made the report incomplete. An empty list means
637    /// every requested detector completed successfully.
638    pub errors: Vec<String>,
639    /// Findings dropped by an inline `// judge-ignore: <rule> — <reason>`
640    /// comment before this report was built (see
641    /// [`crate::suppression::apply_inline_suppressions`], todo.md §5) — the
642    /// only trace a suppression leaves, since the findings themselves are
643    /// gone as if they never fired. Additive in schema v2 — no version bump;
644    /// omitted from JSON when zero, matching `analysis_universe`'s pattern.
645    #[serde(skip_serializing_if = "is_zero")]
646    pub suppressed_inline: usize,
647    /// Per-crate public-API-surface item count (see
648    /// [`crate::rules::api_surface::ApiSurfaceSize`], todo.md §I "API-Surface-Größe
649    /// pro Crate, Trend gegen Baseline") — additive, no version bump; only
650    /// `cargo judge api-surface` fills it in, matching `analysis_universe`'s
651    /// pattern of an omitted-when-absent field.
652    #[serde(skip_serializing_if = "Option::is_none")]
653    pub api_surface_size: Option<HashMap<String, usize>>,
654    /// Source files that exist in the working tree but not in the committed
655    /// revision used for historical analysis. They have no history facts;
656    /// this is not an analyzer error. Additive in schema v2 and omitted when
657    /// every source file is available in `HEAD`.
658    #[serde(skip_serializing_if = "Vec::is_empty")]
659    pub history_unavailable: Vec<PathBuf>,
660}
661
662fn is_zero(count: &usize) -> bool {
663    *count == 0
664}
665
666impl Report {
667    pub fn new(findings: Vec<Finding>) -> Self {
668        Self::with_errors(findings, Vec::new())
669    }
670
671    pub fn with_errors(findings: Vec<Finding>, errors: Vec<String>) -> Self {
672        let gating = findings.iter().filter(|f| f.is_gating()).count();
673        Self {
674            schema_version: SCHEMA_VERSION,
675            analysis_universe: None,
676            counts: VerdictEffectCounts {
677                gating,
678                advisory: findings.len() - gating,
679            },
680            findings,
681            errors,
682            suppressed_inline: 0,
683            api_surface_size: None,
684            history_unavailable: Vec::new(),
685        }
686    }
687
688    /// Attaches the analysis view — builder-style, so the many existing
689    /// [`Report::new`]/[`Report::with_errors`] call sites stay untouched and
690    /// only the commands that describe their universe opt in.
691    pub fn with_universe(mut self, universe: AnalysisUniverse) -> Self {
692        self.analysis_universe = Some(universe);
693        self
694    }
695
696    /// Records how many findings an inline `judge-ignore` comment dropped
697    /// before this report was built — builder-style like [`with_universe`],
698    /// so only commands that actually run the filter opt in.
699    ///
700    /// [`with_universe`]: Report::with_universe
701    pub fn with_suppressed_inline(mut self, count: usize) -> Self {
702        self.suppressed_inline = count;
703        self
704    }
705
706    /// Attaches the per-crate api-surface-size count — builder-style like
707    /// [`with_universe`](Self::with_universe), so only `cargo judge
708    /// api-surface` opts in.
709    pub fn with_api_surface_size(mut self, size: HashMap<String, usize>) -> Self {
710        self.api_surface_size = Some(size);
711        self
712    }
713
714    /// Marks working-tree files for which no committed history was available,
715    /// without turning that ordinary in-progress state into a report error.
716    pub fn with_history_unavailable(mut self, files: Vec<PathBuf>) -> Self {
717        self.history_unavailable = files;
718        self
719    }
720}
721
722/// Findings with no recorded cause — what TTY/Markdown show by default
723/// (see todo.md §7 "Kausale Finding-Gruppen", §14.2 P0#2). `--show-cascades`
724/// bypasses this and shows every finding, root or not.
725pub fn root_findings(findings: &[Finding]) -> Vec<&Finding> {
726    findings.iter().filter(|f| f.caused_by.is_empty()).collect()
727}
728
729/// Sorts findings worst-first (`Fail` before `Warn` before `Info`), stable
730/// otherwise. Used to merge findings from multiple detectors into one
731/// ranked view without inventing a numeric score across them (see todo.md
732/// §4 "Decision Surface" — the score itself needs crate-type profiles that
733/// don't exist yet; this is the part that doesn't).
734pub fn sort_by_severity_desc(findings: &mut [Finding]) {
735    findings.sort_by_key(|finding| std::cmp::Reverse(finding.severity));
736}
737
738/// Rewrites workspace-local absolute paths to repository-relative paths.
739/// Finding ids embed their location in several detectors, so the id must be
740/// rebased together with the structured location to remain stable across
741/// different checkout directories. Causal edge references are rebased along
742/// with the ids they point to, so exported `caused_by`/`causes` never dangle
743/// after a rebase.
744pub fn relativize_paths(findings: &mut [Finding], workspace_root: &Path) {
745    let mut renames: HashMap<FindingId, FindingId> = HashMap::new();
746    for finding in findings.iter_mut() {
747        let Ok(relative) = finding.location.file.strip_prefix(workspace_root) else {
748            continue;
749        };
750        let relative = relative.to_path_buf();
751        // The id and item_path are UTF-8 strings, so the embedded path text
752        // can only be rebased when both the absolute and the stripped path
753        // render as valid UTF-8. A non-UTF-8 path can never appear verbatim
754        // in an id; substituting its lossy rendering (as this function once
755        // did) could corrupt an id that merely resembles it, so such paths
756        // keep their id/item_path untouched and only the structured
757        // location is rebased.
758        if let (Some(absolute_text), Some(relative_text)) =
759            (finding.location.file.to_str(), relative.to_str())
760        {
761            let rebased_id = finding.id.as_str().replace(absolute_text, relative_text);
762            if rebased_id != finding.id.as_str() {
763                let rebased_id = FindingId::from(rebased_id);
764                renames.insert(finding.id.clone(), rebased_id.clone());
765                finding.id = rebased_id;
766            }
767            if finding.location.item_path == absolute_text {
768                finding.location.item_path = relative_text.to_string();
769            }
770        }
771        finding.location.file = relative;
772    }
773    if renames.is_empty() {
774        return;
775    }
776    for finding in findings {
777        for edge_id in finding
778            .caused_by
779            .iter_mut()
780            .chain(finding.causes.iter_mut())
781        {
782            if let Some(rebased) = renames.get(edge_id) {
783                *edge_id = rebased.clone();
784            }
785        }
786    }
787}
788
789/// Rejected [`FindingGraph`] mutations. Every invalid state is an error, not
790/// a panic — callers decide how to surface it.
791#[derive(Debug, PartialEq, Eq)]
792pub enum GraphError {
793    /// `add_finding` saw an id that is already in the graph.
794    DuplicateFinding(FindingId),
795    /// `add_edge` referenced an id with no finding in the graph.
796    UnknownFinding(FindingId),
797    /// `add_edge` saw a cause → effect pair that is already stored.
798    DuplicateEdge { cause: FindingId, effect: FindingId },
799    /// `add_edge` would close a loop; the path lists the finding ids that
800    /// would form it (first and last entry are the same id).
801    Cycle { cycle: Vec<FindingId> },
802}
803
804impl std::fmt::Display for GraphError {
805    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
806        match self {
807            Self::DuplicateFinding(id) => write!(f, "duplicate finding id: {id}"),
808            Self::UnknownFinding(id) => write!(f, "edge references unknown finding id: {id}"),
809            Self::DuplicateEdge { cause, effect } => {
810                write!(f, "duplicate edge: {cause} -> {effect}")
811            }
812            Self::Cycle { cycle } => {
813                let path: Vec<&str> = cycle.iter().map(FindingId::as_str).collect();
814                write!(f, "cycle in finding graph: {}", path.join(" -> "))
815            }
816        }
817    }
818}
819
820impl std::error::Error for GraphError {}
821
822/// Sole owner of findings and their causal edges (todo.md §15.2). Each edge
823/// is stored exactly once as a cause → effect pair; the per-finding
824/// `caused_by`/`causes` views are derived from that one set, so root
825/// reduction and cycle checking can never read diverging truths.
826/// [`FindingGraph::into_findings`] exports findings with both directions
827/// filled in, keeping the schema-v2 JSON shape unchanged.
828#[derive(Debug, Default)]
829pub struct FindingGraph {
830    findings: Vec<Finding>,
831    ids: Vec<FindingId>,
832    index_by_id: HashMap<FindingId, usize>,
833    /// `(cause, effect)` index pairs — the single stored edge direction.
834    edges: BTreeSet<(usize, usize)>,
835}
836
837impl FindingGraph {
838    pub fn new() -> Self {
839        Self::default()
840    }
841
842    /// Adds a finding, rejecting ids already present in the graph. Callers
843    /// pass findings without edges (detectors never set them; edges are
844    /// attached via [`FindingGraph::add_edge`]).
845    pub fn add_finding(&mut self, finding: Finding) -> Result<(), GraphError> {
846        debug_assert!(
847            finding.caused_by.is_empty() && finding.causes.is_empty(),
848            "edges are owned by the graph; add them via add_edge"
849        );
850        let id = finding.id.clone();
851        if self.index_by_id.contains_key(&id) {
852            return Err(GraphError::DuplicateFinding(id));
853        }
854        self.index_by_id.insert(id.clone(), self.findings.len());
855        self.ids.push(id);
856        self.findings.push(finding);
857        Ok(())
858    }
859
860    /// Records that `cause` caused `effect`. Rejects unknown ids, duplicate
861    /// edges, and any edge that would close a cycle (including self-edges).
862    pub fn add_edge(&mut self, cause: &FindingId, effect: &FindingId) -> Result<(), GraphError> {
863        let cause_index = *self
864            .index_by_id
865            .get(cause)
866            .ok_or_else(|| GraphError::UnknownFinding(cause.clone()))?;
867        let effect_index = *self
868            .index_by_id
869            .get(effect)
870            .ok_or_else(|| GraphError::UnknownFinding(effect.clone()))?;
871        if self.edges.contains(&(cause_index, effect_index)) {
872            return Err(GraphError::DuplicateEdge {
873                cause: cause.clone(),
874                effect: effect.clone(),
875            });
876        }
877        if cause_index == effect_index {
878            return Err(GraphError::Cycle {
879                cycle: vec![cause.clone(), cause.clone()],
880            });
881        }
882        let mut visited = vec![false; self.findings.len()];
883        let mut path = Vec::new();
884        if self.find_path(effect_index, cause_index, &mut visited, &mut path) {
885            // `path` runs effect -> … -> cause; prepending the cause closes
886            // the reported loop (first and last entry are the same id).
887            let mut cycle = Vec::with_capacity(path.len() + 1);
888            cycle.push(cause.clone());
889            cycle.extend(path.iter().map(|&index| self.ids[index].clone()));
890            return Err(GraphError::Cycle { cycle });
891        }
892        self.edges.insert((cause_index, effect_index));
893        Ok(())
894    }
895
896    /// Depth-first search for a path `from` -> … -> `to` along stored edges,
897    /// recording the node path when one exists.
898    fn find_path(
899        &self,
900        from: usize,
901        to: usize,
902        visited: &mut [bool],
903        path: &mut Vec<usize>,
904    ) -> bool {
905        path.push(from);
906        if from == to {
907            return true;
908        }
909        visited[from] = true;
910        for &(_, next) in self.edges.range((from, usize::MIN)..=(from, usize::MAX)) {
911            if !visited[next] && self.find_path(next, to, visited, path) {
912                return true;
913            }
914        }
915        path.pop();
916        false
917    }
918
919    /// Findings with no recorded cause, in insertion order — the same edge
920    /// set the cycle check validates.
921    pub fn roots(&self) -> Vec<&Finding> {
922        let mut has_cause = vec![false; self.findings.len()];
923        for &(_, effect) in &self.edges {
924            has_cause[effect] = true;
925        }
926        self.findings
927            .iter()
928            .zip(&has_cause)
929            .filter(|(_, has_cause)| !**has_cause)
930            .map(|(finding, _)| finding)
931            .collect()
932    }
933
934    /// Findings that `id` caused (the cascade direction), derived from the
935    /// stored edges. Unknown ids yield an empty list.
936    pub fn causes_of(&self, id: &FindingId) -> Vec<&Finding> {
937        let Some(index) = self.index_of(id) else {
938            return Vec::new();
939        };
940        self.edges
941            .range((index, usize::MIN)..=(index, usize::MAX))
942            .map(|&(_, effect)| &self.findings[effect])
943            .collect()
944    }
945
946    /// Findings that caused `id` (the root-cause direction), derived from
947    /// the stored edges. Unknown ids yield an empty list.
948    pub fn caused_by_of(&self, id: &FindingId) -> Vec<&Finding> {
949        let Some(index) = self.index_of(id) else {
950            return Vec::new();
951        };
952        self.edges
953            .iter()
954            .filter(|&&(_, effect)| effect == index)
955            .map(|&(cause, _)| &self.findings[cause])
956            .collect()
957    }
958
959    /// Resolves `id` to its internal node index, or `None` for an unknown
960    /// id — the shared fallback [`causes_of`](Self::causes_of) and
961    /// [`caused_by_of`](Self::caused_by_of) both bail out to an empty list
962    /// on.
963    fn index_of(&self, id: &FindingId) -> Option<usize> {
964        self.index_by_id.get(id).copied()
965    }
966
967    /// Consumes the graph and returns the findings in insertion order with
968    /// `caused_by`/`causes` filled in from the single stored edge set — the
969    /// serialized schema-v2 shape is exactly what it was when the fields
970    /// were set directly.
971    pub fn into_findings(mut self) -> Vec<Finding> {
972        for &(cause, effect) in &self.edges {
973            let cause_id = self.ids[cause].clone();
974            let effect_id = self.ids[effect].clone();
975            self.findings[cause].causes.push(effect_id);
976            self.findings[effect].caused_by.push(cause_id);
977        }
978        self.findings
979    }
980}
981
982#[cfg(test)]
983mod tests {
984    use super::*;
985
986    fn finding(id: &str) -> Finding {
987        Finding::new(
988            id.to_string(),
989            "test-rule".to_string(),
990            Severity::Warn,
991            Location {
992                file: PathBuf::from("src/lib.rs"),
993                line: OneBasedLine::FIRST,
994                item_path: "crate::lib".to_string(),
995            },
996            EvidenceClass::Heuristic,
997            Origin::Code,
998            None,
999        )
1000    }
1001
1002    fn id(value: &str) -> FindingId {
1003        FindingId::from(value)
1004    }
1005
1006    fn graph_of(ids: &[&str]) -> FindingGraph {
1007        let mut graph = FindingGraph::new();
1008        for finding_id in ids {
1009            graph.add_finding(finding(finding_id)).unwrap();
1010        }
1011        graph
1012    }
1013
1014    #[test]
1015    fn graph_rejects_duplicate_finding_ids() {
1016        let mut graph = graph_of(&["a"]);
1017        let err = graph.add_finding(finding("a")).unwrap_err();
1018        assert_eq!(err, GraphError::DuplicateFinding(id("a")));
1019        assert_eq!(graph.into_findings().len(), 1);
1020    }
1021
1022    #[test]
1023    fn graph_rejects_edges_to_unknown_findings() {
1024        let mut graph = graph_of(&["a"]);
1025        let err = graph.add_edge(&id("a"), &id("dangling")).unwrap_err();
1026        assert_eq!(err, GraphError::UnknownFinding(id("dangling")));
1027        let err = graph.add_edge(&id("dangling"), &id("a")).unwrap_err();
1028        assert_eq!(err, GraphError::UnknownFinding(id("dangling")));
1029    }
1030
1031    #[test]
1032    fn graph_rejects_duplicate_edges() {
1033        let mut graph = graph_of(&["a", "b"]);
1034        graph.add_edge(&id("a"), &id("b")).unwrap();
1035        let err = graph.add_edge(&id("a"), &id("b")).unwrap_err();
1036        assert_eq!(
1037            err,
1038            GraphError::DuplicateEdge {
1039                cause: id("a"),
1040                effect: id("b"),
1041            }
1042        );
1043    }
1044
1045    #[test]
1046    fn graph_rejects_self_cycles() {
1047        let mut graph = graph_of(&["a"]);
1048        let err = graph.add_edge(&id("a"), &id("a")).unwrap_err();
1049        assert_eq!(
1050            err,
1051            GraphError::Cycle {
1052                cycle: vec![id("a"), id("a")],
1053            }
1054        );
1055    }
1056
1057    #[test]
1058    fn graph_rejects_direct_cycles() {
1059        let mut graph = graph_of(&["a", "b"]);
1060        graph.add_edge(&id("a"), &id("b")).unwrap();
1061        let err = graph.add_edge(&id("b"), &id("a")).unwrap_err();
1062        assert_eq!(
1063            err,
1064            GraphError::Cycle {
1065                cycle: vec![id("b"), id("a"), id("b")],
1066            }
1067        );
1068    }
1069
1070    #[test]
1071    fn graph_rejects_indirect_cycles_and_accepts_acyclic_edges() {
1072        let mut graph = graph_of(&["a", "b", "c"]);
1073        graph.add_edge(&id("a"), &id("b")).unwrap();
1074        graph.add_edge(&id("b"), &id("c")).unwrap();
1075        let err = graph.add_edge(&id("c"), &id("a")).unwrap_err();
1076        assert_eq!(
1077            err,
1078            GraphError::Cycle {
1079                cycle: vec![id("c"), id("a"), id("b"), id("c")],
1080            }
1081        );
1082    }
1083
1084    #[test]
1085    fn graph_roots_are_findings_without_a_recorded_cause() {
1086        let mut graph = graph_of(&["a", "b", "c"]);
1087        graph.add_edge(&id("a"), &id("b")).unwrap();
1088        let root_ids: Vec<&str> = graph.roots().iter().map(|f| f.id.as_str()).collect();
1089        assert_eq!(root_ids, ["a", "c"]);
1090    }
1091
1092    #[test]
1093    fn edge_directions_are_derived_from_the_single_stored_edge_set() {
1094        let mut graph = graph_of(&["a", "b", "c"]);
1095        graph.add_edge(&id("a"), &id("b")).unwrap();
1096        graph.add_edge(&id("a"), &id("c")).unwrap();
1097
1098        let effects: Vec<&str> = graph
1099            .causes_of(&id("a"))
1100            .iter()
1101            .map(|f| f.id.as_str())
1102            .collect();
1103        assert_eq!(effects, ["b", "c"]);
1104
1105        let causes: Vec<&str> = graph
1106            .caused_by_of(&id("b"))
1107            .iter()
1108            .map(|f| f.id.as_str())
1109            .collect();
1110        assert_eq!(causes, ["a"]);
1111
1112        assert!(graph.causes_of(&id("missing")).is_empty());
1113        assert!(graph.caused_by_of(&id("missing")).is_empty());
1114    }
1115
1116    #[test]
1117    fn graph_export_serializes_identically_to_the_v2_finding_shape() {
1118        let mut graph = graph_of(&["a", "b"]);
1119        graph.add_edge(&id("a"), &id("b")).unwrap();
1120        let findings = graph.into_findings();
1121        let json = serde_json::to_value(&findings).unwrap();
1122
1123        assert_eq!(
1124            json,
1125            serde_json::json!([
1126                {
1127                    "id": "a",
1128                    "rule": "test-rule",
1129                    "severity": "warn",
1130                    "location": {
1131                        "file": "src/lib.rs",
1132                        "line": 1,
1133                        "item_path": "crate::lib",
1134                    },
1135                    "evidence_class": "heuristic",
1136                    "origin": "code",
1137                    "evidence": null,
1138                    "caused_by": [],
1139                    "causes": ["b"],
1140                },
1141                {
1142                    "id": "b",
1143                    "rule": "test-rule",
1144                    "severity": "warn",
1145                    "location": {
1146                        "file": "src/lib.rs",
1147                        "line": 1,
1148                        "item_path": "crate::lib",
1149                    },
1150                    "evidence_class": "heuristic",
1151                    "origin": "code",
1152                    "evidence": null,
1153                    "caused_by": ["a"],
1154                    "causes": [],
1155                },
1156            ])
1157        );
1158    }
1159
1160    #[test]
1161    fn root_findings_excludes_those_with_a_recorded_cause() {
1162        let mut graph = graph_of(&["a", "b"]);
1163        graph.add_edge(&id("a"), &id("b")).unwrap();
1164        let findings = graph.into_findings();
1165
1166        let roots = root_findings(&findings);
1167
1168        assert_eq!(roots.len(), 1);
1169        assert_eq!(roots[0].id, "a");
1170    }
1171
1172    #[test]
1173    fn report_serializes_with_schema_version_and_snake_case_enums() {
1174        let report = Report::new(vec![finding("a")]);
1175        let json = serde_json::to_value(&report).unwrap();
1176
1177        assert_eq!(json["schema_version"], SCHEMA_VERSION);
1178        assert_eq!(json["findings"][0]["severity"], "warn");
1179        assert_eq!(json["findings"][0]["origin"], "code");
1180        assert_eq!(json["findings"][0]["evidence_class"], "heuristic");
1181        assert_eq!(json["counts"]["gating"], 0);
1182        assert_eq!(json["counts"]["advisory"], 1);
1183        assert_eq!(json["errors"], serde_json::json!([]));
1184    }
1185
1186    #[test]
1187    fn report_marks_uncommitted_files_without_promoting_them_to_errors() {
1188        let report = Report::new(Vec::new())
1189            .with_history_unavailable(vec![PathBuf::from("src/new_module.rs")]);
1190        let json = serde_json::to_value(&report).unwrap();
1191
1192        assert_eq!(json["errors"], serde_json::json!([]));
1193        assert_eq!(
1194            json["history_unavailable"],
1195            serde_json::json!(["src/new_module.rs"])
1196        );
1197    }
1198
1199    /// Full-shape drift guard for the top-level JSON envelope (todo.md
1200    /// "Stabiles JSON-Schema mit Semver-Garantie"): unlike the field-by-field
1201    /// checks above, this compares the entire serialized `Report` against a
1202    /// literal fixture, so an accidental new/renamed/removed field at any
1203    /// level — not just the ones already asserted — fails the test.
1204    #[test]
1205    fn report_serializes_to_the_full_expected_json_shape() {
1206        let report = Report::new(vec![finding("a")]);
1207        let json = serde_json::to_value(&report).unwrap();
1208
1209        assert_eq!(
1210            json,
1211            serde_json::json!({
1212                "schema_version": SCHEMA_VERSION,
1213                "counts": {
1214                    "gating": 0,
1215                    "advisory": 1,
1216                },
1217                "findings": [
1218                    {
1219                        "id": "a",
1220                        "rule": "test-rule",
1221                        "severity": "warn",
1222                        "location": {
1223                            "file": "src/lib.rs",
1224                            "line": 1,
1225                            "item_path": "crate::lib",
1226                        },
1227                        "evidence_class": "heuristic",
1228                        "origin": "code",
1229                        "evidence": null,
1230                        "caused_by": [],
1231                        "causes": [],
1232                    },
1233                ],
1234                "errors": [],
1235            })
1236        );
1237    }
1238
1239    #[test]
1240    fn only_heuristic_findings_are_advisory() {
1241        let mut gating = finding("gating");
1242        gating.evidence_class = EvidenceClass::DerivedFact;
1243        assert!(gating.is_gating());
1244        gating.evidence_class = EvidenceClass::BoundedSemantic;
1245        assert!(gating.is_gating());
1246        gating.evidence_class = EvidenceClass::ExternalMeasurement;
1247        assert!(gating.is_gating());
1248
1249        let advisory = finding("advisory");
1250        assert_eq!(advisory.evidence_class, EvidenceClass::Heuristic);
1251        assert!(!advisory.is_gating());
1252    }
1253
1254    #[test]
1255    fn sort_by_severity_desc_puts_fail_before_warn_before_info() {
1256        let mut info = finding("info");
1257        info.severity = Severity::Info;
1258        let mut warn = finding("warn");
1259        warn.severity = Severity::Warn;
1260        let mut fail = finding("fail");
1261        fail.severity = Severity::Fail;
1262
1263        let mut findings = vec![info, warn, fail];
1264        sort_by_severity_desc(&mut findings);
1265
1266        let ids: Vec<_> = findings.iter().map(|f| f.id.as_str()).collect();
1267        assert_eq!(ids, ["fail", "warn", "info"]);
1268    }
1269
1270    #[test]
1271    fn one_based_line_serializes_as_the_bare_number_and_rejects_zero() {
1272        assert!(OneBasedLine::new(0).is_none());
1273        let line = OneBasedLine::new(42).unwrap();
1274        assert_eq!(line.get(), 42);
1275        assert_eq!(serde_json::to_value(line).unwrap(), serde_json::json!(42));
1276        assert_eq!(
1277            serde_json::from_value::<OneBasedLine>(serde_json::json!(42)).unwrap(),
1278            line
1279        );
1280        assert!(serde_json::from_value::<OneBasedLine>(serde_json::json!(0)).is_err());
1281    }
1282
1283    #[test]
1284    fn relativize_paths_rebases_location_and_embedded_id() {
1285        let mut finding = finding("hotspot:/tmp/project/src/lib.rs");
1286        finding.location.file = PathBuf::from("/tmp/project/src/lib.rs");
1287        finding.location.item_path = "/tmp/project/src/lib.rs".to_string();
1288
1289        relativize_paths(
1290            std::slice::from_mut(&mut finding),
1291            Path::new("/tmp/project"),
1292        );
1293
1294        assert_eq!(finding.id, "hotspot:src/lib.rs");
1295        assert_eq!(finding.location.file, PathBuf::from("src/lib.rs"));
1296        assert_eq!(finding.location.item_path, "src/lib.rs");
1297    }
1298
1299    #[test]
1300    fn relativize_paths_rebases_edge_references_alongside_ids() {
1301        let mut graph = FindingGraph::new();
1302        let mut cause = finding("hotspot:/tmp/project/src/lib.rs");
1303        cause.location.file = PathBuf::from("/tmp/project/src/lib.rs");
1304        graph.add_finding(cause).unwrap();
1305        graph.add_finding(finding("other")).unwrap();
1306        graph
1307            .add_edge(&id("hotspot:/tmp/project/src/lib.rs"), &id("other"))
1308            .unwrap();
1309        let mut findings = graph.into_findings();
1310
1311        relativize_paths(&mut findings, Path::new("/tmp/project"));
1312
1313        assert_eq!(findings[0].id, "hotspot:src/lib.rs");
1314        assert_eq!(findings[0].causes, vec![id("other")]);
1315        assert_eq!(findings[1].caused_by, vec![id("hotspot:src/lib.rs")]);
1316    }
1317
1318    /// A minimal real workspace with a lib and a bin target, loaded through
1319    /// the ingest layer — [`AnalysisUniverse`] describes an ingested
1320    /// workspace, so its tests need one. Not a git repository, so `commit`
1321    /// is honestly `None`.
1322    fn fixture_workspace(dir: &crate::test_util::TempDir) -> crate::ingest::Workspace {
1323        std::fs::write(
1324            dir.join("Cargo.toml"),
1325            r#"
1326[package]
1327name = "universe-fixture"
1328version = "0.1.0"
1329edition = "2021"
1330"#,
1331        )
1332        .unwrap();
1333        std::fs::create_dir_all(dir.join("src")).unwrap();
1334        std::fs::write(dir.join("src/lib.rs"), "pub fn hello() {}\n").unwrap();
1335        std::fs::write(dir.join("src/main.rs"), "fn main() {}\n").unwrap();
1336        crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap()
1337    }
1338
1339    #[test]
1340    fn deep_universe_is_fully_populated_and_reflects_include_tests() {
1341        let dir = crate::test_util::TempDir::new("universe-deep");
1342        let workspace = fixture_workspace(&dir);
1343
1344        let with_tests = AnalysisUniverse::deep(&workspace, true);
1345        assert_eq!(with_tests.tier, "deep");
1346        assert_eq!(with_tests.commit, None, "fixture is not a git repository");
1347        assert_eq!(with_tests.targets, ["bin", "lib"]);
1348        assert_eq!(with_tests.features, ["all"]);
1349        assert!(!with_tests.platform.is_empty());
1350        assert!(with_tests.include_tests);
1351        assert!(!with_tests.include_generated);
1352        assert_eq!(
1353            with_tests.entry_points,
1354            [
1355                "fn-main-bin",
1356                "fn-main-example",
1357                "test",
1358                "bench",
1359                "no-mangle",
1360                "export-name",
1361                "wasm-bindgen"
1362            ]
1363        );
1364        assert_eq!(with_tests.proc_macro_expansion, FidelityStatus::Disabled);
1365        assert_eq!(with_tests.build_scripts, FidelityStatus::Disabled);
1366        assert_eq!(with_tests.judge_version, env!("CARGO_PKG_VERSION"));
1367
1368        let without_tests = AnalysisUniverse::deep(&workspace, false);
1369        assert!(!without_tests.include_tests);
1370        assert!(
1371            !without_tests.entry_points.iter().any(|kind| kind == "test")
1372                && !without_tests
1373                    .entry_points
1374                    .iter()
1375                    .any(|kind| kind == "bench"),
1376            "test/bench entry-point kinds must only be listed with --include-tests"
1377        );
1378    }
1379
1380    #[test]
1381    fn fast_universe_reports_not_applicable_fidelity_and_no_entry_point_model() {
1382        let dir = crate::test_util::TempDir::new("universe-fast");
1383        let workspace = fixture_workspace(&dir);
1384
1385        let universe = AnalysisUniverse::fast(&workspace, true);
1386        assert_eq!(universe.tier, "fast");
1387        assert_eq!(universe.targets, ["bin", "lib"]);
1388        assert!(
1389            universe.features.is_empty(),
1390            "the Fast Tier is feature-blind and must not claim a feature selection"
1391        );
1392        assert!(
1393            universe.entry_points.is_empty(),
1394            "the Fast Tier makes no reachability claims and has no entry-point model"
1395        );
1396        assert!(universe.include_tests);
1397        assert!(universe.include_generated);
1398        assert_eq!(universe.proc_macro_expansion, FidelityStatus::NotApplicable);
1399        assert_eq!(universe.build_scripts, FidelityStatus::NotApplicable);
1400        assert_eq!(universe.judge_version, env!("CARGO_PKG_VERSION"));
1401    }
1402
1403    #[test]
1404    fn report_serializes_universe_when_present_and_omits_it_when_absent() {
1405        let bare = Report::new(Vec::new());
1406        let bare_json = serde_json::to_value(&bare).unwrap();
1407        assert!(
1408            bare_json.get("analysis_universe").is_none(),
1409            "a universe-less v2 report must omit the field, not emit null"
1410        );
1411        assert_eq!(bare_json["schema_version"], SCHEMA_VERSION);
1412
1413        let dir = crate::test_util::TempDir::new("universe-serialize");
1414        let workspace = fixture_workspace(&dir);
1415        let report =
1416            Report::new(Vec::new()).with_universe(AnalysisUniverse::deep(&workspace, true));
1417        let json = serde_json::to_value(&report).unwrap();
1418
1419        let universe = &json["analysis_universe"];
1420        assert_eq!(universe["tier"], "deep");
1421        assert_eq!(universe["commit"], serde_json::Value::Null);
1422        assert_eq!(universe["targets"], serde_json::json!(["bin", "lib"]));
1423        assert_eq!(universe["features"], serde_json::json!(["all"]));
1424        assert_eq!(universe["include_tests"], true);
1425        assert_eq!(universe["include_generated"], false);
1426        assert_eq!(universe["proc_macro_expansion"], "disabled");
1427        assert_eq!(universe["build_scripts"], "disabled");
1428        assert_eq!(universe["judge_version"], env!("CARGO_PKG_VERSION"));
1429        assert!(
1430            universe["entry_points"]
1431                .as_array()
1432                .unwrap()
1433                .contains(&serde_json::json!("fn-main-bin"))
1434        );
1435    }
1436}