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    pub dead_code_issues: usize,
30    pub dead_code_has_errors: bool,
31    pub complexity_findings: usize,
32    pub max_cyclomatic: Option<u16>,
33    pub duplication_clone_groups: usize,
34}
35
36/// New-vs-inherited issue counts for audit.
37#[derive(Debug, Default, Clone, Serialize)]
38#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
39pub struct AuditAttribution {
40    pub gate: AuditGate,
41    pub dead_code_introduced: usize,
42    pub dead_code_inherited: usize,
43    pub complexity_introduced: usize,
44    pub complexity_inherited: usize,
45    pub duplication_introduced: usize,
46    pub duplication_inherited: usize,
47}
48
49/// Header fields shared by audit JSON and review-brief subtract sections.
50pub struct AuditJsonHeaderInput {
51    pub schema_version: SchemaVersion,
52    pub version: ToolVersion,
53    pub verdict: AuditVerdict,
54    pub changed_files_count: u32,
55    pub base_ref: String,
56    pub base_description: Option<String>,
57    pub head_sha: Option<String>,
58    pub elapsed_ms: ElapsedMs,
59    pub base_snapshot_skipped: Option<bool>,
60    pub summary: AuditSummary,
61    pub attribution: AuditAttribution,
62}
63
64/// Typed audit JSON assembly input.
65pub struct AuditJsonOutputInput<DeadCode, Duplication, Complexity> {
66    pub header: AuditJsonHeaderInput,
67    pub meta: Option<fallow_types::envelope::Meta>,
68    pub dead_code: Option<DeadCode>,
69    pub duplication: Option<Duplication>,
70    pub complexity: Option<Complexity>,
71    pub next_steps: Vec<NextStep>,
72}
73
74/// Typed audit SARIF assembly input.
75#[derive(Clone, Copy)]
76pub struct AuditSarifOutputInput<'a> {
77    pub dead_code: Option<&'a serde_json::Value>,
78    pub duplication: Option<&'a DuplicationReport>,
79    pub health: Option<&'a serde_json::Value>,
80}
81
82/// Typed audit CodeClimate assembly input.
83pub struct AuditCodeClimateOutputInput {
84    pub dead_code: Vec<CodeClimateIssue>,
85    pub duplication: Vec<CodeClimateIssue>,
86    pub health: Vec<CodeClimateIssue>,
87}
88
89#[derive(Serialize)]
90struct AuditHeaderOutput {
91    schema_version: SchemaVersion,
92    version: ToolVersion,
93    command: AuditCommand,
94    verdict: AuditVerdict,
95    changed_files_count: u32,
96    base_ref: String,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    base_description: Option<String>,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    head_sha: Option<String>,
101    elapsed_ms: ElapsedMs,
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    base_snapshot_skipped: Option<bool>,
104    summary: AuditSummary,
105    attribution: AuditAttribution,
106}
107
108fn audit_header_output(input: AuditJsonHeaderInput) -> AuditHeaderOutput {
109    AuditHeaderOutput {
110        schema_version: input.schema_version,
111        version: input.version,
112        command: AuditCommand::Audit,
113        verdict: input.verdict,
114        changed_files_count: input.changed_files_count,
115        base_ref: input.base_ref,
116        base_description: input.base_description,
117        head_sha: input.head_sha,
118        elapsed_ms: input.elapsed_ms,
119        base_snapshot_skipped: input.base_snapshot_skipped,
120        summary: input.summary,
121        attribution: input.attribution,
122    }
123}
124
125/// Build the audit header JSON object used by review brief output.
126///
127/// # Errors
128///
129/// Returns a serde error if one of the typed header fields cannot be converted
130/// to JSON.
131pub fn build_audit_header_json(
132    input: AuditJsonHeaderInput,
133) -> Result<serde_json::Value, serde_json::Error> {
134    serde_json::to_value(audit_header_output(input))
135}
136
137/// Build the audit header as an object map for composed output contracts such
138/// as review briefs.
139///
140/// # Errors
141///
142/// Returns a serde error if one of the typed header fields cannot be converted
143/// to JSON, or if the typed header unexpectedly does not serialize to an
144/// object.
145pub fn build_audit_header_map(
146    input: AuditJsonHeaderInput,
147) -> Result<serde_json::Map<String, serde_json::Value>, serde_json::Error> {
148    match build_audit_header_json(input)? {
149        serde_json::Value::Object(header) => Ok(header),
150        _ => unreachable!("AuditHeaderOutput serializes to an object"),
151    }
152}
153
154/// Build the typed audit metadata carried by a review brief envelope.
155#[must_use]
156pub fn build_review_brief_header(
157    input: AuditJsonHeaderInput,
158) -> fallow_output::ReviewBriefHeader<AuditVerdict, AuditSummary, AuditAttribution> {
159    fallow_output::ReviewBriefHeader {
160        version: input.version,
161        verdict: input.verdict,
162        changed_files_count: input.changed_files_count,
163        base_ref: input.base_ref,
164        base_description: input.base_description,
165        head_sha: input.head_sha,
166        elapsed_ms: input.elapsed_ms,
167        base_snapshot_skipped: input.base_snapshot_skipped,
168        summary: input.summary,
169        attribution: input.attribution,
170    }
171}
172
173/// Serialize a typed audit JSON output envelope.
174///
175/// # Errors
176///
177/// Returns a serde error if the envelope or one of its nested payload sections
178/// cannot be converted to JSON.
179pub fn serialize_audit_json<DeadCode, Duplication, Complexity>(
180    input: AuditJsonOutputInput<DeadCode, Duplication, Complexity>,
181    mode: RootEnvelopeMode,
182    analysis_run_id: Option<&str>,
183) -> Result<serde_json::Value, serde_json::Error>
184where
185    DeadCode: Serialize,
186    Duplication: Serialize,
187    Complexity: Serialize,
188{
189    let header = audit_header_output(input.header);
190    let complexity = input.complexity.map(serde_json::to_value).transpose()?;
191    let output = fallow_output::AuditOutput {
192        schema_version: header.schema_version,
193        version: header.version,
194        command: header.command,
195        verdict: header.verdict,
196        changed_files_count: header.changed_files_count,
197        base_ref: header.base_ref,
198        base_description: header.base_description,
199        head_sha: header.head_sha,
200        elapsed_ms: header.elapsed_ms,
201        base_snapshot_skipped: header.base_snapshot_skipped,
202        summary: header.summary,
203        attribution: header.attribution,
204        meta: input.meta,
205        dead_code: input.dead_code,
206        duplication: input.duplication,
207        complexity,
208        next_steps: input.next_steps,
209    };
210    let mut value = fallow_output::serialize_audit_json_output(output, mode, analysis_run_id)?;
211    attach_audit_styling_attribution(&mut value);
212    Ok(value)
213}
214
215/// Add styling attribution totals derived from annotated styling findings.
216///
217/// This keeps the public Rust attribution and finding structs source-compatible
218/// while extending audit-family JSON envelopes with the wire-only fields.
219pub fn attach_audit_styling_attribution(value: &mut serde_json::Value) {
220    let findings = value
221        .get("complexity")
222        .and_then(|complexity| complexity.get("styling_findings"))
223        .and_then(serde_json::Value::as_array);
224    let styling_introduced = findings.map_or(0, |items| {
225        items
226            .iter()
227            .filter(|item| {
228                item.get("introduced").and_then(serde_json::Value::as_bool) == Some(true)
229            })
230            .count()
231    });
232    let styling_inherited = findings.map_or(0, |items| {
233        items
234            .iter()
235            .filter(|item| {
236                item.get("introduced").and_then(serde_json::Value::as_bool) == Some(false)
237            })
238            .count()
239    });
240    if let Some(attribution) = value
241        .get_mut("attribution")
242        .and_then(serde_json::Value::as_object_mut)
243    {
244        attribution.insert(
245            "styling_introduced".to_string(),
246            serde_json::json!(styling_introduced),
247        );
248        attribution.insert(
249            "styling_inherited".to_string(),
250            serde_json::json!(styling_inherited),
251        );
252    }
253}
254
255/// Build the combined SARIF document for `fallow audit`.
256#[must_use]
257pub fn build_audit_sarif(input: AuditSarifOutputInput<'_>) -> serde_json::Value {
258    let mut all_runs = Vec::new();
259
260    if let Some(sarif) = input.dead_code {
261        extend_sarif_runs(&mut all_runs, sarif);
262    }
263
264    if let Some(duplication) = input.duplication
265        && !duplication.clone_groups.is_empty()
266    {
267        all_runs.push(build_audit_duplication_sarif_run(duplication));
268    }
269
270    if let Some(sarif) = input.health {
271        extend_sarif_runs(&mut all_runs, sarif);
272    }
273
274    serde_json::json!({
275        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
276        "version": "2.1.0",
277        "runs": all_runs,
278    })
279}
280
281fn extend_sarif_runs(all_runs: &mut Vec<serde_json::Value>, sarif: &serde_json::Value) {
282    if let Some(runs) = sarif.get("runs").and_then(|runs| runs.as_array()) {
283        all_runs.extend(runs.iter().cloned());
284    }
285}
286
287fn build_audit_duplication_sarif_run(duplication: &DuplicationReport) -> serde_json::Value {
288    serde_json::json!({
289        "tool": {
290            "driver": {
291                "name": "fallow",
292                "version": env!("CARGO_PKG_VERSION"),
293                "informationUri": "https://github.com/fallow-rs/fallow",
294            }
295        },
296        "automationDetails": { "id": "fallow/audit/dupes" },
297        "results": duplication.clone_groups.iter().enumerate().map(|(i, group)| {
298            serde_json::json!({
299                "ruleId": "fallow/code-duplication",
300                "level": "warning",
301                "message": {
302                    "text": format!(
303                        "Clone group {} ({} lines, {} instances)",
304                        i + 1,
305                        group.line_count,
306                        group.instances.len()
307                    ),
308                },
309            })
310        }).collect::<Vec<_>>()
311    })
312}
313
314/// Build combined CodeClimate issues for `fallow audit`.
315#[must_use]
316pub fn build_audit_codeclimate_issues(input: AuditCodeClimateOutputInput) -> Vec<CodeClimateIssue> {
317    let mut all_issues = input.dead_code;
318    all_issues.extend(input.duplication);
319    all_issues.extend(input.health);
320    all_issues
321}
322
323/// Build the combined CodeClimate JSON array for `fallow audit`.
324#[must_use]
325pub fn build_audit_codeclimate(input: AuditCodeClimateOutputInput) -> serde_json::Value {
326    codeclimate_issues_to_value(&build_audit_codeclimate_issues(input))
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn audit_verdict_uses_snake_case_wire_names() {
335        let value = serde_json::to_value(AuditVerdict::Pass).expect("serialize verdict");
336        assert_eq!(value, serde_json::json!("pass"));
337    }
338
339    fn header_input() -> AuditJsonHeaderInput {
340        AuditJsonHeaderInput {
341            schema_version: SchemaVersion(7),
342            version: ToolVersion("0.0.0-test".to_string()),
343            verdict: AuditVerdict::Pass,
344            changed_files_count: 5,
345            base_ref: "abc123".to_string(),
346            base_description: Some("merge-base with origin/main".to_string()),
347            head_sha: Some("def456".to_string()),
348            elapsed_ms: ElapsedMs(12),
349            base_snapshot_skipped: Some(true),
350            summary: AuditSummary {
351                dead_code_issues: 0,
352                dead_code_has_errors: false,
353                complexity_findings: 0,
354                max_cyclomatic: None,
355                duplication_clone_groups: 0,
356            },
357            attribution: AuditAttribution {
358                gate: AuditGate::NewOnly,
359                ..AuditAttribution::default()
360            },
361        }
362    }
363
364    #[test]
365    fn audit_header_json_uses_typed_contract_fields() {
366        let value = build_audit_header_json(header_input()).expect("serialize audit header");
367
368        assert_eq!(value["schema_version"], 7);
369        assert_eq!(value["command"], "audit");
370        assert_eq!(value["base_description"], "merge-base with origin/main");
371        assert_eq!(value["head_sha"], "def456");
372        assert_eq!(value["base_snapshot_skipped"], true);
373    }
374
375    #[test]
376    fn audit_header_map_uses_typed_contract_fields() {
377        let header = build_audit_header_map(header_input()).expect("serialize audit header");
378
379        assert_eq!(header["schema_version"], 7);
380        assert_eq!(header["command"], "audit");
381        assert_eq!(header["base_description"], "merge-base with origin/main");
382    }
383
384    #[test]
385    fn audit_json_serializer_applies_root_kind_and_sections() {
386        let value = serialize_audit_json(
387            AuditJsonOutputInput {
388                header: header_input(),
389                meta: None,
390                dead_code: Some(serde_json::json!({"total_issues": 0})),
391                duplication: None::<serde_json::Value>,
392                complexity: None::<serde_json::Value>,
393                next_steps: Vec::new(),
394            },
395            RootEnvelopeMode::Tagged,
396            Some("run-1"),
397        )
398        .expect("serialize audit output");
399
400        assert_eq!(value["kind"], "audit");
401        assert_eq!(value["dead_code"]["total_issues"], 0);
402        assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-1");
403    }
404
405    #[test]
406    fn audit_sarif_combines_runs_and_duplication_run() {
407        let duplication = DuplicationReport {
408            clone_groups: vec![fallow_types::duplicates::CloneGroup {
409                instances: vec![
410                    fallow_types::duplicates::CloneInstance {
411                        file: "src/a.ts".into(),
412                        start_line: 1,
413                        end_line: 12,
414                        start_col: 1,
415                        end_col: 1,
416                        fragment: "duplicated();".to_string(),
417                    },
418                    fallow_types::duplicates::CloneInstance {
419                        file: "src/b.ts".into(),
420                        start_line: 1,
421                        end_line: 12,
422                        start_col: 1,
423                        end_col: 1,
424                        fragment: "duplicated();".to_string(),
425                    },
426                ],
427                token_count: 40,
428                line_count: 12,
429            }],
430            ..DuplicationReport::default()
431        };
432        let dead_code = serde_json::json!({"runs": [{"automationDetails": {"id": "check"}}]});
433        let health = serde_json::json!({"runs": [{"automationDetails": {"id": "health"}}]});
434
435        let value = build_audit_sarif(AuditSarifOutputInput {
436            dead_code: Some(&dead_code),
437            duplication: Some(&duplication),
438            health: Some(&health),
439        });
440
441        assert_eq!(value["version"], "2.1.0");
442        assert_eq!(value["runs"].as_array().expect("runs").len(), 3);
443        assert_eq!(
444            value["runs"][1]["automationDetails"]["id"],
445            "fallow/audit/dupes"
446        );
447    }
448
449    #[test]
450    fn audit_codeclimate_combines_issue_sections() {
451        let issue = CodeClimateIssue {
452            kind: fallow_output::CodeClimateIssueKind::Issue,
453            check_name: "fallow/test".to_string(),
454            description: "test".to_string(),
455            severity: fallow_output::CodeClimateSeverity::Minor,
456            fingerprint: "abc".to_string(),
457            location: fallow_output::CodeClimateLocation {
458                path: "src/a.ts".to_string(),
459                lines: fallow_output::CodeClimateLines { begin: 1 },
460            },
461            categories: vec!["Bug Risk".to_string()],
462            owner: None,
463            group: None,
464        };
465
466        let value = build_audit_codeclimate(AuditCodeClimateOutputInput {
467            dead_code: vec![issue.clone()],
468            duplication: vec![issue.clone()],
469            health: vec![issue],
470        });
471
472        assert_eq!(value.as_array().expect("issues").len(), 3);
473    }
474}