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