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 header: AuditJsonHeaderInput,
95 pub meta: Option<fallow_types::envelope::Meta>,
97 pub dead_code: Option<DeadCode>,
99 pub duplication: Option<Duplication>,
101 pub complexity: Option<Complexity>,
103 pub next_steps: Vec<NextStep>,
105}
106
107#[derive(Clone, Copy)]
109pub struct AuditSarifOutputInput<'a> {
110 pub dead_code: Option<&'a serde_json::Value>,
112 pub duplication: Option<&'a DuplicationReport>,
115 pub health: Option<&'a serde_json::Value>,
117}
118
119pub struct AuditCodeClimateOutputInput {
121 pub dead_code: Vec<CodeClimateIssue>,
123 pub duplication: Vec<CodeClimateIssue>,
125 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
165pub 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
177pub 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#[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
213pub 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
255pub 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
266pub 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
297pub 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#[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#[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#[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}