fallow-output 3.26.0

Output contract types for fallow reports
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
//! Impact report output contracts.

use crate::root_envelopes::{RootEnvelopeMode, attach_telemetry_meta, serialize_named_json_output};
use fallow_types::envelope::Meta;
use serde::{Deserialize, Serialize};

/// Per-category issue counts captured at a recorded run.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ImpactCounts {
    /// Sum of the category counts.
    pub total_issues: usize,
    /// Dead-code findings.
    pub dead_code: usize,
    /// Complexity findings.
    pub complexity: usize,
    /// Duplication findings.
    pub duplication: usize,
}

impl ImpactCounts {
    /// Counts with `total_issues` derived as the sum of the categories.
    #[must_use]
    pub fn from_combined(dead_code: usize, complexity: usize, duplication: usize) -> Self {
        Self {
            total_issues: dead_code + complexity + duplication,
            dead_code,
            complexity,
            duplication,
        }
    }
}

/// Recorded gate runs grouped by the gate that produced them. Local
/// provenance only: the store never leaves the machine, so this answers "where
/// do my gate runs come from", never "how widely is fallow adopted".
///
/// Counted over the recorded runs the store still holds, which is the same
/// window `record_count` reports. The store keeps a bounded number of runs and
/// drops the oldest, so on a long-lived project these are the shape of recent
/// gate activity, not a lifetime total: read them as a floor. Absent when no
/// run in that window carries a gate source, which is not the same as "no gate
/// ever ran here".
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct GateRunCounts {
    /// Runs recorded by the agent gate (`--gate-marker agent`).
    pub agent: usize,
    /// Runs recorded by the git pre-commit hook (`--gate-marker pre-commit`).
    pub pre_commit: usize,
    /// Runs recorded by a CI gate (`--gate-marker ci`).
    pub ci: usize,
    /// Gate runs whose marker this build does not recognise, plus every gate
    /// run recorded before the store kept its source (store schema 6 and older).
    pub unknown: usize,
}

impl GateRunCounts {
    /// Whether any gate run was recorded at all.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.agent == 0 && self.pre_commit == 0 && self.ci == 0 && self.unknown == 0
    }
}

/// A commit-gate containment event recorded by `fallow impact`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ContainmentEvent {
    /// Timestamp when the commit gate blocked the commit.
    pub blocked_at: String,
    /// Timestamp when a later run passed clean.
    pub cleared_at: String,
    /// Abbreviated SHA of the cleared commit, when in a git repo.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub git_sha: Option<String>,
    /// Finding counts at the moment the gate blocked.
    pub blocked_counts: ImpactCounts,
}

/// A resolved or suppressed finding attribution event.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ResolutionEvent {
    /// Finding kind that was resolved or suppressed, e.g. `unused-export`.
    pub kind: String,
    /// Root-relative path of the resolved finding.
    pub path: String,
    /// Symbol name, for symbol-level findings.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub symbol: Option<String>,
    /// Abbreviated SHA of the resolving commit, when in a git repo.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub git_sha: Option<String>,
    /// Timestamp the resolution was recorded.
    pub timestamp: String,
}

/// Why Impact tracking is (or is not) active for a project. `Project` = an
/// explicit per-repo `enable`; `User` = the user-global default with no per-repo
/// decision; `Default` = off (no per-repo decision and no global default).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "lowercase")]
pub enum EnabledSource {
    /// Explicit per-repo enable/disable decision.
    Project,
    /// User-global default with no per-repo decision.
    User,
    /// Off: no per-repo decision and no global default.
    Default,
}

/// Direction of a count trend between two recorded runs.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum ImpactTrendDirection {
    /// Issue count went down.
    Improving,
    /// Issue count went up.
    Declining,
    /// Within tolerance.
    Stable,
}

/// A computed trend between the two most recent records.
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct TrendSummary {
    /// Trend direction between the two runs.
    pub direction: ImpactTrendDirection,
    /// Signed delta in total issues, current minus previous.
    pub total_delta: i64,
    /// Total issues in the earlier run.
    pub previous_total: usize,
    /// Total issues in the later run.
    pub current_total: usize,
}

/// Wire-version discriminator for [`ImpactReport`]. Independent from the global
/// `SchemaVersion` (the impact report versions on its own cadence) and from the
/// on-disk `STORE_SCHEMA_VERSION` (the persisted store shape versions
/// separately). Serializes as a string `const` so JSON consumers can switch on
/// it, matching the other independently-versioned envelopes (e.g.
/// `CoverageAnalyzeSchemaVersion`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum ImpactReportSchemaVersion {
    /// First release of the `fallow impact --format json` shape.
    #[serde(rename = "1")]
    V1,
    /// Expands the required semantic omission reason-code enum.
    #[serde(rename = "2")]
    V2,
}

/// The rendered impact report, derived purely from the store.
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schema", schemars(title = "fallow impact --format json"))]
pub struct ImpactReport {
    /// Output-shape version for this report, so JSON consumers have a
    /// forward-compat signal independent of the on-disk store version. Always
    /// present; bumped only on a breaking change to this report's wire shape.
    pub schema_version: ImpactReportSchemaVersion,
    /// Whether impact tracking is active for this project.
    pub enabled: bool,
    /// WHY tracking is on or off: `project` (an explicit per-repo enable/disable
    /// decision), `user` (the user-global default with no per-repo decision), or
    /// `default` (off, no per-repo decision and no global default). Combine with
    /// `explicit_decision` to tell a never-asked off-state (`enabled:false`,
    /// `explicit_decision:false`, offer to enable) from a declined-here one
    /// (`enabled:false`, `explicit_decision:true`, do not nag).
    pub enabled_source: EnabledSource,
    /// Number of recorded runs in the store.
    pub record_count: usize,
    /// `_meta` block with docs and field definitions, when `--explain` was
    /// passed.
    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
    pub meta: Option<Meta>,
    /// Timestamp of the earliest recorded run; absent with no records.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub first_recorded: Option<String>,
    /// Git SHA of the most recent recorded run, so a consumer can tell which
    /// commit the `surfacing` counts belong to. This is an ABBREVIATED SHA
    /// (`git rev-parse --short`), so it is for display/correlation only and will
    /// not match a full 40-character SHA from `$GITHUB_SHA` or the git API
    /// without expansion. None when the latest run had no SHA (not a git repo)
    /// or there are no records yet.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub latest_git_sha: Option<String>,
    /// Counts from the most recent recorded run. These are CHANGED-FILE scoped
    /// (each record comes from a `fallow audit` run, whose default `new-only`
    /// gate counts only findings in the changed files of that run), NOT a
    /// whole-project total.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub surfacing: Option<ImpactCounts>,
    /// Trend between the two most recent records. None until two records exist.
    /// Trend between the two most recent changed-file records. None until two
    /// records exist.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub trend: Option<TrendSummary>,
    /// Counts from the most recent whole-project `fallow` run. WHOLE-PROJECT
    /// scope (not changed-file), so this is the current issue total across the
    /// whole repo, context next to the actionable changed-file `surfacing`
    /// count. None until a full `fallow` run has been recorded. v1.6.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub project_surfacing: Option<ImpactCounts>,
    /// Trend between the two most recent whole-project records. Comparable over
    /// time (same whole-project denominator every run), unlike the changed-file
    /// `trend`. None until two full `fallow` runs exist. v1.6.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub project_trend: Option<TrendSummary>,
    /// Recorded gate runs grouped by source, over the same bounded window of
    /// recorded runs `record_count` reports. A floor, not a lifetime total, and
    /// absent when no run in that window carries a gate source. Local
    /// provenance, never an adoption metric.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub gate_runs: Option<GateRunCounts>,
    /// Lifetime count of commit-gate containment events.
    pub containment_count: usize,
    /// Most recent containment events (newest last), capped for display.
    pub recent_containment: Vec<ContainmentEvent>,
    /// Lifetime count of findings fallow credits as genuinely resolved (code
    /// removed or refactored, never a `fallow-ignore`). v1.5.
    pub resolved_total: usize,
    /// Lifetime count of findings silenced by a newly-added `fallow-ignore`.
    /// Reported as honest context, never as a win. v1.5.
    pub suppressed_total: usize,
    /// Most recent resolution events (newest last), capped for display. v1.5.
    pub recent_resolved: Vec<ResolutionEvent>,
    /// Whether per-finding attribution has a baseline yet. False on a freshly
    /// upgraded v1 store (no frontier captured), which the renderer uses to show
    /// "resolution tracking starts from your next run" instead of a bare zero.
    pub attribution_active: bool,
    /// Whether the local agent onboarding prompt has been explicitly declined.
    /// Stored in the user config dir (per project) so agents avoid cross-session
    /// nags without writing into the repo.
    pub onboarding_declined: bool,
    /// Whether the user ever made an explicit enable/disable decision for
    /// Impact tracking. `enabled: false` with `explicit_decision: false` means
    /// "never asked"; with `true` it means "asked and declined". Agents use
    /// this to offer the impact opt-in exactly once per project.
    pub explicit_decision: bool,
}

/// Independent wire-version for the cross-repo report, on its own cadence (it
/// versions separately from the per-project `ImpactReportSchemaVersion` and the
/// on-disk `STORE_SCHEMA_VERSION`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum CrossRepoImpactSchemaVersion {
    /// First release of the `fallow impact --all --format json` shape.
    #[serde(rename = "1")]
    V1,
    /// Expands the required semantic omission reason-code enum in embedded reports.
    #[serde(rename = "2")]
    V2,
}

/// Grand totals across every tracked project (including repos whose directory no
/// longer exists on disk: their past wins still count toward lifetime impact).
#[derive(Debug, Clone, Default, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CrossRepoTotals {
    /// Lifetime genuinely-resolved findings across projects.
    pub resolved_total: usize,
    /// Lifetime `fallow-ignore` suppressions across projects.
    pub suppressed_total: usize,
    /// Lifetime commit-gate containment events across projects.
    pub containment_count: usize,
    /// Sum of whole-project issue totals across projects that have a full-run
    /// baseline, as of EACH project's last full `fallow` run (not a simultaneous
    /// snapshot).
    pub project_wide_issues: usize,
    /// Projects that have recorded at least one full `fallow` run.
    pub projects_with_baseline: usize,
}

/// One project's row in the cross-repo roll-up.
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CrossRepoProjectEntry {
    /// Stable, non-reversible project key (the store filename stem); the
    /// cross-tool/cross-run JOIN key. NEVER a path.
    pub project_key: String,
    /// Repo basename for display (never a full path). Absent on pre-v5 stores
    /// (the row falls back to the short key).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub label: Option<String>,
    /// Timestamp of the project's most recent recorded run (changed-file or
    /// whole-project), for the LAST RUN column and the default `recent` sort.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_recorded: Option<String>,
    /// The full per-project report (identical shape to `fallow impact --format
    /// json`), reused verbatim so the per-project wire contract is the sub-shape.
    pub report: ImpactReport,
}

/// The cross-repo aggregate report, `fallow impact --all --format json`.
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[cfg_attr(
    feature = "schema",
    schemars(title = "fallow impact --all --format json")
)]
pub struct CrossRepoImpactReport {
    /// Cross-repo output schema version; currently serialized as the string `"2"`.
    pub schema_version: CrossRepoImpactSchemaVersion,
    /// Per-project stores successfully parsed (add `unreadable_count` for the
    /// total number of store files found in the user config dir).
    pub project_count: usize,
    /// Stores with recorded history (the rows in `projects`); excludes
    /// enabled-but-empty stores, which are still counted in `project_count`.
    pub tracked_count: usize,
    /// Stores that failed to parse and were skipped (corrupt or newer-schema).
    pub unreadable_count: usize,
    /// Grand totals across every tracked project.
    pub totals: CrossRepoTotals,
    /// Per-project rows, one for each store with recorded history.
    pub projects: Vec<CrossRepoProjectEntry>,
}

/// Serialize the `fallow impact --format json` envelope.
///
/// # Errors
///
/// Returns a serde error when the report cannot be converted to JSON.
pub fn serialize_impact_json_output(
    report: ImpactReport,
    mode: RootEnvelopeMode,
    analysis_run_id: Option<&str>,
) -> Result<serde_json::Value, serde_json::Error> {
    let mut value = serialize_named_json_output(report, "impact", mode)?;
    attach_telemetry_meta(&mut value, analysis_run_id);
    Ok(value)
}

/// Serialize the `fallow impact --all --format json` envelope.
///
/// # Errors
///
/// Returns a serde error when the report cannot be converted to JSON.
pub fn serialize_cross_repo_impact_json_output(
    report: CrossRepoImpactReport,
    mode: RootEnvelopeMode,
    analysis_run_id: Option<&str>,
) -> Result<serde_json::Value, serde_json::Error> {
    let mut value = serialize_named_json_output(report, "impact-cross-repo", mode)?;
    attach_telemetry_meta(&mut value, analysis_run_id);
    Ok(value)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn impact_report() -> ImpactReport {
        ImpactReport {
            schema_version: ImpactReportSchemaVersion::V2,
            enabled: true,
            enabled_source: EnabledSource::Project,
            record_count: 0,
            meta: None,
            first_recorded: None,
            latest_git_sha: None,
            surfacing: None,
            trend: None,
            project_surfacing: None,
            project_trend: None,
            gate_runs: None,
            containment_count: 0,
            recent_containment: Vec::new(),
            resolved_total: 0,
            suppressed_total: 0,
            recent_resolved: Vec::new(),
            attribution_active: false,
            onboarding_declined: false,
            explicit_decision: true,
        }
    }

    #[test]
    fn impact_json_output_uses_named_root_contract() {
        let value =
            serialize_impact_json_output(impact_report(), RootEnvelopeMode::Tagged, Some("run-1"))
                .expect("impact report should serialize");

        assert_eq!(value["kind"], "impact");
        assert_eq!(value["schema_version"], "2");
        assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-1");
    }

    #[test]
    fn cross_repo_impact_json_output_uses_named_root_contract() {
        let report = CrossRepoImpactReport {
            schema_version: CrossRepoImpactSchemaVersion::V2,
            project_count: 1,
            tracked_count: 1,
            unreadable_count: 0,
            totals: CrossRepoTotals::default(),
            projects: vec![CrossRepoProjectEntry {
                project_key: "demo".to_string(),
                label: None,
                last_recorded: None,
                report: impact_report(),
            }],
        };

        let value = serialize_cross_repo_impact_json_output(
            report,
            RootEnvelopeMode::Tagged,
            Some("run-2"),
        )
        .expect("cross-repo impact report should serialize");

        assert_eq!(value["kind"], "impact-cross-repo");
        assert_eq!(value["schema_version"], "2");
        assert_eq!(value["project_count"], 1);
        assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-2");
    }
}