1use std::path::{Path, PathBuf};
4
5use fallow_engine::duplicates::{CloneGroup, DuplicationReport};
6use fallow_output::{
7 CoverageIntelligenceRecommendation, CoverageIntelligenceReport, CoverageIntelligenceVerdict,
8 ExceededThreshold, FindingSeverity, HealthReport, RuntimeCoverageReport,
9 RuntimeCoverageVerdict, SarifDocumentInput, SarifResultInput, build_sarif_document,
10 build_sarif_result, normalize_uri,
11};
12use rustc_hash::FxHashMap;
13
14type SarifRuleBuilder<'a> = dyn Fn(&str, &str, &str) -> serde_json::Value + 'a;
15
16#[derive(Default)]
17struct SourceSnippetCache {
18 files: FxHashMap<PathBuf, Vec<String>>,
19}
20
21impl SourceSnippetCache {
22 fn line(&mut self, path: &Path, line: u32) -> Option<String> {
23 if line == 0 {
24 return None;
25 }
26 if !self.files.contains_key(path) {
27 let lines = std::fs::read_to_string(path)
28 .ok()
29 .map(|source| source.lines().map(str::to_owned).collect())
30 .unwrap_or_default();
31 self.files.insert(path.to_path_buf(), lines);
32 }
33 self.files
34 .get(path)
35 .and_then(|lines| lines.get(line.saturating_sub(1) as usize))
36 .cloned()
37 }
38}
39
40#[must_use]
42pub fn build_duplication_sarif(
43 report: &DuplicationReport,
44 root: &Path,
45 rule_builder: &SarifRuleBuilder<'_>,
46) -> serde_json::Value {
47 build_duplication_sarif_with_group(report, root, rule_builder, |_| None)
48}
49
50#[must_use]
52pub fn build_grouped_duplication_sarif(
53 report: &DuplicationReport,
54 root: &Path,
55 rule_builder: &SarifRuleBuilder<'_>,
56 group_for_clone: impl Fn(&CloneGroup) -> String,
57) -> serde_json::Value {
58 build_duplication_sarif_with_group(report, root, rule_builder, |group| {
59 Some(group_for_clone(group))
60 })
61}
62
63#[expect(
64 clippy::cast_possible_truncation,
65 reason = "line and column values are bounded by source size"
66)]
67fn build_duplication_sarif_with_group(
68 report: &DuplicationReport,
69 root: &Path,
70 rule_builder: &SarifRuleBuilder<'_>,
71 group_for_clone: impl Fn(&CloneGroup) -> Option<String>,
72) -> serde_json::Value {
73 let mut sarif_results = Vec::new();
74 let mut snippets = SourceSnippetCache::default();
75
76 for (i, group) in report.clone_groups.iter().enumerate() {
77 let group_value = group_for_clone(group);
78 for instance in &group.instances {
79 let uri = relative_uri(&instance.file, root);
80 let source_snippet = snippets.line(&instance.file, instance.start_line as u32);
81 let mut result = sarif_result_with_snippet(
82 "fallow/code-duplication",
83 "warning",
84 &format!(
85 "Code clone group {} ({} lines, {} instances)",
86 i + 1,
87 group.line_count,
88 group.instances.len()
89 ),
90 &uri,
91 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
92 source_snippet.as_deref(),
93 );
94 if let Some(group) = &group_value {
95 set_sarif_result_property(&mut result, "group", group.clone());
96 }
97 sarif_results.push(result);
98 }
99 }
100
101 let rules = vec![rule_builder(
102 "fallow/code-duplication",
103 "Duplicated code block",
104 "warning",
105 )];
106 sarif_document(&sarif_results, &rules)
107}
108
109#[must_use]
111pub fn build_health_sarif(
112 report: &HealthReport,
113 root: &Path,
114 rule_builder: &SarifRuleBuilder<'_>,
115) -> serde_json::Value {
116 let mut sarif_results = Vec::new();
117 let mut snippets = SourceSnippetCache::default();
118
119 append_health_sarif_results(report, root, &mut sarif_results, &mut snippets);
120 let health_rules = health_sarif_rules(rule_builder);
121 sarif_document(&sarif_results, &health_rules)
122}
123
124pub fn annotate_sarif_results(
126 sarif: &mut serde_json::Value,
127 property: &str,
128 mut value_for_uri: impl FnMut(&str) -> String,
129) {
130 if let Some(runs) = sarif
131 .get_mut("runs")
132 .and_then(serde_json::Value::as_array_mut)
133 {
134 for run in runs {
135 if let Some(results) = run
136 .get_mut("results")
137 .and_then(serde_json::Value::as_array_mut)
138 {
139 for result in results {
140 let uri = result
141 .pointer("/locations/0/physicalLocation/artifactLocation/uri")
142 .and_then(serde_json::Value::as_str)
143 .unwrap_or("");
144 let value = value_for_uri(uri);
145 set_sarif_result_property(result, property, value);
146 }
147 }
148 }
149 }
150}
151
152fn set_sarif_result_property(result: &mut serde_json::Value, key: &str, value: String) {
153 let Some(result) = result.as_object_mut() else {
154 return;
155 };
156 let props = result
157 .entry("properties")
158 .or_insert_with(|| serde_json::json!({}));
159 let Some(props) = props.as_object_mut() else {
160 return;
161 };
162 props.insert(key.to_string(), serde_json::Value::String(value));
163}
164
165fn append_health_sarif_results(
166 report: &HealthReport,
167 root: &Path,
168 sarif_results: &mut Vec<serde_json::Value>,
169 snippets: &mut SourceSnippetCache,
170) {
171 append_complexity_sarif_results(sarif_results, report, root, snippets);
172
173 if let Some(ref production) = report.runtime_coverage {
174 append_runtime_coverage_sarif_results(sarif_results, production, root, snippets);
175 }
176 if let Some(ref intelligence) = report.coverage_intelligence {
177 append_coverage_intelligence_sarif_results(sarif_results, intelligence, root, snippets);
178 }
179
180 append_refactoring_target_sarif_results(sarif_results, report, root);
181 append_coverage_gap_sarif_results(sarif_results, report, root, snippets);
182}
183
184fn health_sarif_rules(rule_builder: &SarifRuleBuilder<'_>) -> Vec<serde_json::Value> {
185 let mut rules = health_complexity_sarif_rules(rule_builder);
186 rules.extend(health_runtime_sarif_rules(rule_builder));
187 rules.extend(health_coverage_intelligence_sarif_rules(rule_builder));
188 rules
189}
190
191fn health_complexity_sarif_rules(rule_builder: &SarifRuleBuilder<'_>) -> Vec<serde_json::Value> {
192 vec![
193 rule_builder(
194 "fallow/high-cyclomatic-complexity",
195 "Function has high cyclomatic complexity",
196 "note",
197 ),
198 rule_builder(
199 "fallow/high-cognitive-complexity",
200 "Function has high cognitive complexity",
201 "note",
202 ),
203 rule_builder(
204 "fallow/high-complexity",
205 "Function exceeds both complexity thresholds",
206 "note",
207 ),
208 rule_builder(
209 "fallow/high-crap-score",
210 "Function has a high CRAP score (high complexity combined with low coverage)",
211 "warning",
212 ),
213 rule_builder(
214 "fallow/refactoring-target",
215 "File identified as a high-priority refactoring candidate",
216 "warning",
217 ),
218 ]
219}
220
221fn health_runtime_sarif_rules(rule_builder: &SarifRuleBuilder<'_>) -> Vec<serde_json::Value> {
222 vec![
223 rule_builder(
224 "fallow/untested-file",
225 "Runtime-reachable file has no test dependency path",
226 "warning",
227 ),
228 rule_builder(
229 "fallow/untested-export",
230 "Runtime-reachable export has no test dependency path",
231 "warning",
232 ),
233 rule_builder(
234 "fallow/runtime-safe-to-delete",
235 "Function is statically unused and was never invoked in production",
236 "warning",
237 ),
238 rule_builder(
239 "fallow/runtime-review-required",
240 "Function is statically used but was never invoked in production",
241 "warning",
242 ),
243 rule_builder(
244 "fallow/runtime-low-traffic",
245 "Function was invoked below the low-traffic threshold relative to total trace count",
246 "note",
247 ),
248 rule_builder(
249 "fallow/runtime-coverage-unavailable",
250 "Runtime coverage could not be resolved for this function",
251 "note",
252 ),
253 rule_builder(
254 "fallow/runtime-coverage",
255 "Runtime coverage finding",
256 "note",
257 ),
258 ]
259}
260
261fn health_coverage_intelligence_sarif_rules(
262 rule_builder: &SarifRuleBuilder<'_>,
263) -> Vec<serde_json::Value> {
264 vec![
265 rule_builder(
266 "fallow/coverage-intelligence-risky-change",
267 "Changed hot path combines high CRAP and low test coverage",
268 "warning",
269 ),
270 rule_builder(
271 "fallow/coverage-intelligence-delete",
272 "Static and runtime evidence indicate code can be deleted",
273 "warning",
274 ),
275 rule_builder(
276 "fallow/coverage-intelligence-review",
277 "Cold reachable uncovered code needs owner review",
278 "warning",
279 ),
280 rule_builder(
281 "fallow/coverage-intelligence-refactor",
282 "Hot covered code has high CRAP and should be refactored carefully",
283 "warning",
284 ),
285 ]
286}
287
288fn append_complexity_sarif_results(
289 sarif_results: &mut Vec<serde_json::Value>,
290 report: &HealthReport,
291 root: &Path,
292 snippets: &mut SourceSnippetCache,
293) {
294 for finding in &report.findings {
295 let uri = relative_uri(&finding.path, root);
296 let (rule_id, message) = health_complexity_sarif_message(finding, report);
297 let level = match finding.severity {
298 FindingSeverity::Critical => "error",
299 FindingSeverity::High => "warning",
300 FindingSeverity::Moderate => "note",
301 };
302 let source_snippet = snippets.line(&finding.path, finding.line);
303 sarif_results.push(sarif_result_with_snippet(
304 rule_id,
305 level,
306 &message,
307 &uri,
308 Some((finding.line, finding.col + 1)),
309 source_snippet.as_deref(),
310 ));
311 }
312}
313
314fn health_complexity_sarif_message(
315 finding: &fallow_output::ComplexityViolation,
316 report: &HealthReport,
317) -> (&'static str, String) {
318 match finding.exceeded {
319 ExceededThreshold::Cyclomatic => (
320 "fallow/high-cyclomatic-complexity",
321 format!(
322 "'{}' has cyclomatic complexity {} (threshold: {})",
323 finding.name, finding.cyclomatic, report.summary.max_cyclomatic_threshold,
324 ),
325 ),
326 ExceededThreshold::Cognitive => (
327 "fallow/high-cognitive-complexity",
328 format!(
329 "'{}' has cognitive complexity {} (threshold: {})",
330 finding.name, finding.cognitive, report.summary.max_cognitive_threshold,
331 ),
332 ),
333 ExceededThreshold::Both => (
334 "fallow/high-complexity",
335 format!(
336 "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
337 finding.name,
338 finding.cyclomatic,
339 report.summary.max_cyclomatic_threshold,
340 finding.cognitive,
341 report.summary.max_cognitive_threshold,
342 ),
343 ),
344 ExceededThreshold::Crap
345 | ExceededThreshold::CyclomaticCrap
346 | ExceededThreshold::CognitiveCrap
347 | ExceededThreshold::All => {
348 let crap = finding.crap.unwrap_or(0.0);
349 let coverage = finding
350 .coverage_pct
351 .map(|pct| format!(", coverage {pct:.0}%"))
352 .unwrap_or_default();
353 (
354 "fallow/high-crap-score",
355 format!(
356 "'{}' has CRAP score {:.1} (threshold: {:.1}, cyclomatic {}{})",
357 finding.name,
358 crap,
359 report.summary.max_crap_threshold,
360 finding.cyclomatic,
361 coverage,
362 ),
363 )
364 }
365 }
366}
367
368fn append_refactoring_target_sarif_results(
369 sarif_results: &mut Vec<serde_json::Value>,
370 report: &HealthReport,
371 root: &Path,
372) {
373 for target in &report.targets {
374 let uri = relative_uri(&target.path, root);
375 let message = format!(
376 "[{}] {} (priority: {:.1}, efficiency: {:.1}, effort: {}, confidence: {})",
377 target.category.label(),
378 target.recommendation,
379 target.priority,
380 target.efficiency,
381 target.effort.label(),
382 target.confidence.label(),
383 );
384 sarif_results.push(sarif_result(
385 "fallow/refactoring-target",
386 "warning",
387 &message,
388 &uri,
389 None,
390 ));
391 }
392}
393
394fn append_coverage_gap_sarif_results(
395 sarif_results: &mut Vec<serde_json::Value>,
396 report: &HealthReport,
397 root: &Path,
398 snippets: &mut SourceSnippetCache,
399) {
400 let Some(ref gaps) = report.coverage_gaps else {
401 return;
402 };
403 for item in &gaps.files {
404 let uri = relative_uri(&item.file.path, root);
405 let message = format!(
406 "File is runtime-reachable but has no test dependency path ({} value export{})",
407 item.file.value_export_count,
408 if item.file.value_export_count == 1 {
409 ""
410 } else {
411 "s"
412 },
413 );
414 sarif_results.push(sarif_result(
415 "fallow/untested-file",
416 "warning",
417 &message,
418 &uri,
419 None,
420 ));
421 }
422
423 for item in &gaps.exports {
424 let uri = relative_uri(&item.export.path, root);
425 let message = format!(
426 "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
427 item.export.export_name
428 );
429 let source_snippet = snippets.line(&item.export.path, item.export.line);
430 sarif_results.push(sarif_result_with_snippet(
431 "fallow/untested-export",
432 "warning",
433 &message,
434 &uri,
435 Some((item.export.line, item.export.col + 1)),
436 source_snippet.as_deref(),
437 ));
438 }
439}
440
441fn append_runtime_coverage_sarif_results(
442 sarif_results: &mut Vec<serde_json::Value>,
443 production: &RuntimeCoverageReport,
444 root: &Path,
445 snippets: &mut SourceSnippetCache,
446) {
447 for finding in &production.findings {
448 let uri = relative_uri(&finding.path, root);
449 let rule_id = match finding.verdict {
450 RuntimeCoverageVerdict::SafeToDelete => "fallow/runtime-safe-to-delete",
451 RuntimeCoverageVerdict::ReviewRequired => "fallow/runtime-review-required",
452 RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
453 RuntimeCoverageVerdict::CoverageUnavailable => "fallow/runtime-coverage-unavailable",
454 RuntimeCoverageVerdict::Active | RuntimeCoverageVerdict::Unknown => {
455 "fallow/runtime-coverage"
456 }
457 };
458 let level = match finding.verdict {
459 RuntimeCoverageVerdict::SafeToDelete | RuntimeCoverageVerdict::ReviewRequired => {
460 "warning"
461 }
462 _ => "note",
463 };
464 let invocations_hint = finding.invocations.map_or_else(
465 || "untracked".to_owned(),
466 |hits| format!("{hits} invocations"),
467 );
468 let message = format!(
469 "'{}' runtime coverage verdict: {} ({})",
470 finding.function,
471 finding.verdict.human_label(),
472 invocations_hint,
473 );
474 let source_snippet = snippets.line(&finding.path, finding.line);
475 sarif_results.push(sarif_result_with_snippet(
476 rule_id,
477 level,
478 &message,
479 &uri,
480 Some((finding.line, 1)),
481 source_snippet.as_deref(),
482 ));
483 }
484}
485
486fn append_coverage_intelligence_sarif_results(
487 sarif_results: &mut Vec<serde_json::Value>,
488 intelligence: &CoverageIntelligenceReport,
489 root: &Path,
490 snippets: &mut SourceSnippetCache,
491) {
492 for finding in &intelligence.findings {
493 let rule_id = coverage_intelligence_rule_id(finding.recommendation);
494 let level = match finding.verdict {
495 CoverageIntelligenceVerdict::Clean | CoverageIntelligenceVerdict::Unknown => continue,
496 _ => "warning",
497 };
498 let uri = relative_uri(&finding.path, root);
499 let identity = finding.identity.as_deref().unwrap_or("code");
500 let signals = finding
501 .signals
502 .iter()
503 .map(ToString::to_string)
504 .collect::<Vec<_>>()
505 .join(", ");
506 let message = format!(
507 "'{}' coverage intelligence verdict: {} ({}, signals: {})",
508 identity, finding.verdict, finding.recommendation, signals,
509 );
510 let source_snippet = snippets.line(&finding.path, finding.line);
511 let mut result = sarif_result_with_snippet(
512 rule_id,
513 level,
514 &message,
515 &uri,
516 Some((finding.line, 1)),
517 source_snippet.as_deref(),
518 );
519 result["properties"] = serde_json::json!({
520 "coverage_intelligence_id": &finding.id,
521 "verdict": finding.verdict,
522 "recommendation": finding.recommendation,
523 "confidence": finding.confidence,
524 "signals": &finding.signals,
525 "related_ids": &finding.related_ids,
526 });
527 sarif_results.push(result);
528 }
529}
530
531fn coverage_intelligence_rule_id(
532 recommendation: CoverageIntelligenceRecommendation,
533) -> &'static str {
534 match recommendation {
535 CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge => {
536 "fallow/coverage-intelligence-risky-change"
537 }
538 CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner => {
539 "fallow/coverage-intelligence-delete"
540 }
541 CoverageIntelligenceRecommendation::ReviewBeforeChanging => {
542 "fallow/coverage-intelligence-review"
543 }
544 CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior => {
545 "fallow/coverage-intelligence-refactor"
546 }
547 }
548}
549
550fn sarif_document(
551 sarif_results: &[serde_json::Value],
552 sarif_rules: &[serde_json::Value],
553) -> serde_json::Value {
554 build_sarif_document(SarifDocumentInput {
555 results: sarif_results,
556 rules: sarif_rules,
557 tool_version: env!("CARGO_PKG_VERSION"),
558 })
559}
560
561fn sarif_result(
562 rule_id: &str,
563 level: &str,
564 message: &str,
565 uri: &str,
566 region: Option<(u32, u32)>,
567) -> serde_json::Value {
568 sarif_result_with_snippet(rule_id, level, message, uri, region, None)
569}
570
571fn sarif_result_with_snippet(
572 rule_id: &str,
573 level: &str,
574 message: &str,
575 uri: &str,
576 region: Option<(u32, u32)>,
577 snippet: Option<&str>,
578) -> serde_json::Value {
579 build_sarif_result(SarifResultInput {
580 rule_id,
581 level,
582 message,
583 uri,
584 region,
585 snippet,
586 })
587}
588
589fn relative_uri(path: &Path, root: &Path) -> String {
590 normalize_uri(
591 &path
592 .strip_prefix(root)
593 .unwrap_or(path)
594 .display()
595 .to_string(),
596 )
597}
598
599#[cfg(test)]
600mod tests {
601 use std::path::PathBuf;
602
603 use fallow_engine::duplicates::{CloneGroup, CloneInstance, DuplicationStats};
604 use fallow_output::{SarifRuleInput, build_sarif_rule};
605
606 use super::*;
607
608 fn rule(id: &str, short_description: &str, level: &str) -> serde_json::Value {
609 build_sarif_rule(SarifRuleInput {
610 id,
611 short_description,
612 level,
613 full_description: None,
614 help_uri: None,
615 })
616 }
617
618 #[test]
619 fn grouped_duplication_sarif_attaches_group_property() {
620 let root = PathBuf::from("/repo");
621 let report = DuplicationReport {
622 clone_groups: vec![CloneGroup {
623 instances: vec![CloneInstance {
624 file: root.join("src/a.ts"),
625 start_line: 2,
626 end_line: 5,
627 start_col: 0,
628 end_col: 1,
629 fragment: "copy();".to_string(),
630 }],
631 token_count: 10,
632 line_count: 4,
633 }],
634 clone_families: Vec::new(),
635 mirrored_directories: Vec::new(),
636 stats: DuplicationStats::default(),
637 };
638
639 let sarif = build_grouped_duplication_sarif(&report, &root, &rule, |_| "src".to_string());
640
641 assert_eq!(sarif["runs"][0]["results"][0]["properties"]["group"], "src");
642 assert_eq!(
643 sarif["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["artifactLocation"]
644 ["uri"],
645 "src/a.ts"
646 );
647 }
648}