Skip to main content

fallow_api/
audit_output.rs

1//! Shared audit JSON payload contracts for programmatic consumers.
2
3use fallow_config::AuditGate;
4use fallow_output::{AuditCommand, CodeClimateIssue, codeclimate_issues_to_value};
5use fallow_types::duplicates::DuplicationReport;
6use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
7use fallow_types::output::NextStep;
8use serde::Serialize;
9
10/// Verdict for the audit command.
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
12#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
13#[serde(rename_all = "snake_case")]
14pub enum AuditVerdict {
15    /// No issues in changed files.
16    Pass,
17    /// Issues found, but all are warn-severity.
18    Warn,
19    /// Error-severity issues found in changed files.
20    Fail,
21}
22
23/// Per-category summary counts for the audit result.
24#[derive(Debug, Clone, Serialize)]
25#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
26pub struct AuditSummary {
27    /// Total dead-code issues reported for the changed files.
28    pub dead_code_issues: usize,
29    /// Whether any reported dead-code issue has error severity.
30    pub dead_code_has_errors: bool,
31    /// Total complexity findings reported for the changed files.
32    pub complexity_findings: usize,
33    /// Highest cyclomatic complexity among the findings; `None` when there
34    /// are no complexity findings.
35    pub max_cyclomatic: Option<u16>,
36    /// Clone groups touching the changed files.
37    pub duplication_clone_groups: usize,
38}
39
40/// New-vs-inherited issue counts for audit.
41#[derive(Debug, Default, Clone, Serialize)]
42#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
43pub struct AuditAttribution {
44    /// Configured gate: `new-only` fails only on introduced findings,
45    /// `all` also fails on inherited ones.
46    pub gate: AuditGate,
47    /// Dead-code findings absent from the base snapshot.
48    pub dead_code_introduced: usize,
49    /// Dead-code findings already present in the base snapshot.
50    pub dead_code_inherited: usize,
51    /// Complexity findings absent from the base snapshot.
52    pub complexity_introduced: usize,
53    /// Complexity findings already present in the base snapshot.
54    pub complexity_inherited: usize,
55    /// Clone groups absent from the base snapshot.
56    pub duplication_introduced: usize,
57    /// Clone groups already present in the base snapshot.
58    pub duplication_inherited: usize,
59}
60
61/// Header fields shared by audit JSON and review-brief subtract sections.
62pub struct AuditJsonHeaderInput {
63    /// Output schema version of the envelope.
64    pub schema_version: SchemaVersion,
65    /// Fallow version that produced the output.
66    pub version: ToolVersion,
67    /// Overall audit verdict.
68    pub verdict: AuditVerdict,
69    /// Number of changed files the audit analyzed.
70    pub changed_files_count: u32,
71    /// Git revision the audit compared against.
72    pub base_ref: String,
73    /// Human-readable description of how the base was chosen, such as
74    /// `merge-base with origin/main`; omitted from JSON when `None`.
75    pub base_description: Option<String>,
76    /// Commit SHA of the analyzed head; omitted from JSON when `None`.
77    pub head_sha: Option<String>,
78    /// Audit wall time in milliseconds.
79    pub elapsed_ms: ElapsedMs,
80    /// `Some(true)` when the base snapshot was skipped, so no attribution
81    /// ran; omitted from JSON when `None`.
82    pub base_snapshot_skipped: Option<bool>,
83    /// Per-category issue counts.
84    pub summary: AuditSummary,
85    /// New-vs-inherited counts and the configured gate.
86    pub attribution: AuditAttribution,
87}
88
89/// Typed audit JSON assembly input.
90pub struct AuditJsonOutputInput<DeadCode, Duplication, Complexity> {
91    /// Every gate this run evaluated, absent when it evaluated none. The
92    /// programmatic route runs no CLI-layer gate and leaves this `None`.
93    pub gate_outcomes: Option<fallow_output::GateOutcomes>,
94
95    /// Envelope header fields.
96    pub header: AuditJsonHeaderInput,
97    /// Optional explain metadata block.
98    pub meta: Option<fallow_types::envelope::Meta>,
99    /// Dead-code section payload; `None` omits the section.
100    pub dead_code: Option<DeadCode>,
101    /// Duplication section payload; `None` omits the section.
102    pub duplication: Option<Duplication>,
103    /// Complexity (health) section payload; `None` omits the section.
104    pub complexity: Option<Complexity>,
105    /// Suggested follow-up commands for the consumer.
106    pub next_steps: Vec<NextStep>,
107}
108
109/// Typed audit SARIF assembly input.
110#[derive(Clone, Copy)]
111pub struct AuditSarifOutputInput<'a> {
112    /// Prebuilt dead-code SARIF document whose runs are merged in.
113    pub dead_code: Option<&'a serde_json::Value>,
114    /// Duplication report converted into a dedicated SARIF run; skipped when
115    /// it has no clone groups.
116    pub duplication: Option<&'a DuplicationReport>,
117    /// Prebuilt health SARIF document whose runs are merged in.
118    pub health: Option<&'a serde_json::Value>,
119}
120
121/// Typed audit CodeClimate assembly input.
122pub struct AuditCodeClimateOutputInput {
123    /// Dead-code issues, emitted first in the combined array.
124    pub dead_code: Vec<CodeClimateIssue>,
125    /// Duplication issues, emitted after dead code.
126    pub duplication: Vec<CodeClimateIssue>,
127    /// Health issues, emitted last.
128    pub health: Vec<CodeClimateIssue>,
129}
130
131#[derive(Serialize)]
132struct AuditHeaderOutput {
133    schema_version: SchemaVersion,
134    version: ToolVersion,
135    command: AuditCommand,
136    verdict: AuditVerdict,
137    changed_files_count: u32,
138    base_ref: String,
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    base_description: Option<String>,
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    head_sha: Option<String>,
143    elapsed_ms: ElapsedMs,
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    base_snapshot_skipped: Option<bool>,
146    summary: AuditSummary,
147    attribution: AuditAttribution,
148}
149
150fn audit_header_output(input: AuditJsonHeaderInput) -> AuditHeaderOutput {
151    AuditHeaderOutput {
152        schema_version: input.schema_version,
153        version: input.version,
154        command: AuditCommand::Audit,
155        verdict: input.verdict,
156        changed_files_count: input.changed_files_count,
157        base_ref: input.base_ref,
158        base_description: input.base_description,
159        head_sha: input.head_sha,
160        elapsed_ms: input.elapsed_ms,
161        base_snapshot_skipped: input.base_snapshot_skipped,
162        summary: input.summary,
163        attribution: input.attribution,
164    }
165}
166
167/// Build the audit header JSON object used by review brief output.
168///
169/// # Errors
170///
171/// Returns a serde error if one of the typed header fields cannot be converted
172/// to JSON.
173pub fn build_audit_header_json(
174    input: AuditJsonHeaderInput,
175) -> Result<serde_json::Value, serde_json::Error> {
176    serde_json::to_value(audit_header_output(input))
177}
178
179/// Build the audit header as an object map for composed output contracts such
180/// as review briefs.
181///
182/// # Errors
183///
184/// Returns a serde error if one of the typed header fields cannot be converted
185/// to JSON, or if the typed header unexpectedly does not serialize to an
186/// object.
187pub fn build_audit_header_map(
188    input: AuditJsonHeaderInput,
189) -> Result<serde_json::Map<String, serde_json::Value>, serde_json::Error> {
190    match build_audit_header_json(input)? {
191        serde_json::Value::Object(header) => Ok(header),
192        _ => unreachable!("AuditHeaderOutput serializes to an object"),
193    }
194}
195
196/// Build the typed audit metadata carried by a review brief envelope.
197#[must_use]
198pub fn build_review_brief_header(
199    input: AuditJsonHeaderInput,
200) -> fallow_output::ReviewBriefHeader<AuditVerdict, AuditSummary, AuditAttribution> {
201    fallow_output::ReviewBriefHeader {
202        version: input.version,
203        verdict: input.verdict,
204        changed_files_count: input.changed_files_count,
205        base_ref: input.base_ref,
206        base_description: input.base_description,
207        head_sha: input.head_sha,
208        elapsed_ms: input.elapsed_ms,
209        base_snapshot_skipped: input.base_snapshot_skipped,
210        summary: input.summary,
211        attribution: input.attribution,
212    }
213}
214
215/// Serialize a typed audit JSON output envelope.
216///
217/// # Errors
218///
219/// Returns a serde error if the envelope or one of its nested payload sections
220/// cannot be converted to JSON.
221pub fn serialize_audit_json<DeadCode, Duplication, Complexity>(
222    input: AuditJsonOutputInput<DeadCode, Duplication, Complexity>,
223    analysis_run_id: Option<&str>,
224) -> Result<serde_json::Value, serde_json::Error>
225where
226    DeadCode: Serialize,
227    Duplication: Serialize,
228    Complexity: Serialize,
229{
230    let header = audit_header_output(input.header);
231    let complexity = input.complexity.map(serde_json::to_value).transpose()?;
232    let output = fallow_output::AuditOutput {
233        schema_version: header.schema_version,
234        version: header.version,
235        command: header.command,
236        verdict: header.verdict,
237        changed_files_count: header.changed_files_count,
238        base_ref: header.base_ref,
239        base_description: header.base_description,
240        head_sha: header.head_sha,
241        elapsed_ms: header.elapsed_ms,
242        base_snapshot_skipped: header.base_snapshot_skipped,
243        summary: header.summary,
244        attribution: header.attribution,
245        gate_outcomes: input.gate_outcomes,
246        meta: input.meta,
247        dead_code: input.dead_code,
248        duplication: input.duplication,
249        complexity,
250        next_steps: input.next_steps,
251    };
252    let mut value = fallow_output::serialize_audit_json_output(output, analysis_run_id)?;
253    attach_audit_wire_attribution(&mut value);
254    Ok(value)
255}
256
257/// Attach every wire-only audit attribution derivation in one pass: styling
258/// counts and the duplication demotion counter.
259///
260/// The single entry point for audit-family envelopes (audit JSON and the
261/// review brief), so a future envelope cannot wire one derivation and miss
262/// the other.
263pub fn attach_audit_wire_attribution(value: &mut serde_json::Value) {
264    attach_audit_styling_attribution(value);
265    attach_audit_duplication_demotion_attribution(value);
266}
267
268/// Add `attribution.duplication_demoted`, derived from serialized clone
269/// groups.
270///
271/// Counts `duplication.clone_groups[]` entries carrying a `demotion_reason`
272/// (set by the new-only gate when it demotes an introduced group to
273/// inherited, issue #2220). Always inserted when an `attribution` object is
274/// present: an integer, `0` when the duplication section is absent. Wire-only,
275/// mirroring [`attach_audit_styling_attribution`], so the public Rust
276/// attribution struct stays source-compatible.
277pub fn attach_audit_duplication_demotion_attribution(value: &mut serde_json::Value) {
278    let demoted = value
279        .get("duplication")
280        .and_then(|duplication| duplication.get("clone_groups"))
281        .and_then(serde_json::Value::as_array)
282        .map_or(0, |items| {
283            items
284                .iter()
285                .filter(|item| item.get("demotion_reason").is_some())
286                .count()
287        });
288    if let Some(attribution) = value
289        .get_mut("attribution")
290        .and_then(serde_json::Value::as_object_mut)
291    {
292        attribution.insert(
293            "duplication_demoted".to_string(),
294            serde_json::json!(demoted),
295        );
296    }
297}
298
299/// Add styling attribution totals derived from annotated styling findings.
300///
301/// This keeps the public Rust attribution and finding structs source-compatible
302/// while extending audit-family JSON envelopes with the wire-only fields.
303pub fn attach_audit_styling_attribution(value: &mut serde_json::Value) {
304    let findings = value
305        .get("complexity")
306        .and_then(|complexity| complexity.get("styling_findings"))
307        .and_then(serde_json::Value::as_array);
308    let styling_introduced = findings.map_or(0, |items| {
309        items
310            .iter()
311            .filter(|item| {
312                item.get("introduced").and_then(serde_json::Value::as_bool) == Some(true)
313            })
314            .count()
315    });
316    let styling_inherited = findings.map_or(0, |items| {
317        items
318            .iter()
319            .filter(|item| {
320                item.get("introduced").and_then(serde_json::Value::as_bool) == Some(false)
321            })
322            .count()
323    });
324    if let Some(attribution) = value
325        .get_mut("attribution")
326        .and_then(serde_json::Value::as_object_mut)
327    {
328        attribution.insert(
329            "styling_introduced".to_string(),
330            serde_json::json!(styling_introduced),
331        );
332        attribution.insert(
333            "styling_inherited".to_string(),
334            serde_json::json!(styling_inherited),
335        );
336    }
337}
338
339/// Build the combined SARIF document for `fallow audit`.
340#[must_use]
341pub fn build_audit_sarif(input: AuditSarifOutputInput<'_>) -> serde_json::Value {
342    let mut all_runs = Vec::new();
343
344    if let Some(sarif) = input.dead_code {
345        extend_sarif_runs(&mut all_runs, sarif);
346    }
347
348    if let Some(duplication) = input.duplication
349        && !duplication.clone_groups.is_empty()
350    {
351        all_runs.push(build_audit_duplication_sarif_run(duplication));
352    }
353
354    if let Some(sarif) = input.health {
355        extend_sarif_runs(&mut all_runs, sarif);
356    }
357
358    serde_json::json!({
359        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
360        "version": "2.1.0",
361        "runs": all_runs,
362    })
363}
364
365fn extend_sarif_runs(all_runs: &mut Vec<serde_json::Value>, sarif: &serde_json::Value) {
366    if let Some(runs) = sarif.get("runs").and_then(|runs| runs.as_array()) {
367        all_runs.extend(runs.iter().cloned());
368    }
369}
370
371fn build_audit_duplication_sarif_run(duplication: &DuplicationReport) -> serde_json::Value {
372    serde_json::json!({
373        "tool": {
374            "driver": {
375                "name": "fallow",
376                "version": env!("CARGO_PKG_VERSION"),
377                "informationUri": "https://github.com/fallow-rs/fallow",
378            }
379        },
380        "automationDetails": { "id": "fallow/audit/dupes" },
381        "results": duplication.clone_groups.iter().enumerate().map(|(i, group)| {
382            serde_json::json!({
383                "ruleId": "fallow/code-duplication",
384                "level": "warning",
385                "message": {
386                    "text": format!(
387                        "Clone group {} ({} lines, {} instances)",
388                        i + 1,
389                        group.line_count,
390                        group.instances.len()
391                    ),
392                },
393            })
394        }).collect::<Vec<_>>()
395    })
396}
397
398/// Build combined CodeClimate issues for `fallow audit`.
399#[must_use]
400pub fn build_audit_codeclimate_issues(input: AuditCodeClimateOutputInput) -> Vec<CodeClimateIssue> {
401    let mut all_issues = input.dead_code;
402    all_issues.extend(input.duplication);
403    all_issues.extend(input.health);
404    all_issues
405}
406
407/// Build the combined CodeClimate JSON array for `fallow audit`.
408#[must_use]
409pub fn build_audit_codeclimate(input: AuditCodeClimateOutputInput) -> serde_json::Value {
410    codeclimate_issues_to_value(&build_audit_codeclimate_issues(input))
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    #[test]
418    fn audit_verdict_uses_snake_case_wire_names() {
419        let value = serde_json::to_value(AuditVerdict::Pass).expect("serialize verdict");
420        assert_eq!(value, serde_json::json!("pass"));
421    }
422
423    fn header_input() -> AuditJsonHeaderInput {
424        AuditJsonHeaderInput {
425            schema_version: SchemaVersion(7),
426            version: ToolVersion("0.0.0-test".to_string()),
427            verdict: AuditVerdict::Pass,
428            changed_files_count: 5,
429            base_ref: "abc123".to_string(),
430            base_description: Some("merge-base with origin/main".to_string()),
431            head_sha: Some("def456".to_string()),
432            elapsed_ms: ElapsedMs(12),
433            base_snapshot_skipped: Some(true),
434            summary: AuditSummary {
435                dead_code_issues: 0,
436                dead_code_has_errors: false,
437                complexity_findings: 0,
438                max_cyclomatic: None,
439                duplication_clone_groups: 0,
440            },
441            attribution: AuditAttribution {
442                gate: AuditGate::NewOnly,
443                ..AuditAttribution::default()
444            },
445        }
446    }
447
448    #[test]
449    fn audit_header_json_uses_typed_contract_fields() {
450        let value = build_audit_header_json(header_input()).expect("serialize audit header");
451
452        assert_eq!(value["schema_version"], 7);
453        assert_eq!(value["command"], "audit");
454        assert_eq!(value["base_description"], "merge-base with origin/main");
455        assert_eq!(value["head_sha"], "def456");
456        assert_eq!(value["base_snapshot_skipped"], true);
457    }
458
459    #[test]
460    fn audit_header_map_uses_typed_contract_fields() {
461        let header = build_audit_header_map(header_input()).expect("serialize audit header");
462
463        assert_eq!(header["schema_version"], 7);
464        assert_eq!(header["command"], "audit");
465        assert_eq!(header["base_description"], "merge-base with origin/main");
466    }
467
468    #[test]
469    fn audit_json_serializer_applies_root_kind_and_sections() {
470        let value = serialize_audit_json(
471            AuditJsonOutputInput {
472                gate_outcomes: None,
473                header: header_input(),
474                meta: None,
475                dead_code: Some(serde_json::json!({"total_issues": 0})),
476                duplication: None::<serde_json::Value>,
477                complexity: None::<serde_json::Value>,
478                next_steps: Vec::new(),
479            },
480            Some("run-1"),
481        )
482        .expect("serialize audit output");
483
484        assert_eq!(value["kind"], "audit");
485        assert_eq!(value["dead_code"]["total_issues"], 0);
486        assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-1");
487    }
488
489    #[test]
490    fn audit_sarif_combines_runs_and_duplication_run() {
491        let duplication = DuplicationReport {
492            clone_groups: vec![fallow_types::duplicates::CloneGroup {
493                instances: vec![
494                    fallow_types::duplicates::CloneInstance {
495                        file: "src/a.ts".into(),
496                        start_line: 1,
497                        end_line: 12,
498                        start_col: 1,
499                        end_col: 1,
500                        fragment: "duplicated();".to_string(),
501                    },
502                    fallow_types::duplicates::CloneInstance {
503                        file: "src/b.ts".into(),
504                        start_line: 1,
505                        end_line: 12,
506                        start_col: 1,
507                        end_col: 1,
508                        fragment: "duplicated();".to_string(),
509                    },
510                ],
511                token_count: 40,
512                line_count: 12,
513                similarity: None,
514            }],
515            ..DuplicationReport::default()
516        };
517        let dead_code = serde_json::json!({"runs": [{"automationDetails": {"id": "check"}}]});
518        let health = serde_json::json!({"runs": [{"automationDetails": {"id": "health"}}]});
519
520        let value = build_audit_sarif(AuditSarifOutputInput {
521            dead_code: Some(&dead_code),
522            duplication: Some(&duplication),
523            health: Some(&health),
524        });
525
526        assert_eq!(value["version"], "2.1.0");
527        assert_eq!(value["runs"].as_array().expect("runs").len(), 3);
528        assert_eq!(
529            value["runs"][1]["automationDetails"]["id"],
530            "fallow/audit/dupes"
531        );
532    }
533
534    #[test]
535    fn audit_codeclimate_combines_issue_sections() {
536        let issue = CodeClimateIssue {
537            kind: fallow_output::CodeClimateIssueKind::Issue,
538            check_name: "fallow/test".to_string(),
539            description: "test".to_string(),
540            severity: fallow_output::CodeClimateSeverity::Minor,
541            fingerprint: "abc".to_string(),
542            location: fallow_output::CodeClimateLocation {
543                path: "src/a.ts".to_string(),
544                lines: fallow_output::CodeClimateLines {
545                    begin: 1,
546                    end: None,
547                },
548            },
549            other_locations: Vec::new(),
550            categories: vec!["Bug Risk".to_string()],
551            owner: None,
552            group: None,
553        };
554
555        let value = build_audit_codeclimate(AuditCodeClimateOutputInput {
556            dead_code: vec![issue.clone()],
557            duplication: vec![issue.clone()],
558            health: vec![issue],
559        });
560
561        assert_eq!(value.as_array().expect("issues").len(), 3);
562    }
563}