fallow_output/impact.rs
1//! Impact report output contracts.
2
3use crate::root_envelopes::{RootEnvelopeMode, attach_telemetry_meta, serialize_named_json_output};
4use fallow_types::envelope::Meta;
5use serde::{Deserialize, Serialize};
6
7/// Per-category issue counts captured at a recorded run.
8#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
9#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
10pub struct ImpactCounts {
11 /// Sum of the category counts.
12 pub total_issues: usize,
13 /// Dead-code findings.
14 pub dead_code: usize,
15 /// Complexity findings.
16 pub complexity: usize,
17 /// Duplication findings.
18 pub duplication: usize,
19}
20
21impl ImpactCounts {
22 /// Counts with `total_issues` derived as the sum of the categories.
23 #[must_use]
24 pub fn from_combined(dead_code: usize, complexity: usize, duplication: usize) -> Self {
25 Self {
26 total_issues: dead_code + complexity + duplication,
27 dead_code,
28 complexity,
29 duplication,
30 }
31 }
32}
33
34/// Recorded gate runs grouped by the gate that produced them. Local
35/// provenance only: the store never leaves the machine, so this answers "where
36/// do my gate runs come from", never "how widely is fallow adopted".
37///
38/// Counted over the recorded runs the store still holds, which is the same
39/// window `record_count` reports. The store keeps a bounded number of runs and
40/// drops the oldest, so on a long-lived project these are the shape of recent
41/// gate activity, not a lifetime total: read them as a floor. Absent when no
42/// run in that window carries a gate source, which is not the same as "no gate
43/// ever ran here".
44#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
45#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
46pub struct GateRunCounts {
47 /// Runs recorded by the agent gate (`--gate-marker agent`).
48 pub agent: usize,
49 /// Runs recorded by the git pre-commit hook (`--gate-marker pre-commit`).
50 pub pre_commit: usize,
51 /// Runs recorded by a CI gate (`--gate-marker ci`).
52 pub ci: usize,
53 /// Gate runs whose marker this build does not recognise, plus every gate
54 /// run recorded before the store kept its source (store schema 6 and older).
55 pub unknown: usize,
56}
57
58impl GateRunCounts {
59 /// Whether any gate run was recorded at all.
60 #[must_use]
61 pub fn is_empty(&self) -> bool {
62 self.agent == 0 && self.pre_commit == 0 && self.ci == 0 && self.unknown == 0
63 }
64}
65
66/// A commit-gate containment event recorded by `fallow impact`.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
69pub struct ContainmentEvent {
70 /// Timestamp when the commit gate blocked the commit.
71 pub blocked_at: String,
72 /// Timestamp when a later run passed clean.
73 pub cleared_at: String,
74 /// Abbreviated SHA of the cleared commit, when in a git repo.
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub git_sha: Option<String>,
77 /// Finding counts at the moment the gate blocked.
78 pub blocked_counts: ImpactCounts,
79}
80
81/// A resolved or suppressed finding attribution event.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
84pub struct ResolutionEvent {
85 /// Finding kind that was resolved or suppressed, e.g. `unused-export`.
86 pub kind: String,
87 /// Root-relative path of the resolved finding.
88 pub path: String,
89 /// Symbol name, for symbol-level findings.
90 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub symbol: Option<String>,
92 /// Abbreviated SHA of the resolving commit, when in a git repo.
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub git_sha: Option<String>,
95 /// Timestamp the resolution was recorded.
96 pub timestamp: String,
97}
98
99/// Why Impact tracking is (or is not) active for a project. `Project` = an
100/// explicit per-repo `enable`; `User` = the user-global default with no per-repo
101/// decision; `Default` = off (no per-repo decision and no global default).
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
103#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
104#[serde(rename_all = "lowercase")]
105pub enum EnabledSource {
106 /// Explicit per-repo enable/disable decision.
107 Project,
108 /// User-global default with no per-repo decision.
109 User,
110 /// Off: no per-repo decision and no global default.
111 Default,
112}
113
114/// Direction of a count trend between two recorded runs.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
116#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
117#[serde(rename_all = "snake_case")]
118pub enum ImpactTrendDirection {
119 /// Issue count went down.
120 Improving,
121 /// Issue count went up.
122 Declining,
123 /// Within tolerance.
124 Stable,
125}
126
127/// A computed trend between the two most recent records.
128#[derive(Debug, Clone, Serialize)]
129#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
130pub struct TrendSummary {
131 /// Trend direction between the two runs.
132 pub direction: ImpactTrendDirection,
133 /// Signed delta in total issues, current minus previous.
134 pub total_delta: i64,
135 /// Total issues in the earlier run.
136 pub previous_total: usize,
137 /// Total issues in the later run.
138 pub current_total: usize,
139}
140
141/// Wire-version discriminator for [`ImpactReport`]. Independent from the global
142/// `SchemaVersion` (the impact report versions on its own cadence) and from the
143/// on-disk `STORE_SCHEMA_VERSION` (the persisted store shape versions
144/// separately). Serializes as a string `const` so JSON consumers can switch on
145/// it, matching the other independently-versioned envelopes (e.g.
146/// `CoverageAnalyzeSchemaVersion`).
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
148#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
149pub enum ImpactReportSchemaVersion {
150 /// First release of the `fallow impact --format json` shape.
151 #[serde(rename = "1")]
152 V1,
153 /// Expands the required semantic omission reason-code enum.
154 #[serde(rename = "2")]
155 V2,
156}
157
158/// The rendered impact report, derived purely from the store.
159#[derive(Debug, Clone, Serialize)]
160#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
161#[cfg_attr(feature = "schema", schemars(title = "fallow impact --format json"))]
162pub struct ImpactReport {
163 /// Output-shape version for this report, so JSON consumers have a
164 /// forward-compat signal independent of the on-disk store version. Always
165 /// present; bumped only on a breaking change to this report's wire shape.
166 pub schema_version: ImpactReportSchemaVersion,
167 /// Whether impact tracking is active for this project.
168 pub enabled: bool,
169 /// WHY tracking is on or off: `project` (an explicit per-repo enable/disable
170 /// decision), `user` (the user-global default with no per-repo decision), or
171 /// `default` (off, no per-repo decision and no global default). Combine with
172 /// `explicit_decision` to tell a never-asked off-state (`enabled:false`,
173 /// `explicit_decision:false`, offer to enable) from a declined-here one
174 /// (`enabled:false`, `explicit_decision:true`, do not nag).
175 pub enabled_source: EnabledSource,
176 /// Number of recorded runs in the store.
177 pub record_count: usize,
178 /// `_meta` block with docs and field definitions, when `--explain` was
179 /// passed.
180 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
181 pub meta: Option<Meta>,
182 /// Timestamp of the earliest recorded run; absent with no records.
183 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub first_recorded: Option<String>,
185 /// Git SHA of the most recent recorded run, so a consumer can tell which
186 /// commit the `surfacing` counts belong to. This is an ABBREVIATED SHA
187 /// (`git rev-parse --short`), so it is for display/correlation only and will
188 /// not match a full 40-character SHA from `$GITHUB_SHA` or the git API
189 /// without expansion. None when the latest run had no SHA (not a git repo)
190 /// or there are no records yet.
191 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub latest_git_sha: Option<String>,
193 /// Counts from the most recent recorded run. These are CHANGED-FILE scoped
194 /// (each record comes from a `fallow audit` run, whose default `new-only`
195 /// gate counts only findings in the changed files of that run), NOT a
196 /// whole-project total.
197 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub surfacing: Option<ImpactCounts>,
199 /// Trend between the two most recent records. None until two records exist.
200 /// Trend between the two most recent changed-file records. None until two
201 /// records exist.
202 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub trend: Option<TrendSummary>,
204 /// Counts from the most recent whole-project `fallow` run. WHOLE-PROJECT
205 /// scope (not changed-file), so this is the current issue total across the
206 /// whole repo, context next to the actionable changed-file `surfacing`
207 /// count. None until a full `fallow` run has been recorded. v1.6.
208 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub project_surfacing: Option<ImpactCounts>,
210 /// Trend between the two most recent whole-project records. Comparable over
211 /// time (same whole-project denominator every run), unlike the changed-file
212 /// `trend`. None until two full `fallow` runs exist. v1.6.
213 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub project_trend: Option<TrendSummary>,
215 /// Recorded gate runs grouped by source, over the same bounded window of
216 /// recorded runs `record_count` reports. A floor, not a lifetime total, and
217 /// absent when no run in that window carries a gate source. Local
218 /// provenance, never an adoption metric.
219 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub gate_runs: Option<GateRunCounts>,
221 /// Lifetime count of commit-gate containment events.
222 pub containment_count: usize,
223 /// Most recent containment events (newest last), capped for display.
224 pub recent_containment: Vec<ContainmentEvent>,
225 /// Lifetime count of findings fallow credits as genuinely resolved (code
226 /// removed or refactored, never a `fallow-ignore`). v1.5.
227 pub resolved_total: usize,
228 /// Lifetime count of findings silenced by a newly-added `fallow-ignore`.
229 /// Reported as honest context, never as a win. v1.5.
230 pub suppressed_total: usize,
231 /// Most recent resolution events (newest last), capped for display. v1.5.
232 pub recent_resolved: Vec<ResolutionEvent>,
233 /// Whether per-finding attribution has a baseline yet. False on a freshly
234 /// upgraded v1 store (no frontier captured), which the renderer uses to show
235 /// "resolution tracking starts from your next run" instead of a bare zero.
236 pub attribution_active: bool,
237 /// Whether the local agent onboarding prompt has been explicitly declined.
238 /// Stored in the user config dir (per project) so agents avoid cross-session
239 /// nags without writing into the repo.
240 pub onboarding_declined: bool,
241 /// Whether the user ever made an explicit enable/disable decision for
242 /// Impact tracking. `enabled: false` with `explicit_decision: false` means
243 /// "never asked"; with `true` it means "asked and declined". Agents use
244 /// this to offer the impact opt-in exactly once per project.
245 pub explicit_decision: bool,
246}
247
248/// Independent wire-version for the cross-repo report, on its own cadence (it
249/// versions separately from the per-project `ImpactReportSchemaVersion` and the
250/// on-disk `STORE_SCHEMA_VERSION`).
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
252#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
253pub enum CrossRepoImpactSchemaVersion {
254 /// First release of the `fallow impact --all --format json` shape.
255 #[serde(rename = "1")]
256 V1,
257 /// Expands the required semantic omission reason-code enum in embedded reports.
258 #[serde(rename = "2")]
259 V2,
260}
261
262/// Grand totals across every tracked project (including repos whose directory no
263/// longer exists on disk: their past wins still count toward lifetime impact).
264#[derive(Debug, Clone, Default, Serialize)]
265#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
266pub struct CrossRepoTotals {
267 /// Lifetime genuinely-resolved findings across projects.
268 pub resolved_total: usize,
269 /// Lifetime `fallow-ignore` suppressions across projects.
270 pub suppressed_total: usize,
271 /// Lifetime commit-gate containment events across projects.
272 pub containment_count: usize,
273 /// Sum of whole-project issue totals across projects that have a full-run
274 /// baseline, as of EACH project's last full `fallow` run (not a simultaneous
275 /// snapshot).
276 pub project_wide_issues: usize,
277 /// Projects that have recorded at least one full `fallow` run.
278 pub projects_with_baseline: usize,
279}
280
281/// One project's row in the cross-repo roll-up.
282#[derive(Debug, Clone, Serialize)]
283#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
284pub struct CrossRepoProjectEntry {
285 /// Stable, non-reversible project key (the store filename stem); the
286 /// cross-tool/cross-run JOIN key. NEVER a path.
287 pub project_key: String,
288 /// Repo basename for display (never a full path). Absent on pre-v5 stores
289 /// (the row falls back to the short key).
290 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub label: Option<String>,
292 /// Timestamp of the project's most recent recorded run (changed-file or
293 /// whole-project), for the LAST RUN column and the default `recent` sort.
294 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub last_recorded: Option<String>,
296 /// The full per-project report (identical shape to `fallow impact --format
297 /// json`), reused verbatim so the per-project wire contract is the sub-shape.
298 pub report: ImpactReport,
299}
300
301/// The cross-repo aggregate report, `fallow impact --all --format json`.
302#[derive(Debug, Clone, Serialize)]
303#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
304#[cfg_attr(
305 feature = "schema",
306 schemars(title = "fallow impact --all --format json")
307)]
308pub struct CrossRepoImpactReport {
309 /// Cross-repo output schema version; currently serialized as the string `"2"`.
310 pub schema_version: CrossRepoImpactSchemaVersion,
311 /// Per-project stores successfully parsed (add `unreadable_count` for the
312 /// total number of store files found in the user config dir).
313 pub project_count: usize,
314 /// Stores with recorded history (the rows in `projects`); excludes
315 /// enabled-but-empty stores, which are still counted in `project_count`.
316 pub tracked_count: usize,
317 /// Stores that failed to parse and were skipped (corrupt or newer-schema).
318 pub unreadable_count: usize,
319 /// Grand totals across every tracked project.
320 pub totals: CrossRepoTotals,
321 /// Per-project rows, one for each store with recorded history.
322 pub projects: Vec<CrossRepoProjectEntry>,
323}
324
325/// Serialize the `fallow impact --format json` envelope.
326///
327/// # Errors
328///
329/// Returns a serde error when the report cannot be converted to JSON.
330pub fn serialize_impact_json_output(
331 report: ImpactReport,
332 mode: RootEnvelopeMode,
333 analysis_run_id: Option<&str>,
334) -> Result<serde_json::Value, serde_json::Error> {
335 let mut value = serialize_named_json_output(report, "impact", mode)?;
336 attach_telemetry_meta(&mut value, analysis_run_id);
337 Ok(value)
338}
339
340/// Serialize the `fallow impact --all --format json` envelope.
341///
342/// # Errors
343///
344/// Returns a serde error when the report cannot be converted to JSON.
345pub fn serialize_cross_repo_impact_json_output(
346 report: CrossRepoImpactReport,
347 mode: RootEnvelopeMode,
348 analysis_run_id: Option<&str>,
349) -> Result<serde_json::Value, serde_json::Error> {
350 let mut value = serialize_named_json_output(report, "impact-cross-repo", mode)?;
351 attach_telemetry_meta(&mut value, analysis_run_id);
352 Ok(value)
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 fn impact_report() -> ImpactReport {
360 ImpactReport {
361 schema_version: ImpactReportSchemaVersion::V2,
362 enabled: true,
363 enabled_source: EnabledSource::Project,
364 record_count: 0,
365 meta: None,
366 first_recorded: None,
367 latest_git_sha: None,
368 surfacing: None,
369 trend: None,
370 project_surfacing: None,
371 project_trend: None,
372 gate_runs: None,
373 containment_count: 0,
374 recent_containment: Vec::new(),
375 resolved_total: 0,
376 suppressed_total: 0,
377 recent_resolved: Vec::new(),
378 attribution_active: false,
379 onboarding_declined: false,
380 explicit_decision: true,
381 }
382 }
383
384 #[test]
385 fn impact_json_output_uses_named_root_contract() {
386 let value =
387 serialize_impact_json_output(impact_report(), RootEnvelopeMode::Tagged, Some("run-1"))
388 .expect("impact report should serialize");
389
390 assert_eq!(value["kind"], "impact");
391 assert_eq!(value["schema_version"], "2");
392 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-1");
393 }
394
395 #[test]
396 fn cross_repo_impact_json_output_uses_named_root_contract() {
397 let report = CrossRepoImpactReport {
398 schema_version: CrossRepoImpactSchemaVersion::V2,
399 project_count: 1,
400 tracked_count: 1,
401 unreadable_count: 0,
402 totals: CrossRepoTotals::default(),
403 projects: vec![CrossRepoProjectEntry {
404 project_key: "demo".to_string(),
405 label: None,
406 last_recorded: None,
407 report: impact_report(),
408 }],
409 };
410
411 let value = serialize_cross_repo_impact_json_output(
412 report,
413 RootEnvelopeMode::Tagged,
414 Some("run-2"),
415 )
416 .expect("cross-repo impact report should serialize");
417
418 assert_eq!(value["kind"], "impact-cross-repo");
419 assert_eq!(value["schema_version"], "2");
420 assert_eq!(value["project_count"], 1);
421 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-2");
422 }
423}