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