1use std::path::{Path, PathBuf};
4
5use fallow_output::{
6 CoverageIntelligenceRecommendation, CoverageIntelligenceReport, CoverageIntelligenceVerdict,
7 ExceededThreshold, FindingSeverity, HealthReport, RuntimeCoverageReport,
8 RuntimeCoverageVerdict, SarifDocumentInput, SarifResultInput, StylingFindingSeverity,
9 build_sarif_document, build_sarif_result, normalize_uri,
10};
11use fallow_types::duplicates::{CloneGroup, DuplicationReport};
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, report);
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 append_styling_sarif_results(sarif_results, report, root);
183}
184
185fn append_styling_sarif_results(
189 sarif_results: &mut Vec<serde_json::Value>,
190 report: &HealthReport,
191 root: &Path,
192) {
193 for finding in &report.styling_findings {
194 let uri = relative_uri(std::path::Path::new(&finding.path), root);
195 let message = format!(
196 "[{}] {}: `{}`",
197 finding.code, finding.sub_kind, finding.value
198 );
199 sarif_results.push(sarif_result(
200 &format!("fallow/{}", finding.code),
201 styling_sarif_level(finding.effective_severity),
202 &message,
203 &uri,
204 Some((finding.line, 1)),
205 ));
206 }
207}
208
209fn health_styling_sarif_rules(
210 rule_builder: &SarifRuleBuilder<'_>,
211 report: &HealthReport,
212) -> Vec<serde_json::Value> {
213 vec![
214 rule_builder(
215 "fallow/css-token-drift",
216 "CSS / CSS-in-JS design-token drift (a hardcoded value where a token exists)",
217 styling_rule_default_level(report, "css-token-drift"),
218 ),
219 rule_builder(
220 "fallow/css-duplicate-block",
221 "CSS / CSS-in-JS duplicate declaration block",
222 styling_rule_default_level(report, "css-duplicate-block"),
223 ),
224 rule_builder(
225 "fallow/css-selector-complexity",
226 "CSS selector complexity, deep nesting, or important density",
227 styling_rule_default_level(report, "css-selector-complexity"),
228 ),
229 rule_builder(
230 "fallow/css-dead-surface",
231 "CSS / CSS-in-JS dead styling surface",
232 styling_rule_default_level(report, "css-dead-surface"),
233 ),
234 rule_builder(
235 "fallow/css-broken-reference",
236 "CSS / CSS-in-JS reference resolves to no known styling definition",
237 styling_rule_default_level(report, "css-broken-reference"),
238 ),
239 ]
240}
241
242fn health_sarif_rules(
243 rule_builder: &SarifRuleBuilder<'_>,
244 report: &HealthReport,
245) -> Vec<serde_json::Value> {
246 let mut rules = health_complexity_sarif_rules(rule_builder);
247 rules.extend(health_runtime_sarif_rules(rule_builder));
248 rules.extend(health_coverage_intelligence_sarif_rules(rule_builder));
249 rules.extend(health_styling_sarif_rules(rule_builder, report));
250 rules
251}
252
253fn styling_rule_default_level(report: &HealthReport, code: &str) -> &'static str {
254 if report.styling_findings.iter().any(|finding| {
255 finding.code == code && finding.effective_severity == StylingFindingSeverity::Error
256 }) {
257 "error"
258 } else {
259 "warning"
260 }
261}
262
263const fn styling_sarif_level(severity: StylingFindingSeverity) -> &'static str {
264 match severity {
265 StylingFindingSeverity::Error => "error",
266 StylingFindingSeverity::Warn => "warning",
267 }
268}
269
270fn health_complexity_sarif_rules(rule_builder: &SarifRuleBuilder<'_>) -> Vec<serde_json::Value> {
271 vec![
272 rule_builder(
273 "fallow/high-cyclomatic-complexity",
274 "Function has high cyclomatic complexity",
275 "note",
276 ),
277 rule_builder(
278 "fallow/high-cognitive-complexity",
279 "Function has high cognitive complexity",
280 "note",
281 ),
282 rule_builder(
283 "fallow/high-complexity",
284 "Function exceeds both complexity thresholds",
285 "note",
286 ),
287 rule_builder(
288 "fallow/high-crap-score",
289 "Function has a high CRAP score (high complexity combined with low coverage)",
290 "warning",
291 ),
292 rule_builder(
293 "fallow/refactoring-target",
294 "File identified as a high-priority refactoring candidate",
295 "warning",
296 ),
297 ]
298}
299
300fn health_runtime_sarif_rules(rule_builder: &SarifRuleBuilder<'_>) -> Vec<serde_json::Value> {
301 vec![
302 rule_builder(
303 "fallow/untested-file",
304 "Runtime-reachable file has no test dependency path",
305 "warning",
306 ),
307 rule_builder(
308 "fallow/untested-export",
309 "Runtime-reachable export has no test dependency path",
310 "warning",
311 ),
312 rule_builder(
313 "fallow/runtime-safe-to-delete",
314 "Function is statically unused and was never invoked in production",
315 "warning",
316 ),
317 rule_builder(
318 "fallow/runtime-review-required",
319 "Function is statically used but was never invoked in production",
320 "warning",
321 ),
322 rule_builder(
323 "fallow/runtime-low-traffic",
324 "Function was invoked below the low-traffic threshold relative to total trace count",
325 "note",
326 ),
327 rule_builder(
328 "fallow/runtime-coverage-unavailable",
329 "Runtime coverage could not be resolved for this function",
330 "note",
331 ),
332 rule_builder(
333 "fallow/runtime-coverage",
334 "Runtime coverage finding",
335 "note",
336 ),
337 ]
338}
339
340fn health_coverage_intelligence_sarif_rules(
341 rule_builder: &SarifRuleBuilder<'_>,
342) -> Vec<serde_json::Value> {
343 vec![
344 rule_builder(
345 "fallow/coverage-intelligence-risky-change",
346 "Changed hot path combines high CRAP and low test coverage",
347 "warning",
348 ),
349 rule_builder(
350 "fallow/coverage-intelligence-delete",
351 "Static and runtime evidence indicate code can be deleted",
352 "warning",
353 ),
354 rule_builder(
355 "fallow/coverage-intelligence-review",
356 "Cold reachable uncovered code needs owner review",
357 "warning",
358 ),
359 rule_builder(
360 "fallow/coverage-intelligence-refactor",
361 "Hot covered code has high CRAP and should be refactored carefully",
362 "warning",
363 ),
364 ]
365}
366
367fn append_complexity_sarif_results(
368 sarif_results: &mut Vec<serde_json::Value>,
369 report: &HealthReport,
370 root: &Path,
371 snippets: &mut SourceSnippetCache,
372) {
373 for finding in &report.findings {
374 let uri = relative_uri(&finding.path, root);
375 let (rule_id, message) = health_complexity_sarif_message(finding, report);
376 let level = match finding.severity {
377 FindingSeverity::Critical => "error",
378 FindingSeverity::High => "warning",
379 FindingSeverity::Moderate => "note",
380 };
381 let source_snippet = snippets.line(&finding.path, finding.line);
382 sarif_results.push(sarif_result_with_snippet(
383 rule_id,
384 level,
385 &message,
386 &uri,
387 Some((finding.line, finding.col + 1)),
388 source_snippet.as_deref(),
389 ));
390 }
391}
392
393fn health_complexity_sarif_message(
394 finding: &fallow_output::ComplexityViolation,
395 report: &HealthReport,
396) -> (&'static str, String) {
397 match finding.exceeded {
398 ExceededThreshold::Cyclomatic => (
399 "fallow/high-cyclomatic-complexity",
400 format!(
401 "'{}' has cyclomatic complexity {} (threshold: {})",
402 finding.name, finding.cyclomatic, report.summary.max_cyclomatic_threshold,
403 ),
404 ),
405 ExceededThreshold::Cognitive => (
406 "fallow/high-cognitive-complexity",
407 format!(
408 "'{}' has cognitive complexity {} (threshold: {})",
409 finding.name, finding.cognitive, report.summary.max_cognitive_threshold,
410 ),
411 ),
412 ExceededThreshold::Both => (
413 "fallow/high-complexity",
414 format!(
415 "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
416 finding.name,
417 finding.cyclomatic,
418 report.summary.max_cyclomatic_threshold,
419 finding.cognitive,
420 report.summary.max_cognitive_threshold,
421 ),
422 ),
423 ExceededThreshold::Crap
424 | ExceededThreshold::CyclomaticCrap
425 | ExceededThreshold::CognitiveCrap
426 | ExceededThreshold::All => {
427 let crap = finding.crap.unwrap_or(0.0);
428 let coverage = finding
429 .coverage_pct
430 .map(|pct| format!(", coverage {pct:.0}%"))
431 .unwrap_or_default();
432 (
433 "fallow/high-crap-score",
434 format!(
435 "'{}' has CRAP score {:.1} (threshold: {:.1}, cyclomatic {}{})",
436 finding.name,
437 crap,
438 report.summary.max_crap_threshold,
439 finding.cyclomatic,
440 coverage,
441 ),
442 )
443 }
444 }
445}
446
447fn append_refactoring_target_sarif_results(
448 sarif_results: &mut Vec<serde_json::Value>,
449 report: &HealthReport,
450 root: &Path,
451) {
452 for target in &report.targets {
453 let uri = relative_uri(&target.path, root);
454 let message = format!(
455 "[{}] {} (priority: {:.1}, efficiency: {:.1}, effort: {}, confidence: {})",
456 target.category.label(),
457 target.recommendation,
458 target.priority,
459 target.efficiency,
460 target.effort.label(),
461 target.confidence.label(),
462 );
463 sarif_results.push(sarif_result(
464 "fallow/refactoring-target",
465 "warning",
466 &message,
467 &uri,
468 None,
469 ));
470 }
471}
472
473fn append_coverage_gap_sarif_results(
474 sarif_results: &mut Vec<serde_json::Value>,
475 report: &HealthReport,
476 root: &Path,
477 snippets: &mut SourceSnippetCache,
478) {
479 let Some(ref gaps) = report.coverage_gaps else {
480 return;
481 };
482 for item in &gaps.files {
483 let uri = relative_uri(&item.file.path, root);
484 let message = format!(
485 "File is runtime-reachable but has no test dependency path ({} value export{})",
486 item.file.value_export_count,
487 if item.file.value_export_count == 1 {
488 ""
489 } else {
490 "s"
491 },
492 );
493 sarif_results.push(sarif_result(
494 "fallow/untested-file",
495 "warning",
496 &message,
497 &uri,
498 None,
499 ));
500 }
501
502 for item in &gaps.exports {
503 let uri = relative_uri(&item.export.path, root);
504 let message = format!(
505 "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
506 item.export.export_name
507 );
508 let source_snippet = snippets.line(&item.export.path, item.export.line);
509 sarif_results.push(sarif_result_with_snippet(
510 "fallow/untested-export",
511 "warning",
512 &message,
513 &uri,
514 Some((item.export.line, item.export.col + 1)),
515 source_snippet.as_deref(),
516 ));
517 }
518}
519
520fn append_runtime_coverage_sarif_results(
521 sarif_results: &mut Vec<serde_json::Value>,
522 production: &RuntimeCoverageReport,
523 root: &Path,
524 snippets: &mut SourceSnippetCache,
525) {
526 for finding in &production.findings {
527 let uri = relative_uri(&finding.path, root);
528 let rule_id = match finding.verdict {
529 RuntimeCoverageVerdict::SafeToDelete => "fallow/runtime-safe-to-delete",
530 RuntimeCoverageVerdict::ReviewRequired => "fallow/runtime-review-required",
531 RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
532 RuntimeCoverageVerdict::CoverageUnavailable => "fallow/runtime-coverage-unavailable",
533 RuntimeCoverageVerdict::Active | RuntimeCoverageVerdict::Unknown => {
534 "fallow/runtime-coverage"
535 }
536 };
537 let level = match finding.verdict {
538 RuntimeCoverageVerdict::SafeToDelete | RuntimeCoverageVerdict::ReviewRequired => {
539 "warning"
540 }
541 _ => "note",
542 };
543 let invocations_hint = finding.invocations.map_or_else(
544 || "untracked".to_owned(),
545 |hits| format!("{hits} invocations"),
546 );
547 let message = format!(
548 "'{}' runtime coverage verdict: {} ({})",
549 finding.function,
550 finding.verdict.human_label(),
551 invocations_hint,
552 );
553 let source_snippet = snippets.line(&finding.path, finding.line);
554 sarif_results.push(sarif_result_with_snippet(
555 rule_id,
556 level,
557 &message,
558 &uri,
559 Some((finding.line, 1)),
560 source_snippet.as_deref(),
561 ));
562 }
563}
564
565fn append_coverage_intelligence_sarif_results(
566 sarif_results: &mut Vec<serde_json::Value>,
567 intelligence: &CoverageIntelligenceReport,
568 root: &Path,
569 snippets: &mut SourceSnippetCache,
570) {
571 for finding in &intelligence.findings {
572 let rule_id = coverage_intelligence_rule_id(finding.recommendation);
573 let level = match finding.verdict {
574 CoverageIntelligenceVerdict::Clean | CoverageIntelligenceVerdict::Unknown => continue,
575 _ => "warning",
576 };
577 let uri = relative_uri(&finding.path, root);
578 let identity = finding.identity.as_deref().unwrap_or("code");
579 let signals = finding
580 .signals
581 .iter()
582 .map(ToString::to_string)
583 .collect::<Vec<_>>()
584 .join(", ");
585 let message = format!(
586 "'{}' coverage intelligence verdict: {} ({}, signals: {})",
587 identity, finding.verdict, finding.recommendation, signals,
588 );
589 let source_snippet = snippets.line(&finding.path, finding.line);
590 let mut result = sarif_result_with_snippet(
591 rule_id,
592 level,
593 &message,
594 &uri,
595 Some((finding.line, 1)),
596 source_snippet.as_deref(),
597 );
598 result["properties"] = serde_json::json!({
599 "coverage_intelligence_id": &finding.id,
600 "verdict": finding.verdict,
601 "recommendation": finding.recommendation,
602 "confidence": finding.confidence,
603 "signals": &finding.signals,
604 "related_ids": &finding.related_ids,
605 });
606 sarif_results.push(result);
607 }
608}
609
610fn coverage_intelligence_rule_id(
611 recommendation: CoverageIntelligenceRecommendation,
612) -> &'static str {
613 match recommendation {
614 CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge => {
615 "fallow/coverage-intelligence-risky-change"
616 }
617 CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner => {
618 "fallow/coverage-intelligence-delete"
619 }
620 CoverageIntelligenceRecommendation::ReviewBeforeChanging => {
621 "fallow/coverage-intelligence-review"
622 }
623 CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior => {
624 "fallow/coverage-intelligence-refactor"
625 }
626 }
627}
628
629fn sarif_document(
630 sarif_results: &[serde_json::Value],
631 sarif_rules: &[serde_json::Value],
632) -> serde_json::Value {
633 build_sarif_document(SarifDocumentInput {
634 results: sarif_results,
635 rules: sarif_rules,
636 tool_version: env!("CARGO_PKG_VERSION"),
637 })
638}
639
640fn sarif_result(
641 rule_id: &str,
642 level: &str,
643 message: &str,
644 uri: &str,
645 region: Option<(u32, u32)>,
646) -> serde_json::Value {
647 sarif_result_with_snippet(rule_id, level, message, uri, region, None)
648}
649
650fn sarif_result_with_snippet(
651 rule_id: &str,
652 level: &str,
653 message: &str,
654 uri: &str,
655 region: Option<(u32, u32)>,
656 snippet: Option<&str>,
657) -> serde_json::Value {
658 build_sarif_result(SarifResultInput {
659 rule_id,
660 level,
661 message,
662 uri,
663 region,
664 snippet,
665 })
666}
667
668fn relative_uri(path: &Path, root: &Path) -> String {
669 normalize_uri(
670 &path
671 .strip_prefix(root)
672 .unwrap_or(path)
673 .display()
674 .to_string(),
675 )
676}
677
678#[cfg(test)]
679mod tests {
680 use std::path::PathBuf;
681
682 use fallow_output::{SarifRuleInput, build_sarif_rule};
683 use fallow_types::duplicates::{CloneGroup, CloneInstance, DuplicationStats};
684
685 use super::*;
686
687 fn rule(id: &str, short_description: &str, level: &str) -> serde_json::Value {
688 build_sarif_rule(SarifRuleInput {
689 id,
690 short_description,
691 level,
692 full_description: None,
693 help_uri: None,
694 })
695 }
696
697 #[test]
698 fn grouped_duplication_sarif_attaches_group_property() {
699 let root = PathBuf::from("/repo");
700 let report = DuplicationReport {
701 clone_groups: vec![CloneGroup {
702 instances: vec![CloneInstance {
703 file: root.join("src/a.ts"),
704 start_line: 2,
705 end_line: 5,
706 start_col: 0,
707 end_col: 1,
708 fragment: "copy();".to_string(),
709 }],
710 token_count: 10,
711 line_count: 4,
712 }],
713 clone_families: Vec::new(),
714 mirrored_directories: Vec::new(),
715 stats: DuplicationStats::default(),
716 };
717
718 let sarif = build_grouped_duplication_sarif(&report, &root, &rule, |_| "src".to_string());
719
720 assert_eq!(sarif["runs"][0]["results"][0]["properties"]["group"], "src");
721 assert_eq!(
722 sarif["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["artifactLocation"]
723 ["uri"],
724 "src/a.ts"
725 );
726 }
727}