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