1use fallow_config::{AuditGate, AuditWorsened};
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#[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 Pass,
17 Warn,
19 Fail,
21}
22
23#[derive(Debug, Clone, Serialize)]
25#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
26pub struct AuditSummary {
27 pub dead_code_issues: usize,
29 pub dead_code_has_errors: bool,
31 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
35 pub dead_code_skipped: bool,
36 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
40 pub complexity_skipped: bool,
41 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
45 pub duplication_skipped: bool,
46 pub complexity_findings: usize,
48 pub max_cyclomatic: Option<u16>,
51 pub duplication_clone_groups: usize,
53}
54
55#[derive(Debug, Default, Clone, Serialize)]
57#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
58pub struct AuditAttribution {
59 pub gate: AuditGate,
62 #[serde(default)]
66 pub worsened: AuditWorsened,
67 pub dead_code_introduced: usize,
69 pub dead_code_inherited: usize,
71 pub complexity_introduced: usize,
73 pub complexity_inherited: usize,
75 #[serde(default)]
79 pub complexity_worsened: usize,
80 pub duplication_introduced: usize,
82 pub duplication_inherited: usize,
84}
85
86pub struct AuditJsonHeaderInput {
88 pub schema_version: SchemaVersion,
90 pub version: ToolVersion,
92 pub verdict: AuditVerdict,
94 pub changed_files_count: u32,
96 pub base_ref: String,
98 pub base_description: Option<String>,
101 pub head_sha: Option<String>,
103 pub elapsed_ms: ElapsedMs,
105 pub base_snapshot_skipped: Option<bool>,
108 pub summary: AuditSummary,
110 pub attribution: AuditAttribution,
112}
113
114pub struct AuditJsonOutputInput<DeadCode, Duplication, Complexity> {
116 pub gate_outcomes: Option<fallow_output::GateOutcomes>,
119
120 pub header: AuditJsonHeaderInput,
122 pub meta: Option<fallow_types::envelope::Meta>,
124 pub dead_code: Option<DeadCode>,
126 pub duplication: Option<Duplication>,
128 pub complexity: Option<Complexity>,
130 pub diff_risk: fallow_output::DiffRiskReport,
132 pub next_steps: Vec<NextStep>,
134}
135
136#[derive(Clone, Copy)]
138pub struct AuditSarifOutputInput<'a> {
139 pub dead_code: Option<&'a serde_json::Value>,
141 pub duplication: Option<&'a DuplicationReport>,
144 pub health: Option<&'a serde_json::Value>,
146 pub diff_risk: Option<&'a fallow_output::DiffRiskReport>,
149}
150
151pub struct AuditCodeClimateOutputInput {
153 pub dead_code: Vec<CodeClimateIssue>,
155 pub duplication: Vec<CodeClimateIssue>,
157 pub health: Vec<CodeClimateIssue>,
159 pub diff_risk: Vec<CodeClimateIssue>,
161}
162
163#[derive(Serialize)]
164struct AuditHeaderOutput {
165 schema_version: SchemaVersion,
166 version: ToolVersion,
167 command: AuditCommand,
168 verdict: AuditVerdict,
169 changed_files_count: u32,
170 base_ref: String,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
172 base_description: Option<String>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
174 head_sha: Option<String>,
175 elapsed_ms: ElapsedMs,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
177 base_snapshot_skipped: Option<bool>,
178 summary: AuditSummary,
179 attribution: AuditAttribution,
180}
181
182fn audit_header_output(input: AuditJsonHeaderInput) -> AuditHeaderOutput {
183 AuditHeaderOutput {
184 schema_version: input.schema_version,
185 version: input.version,
186 command: AuditCommand::Audit,
187 verdict: input.verdict,
188 changed_files_count: input.changed_files_count,
189 base_ref: input.base_ref,
190 base_description: input.base_description,
191 head_sha: input.head_sha,
192 elapsed_ms: input.elapsed_ms,
193 base_snapshot_skipped: input.base_snapshot_skipped,
194 summary: input.summary,
195 attribution: input.attribution,
196 }
197}
198
199pub fn build_audit_header_json(
206 input: AuditJsonHeaderInput,
207) -> Result<serde_json::Value, serde_json::Error> {
208 serde_json::to_value(audit_header_output(input))
209}
210
211pub fn build_audit_header_map(
220 input: AuditJsonHeaderInput,
221) -> Result<serde_json::Map<String, serde_json::Value>, serde_json::Error> {
222 match build_audit_header_json(input)? {
223 serde_json::Value::Object(header) => Ok(header),
224 _ => unreachable!("AuditHeaderOutput serializes to an object"),
225 }
226}
227
228#[must_use]
230pub fn build_review_brief_header(
231 input: AuditJsonHeaderInput,
232) -> fallow_output::ReviewBriefHeader<AuditVerdict, AuditSummary, AuditAttribution> {
233 fallow_output::ReviewBriefHeader {
234 version: input.version,
235 verdict: input.verdict,
236 changed_files_count: input.changed_files_count,
237 base_ref: input.base_ref,
238 base_description: input.base_description,
239 head_sha: input.head_sha,
240 elapsed_ms: input.elapsed_ms,
241 base_snapshot_skipped: input.base_snapshot_skipped,
242 summary: input.summary,
243 attribution: input.attribution,
244 }
245}
246
247pub fn serialize_audit_json<DeadCode, Duplication, Complexity>(
254 input: AuditJsonOutputInput<DeadCode, Duplication, Complexity>,
255 analysis_run_id: Option<&str>,
256) -> Result<serde_json::Value, serde_json::Error>
257where
258 DeadCode: Serialize,
259 Duplication: Serialize,
260 Complexity: Serialize,
261{
262 let header = audit_header_output(input.header);
263 let complexity = input.complexity.map(serde_json::to_value).transpose()?;
264 let output = fallow_output::AuditOutput {
265 schema_version: header.schema_version,
266 version: header.version,
267 command: header.command,
268 verdict: header.verdict,
269 changed_files_count: header.changed_files_count,
270 base_ref: header.base_ref,
271 base_description: header.base_description,
272 head_sha: header.head_sha,
273 elapsed_ms: header.elapsed_ms,
274 base_snapshot_skipped: header.base_snapshot_skipped,
275 summary: header.summary,
276 attribution: header.attribution,
277 gate_outcomes: input.gate_outcomes,
278 meta: input.meta,
279 dead_code: input.dead_code,
280 duplication: input.duplication,
281 complexity,
282 diff_risk: (!input.diff_risk.is_empty()).then_some(input.diff_risk),
283 next_steps: input.next_steps,
284 };
285 let mut value = fallow_output::serialize_audit_json_output(output, analysis_run_id)?;
286 attach_audit_wire_attribution(&mut value);
287 Ok(value)
288}
289
290pub fn attach_audit_wire_attribution(value: &mut serde_json::Value) {
297 attach_audit_styling_attribution(value);
298 attach_audit_duplication_demotion_attribution(value);
299 attach_audit_diff_risk_attribution(value);
300}
301
302pub fn attach_audit_diff_risk_attribution(value: &mut serde_json::Value) {
310 let introduced = value
311 .get("diff_risk")
312 .and_then(|section| section.get("findings"))
313 .and_then(serde_json::Value::as_array)
314 .map_or(0, Vec::len);
315 if let Some(attribution) = value
316 .get_mut("attribution")
317 .and_then(serde_json::Value::as_object_mut)
318 {
319 attribution.insert(
320 "diff_risk_introduced".to_string(),
321 serde_json::json!(introduced),
322 );
323 }
324}
325
326pub fn attach_audit_duplication_demotion_attribution(value: &mut serde_json::Value) {
336 let demoted = value
337 .get("duplication")
338 .and_then(|duplication| duplication.get("clone_groups"))
339 .and_then(serde_json::Value::as_array)
340 .map_or(0, |items| {
341 items
342 .iter()
343 .filter(|item| item.get("demotion_reason").is_some())
344 .count()
345 });
346 if let Some(attribution) = value
347 .get_mut("attribution")
348 .and_then(serde_json::Value::as_object_mut)
349 {
350 attribution.insert(
351 "duplication_demoted".to_string(),
352 serde_json::json!(demoted),
353 );
354 }
355}
356
357pub fn attach_audit_styling_attribution(value: &mut serde_json::Value) {
362 let findings = value
363 .get("complexity")
364 .and_then(|complexity| complexity.get("styling_findings"))
365 .and_then(serde_json::Value::as_array);
366 let styling_introduced = findings.map_or(0, |items| {
367 items
368 .iter()
369 .filter(|item| {
370 item.get("introduced").and_then(serde_json::Value::as_bool) == Some(true)
371 })
372 .count()
373 });
374 let styling_inherited = findings.map_or(0, |items| {
375 items
376 .iter()
377 .filter(|item| {
378 item.get("introduced").and_then(serde_json::Value::as_bool) == Some(false)
379 })
380 .count()
381 });
382 if let Some(attribution) = value
383 .get_mut("attribution")
384 .and_then(serde_json::Value::as_object_mut)
385 {
386 attribution.insert(
387 "styling_introduced".to_string(),
388 serde_json::json!(styling_introduced),
389 );
390 attribution.insert(
391 "styling_inherited".to_string(),
392 serde_json::json!(styling_inherited),
393 );
394 }
395}
396
397#[must_use]
399pub fn build_audit_sarif(input: AuditSarifOutputInput<'_>) -> serde_json::Value {
400 let mut all_runs = Vec::new();
401
402 if let Some(sarif) = input.dead_code {
403 extend_sarif_runs(&mut all_runs, sarif);
404 }
405
406 if let Some(duplication) = input.duplication
407 && !duplication.clone_groups.is_empty()
408 {
409 all_runs.push(build_audit_duplication_sarif_run(duplication));
410 }
411
412 if let Some(sarif) = input.health {
413 extend_sarif_runs(&mut all_runs, sarif);
414 }
415
416 if let Some(diff_risk) = input.diff_risk
417 && !diff_risk.is_empty()
418 {
419 all_runs.push(build_audit_diff_risk_sarif_run(diff_risk));
420 }
421
422 serde_json::json!({
423 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
424 "version": "2.1.0",
425 "runs": all_runs,
426 })
427}
428
429fn extend_sarif_runs(all_runs: &mut Vec<serde_json::Value>, sarif: &serde_json::Value) {
430 if let Some(runs) = sarif.get("runs").and_then(|runs| runs.as_array()) {
431 all_runs.extend(runs.iter().cloned());
432 }
433}
434
435fn build_audit_duplication_sarif_run(duplication: &DuplicationReport) -> serde_json::Value {
436 serde_json::json!({
437 "tool": {
438 "driver": {
439 "name": "fallow",
440 "version": env!("CARGO_PKG_VERSION"),
441 "informationUri": "https://github.com/fallow-rs/fallow",
442 }
443 },
444 "automationDetails": { "id": "fallow/audit/dupes" },
445 "results": duplication.clone_groups.iter().enumerate().map(|(i, group)| {
446 serde_json::json!({
447 "ruleId": "fallow/code-duplication",
448 "level": "warning",
449 "message": {
450 "text": format!(
451 "Clone group {} ({} lines, {} instances)",
452 i + 1,
453 group.line_count,
454 group.instances.len()
455 ),
456 },
457 })
458 }).collect::<Vec<_>>()
459 })
460}
461
462fn build_audit_diff_risk_sarif_run(report: &fallow_output::DiffRiskReport) -> serde_json::Value {
463 let mut rules: Vec<fallow_output::DiffRiskRule> =
464 report.findings.iter().map(|finding| finding.rule).collect();
465 rules.sort();
466 rules.dedup();
467 let rule_defs: Vec<serde_json::Value> = rules
468 .iter()
469 .map(|rule| {
470 serde_json::json!({
471 "id": rule.sarif_rule_id(),
472 "shortDescription": { "text": rule.description() },
473 "helpUri": fallow_output::rule_docs_url("cli/audit#diff-risk-rules"),
474 })
475 })
476 .collect();
477 let results: Vec<serde_json::Value> = report
478 .findings
479 .iter()
480 .map(|finding| {
481 let level = match finding.severity {
482 fallow_output::DiffRiskSeverity::Error => "error",
483 fallow_output::DiffRiskSeverity::Warn => "warning",
484 };
485 let mut physical = serde_json::json!({
486 "artifactLocation": { "uri": finding.path },
487 });
488 if let Some(line) = finding.line {
489 physical["region"] = serde_json::json!({ "startLine": line });
490 }
491 let mut result = serde_json::json!({
492 "ruleId": finding.rule.sarif_rule_id(),
493 "level": level,
494 "message": { "text": format!("{} ({})", finding.message, finding.evidence) },
495 "locations": [{ "physicalLocation": physical }],
496 });
497 if !finding.related.is_empty() {
498 result["relatedLocations"] = finding
499 .related
500 .iter()
501 .map(|location| {
502 serde_json::json!({
503 "physicalLocation": {
504 "artifactLocation": { "uri": location.path },
505 "region": { "startLine": location.line },
506 }
507 })
508 })
509 .collect();
510 }
511 result
512 })
513 .collect();
514 serde_json::json!({
515 "tool": {
516 "driver": {
517 "name": "fallow",
518 "version": env!("CARGO_PKG_VERSION"),
519 "informationUri": "https://github.com/fallow-rs/fallow",
520 "rules": rule_defs,
521 }
522 },
523 "automationDetails": { "id": "fallow/audit/diff-risk" },
524 "results": results,
525 })
526}
527
528#[must_use]
530pub fn build_audit_codeclimate_issues(input: AuditCodeClimateOutputInput) -> Vec<CodeClimateIssue> {
531 let mut all_issues = input.dead_code;
532 all_issues.extend(input.duplication);
533 all_issues.extend(input.health);
534 all_issues.extend(input.diff_risk);
535 all_issues
536}
537
538#[must_use]
541pub fn build_diff_risk_codeclimate(
542 report: &fallow_output::DiffRiskReport,
543) -> Vec<CodeClimateIssue> {
544 report
545 .findings
546 .iter()
547 .map(|finding| {
548 let check_name = finding.rule.sarif_rule_id();
549 let line = finding.line.unwrap_or(1);
550 let fingerprint = fallow_output::codeclimate_fingerprint_hash(&[
551 &check_name,
552 &finding.path,
553 &line.to_string(),
554 &finding.evidence,
555 ]);
556 fallow_output::build_codeclimate_issue(fallow_output::CodeClimateIssueInput {
557 check_name: &check_name,
558 description: &format!("{} ({})", finding.message, finding.evidence),
559 severity: match finding.severity {
560 fallow_output::DiffRiskSeverity::Error => {
561 fallow_output::CodeClimateSeverity::Major
562 }
563 fallow_output::DiffRiskSeverity::Warn => {
564 fallow_output::CodeClimateSeverity::Minor
565 }
566 },
567 category: "Bug Risk",
568 path: &finding.path,
569 begin_line: finding.line,
570 fingerprint: &fingerprint,
571 })
572 })
573 .collect()
574}
575
576#[must_use]
578pub fn build_audit_codeclimate(input: AuditCodeClimateOutputInput) -> serde_json::Value {
579 codeclimate_issues_to_value(&build_audit_codeclimate_issues(input))
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585
586 #[test]
587 fn audit_verdict_uses_snake_case_wire_names() {
588 let value = serde_json::to_value(AuditVerdict::Pass).expect("serialize verdict");
589 assert_eq!(value, serde_json::json!("pass"));
590 }
591
592 fn header_input() -> AuditJsonHeaderInput {
593 AuditJsonHeaderInput {
594 schema_version: SchemaVersion(7),
595 version: ToolVersion("0.0.0-test".to_string()),
596 verdict: AuditVerdict::Pass,
597 changed_files_count: 5,
598 base_ref: "abc123".to_string(),
599 base_description: Some("merge-base with origin/main".to_string()),
600 head_sha: Some("def456".to_string()),
601 elapsed_ms: ElapsedMs(12),
602 base_snapshot_skipped: Some(true),
603 summary: AuditSummary {
604 dead_code_issues: 0,
605 dead_code_has_errors: false,
606 dead_code_skipped: false,
607 complexity_skipped: false,
608 duplication_skipped: false,
609 complexity_findings: 0,
610 max_cyclomatic: None,
611 duplication_clone_groups: 0,
612 },
613 attribution: AuditAttribution {
614 gate: AuditGate::NewOnly,
615 ..AuditAttribution::default()
616 },
617 }
618 }
619
620 #[test]
621 fn audit_header_json_uses_typed_contract_fields() {
622 let value = build_audit_header_json(header_input()).expect("serialize audit header");
623
624 assert_eq!(value["schema_version"], 7);
625 assert_eq!(value["command"], "audit");
626 assert_eq!(value["base_description"], "merge-base with origin/main");
627 assert_eq!(value["head_sha"], "def456");
628 assert_eq!(value["base_snapshot_skipped"], true);
629 }
630
631 #[test]
632 fn audit_header_map_uses_typed_contract_fields() {
633 let header = build_audit_header_map(header_input()).expect("serialize audit header");
634
635 assert_eq!(header["schema_version"], 7);
636 assert_eq!(header["command"], "audit");
637 assert_eq!(header["base_description"], "merge-base with origin/main");
638 }
639
640 #[test]
641 fn audit_json_serializer_applies_root_kind_and_sections() {
642 let value = serialize_audit_json(
643 AuditJsonOutputInput {
644 gate_outcomes: None,
645 header: header_input(),
646 meta: None,
647 dead_code: Some(serde_json::json!({"total_issues": 0})),
648 duplication: None::<serde_json::Value>,
649 complexity: None::<serde_json::Value>,
650 diff_risk: fallow_output::DiffRiskReport::default(),
651 next_steps: Vec::new(),
652 },
653 Some("run-1"),
654 )
655 .expect("serialize audit output");
656
657 assert_eq!(value["kind"], "audit");
658 assert_eq!(value["dead_code"]["total_issues"], 0);
659 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-1");
660 }
661
662 #[test]
663 fn audit_sarif_combines_runs_and_duplication_run() {
664 let duplication = DuplicationReport {
665 clone_groups: vec![fallow_types::duplicates::CloneGroup {
666 instances: vec![
667 fallow_types::duplicates::CloneInstance {
668 is_symlink: false,
669 file: "src/a.ts".into(),
670 start_line: 1,
671 end_line: 12,
672 start_col: 1,
673 end_col: 1,
674 fragment: "duplicated();".to_string(),
675 },
676 fallow_types::duplicates::CloneInstance {
677 is_symlink: false,
678 file: "src/b.ts".into(),
679 start_line: 1,
680 end_line: 12,
681 start_col: 1,
682 end_col: 1,
683 fragment: "duplicated();".to_string(),
684 },
685 ],
686 token_count: 40,
687 line_count: 12,
688 similarity: None,
689 }],
690 ..DuplicationReport::default()
691 };
692 let dead_code = serde_json::json!({"runs": [{"automationDetails": {"id": "check"}}]});
693 let health = serde_json::json!({"runs": [{"automationDetails": {"id": "health"}}]});
694
695 let value = build_audit_sarif(AuditSarifOutputInput {
696 dead_code: Some(&dead_code),
697 duplication: Some(&duplication),
698 health: Some(&health),
699 diff_risk: None,
700 });
701
702 assert_eq!(value["version"], "2.1.0");
703 assert_eq!(value["runs"].as_array().expect("runs").len(), 3);
704 assert_eq!(
705 value["runs"][1]["automationDetails"]["id"],
706 "fallow/audit/dupes"
707 );
708 }
709
710 #[test]
711 fn audit_codeclimate_combines_issue_sections() {
712 let issue = CodeClimateIssue {
713 kind: fallow_output::CodeClimateIssueKind::Issue,
714 check_name: "fallow/test".to_string(),
715 description: "test".to_string(),
716 severity: fallow_output::CodeClimateSeverity::Minor,
717 fingerprint: "abc".to_string(),
718 location: fallow_output::CodeClimateLocation {
719 path: "src/a.ts".to_string(),
720 lines: fallow_output::CodeClimateLines {
721 begin: 1,
722 end: None,
723 },
724 },
725 other_locations: Vec::new(),
726 categories: vec!["Bug Risk".to_string()],
727 owner: None,
728 group: None,
729 legacy_fingerprint: None,
730 };
731
732 let value = build_audit_codeclimate(AuditCodeClimateOutputInput {
733 dead_code: vec![issue.clone()],
734 duplication: vec![issue.clone()],
735 health: vec![issue.clone()],
736 diff_risk: vec![issue],
737 });
738
739 assert_eq!(value.as_array().expect("issues").len(), 4);
740 }
741}