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}