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