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