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