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