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,
30 pub dead_code_has_errors: bool,
31 pub complexity_findings: usize,
32 pub max_cyclomatic: Option<u16>,
33 pub duplication_clone_groups: usize,
34}
35
36#[derive(Debug, Default, Clone, Serialize)]
38#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
39pub struct AuditAttribution {
40 pub gate: AuditGate,
41 pub dead_code_introduced: usize,
42 pub dead_code_inherited: usize,
43 pub complexity_introduced: usize,
44 pub complexity_inherited: usize,
45 pub duplication_introduced: usize,
46 pub duplication_inherited: usize,
47}
48
49pub struct AuditJsonHeaderInput {
51 pub schema_version: SchemaVersion,
52 pub version: ToolVersion,
53 pub verdict: AuditVerdict,
54 pub changed_files_count: u32,
55 pub base_ref: String,
56 pub base_description: Option<String>,
57 pub head_sha: Option<String>,
58 pub elapsed_ms: ElapsedMs,
59 pub base_snapshot_skipped: Option<bool>,
60 pub summary: AuditSummary,
61 pub attribution: AuditAttribution,
62}
63
64pub struct AuditJsonOutputInput<DeadCode, Duplication, Complexity> {
66 pub header: AuditJsonHeaderInput,
67 pub meta: Option<fallow_types::envelope::Meta>,
68 pub dead_code: Option<DeadCode>,
69 pub duplication: Option<Duplication>,
70 pub complexity: Option<Complexity>,
71 pub next_steps: Vec<NextStep>,
72}
73
74#[derive(Clone, Copy)]
76pub struct AuditSarifOutputInput<'a> {
77 pub dead_code: Option<&'a serde_json::Value>,
78 pub duplication: Option<&'a DuplicationReport>,
79 pub health: Option<&'a serde_json::Value>,
80}
81
82pub struct AuditCodeClimateOutputInput {
84 pub dead_code: Vec<CodeClimateIssue>,
85 pub duplication: Vec<CodeClimateIssue>,
86 pub health: Vec<CodeClimateIssue>,
87}
88
89#[derive(Serialize)]
90struct AuditHeaderOutput {
91 schema_version: SchemaVersion,
92 version: ToolVersion,
93 command: AuditCommand,
94 verdict: AuditVerdict,
95 changed_files_count: u32,
96 base_ref: String,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 base_description: Option<String>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 head_sha: Option<String>,
101 elapsed_ms: ElapsedMs,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
103 base_snapshot_skipped: Option<bool>,
104 summary: AuditSummary,
105 attribution: AuditAttribution,
106}
107
108fn audit_header_output(input: AuditJsonHeaderInput) -> AuditHeaderOutput {
109 AuditHeaderOutput {
110 schema_version: input.schema_version,
111 version: input.version,
112 command: AuditCommand::Audit,
113 verdict: input.verdict,
114 changed_files_count: input.changed_files_count,
115 base_ref: input.base_ref,
116 base_description: input.base_description,
117 head_sha: input.head_sha,
118 elapsed_ms: input.elapsed_ms,
119 base_snapshot_skipped: input.base_snapshot_skipped,
120 summary: input.summary,
121 attribution: input.attribution,
122 }
123}
124
125pub fn build_audit_header_json(
132 input: AuditJsonHeaderInput,
133) -> Result<serde_json::Value, serde_json::Error> {
134 serde_json::to_value(audit_header_output(input))
135}
136
137pub fn build_audit_header_map(
146 input: AuditJsonHeaderInput,
147) -> Result<serde_json::Map<String, serde_json::Value>, serde_json::Error> {
148 match build_audit_header_json(input)? {
149 serde_json::Value::Object(header) => Ok(header),
150 _ => unreachable!("AuditHeaderOutput serializes to an object"),
151 }
152}
153
154#[must_use]
156pub fn build_review_brief_header(
157 input: AuditJsonHeaderInput,
158) -> fallow_output::ReviewBriefHeader<AuditVerdict, AuditSummary, AuditAttribution> {
159 fallow_output::ReviewBriefHeader {
160 version: input.version,
161 verdict: input.verdict,
162 changed_files_count: input.changed_files_count,
163 base_ref: input.base_ref,
164 base_description: input.base_description,
165 head_sha: input.head_sha,
166 elapsed_ms: input.elapsed_ms,
167 base_snapshot_skipped: input.base_snapshot_skipped,
168 summary: input.summary,
169 attribution: input.attribution,
170 }
171}
172
173pub fn serialize_audit_json<DeadCode, Duplication, Complexity>(
180 input: AuditJsonOutputInput<DeadCode, Duplication, Complexity>,
181 mode: RootEnvelopeMode,
182 analysis_run_id: Option<&str>,
183) -> Result<serde_json::Value, serde_json::Error>
184where
185 DeadCode: Serialize,
186 Duplication: Serialize,
187 Complexity: Serialize,
188{
189 let header = audit_header_output(input.header);
190 let complexity = input.complexity.map(serde_json::to_value).transpose()?;
191 let output = fallow_output::AuditOutput {
192 schema_version: header.schema_version,
193 version: header.version,
194 command: header.command,
195 verdict: header.verdict,
196 changed_files_count: header.changed_files_count,
197 base_ref: header.base_ref,
198 base_description: header.base_description,
199 head_sha: header.head_sha,
200 elapsed_ms: header.elapsed_ms,
201 base_snapshot_skipped: header.base_snapshot_skipped,
202 summary: header.summary,
203 attribution: header.attribution,
204 meta: input.meta,
205 dead_code: input.dead_code,
206 duplication: input.duplication,
207 complexity,
208 next_steps: input.next_steps,
209 };
210 let mut value = fallow_output::serialize_audit_json_output(output, mode, analysis_run_id)?;
211 attach_audit_styling_attribution(&mut value);
212 Ok(value)
213}
214
215pub fn attach_audit_styling_attribution(value: &mut serde_json::Value) {
220 let findings = value
221 .get("complexity")
222 .and_then(|complexity| complexity.get("styling_findings"))
223 .and_then(serde_json::Value::as_array);
224 let styling_introduced = findings.map_or(0, |items| {
225 items
226 .iter()
227 .filter(|item| {
228 item.get("introduced").and_then(serde_json::Value::as_bool) == Some(true)
229 })
230 .count()
231 });
232 let styling_inherited = findings.map_or(0, |items| {
233 items
234 .iter()
235 .filter(|item| {
236 item.get("introduced").and_then(serde_json::Value::as_bool) == Some(false)
237 })
238 .count()
239 });
240 if let Some(attribution) = value
241 .get_mut("attribution")
242 .and_then(serde_json::Value::as_object_mut)
243 {
244 attribution.insert(
245 "styling_introduced".to_string(),
246 serde_json::json!(styling_introduced),
247 );
248 attribution.insert(
249 "styling_inherited".to_string(),
250 serde_json::json!(styling_inherited),
251 );
252 }
253}
254
255#[must_use]
257pub fn build_audit_sarif(input: AuditSarifOutputInput<'_>) -> serde_json::Value {
258 let mut all_runs = Vec::new();
259
260 if let Some(sarif) = input.dead_code {
261 extend_sarif_runs(&mut all_runs, sarif);
262 }
263
264 if let Some(duplication) = input.duplication
265 && !duplication.clone_groups.is_empty()
266 {
267 all_runs.push(build_audit_duplication_sarif_run(duplication));
268 }
269
270 if let Some(sarif) = input.health {
271 extend_sarif_runs(&mut all_runs, sarif);
272 }
273
274 serde_json::json!({
275 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
276 "version": "2.1.0",
277 "runs": all_runs,
278 })
279}
280
281fn extend_sarif_runs(all_runs: &mut Vec<serde_json::Value>, sarif: &serde_json::Value) {
282 if let Some(runs) = sarif.get("runs").and_then(|runs| runs.as_array()) {
283 all_runs.extend(runs.iter().cloned());
284 }
285}
286
287fn build_audit_duplication_sarif_run(duplication: &DuplicationReport) -> serde_json::Value {
288 serde_json::json!({
289 "tool": {
290 "driver": {
291 "name": "fallow",
292 "version": env!("CARGO_PKG_VERSION"),
293 "informationUri": "https://github.com/fallow-rs/fallow",
294 }
295 },
296 "automationDetails": { "id": "fallow/audit/dupes" },
297 "results": duplication.clone_groups.iter().enumerate().map(|(i, group)| {
298 serde_json::json!({
299 "ruleId": "fallow/code-duplication",
300 "level": "warning",
301 "message": {
302 "text": format!(
303 "Clone group {} ({} lines, {} instances)",
304 i + 1,
305 group.line_count,
306 group.instances.len()
307 ),
308 },
309 })
310 }).collect::<Vec<_>>()
311 })
312}
313
314#[must_use]
316pub fn build_audit_codeclimate_issues(input: AuditCodeClimateOutputInput) -> Vec<CodeClimateIssue> {
317 let mut all_issues = input.dead_code;
318 all_issues.extend(input.duplication);
319 all_issues.extend(input.health);
320 all_issues
321}
322
323#[must_use]
325pub fn build_audit_codeclimate(input: AuditCodeClimateOutputInput) -> serde_json::Value {
326 codeclimate_issues_to_value(&build_audit_codeclimate_issues(input))
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 #[test]
334 fn audit_verdict_uses_snake_case_wire_names() {
335 let value = serde_json::to_value(AuditVerdict::Pass).expect("serialize verdict");
336 assert_eq!(value, serde_json::json!("pass"));
337 }
338
339 fn header_input() -> AuditJsonHeaderInput {
340 AuditJsonHeaderInput {
341 schema_version: SchemaVersion(7),
342 version: ToolVersion("0.0.0-test".to_string()),
343 verdict: AuditVerdict::Pass,
344 changed_files_count: 5,
345 base_ref: "abc123".to_string(),
346 base_description: Some("merge-base with origin/main".to_string()),
347 head_sha: Some("def456".to_string()),
348 elapsed_ms: ElapsedMs(12),
349 base_snapshot_skipped: Some(true),
350 summary: AuditSummary {
351 dead_code_issues: 0,
352 dead_code_has_errors: false,
353 complexity_findings: 0,
354 max_cyclomatic: None,
355 duplication_clone_groups: 0,
356 },
357 attribution: AuditAttribution {
358 gate: AuditGate::NewOnly,
359 ..AuditAttribution::default()
360 },
361 }
362 }
363
364 #[test]
365 fn audit_header_json_uses_typed_contract_fields() {
366 let value = build_audit_header_json(header_input()).expect("serialize audit header");
367
368 assert_eq!(value["schema_version"], 7);
369 assert_eq!(value["command"], "audit");
370 assert_eq!(value["base_description"], "merge-base with origin/main");
371 assert_eq!(value["head_sha"], "def456");
372 assert_eq!(value["base_snapshot_skipped"], true);
373 }
374
375 #[test]
376 fn audit_header_map_uses_typed_contract_fields() {
377 let header = build_audit_header_map(header_input()).expect("serialize audit header");
378
379 assert_eq!(header["schema_version"], 7);
380 assert_eq!(header["command"], "audit");
381 assert_eq!(header["base_description"], "merge-base with origin/main");
382 }
383
384 #[test]
385 fn audit_json_serializer_applies_root_kind_and_sections() {
386 let value = serialize_audit_json(
387 AuditJsonOutputInput {
388 header: header_input(),
389 meta: None,
390 dead_code: Some(serde_json::json!({"total_issues": 0})),
391 duplication: None::<serde_json::Value>,
392 complexity: None::<serde_json::Value>,
393 next_steps: Vec::new(),
394 },
395 RootEnvelopeMode::Tagged,
396 Some("run-1"),
397 )
398 .expect("serialize audit output");
399
400 assert_eq!(value["kind"], "audit");
401 assert_eq!(value["dead_code"]["total_issues"], 0);
402 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-1");
403 }
404
405 #[test]
406 fn audit_sarif_combines_runs_and_duplication_run() {
407 let duplication = DuplicationReport {
408 clone_groups: vec![fallow_types::duplicates::CloneGroup {
409 instances: vec![
410 fallow_types::duplicates::CloneInstance {
411 file: "src/a.ts".into(),
412 start_line: 1,
413 end_line: 12,
414 start_col: 1,
415 end_col: 1,
416 fragment: "duplicated();".to_string(),
417 },
418 fallow_types::duplicates::CloneInstance {
419 file: "src/b.ts".into(),
420 start_line: 1,
421 end_line: 12,
422 start_col: 1,
423 end_col: 1,
424 fragment: "duplicated();".to_string(),
425 },
426 ],
427 token_count: 40,
428 line_count: 12,
429 }],
430 ..DuplicationReport::default()
431 };
432 let dead_code = serde_json::json!({"runs": [{"automationDetails": {"id": "check"}}]});
433 let health = serde_json::json!({"runs": [{"automationDetails": {"id": "health"}}]});
434
435 let value = build_audit_sarif(AuditSarifOutputInput {
436 dead_code: Some(&dead_code),
437 duplication: Some(&duplication),
438 health: Some(&health),
439 });
440
441 assert_eq!(value["version"], "2.1.0");
442 assert_eq!(value["runs"].as_array().expect("runs").len(), 3);
443 assert_eq!(
444 value["runs"][1]["automationDetails"]["id"],
445 "fallow/audit/dupes"
446 );
447 }
448
449 #[test]
450 fn audit_codeclimate_combines_issue_sections() {
451 let issue = CodeClimateIssue {
452 kind: fallow_output::CodeClimateIssueKind::Issue,
453 check_name: "fallow/test".to_string(),
454 description: "test".to_string(),
455 severity: fallow_output::CodeClimateSeverity::Minor,
456 fingerprint: "abc".to_string(),
457 location: fallow_output::CodeClimateLocation {
458 path: "src/a.ts".to_string(),
459 lines: fallow_output::CodeClimateLines { begin: 1 },
460 },
461 categories: vec!["Bug Risk".to_string()],
462 owner: None,
463 group: None,
464 };
465
466 let value = build_audit_codeclimate(AuditCodeClimateOutputInput {
467 dead_code: vec![issue.clone()],
468 duplication: vec![issue.clone()],
469 health: vec![issue],
470 });
471
472 assert_eq!(value.as_array().expect("issues").len(), 3);
473 }
474}