1use 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#[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 Pass,
19 Warn,
21 Fail,
23}
24
25#[derive(Debug, Clone, Serialize)]
27#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
28pub struct AuditSummary {
29 pub dead_code_issues: usize,
31 pub dead_code_has_errors: bool,
33 pub complexity_findings: usize,
35 pub max_cyclomatic: Option<u16>,
38 pub duplication_clone_groups: usize,
40}
41
42#[derive(Debug, Default, Clone, Serialize)]
44#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
45pub struct AuditAttribution {
46 pub gate: AuditGate,
49 pub dead_code_introduced: usize,
51 pub dead_code_inherited: usize,
53 pub complexity_introduced: usize,
55 pub complexity_inherited: usize,
57 pub duplication_introduced: usize,
59 pub duplication_inherited: usize,
61}
62
63pub struct AuditJsonHeaderInput {
65 pub schema_version: SchemaVersion,
67 pub version: ToolVersion,
69 pub verdict: AuditVerdict,
71 pub changed_files_count: u32,
73 pub base_ref: String,
75 pub base_description: Option<String>,
78 pub head_sha: Option<String>,
80 pub elapsed_ms: ElapsedMs,
82 pub base_snapshot_skipped: Option<bool>,
85 pub summary: AuditSummary,
87 pub attribution: AuditAttribution,
89}
90
91pub struct AuditJsonOutputInput<DeadCode, Duplication, Complexity> {
93 pub gate_outcomes: Option<fallow_output::GateOutcomes>,
96
97 pub header: AuditJsonHeaderInput,
99 pub meta: Option<fallow_types::envelope::Meta>,
101 pub dead_code: Option<DeadCode>,
103 pub duplication: Option<Duplication>,
105 pub complexity: Option<Complexity>,
107 pub next_steps: Vec<NextStep>,
109}
110
111#[derive(Clone, Copy)]
113pub struct AuditSarifOutputInput<'a> {
114 pub dead_code: Option<&'a serde_json::Value>,
116 pub duplication: Option<&'a DuplicationReport>,
119 pub health: Option<&'a serde_json::Value>,
121}
122
123pub struct AuditCodeClimateOutputInput {
125 pub dead_code: Vec<CodeClimateIssue>,
127 pub duplication: Vec<CodeClimateIssue>,
129 pub health: Vec<CodeClimateIssue>,
131}
132
133#[derive(Serialize)]
134struct AuditHeaderOutput {
135 schema_version: SchemaVersion,
136 version: ToolVersion,
137 command: AuditCommand,
138 verdict: AuditVerdict,
139 changed_files_count: u32,
140 base_ref: String,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
142 base_description: Option<String>,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 head_sha: Option<String>,
145 elapsed_ms: ElapsedMs,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 base_snapshot_skipped: Option<bool>,
148 summary: AuditSummary,
149 attribution: AuditAttribution,
150}
151
152fn audit_header_output(input: AuditJsonHeaderInput) -> AuditHeaderOutput {
153 AuditHeaderOutput {
154 schema_version: input.schema_version,
155 version: input.version,
156 command: AuditCommand::Audit,
157 verdict: input.verdict,
158 changed_files_count: input.changed_files_count,
159 base_ref: input.base_ref,
160 base_description: input.base_description,
161 head_sha: input.head_sha,
162 elapsed_ms: input.elapsed_ms,
163 base_snapshot_skipped: input.base_snapshot_skipped,
164 summary: input.summary,
165 attribution: input.attribution,
166 }
167}
168
169pub fn build_audit_header_json(
176 input: AuditJsonHeaderInput,
177) -> Result<serde_json::Value, serde_json::Error> {
178 serde_json::to_value(audit_header_output(input))
179}
180
181pub fn build_audit_header_map(
190 input: AuditJsonHeaderInput,
191) -> Result<serde_json::Map<String, serde_json::Value>, serde_json::Error> {
192 match build_audit_header_json(input)? {
193 serde_json::Value::Object(header) => Ok(header),
194 _ => unreachable!("AuditHeaderOutput serializes to an object"),
195 }
196}
197
198#[must_use]
200pub fn build_review_brief_header(
201 input: AuditJsonHeaderInput,
202) -> fallow_output::ReviewBriefHeader<AuditVerdict, AuditSummary, AuditAttribution> {
203 fallow_output::ReviewBriefHeader {
204 version: input.version,
205 verdict: input.verdict,
206 changed_files_count: input.changed_files_count,
207 base_ref: input.base_ref,
208 base_description: input.base_description,
209 head_sha: input.head_sha,
210 elapsed_ms: input.elapsed_ms,
211 base_snapshot_skipped: input.base_snapshot_skipped,
212 summary: input.summary,
213 attribution: input.attribution,
214 }
215}
216
217pub fn serialize_audit_json<DeadCode, Duplication, Complexity>(
224 input: AuditJsonOutputInput<DeadCode, Duplication, Complexity>,
225 mode: RootEnvelopeMode,
226 analysis_run_id: Option<&str>,
227) -> Result<serde_json::Value, serde_json::Error>
228where
229 DeadCode: Serialize,
230 Duplication: Serialize,
231 Complexity: Serialize,
232{
233 let header = audit_header_output(input.header);
234 let complexity = input.complexity.map(serde_json::to_value).transpose()?;
235 let output = fallow_output::AuditOutput {
236 schema_version: header.schema_version,
237 version: header.version,
238 command: header.command,
239 verdict: header.verdict,
240 changed_files_count: header.changed_files_count,
241 base_ref: header.base_ref,
242 base_description: header.base_description,
243 head_sha: header.head_sha,
244 elapsed_ms: header.elapsed_ms,
245 base_snapshot_skipped: header.base_snapshot_skipped,
246 summary: header.summary,
247 attribution: header.attribution,
248 gate_outcomes: input.gate_outcomes,
249 meta: input.meta,
250 dead_code: input.dead_code,
251 duplication: input.duplication,
252 complexity,
253 next_steps: input.next_steps,
254 };
255 let mut value = fallow_output::serialize_audit_json_output(output, mode, analysis_run_id)?;
256 attach_audit_wire_attribution(&mut value);
257 Ok(value)
258}
259
260pub fn attach_audit_wire_attribution(value: &mut serde_json::Value) {
267 attach_audit_styling_attribution(value);
268 attach_audit_duplication_demotion_attribution(value);
269}
270
271pub fn attach_audit_duplication_demotion_attribution(value: &mut serde_json::Value) {
281 let demoted = value
282 .get("duplication")
283 .and_then(|duplication| duplication.get("clone_groups"))
284 .and_then(serde_json::Value::as_array)
285 .map_or(0, |items| {
286 items
287 .iter()
288 .filter(|item| item.get("demotion_reason").is_some())
289 .count()
290 });
291 if let Some(attribution) = value
292 .get_mut("attribution")
293 .and_then(serde_json::Value::as_object_mut)
294 {
295 attribution.insert(
296 "duplication_demoted".to_string(),
297 serde_json::json!(demoted),
298 );
299 }
300}
301
302pub fn attach_audit_styling_attribution(value: &mut serde_json::Value) {
307 let findings = value
308 .get("complexity")
309 .and_then(|complexity| complexity.get("styling_findings"))
310 .and_then(serde_json::Value::as_array);
311 let styling_introduced = findings.map_or(0, |items| {
312 items
313 .iter()
314 .filter(|item| {
315 item.get("introduced").and_then(serde_json::Value::as_bool) == Some(true)
316 })
317 .count()
318 });
319 let styling_inherited = findings.map_or(0, |items| {
320 items
321 .iter()
322 .filter(|item| {
323 item.get("introduced").and_then(serde_json::Value::as_bool) == Some(false)
324 })
325 .count()
326 });
327 if let Some(attribution) = value
328 .get_mut("attribution")
329 .and_then(serde_json::Value::as_object_mut)
330 {
331 attribution.insert(
332 "styling_introduced".to_string(),
333 serde_json::json!(styling_introduced),
334 );
335 attribution.insert(
336 "styling_inherited".to_string(),
337 serde_json::json!(styling_inherited),
338 );
339 }
340}
341
342#[must_use]
344pub fn build_audit_sarif(input: AuditSarifOutputInput<'_>) -> serde_json::Value {
345 let mut all_runs = Vec::new();
346
347 if let Some(sarif) = input.dead_code {
348 extend_sarif_runs(&mut all_runs, sarif);
349 }
350
351 if let Some(duplication) = input.duplication
352 && !duplication.clone_groups.is_empty()
353 {
354 all_runs.push(build_audit_duplication_sarif_run(duplication));
355 }
356
357 if let Some(sarif) = input.health {
358 extend_sarif_runs(&mut all_runs, sarif);
359 }
360
361 serde_json::json!({
362 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
363 "version": "2.1.0",
364 "runs": all_runs,
365 })
366}
367
368fn extend_sarif_runs(all_runs: &mut Vec<serde_json::Value>, sarif: &serde_json::Value) {
369 if let Some(runs) = sarif.get("runs").and_then(|runs| runs.as_array()) {
370 all_runs.extend(runs.iter().cloned());
371 }
372}
373
374fn build_audit_duplication_sarif_run(duplication: &DuplicationReport) -> serde_json::Value {
375 serde_json::json!({
376 "tool": {
377 "driver": {
378 "name": "fallow",
379 "version": env!("CARGO_PKG_VERSION"),
380 "informationUri": "https://github.com/fallow-rs/fallow",
381 }
382 },
383 "automationDetails": { "id": "fallow/audit/dupes" },
384 "results": duplication.clone_groups.iter().enumerate().map(|(i, group)| {
385 serde_json::json!({
386 "ruleId": "fallow/code-duplication",
387 "level": "warning",
388 "message": {
389 "text": format!(
390 "Clone group {} ({} lines, {} instances)",
391 i + 1,
392 group.line_count,
393 group.instances.len()
394 ),
395 },
396 })
397 }).collect::<Vec<_>>()
398 })
399}
400
401#[must_use]
403pub fn build_audit_codeclimate_issues(input: AuditCodeClimateOutputInput) -> Vec<CodeClimateIssue> {
404 let mut all_issues = input.dead_code;
405 all_issues.extend(input.duplication);
406 all_issues.extend(input.health);
407 all_issues
408}
409
410#[must_use]
412pub fn build_audit_codeclimate(input: AuditCodeClimateOutputInput) -> serde_json::Value {
413 codeclimate_issues_to_value(&build_audit_codeclimate_issues(input))
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419
420 #[test]
421 fn audit_verdict_uses_snake_case_wire_names() {
422 let value = serde_json::to_value(AuditVerdict::Pass).expect("serialize verdict");
423 assert_eq!(value, serde_json::json!("pass"));
424 }
425
426 fn header_input() -> AuditJsonHeaderInput {
427 AuditJsonHeaderInput {
428 schema_version: SchemaVersion(7),
429 version: ToolVersion("0.0.0-test".to_string()),
430 verdict: AuditVerdict::Pass,
431 changed_files_count: 5,
432 base_ref: "abc123".to_string(),
433 base_description: Some("merge-base with origin/main".to_string()),
434 head_sha: Some("def456".to_string()),
435 elapsed_ms: ElapsedMs(12),
436 base_snapshot_skipped: Some(true),
437 summary: AuditSummary {
438 dead_code_issues: 0,
439 dead_code_has_errors: false,
440 complexity_findings: 0,
441 max_cyclomatic: None,
442 duplication_clone_groups: 0,
443 },
444 attribution: AuditAttribution {
445 gate: AuditGate::NewOnly,
446 ..AuditAttribution::default()
447 },
448 }
449 }
450
451 #[test]
452 fn audit_header_json_uses_typed_contract_fields() {
453 let value = build_audit_header_json(header_input()).expect("serialize audit header");
454
455 assert_eq!(value["schema_version"], 7);
456 assert_eq!(value["command"], "audit");
457 assert_eq!(value["base_description"], "merge-base with origin/main");
458 assert_eq!(value["head_sha"], "def456");
459 assert_eq!(value["base_snapshot_skipped"], true);
460 }
461
462 #[test]
463 fn audit_header_map_uses_typed_contract_fields() {
464 let header = build_audit_header_map(header_input()).expect("serialize audit header");
465
466 assert_eq!(header["schema_version"], 7);
467 assert_eq!(header["command"], "audit");
468 assert_eq!(header["base_description"], "merge-base with origin/main");
469 }
470
471 #[test]
472 fn audit_json_serializer_applies_root_kind_and_sections() {
473 let value = serialize_audit_json(
474 AuditJsonOutputInput {
475 gate_outcomes: None,
476 header: header_input(),
477 meta: None,
478 dead_code: Some(serde_json::json!({"total_issues": 0})),
479 duplication: None::<serde_json::Value>,
480 complexity: None::<serde_json::Value>,
481 next_steps: Vec::new(),
482 },
483 RootEnvelopeMode::Tagged,
484 Some("run-1"),
485 )
486 .expect("serialize audit output");
487
488 assert_eq!(value["kind"], "audit");
489 assert_eq!(value["dead_code"]["total_issues"], 0);
490 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-1");
491 }
492
493 #[test]
494 fn audit_sarif_combines_runs_and_duplication_run() {
495 let duplication = DuplicationReport {
496 clone_groups: vec![fallow_types::duplicates::CloneGroup {
497 instances: vec![
498 fallow_types::duplicates::CloneInstance {
499 file: "src/a.ts".into(),
500 start_line: 1,
501 end_line: 12,
502 start_col: 1,
503 end_col: 1,
504 fragment: "duplicated();".to_string(),
505 },
506 fallow_types::duplicates::CloneInstance {
507 file: "src/b.ts".into(),
508 start_line: 1,
509 end_line: 12,
510 start_col: 1,
511 end_col: 1,
512 fragment: "duplicated();".to_string(),
513 },
514 ],
515 token_count: 40,
516 line_count: 12,
517 similarity: None,
518 }],
519 ..DuplicationReport::default()
520 };
521 let dead_code = serde_json::json!({"runs": [{"automationDetails": {"id": "check"}}]});
522 let health = serde_json::json!({"runs": [{"automationDetails": {"id": "health"}}]});
523
524 let value = build_audit_sarif(AuditSarifOutputInput {
525 dead_code: Some(&dead_code),
526 duplication: Some(&duplication),
527 health: Some(&health),
528 });
529
530 assert_eq!(value["version"], "2.1.0");
531 assert_eq!(value["runs"].as_array().expect("runs").len(), 3);
532 assert_eq!(
533 value["runs"][1]["automationDetails"]["id"],
534 "fallow/audit/dupes"
535 );
536 }
537
538 #[test]
539 fn audit_codeclimate_combines_issue_sections() {
540 let issue = CodeClimateIssue {
541 kind: fallow_output::CodeClimateIssueKind::Issue,
542 check_name: "fallow/test".to_string(),
543 description: "test".to_string(),
544 severity: fallow_output::CodeClimateSeverity::Minor,
545 fingerprint: "abc".to_string(),
546 location: fallow_output::CodeClimateLocation {
547 path: "src/a.ts".to_string(),
548 lines: fallow_output::CodeClimateLines {
549 begin: 1,
550 end: None,
551 },
552 },
553 other_locations: Vec::new(),
554 categories: vec!["Bug Risk".to_string()],
555 owner: None,
556 group: None,
557 };
558
559 let value = build_audit_codeclimate(AuditCodeClimateOutputInput {
560 dead_code: vec![issue.clone()],
561 duplication: vec![issue.clone()],
562 health: vec![issue],
563 });
564
565 assert_eq!(value.as_array().expect("issues").len(), 3);
566 }
567}