1use std::path::Path;
2use std::process::ExitCode;
3
4use fallow_config::{RulesConfig, Severity};
5use fallow_core::duplicates::DuplicationReport;
6use fallow_core::results::{AnalysisResults, UnusedDependency, UnusedExport, UnusedMember};
7
8use super::grouping::{self, OwnershipResolver};
9use super::{emit_json, relative_uri};
10use crate::explain;
11
12struct SarifFields {
14 rule_id: &'static str,
15 level: &'static str,
16 message: String,
17 uri: String,
18 region: Option<(u32, u32)>,
19 properties: Option<serde_json::Value>,
20}
21
22const fn severity_to_sarif_level(s: Severity) -> &'static str {
23 match s {
24 Severity::Error => "error",
25 Severity::Warn | Severity::Off => "warning",
26 }
27}
28
29fn sarif_result(
34 rule_id: &str,
35 level: &str,
36 message: &str,
37 uri: &str,
38 region: Option<(u32, u32)>,
39) -> serde_json::Value {
40 let mut physical_location = serde_json::json!({
41 "artifactLocation": { "uri": uri }
42 });
43 if let Some((line, col)) = region {
44 physical_location["region"] = serde_json::json!({
45 "startLine": line,
46 "startColumn": col
47 });
48 }
49 serde_json::json!({
50 "ruleId": rule_id,
51 "level": level,
52 "message": { "text": message },
53 "locations": [{ "physicalLocation": physical_location }]
54 })
55}
56
57fn push_sarif_results<T>(
59 sarif_results: &mut Vec<serde_json::Value>,
60 items: &[T],
61 extract: impl Fn(&T) -> SarifFields,
62) {
63 for item in items {
64 let fields = extract(item);
65 let mut result = sarif_result(
66 fields.rule_id,
67 fields.level,
68 &fields.message,
69 &fields.uri,
70 fields.region,
71 );
72 if let Some(props) = fields.properties {
73 result["properties"] = props;
74 }
75 sarif_results.push(result);
76 }
77}
78
79fn sarif_rule(id: &str, fallback_short: &str, level: &str) -> serde_json::Value {
82 explain::rule_by_id(id).map_or_else(
83 || {
84 serde_json::json!({
85 "id": id,
86 "shortDescription": { "text": fallback_short },
87 "defaultConfiguration": { "level": level }
88 })
89 },
90 |def| {
91 serde_json::json!({
92 "id": id,
93 "shortDescription": { "text": def.short },
94 "fullDescription": { "text": def.full },
95 "helpUri": explain::rule_docs_url(def),
96 "defaultConfiguration": { "level": level }
97 })
98 },
99 )
100}
101
102fn sarif_export_fields(
104 export: &UnusedExport,
105 root: &Path,
106 rule_id: &'static str,
107 level: &'static str,
108 kind: &str,
109 re_kind: &str,
110) -> SarifFields {
111 let label = if export.is_re_export { re_kind } else { kind };
112 SarifFields {
113 rule_id,
114 level,
115 message: format!(
116 "{} '{}' is never imported by other modules",
117 label, export.export_name
118 ),
119 uri: relative_uri(&export.path, root),
120 region: Some((export.line, export.col + 1)),
121 properties: if export.is_re_export {
122 Some(serde_json::json!({ "is_re_export": true }))
123 } else {
124 None
125 },
126 }
127}
128
129fn sarif_dep_fields(
131 dep: &UnusedDependency,
132 root: &Path,
133 rule_id: &'static str,
134 level: &'static str,
135 section: &str,
136) -> SarifFields {
137 SarifFields {
138 rule_id,
139 level,
140 message: format!(
141 "Package '{}' is in {} but never imported",
142 dep.package_name, section
143 ),
144 uri: relative_uri(&dep.path, root),
145 region: if dep.line > 0 {
146 Some((dep.line, 1))
147 } else {
148 None
149 },
150 properties: None,
151 }
152}
153
154fn sarif_member_fields(
156 member: &UnusedMember,
157 root: &Path,
158 rule_id: &'static str,
159 level: &'static str,
160 kind: &str,
161) -> SarifFields {
162 SarifFields {
163 rule_id,
164 level,
165 message: format!(
166 "{} member '{}.{}' is never referenced",
167 kind, member.parent_name, member.member_name
168 ),
169 uri: relative_uri(&member.path, root),
170 region: Some((member.line, member.col + 1)),
171 properties: None,
172 }
173}
174
175fn build_sarif_rules(rules: &RulesConfig) -> Vec<serde_json::Value> {
177 vec![
178 sarif_rule(
179 "fallow/unused-file",
180 "File is not reachable from any entry point",
181 severity_to_sarif_level(rules.unused_files),
182 ),
183 sarif_rule(
184 "fallow/unused-export",
185 "Export is never imported",
186 severity_to_sarif_level(rules.unused_exports),
187 ),
188 sarif_rule(
189 "fallow/unused-type",
190 "Type export is never imported",
191 severity_to_sarif_level(rules.unused_types),
192 ),
193 sarif_rule(
194 "fallow/unused-dependency",
195 "Dependency listed but never imported",
196 severity_to_sarif_level(rules.unused_dependencies),
197 ),
198 sarif_rule(
199 "fallow/unused-dev-dependency",
200 "Dev dependency listed but never imported",
201 severity_to_sarif_level(rules.unused_dev_dependencies),
202 ),
203 sarif_rule(
204 "fallow/unused-optional-dependency",
205 "Optional dependency listed but never imported",
206 severity_to_sarif_level(rules.unused_optional_dependencies),
207 ),
208 sarif_rule(
209 "fallow/type-only-dependency",
210 "Production dependency only used via type-only imports",
211 severity_to_sarif_level(rules.type_only_dependencies),
212 ),
213 sarif_rule(
214 "fallow/test-only-dependency",
215 "Production dependency only imported by test files",
216 severity_to_sarif_level(rules.test_only_dependencies),
217 ),
218 sarif_rule(
219 "fallow/unused-enum-member",
220 "Enum member is never referenced",
221 severity_to_sarif_level(rules.unused_enum_members),
222 ),
223 sarif_rule(
224 "fallow/unused-class-member",
225 "Class member is never referenced",
226 severity_to_sarif_level(rules.unused_class_members),
227 ),
228 sarif_rule(
229 "fallow/unresolved-import",
230 "Import could not be resolved",
231 severity_to_sarif_level(rules.unresolved_imports),
232 ),
233 sarif_rule(
234 "fallow/unlisted-dependency",
235 "Dependency used but not in package.json",
236 severity_to_sarif_level(rules.unlisted_dependencies),
237 ),
238 sarif_rule(
239 "fallow/duplicate-export",
240 "Export name appears in multiple modules",
241 severity_to_sarif_level(rules.duplicate_exports),
242 ),
243 sarif_rule(
244 "fallow/circular-dependency",
245 "Circular dependency chain detected",
246 severity_to_sarif_level(rules.circular_dependencies),
247 ),
248 sarif_rule(
249 "fallow/boundary-violation",
250 "Import crosses an architecture boundary",
251 severity_to_sarif_level(rules.boundary_violation),
252 ),
253 sarif_rule(
254 "fallow/stale-suppression",
255 "Suppression comment or tag no longer matches any issue",
256 severity_to_sarif_level(rules.stale_suppressions),
257 ),
258 ]
259}
260
261#[must_use]
262#[expect(
263 clippy::too_many_lines,
264 reason = "SARIF builder mapping all issue types to SARIF schema"
265)]
266pub fn build_sarif(
267 results: &AnalysisResults,
268 root: &Path,
269 rules: &RulesConfig,
270) -> serde_json::Value {
271 let mut sarif_results = Vec::new();
272
273 push_sarif_results(&mut sarif_results, &results.unused_files, |file| {
274 SarifFields {
275 rule_id: "fallow/unused-file",
276 level: severity_to_sarif_level(rules.unused_files),
277 message: "File is not reachable from any entry point".to_string(),
278 uri: relative_uri(&file.path, root),
279 region: None,
280 properties: None,
281 }
282 });
283
284 push_sarif_results(&mut sarif_results, &results.unused_exports, |export| {
285 sarif_export_fields(
286 export,
287 root,
288 "fallow/unused-export",
289 severity_to_sarif_level(rules.unused_exports),
290 "Export",
291 "Re-export",
292 )
293 });
294
295 push_sarif_results(&mut sarif_results, &results.unused_types, |export| {
296 sarif_export_fields(
297 export,
298 root,
299 "fallow/unused-type",
300 severity_to_sarif_level(rules.unused_types),
301 "Type export",
302 "Type re-export",
303 )
304 });
305
306 push_sarif_results(&mut sarif_results, &results.unused_dependencies, |dep| {
307 sarif_dep_fields(
308 dep,
309 root,
310 "fallow/unused-dependency",
311 severity_to_sarif_level(rules.unused_dependencies),
312 "dependencies",
313 )
314 });
315
316 push_sarif_results(
317 &mut sarif_results,
318 &results.unused_dev_dependencies,
319 |dep| {
320 sarif_dep_fields(
321 dep,
322 root,
323 "fallow/unused-dev-dependency",
324 severity_to_sarif_level(rules.unused_dev_dependencies),
325 "devDependencies",
326 )
327 },
328 );
329
330 push_sarif_results(
331 &mut sarif_results,
332 &results.unused_optional_dependencies,
333 |dep| {
334 sarif_dep_fields(
335 dep,
336 root,
337 "fallow/unused-optional-dependency",
338 severity_to_sarif_level(rules.unused_optional_dependencies),
339 "optionalDependencies",
340 )
341 },
342 );
343
344 push_sarif_results(&mut sarif_results, &results.type_only_dependencies, |dep| {
345 SarifFields {
346 rule_id: "fallow/type-only-dependency",
347 level: severity_to_sarif_level(rules.type_only_dependencies),
348 message: format!(
349 "Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
350 dep.package_name
351 ),
352 uri: relative_uri(&dep.path, root),
353 region: if dep.line > 0 {
354 Some((dep.line, 1))
355 } else {
356 None
357 },
358 properties: None,
359 }
360 });
361
362 push_sarif_results(&mut sarif_results, &results.test_only_dependencies, |dep| {
363 SarifFields {
364 rule_id: "fallow/test-only-dependency",
365 level: severity_to_sarif_level(rules.test_only_dependencies),
366 message: format!(
367 "Package '{}' is only imported by test files (consider moving to devDependencies)",
368 dep.package_name
369 ),
370 uri: relative_uri(&dep.path, root),
371 region: if dep.line > 0 {
372 Some((dep.line, 1))
373 } else {
374 None
375 },
376 properties: None,
377 }
378 });
379
380 push_sarif_results(&mut sarif_results, &results.unused_enum_members, |member| {
381 sarif_member_fields(
382 member,
383 root,
384 "fallow/unused-enum-member",
385 severity_to_sarif_level(rules.unused_enum_members),
386 "Enum",
387 )
388 });
389
390 push_sarif_results(
391 &mut sarif_results,
392 &results.unused_class_members,
393 |member| {
394 sarif_member_fields(
395 member,
396 root,
397 "fallow/unused-class-member",
398 severity_to_sarif_level(rules.unused_class_members),
399 "Class",
400 )
401 },
402 );
403
404 push_sarif_results(&mut sarif_results, &results.unresolved_imports, |import| {
405 SarifFields {
406 rule_id: "fallow/unresolved-import",
407 level: severity_to_sarif_level(rules.unresolved_imports),
408 message: format!("Import '{}' could not be resolved", import.specifier),
409 uri: relative_uri(&import.path, root),
410 region: Some((import.line, import.col + 1)),
411 properties: None,
412 }
413 });
414
415 for dep in &results.unlisted_dependencies {
417 for site in &dep.imported_from {
418 sarif_results.push(sarif_result(
419 "fallow/unlisted-dependency",
420 severity_to_sarif_level(rules.unlisted_dependencies),
421 &format!(
422 "Package '{}' is imported but not listed in package.json",
423 dep.package_name
424 ),
425 &relative_uri(&site.path, root),
426 Some((site.line, site.col + 1)),
427 ));
428 }
429 }
430
431 for dup in &results.duplicate_exports {
433 for loc in &dup.locations {
434 sarif_results.push(sarif_result(
435 "fallow/duplicate-export",
436 severity_to_sarif_level(rules.duplicate_exports),
437 &format!("Export '{}' appears in multiple modules", dup.export_name),
438 &relative_uri(&loc.path, root),
439 Some((loc.line, loc.col + 1)),
440 ));
441 }
442 }
443
444 push_sarif_results(
445 &mut sarif_results,
446 &results.circular_dependencies,
447 |cycle| {
448 let chain: Vec<String> = cycle.files.iter().map(|p| relative_uri(p, root)).collect();
449 let mut display_chain = chain.clone();
450 if let Some(first) = chain.first() {
451 display_chain.push(first.clone());
452 }
453 let first_uri = chain.first().map_or_else(String::new, Clone::clone);
454 SarifFields {
455 rule_id: "fallow/circular-dependency",
456 level: severity_to_sarif_level(rules.circular_dependencies),
457 message: format!(
458 "Circular dependency{}: {}",
459 if cycle.is_cross_package {
460 " (cross-package)"
461 } else {
462 ""
463 },
464 display_chain.join(" \u{2192} ")
465 ),
466 uri: first_uri,
467 region: if cycle.line > 0 {
468 Some((cycle.line, cycle.col + 1))
469 } else {
470 None
471 },
472 properties: None,
473 }
474 },
475 );
476
477 push_sarif_results(
478 &mut sarif_results,
479 &results.boundary_violations,
480 |violation| {
481 let from_uri = relative_uri(&violation.from_path, root);
482 let to_uri = relative_uri(&violation.to_path, root);
483 SarifFields {
484 rule_id: "fallow/boundary-violation",
485 level: severity_to_sarif_level(rules.boundary_violation),
486 message: format!(
487 "Import from zone '{}' to zone '{}' is not allowed ({})",
488 violation.from_zone, violation.to_zone, to_uri,
489 ),
490 uri: from_uri,
491 region: if violation.line > 0 {
492 Some((violation.line, violation.col + 1))
493 } else {
494 None
495 },
496 properties: None,
497 }
498 },
499 );
500
501 push_sarif_results(
502 &mut sarif_results,
503 &results.stale_suppressions,
504 |suppression| SarifFields {
505 rule_id: "fallow/stale-suppression",
506 level: severity_to_sarif_level(rules.stale_suppressions),
507 message: suppression.description(),
508 uri: relative_uri(&suppression.path, root),
509 region: Some((suppression.line, suppression.col + 1)),
510 properties: None,
511 },
512 );
513
514 serde_json::json!({
515 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
516 "version": "2.1.0",
517 "runs": [{
518 "tool": {
519 "driver": {
520 "name": "fallow",
521 "version": env!("CARGO_PKG_VERSION"),
522 "informationUri": "https://github.com/fallow-rs/fallow",
523 "rules": build_sarif_rules(rules)
524 }
525 },
526 "results": sarif_results
527 }]
528 })
529}
530
531pub(super) fn print_sarif(results: &AnalysisResults, root: &Path, rules: &RulesConfig) -> ExitCode {
532 let sarif = build_sarif(results, root, rules);
533 emit_json(&sarif, "SARIF")
534}
535
536pub(super) fn print_grouped_sarif(
542 results: &AnalysisResults,
543 root: &Path,
544 rules: &RulesConfig,
545 resolver: &OwnershipResolver,
546) -> ExitCode {
547 let mut sarif = build_sarif(results, root, rules);
548
549 if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
551 for run in runs {
552 if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
553 for result in results {
554 let uri = result
555 .pointer("/locations/0/physicalLocation/artifactLocation/uri")
556 .and_then(|v| v.as_str())
557 .unwrap_or("");
558 let decoded = uri.replace("%5B", "[").replace("%5D", "]");
561 let owner =
562 grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
563 let props = result
564 .as_object_mut()
565 .expect("SARIF result should be an object")
566 .entry("properties")
567 .or_insert_with(|| serde_json::json!({}));
568 props
569 .as_object_mut()
570 .expect("properties should be an object")
571 .insert("owner".to_string(), serde_json::Value::String(owner));
572 }
573 }
574 }
575 }
576
577 emit_json(&sarif, "SARIF")
578}
579
580#[expect(
581 clippy::cast_possible_truncation,
582 reason = "line/col numbers are bounded by source size"
583)]
584pub(super) fn print_duplication_sarif(report: &DuplicationReport, root: &Path) -> ExitCode {
585 let mut sarif_results = Vec::new();
586
587 for (i, group) in report.clone_groups.iter().enumerate() {
588 for instance in &group.instances {
589 sarif_results.push(sarif_result(
590 "fallow/code-duplication",
591 "warning",
592 &format!(
593 "Code clone group {} ({} lines, {} instances)",
594 i + 1,
595 group.line_count,
596 group.instances.len()
597 ),
598 &relative_uri(&instance.file, root),
599 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
600 ));
601 }
602 }
603
604 let sarif = serde_json::json!({
605 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
606 "version": "2.1.0",
607 "runs": [{
608 "tool": {
609 "driver": {
610 "name": "fallow",
611 "version": env!("CARGO_PKG_VERSION"),
612 "informationUri": "https://github.com/fallow-rs/fallow",
613 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
614 }
615 },
616 "results": sarif_results
617 }]
618 });
619
620 emit_json(&sarif, "SARIF")
621}
622
623#[must_use]
629#[expect(
630 clippy::too_many_lines,
631 reason = "flat rules + results table: adding production-coverage rules pushed past the 150 line threshold but each section is a straightforward sequence of sarif_rule / sarif_result calls"
632)]
633pub fn build_health_sarif(
634 report: &crate::health_types::HealthReport,
635 root: &Path,
636) -> serde_json::Value {
637 use crate::health_types::ExceededThreshold;
638
639 let mut sarif_results = Vec::new();
640
641 for finding in &report.findings {
642 let uri = relative_uri(&finding.path, root);
643 let (rule_id, message) = match finding.exceeded {
647 ExceededThreshold::Cyclomatic => (
648 "fallow/high-cyclomatic-complexity",
649 format!(
650 "'{}' has cyclomatic complexity {} (threshold: {})",
651 finding.name, finding.cyclomatic, report.summary.max_cyclomatic_threshold,
652 ),
653 ),
654 ExceededThreshold::Cognitive => (
655 "fallow/high-cognitive-complexity",
656 format!(
657 "'{}' has cognitive complexity {} (threshold: {})",
658 finding.name, finding.cognitive, report.summary.max_cognitive_threshold,
659 ),
660 ),
661 ExceededThreshold::Both => (
662 "fallow/high-complexity",
663 format!(
664 "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
665 finding.name,
666 finding.cyclomatic,
667 report.summary.max_cyclomatic_threshold,
668 finding.cognitive,
669 report.summary.max_cognitive_threshold,
670 ),
671 ),
672 ExceededThreshold::Crap
673 | ExceededThreshold::CyclomaticCrap
674 | ExceededThreshold::CognitiveCrap
675 | ExceededThreshold::All => {
676 let crap = finding.crap.unwrap_or(0.0);
677 let coverage = finding
678 .coverage_pct
679 .map(|pct| format!(", coverage {pct:.0}%"))
680 .unwrap_or_default();
681 (
682 "fallow/high-crap-score",
683 format!(
684 "'{}' has CRAP score {:.1} (threshold: {:.1}, cyclomatic {}{})",
685 finding.name,
686 crap,
687 report.summary.max_crap_threshold,
688 finding.cyclomatic,
689 coverage,
690 ),
691 )
692 }
693 };
694
695 let level = match finding.severity {
696 crate::health_types::FindingSeverity::Critical => "error",
697 crate::health_types::FindingSeverity::High => "warning",
698 crate::health_types::FindingSeverity::Moderate => "note",
699 };
700 sarif_results.push(sarif_result(
701 rule_id,
702 level,
703 &message,
704 &uri,
705 Some((finding.line, finding.col + 1)),
706 ));
707 }
708
709 if let Some(ref production) = report.production_coverage {
710 append_production_coverage_sarif_results(&mut sarif_results, production, root);
711 }
712
713 for target in &report.targets {
715 let uri = relative_uri(&target.path, root);
716 let message = format!(
717 "[{}] {} (priority: {:.1}, efficiency: {:.1}, effort: {}, confidence: {})",
718 target.category.label(),
719 target.recommendation,
720 target.priority,
721 target.efficiency,
722 target.effort.label(),
723 target.confidence.label(),
724 );
725 sarif_results.push(sarif_result(
726 "fallow/refactoring-target",
727 "warning",
728 &message,
729 &uri,
730 None,
731 ));
732 }
733
734 if let Some(ref gaps) = report.coverage_gaps {
735 for item in &gaps.files {
736 let uri = relative_uri(&item.path, root);
737 let message = format!(
738 "File is runtime-reachable but has no test dependency path ({} value export{})",
739 item.value_export_count,
740 if item.value_export_count == 1 {
741 ""
742 } else {
743 "s"
744 },
745 );
746 sarif_results.push(sarif_result(
747 "fallow/untested-file",
748 "warning",
749 &message,
750 &uri,
751 None,
752 ));
753 }
754
755 for item in &gaps.exports {
756 let uri = relative_uri(&item.path, root);
757 let message = format!(
758 "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
759 item.export_name
760 );
761 sarif_results.push(sarif_result(
762 "fallow/untested-export",
763 "warning",
764 &message,
765 &uri,
766 Some((item.line, item.col + 1)),
767 ));
768 }
769 }
770
771 let health_rules = vec![
772 sarif_rule(
773 "fallow/high-cyclomatic-complexity",
774 "Function has high cyclomatic complexity",
775 "note",
776 ),
777 sarif_rule(
778 "fallow/high-cognitive-complexity",
779 "Function has high cognitive complexity",
780 "note",
781 ),
782 sarif_rule(
783 "fallow/high-complexity",
784 "Function exceeds both complexity thresholds",
785 "note",
786 ),
787 sarif_rule(
788 "fallow/high-crap-score",
789 "Function has a high CRAP score (high complexity combined with low coverage)",
790 "warning",
791 ),
792 sarif_rule(
793 "fallow/refactoring-target",
794 "File identified as a high-priority refactoring candidate",
795 "warning",
796 ),
797 sarif_rule(
798 "fallow/untested-file",
799 "Runtime-reachable file has no test dependency path",
800 "warning",
801 ),
802 sarif_rule(
803 "fallow/untested-export",
804 "Runtime-reachable export has no test dependency path",
805 "warning",
806 ),
807 sarif_rule(
808 "fallow/production-safe-to-delete",
809 "Function is statically unused and was never invoked in production",
810 "warning",
811 ),
812 sarif_rule(
813 "fallow/production-review-required",
814 "Function is statically used but was never invoked in production",
815 "warning",
816 ),
817 sarif_rule(
818 "fallow/production-low-traffic",
819 "Function was invoked below the low-traffic threshold relative to total trace count",
820 "note",
821 ),
822 sarif_rule(
823 "fallow/production-coverage-unavailable",
824 "Production coverage could not be resolved for this function",
825 "note",
826 ),
827 sarif_rule(
828 "fallow/production-coverage",
829 "Production coverage finding",
830 "note",
831 ),
832 ];
833
834 serde_json::json!({
835 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
836 "version": "2.1.0",
837 "runs": [{
838 "tool": {
839 "driver": {
840 "name": "fallow",
841 "version": env!("CARGO_PKG_VERSION"),
842 "informationUri": "https://github.com/fallow-rs/fallow",
843 "rules": health_rules
844 }
845 },
846 "results": sarif_results
847 }]
848 })
849}
850
851fn append_production_coverage_sarif_results(
852 sarif_results: &mut Vec<serde_json::Value>,
853 production: &crate::health_types::ProductionCoverageReport,
854 root: &Path,
855) {
856 for finding in &production.findings {
857 let uri = relative_uri(&finding.path, root);
858 let rule_id = match finding.verdict {
859 crate::health_types::ProductionCoverageVerdict::SafeToDelete => {
860 "fallow/production-safe-to-delete"
861 }
862 crate::health_types::ProductionCoverageVerdict::ReviewRequired => {
863 "fallow/production-review-required"
864 }
865 crate::health_types::ProductionCoverageVerdict::LowTraffic => {
866 "fallow/production-low-traffic"
867 }
868 crate::health_types::ProductionCoverageVerdict::CoverageUnavailable => {
869 "fallow/production-coverage-unavailable"
870 }
871 crate::health_types::ProductionCoverageVerdict::Active
872 | crate::health_types::ProductionCoverageVerdict::Unknown => {
873 "fallow/production-coverage"
874 }
875 };
876 let level = match finding.verdict {
877 crate::health_types::ProductionCoverageVerdict::SafeToDelete
878 | crate::health_types::ProductionCoverageVerdict::ReviewRequired => "warning",
879 _ => "note",
880 };
881 let invocations_hint = finding.invocations.map_or_else(
882 || "untracked".to_owned(),
883 |hits| format!("{hits} invocations"),
884 );
885 let message = format!(
886 "'{}' production coverage verdict: {} ({})",
887 finding.function,
888 finding.verdict.human_label(),
889 invocations_hint,
890 );
891 sarif_results.push(sarif_result(
892 rule_id,
893 level,
894 &message,
895 &uri,
896 Some((finding.line, 1)),
897 ));
898 }
899}
900
901pub(super) fn print_health_sarif(
902 report: &crate::health_types::HealthReport,
903 root: &Path,
904) -> ExitCode {
905 let sarif = build_health_sarif(report, root);
906 emit_json(&sarif, "SARIF")
907}
908
909#[cfg(test)]
910mod tests {
911 use super::*;
912 use crate::report::test_helpers::sample_results;
913 use fallow_core::results::*;
914 use std::path::PathBuf;
915
916 #[test]
917 fn sarif_has_required_top_level_fields() {
918 let root = PathBuf::from("/project");
919 let results = AnalysisResults::default();
920 let sarif = build_sarif(&results, &root, &RulesConfig::default());
921
922 assert_eq!(
923 sarif["$schema"],
924 "https://json.schemastore.org/sarif-2.1.0.json"
925 );
926 assert_eq!(sarif["version"], "2.1.0");
927 assert!(sarif["runs"].is_array());
928 }
929
930 #[test]
931 fn sarif_has_tool_driver_info() {
932 let root = PathBuf::from("/project");
933 let results = AnalysisResults::default();
934 let sarif = build_sarif(&results, &root, &RulesConfig::default());
935
936 let driver = &sarif["runs"][0]["tool"]["driver"];
937 assert_eq!(driver["name"], "fallow");
938 assert!(driver["version"].is_string());
939 assert_eq!(
940 driver["informationUri"],
941 "https://github.com/fallow-rs/fallow"
942 );
943 }
944
945 #[test]
946 fn sarif_declares_all_rules() {
947 let root = PathBuf::from("/project");
948 let results = AnalysisResults::default();
949 let sarif = build_sarif(&results, &root, &RulesConfig::default());
950
951 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
952 .as_array()
953 .expect("rules should be an array");
954 assert_eq!(rules.len(), 16);
955
956 let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
957 assert!(rule_ids.contains(&"fallow/unused-file"));
958 assert!(rule_ids.contains(&"fallow/unused-export"));
959 assert!(rule_ids.contains(&"fallow/unused-type"));
960 assert!(rule_ids.contains(&"fallow/unused-dependency"));
961 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
962 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
963 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
964 assert!(rule_ids.contains(&"fallow/test-only-dependency"));
965 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
966 assert!(rule_ids.contains(&"fallow/unused-class-member"));
967 assert!(rule_ids.contains(&"fallow/unresolved-import"));
968 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
969 assert!(rule_ids.contains(&"fallow/duplicate-export"));
970 assert!(rule_ids.contains(&"fallow/circular-dependency"));
971 assert!(rule_ids.contains(&"fallow/boundary-violation"));
972 }
973
974 #[test]
975 fn sarif_empty_results_no_results_entries() {
976 let root = PathBuf::from("/project");
977 let results = AnalysisResults::default();
978 let sarif = build_sarif(&results, &root, &RulesConfig::default());
979
980 let sarif_results = sarif["runs"][0]["results"]
981 .as_array()
982 .expect("results should be an array");
983 assert!(sarif_results.is_empty());
984 }
985
986 #[test]
987 fn sarif_unused_file_result() {
988 let root = PathBuf::from("/project");
989 let mut results = AnalysisResults::default();
990 results.unused_files.push(UnusedFile {
991 path: root.join("src/dead.ts"),
992 });
993
994 let sarif = build_sarif(&results, &root, &RulesConfig::default());
995 let entries = sarif["runs"][0]["results"].as_array().unwrap();
996 assert_eq!(entries.len(), 1);
997
998 let entry = &entries[0];
999 assert_eq!(entry["ruleId"], "fallow/unused-file");
1000 assert_eq!(entry["level"], "error");
1002 assert_eq!(
1003 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1004 "src/dead.ts"
1005 );
1006 }
1007
1008 #[test]
1009 fn sarif_unused_export_includes_region() {
1010 let root = PathBuf::from("/project");
1011 let mut results = AnalysisResults::default();
1012 results.unused_exports.push(UnusedExport {
1013 path: root.join("src/utils.ts"),
1014 export_name: "helperFn".to_string(),
1015 is_type_only: false,
1016 line: 10,
1017 col: 4,
1018 span_start: 120,
1019 is_re_export: false,
1020 });
1021
1022 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1023 let entry = &sarif["runs"][0]["results"][0];
1024 assert_eq!(entry["ruleId"], "fallow/unused-export");
1025
1026 let region = &entry["locations"][0]["physicalLocation"]["region"];
1027 assert_eq!(region["startLine"], 10);
1028 assert_eq!(region["startColumn"], 5);
1030 }
1031
1032 #[test]
1033 fn sarif_unresolved_import_is_error_level() {
1034 let root = PathBuf::from("/project");
1035 let mut results = AnalysisResults::default();
1036 results.unresolved_imports.push(UnresolvedImport {
1037 path: root.join("src/app.ts"),
1038 specifier: "./missing".to_string(),
1039 line: 1,
1040 col: 0,
1041 specifier_col: 0,
1042 });
1043
1044 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1045 let entry = &sarif["runs"][0]["results"][0];
1046 assert_eq!(entry["ruleId"], "fallow/unresolved-import");
1047 assert_eq!(entry["level"], "error");
1048 }
1049
1050 #[test]
1051 fn sarif_unlisted_dependency_points_to_import_site() {
1052 let root = PathBuf::from("/project");
1053 let mut results = AnalysisResults::default();
1054 results.unlisted_dependencies.push(UnlistedDependency {
1055 package_name: "chalk".to_string(),
1056 imported_from: vec![ImportSite {
1057 path: root.join("src/cli.ts"),
1058 line: 3,
1059 col: 0,
1060 }],
1061 });
1062
1063 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1064 let entry = &sarif["runs"][0]["results"][0];
1065 assert_eq!(entry["ruleId"], "fallow/unlisted-dependency");
1066 assert_eq!(entry["level"], "error");
1067 assert_eq!(
1068 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1069 "src/cli.ts"
1070 );
1071 let region = &entry["locations"][0]["physicalLocation"]["region"];
1072 assert_eq!(region["startLine"], 3);
1073 assert_eq!(region["startColumn"], 1);
1074 }
1075
1076 #[test]
1077 fn sarif_dependency_issues_point_to_package_json() {
1078 let root = PathBuf::from("/project");
1079 let mut results = AnalysisResults::default();
1080 results.unused_dependencies.push(UnusedDependency {
1081 package_name: "lodash".to_string(),
1082 location: DependencyLocation::Dependencies,
1083 path: root.join("package.json"),
1084 line: 5,
1085 });
1086 results.unused_dev_dependencies.push(UnusedDependency {
1087 package_name: "jest".to_string(),
1088 location: DependencyLocation::DevDependencies,
1089 path: root.join("package.json"),
1090 line: 5,
1091 });
1092
1093 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1094 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1095 for entry in entries {
1096 assert_eq!(
1097 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1098 "package.json"
1099 );
1100 }
1101 }
1102
1103 #[test]
1104 fn sarif_duplicate_export_emits_one_result_per_location() {
1105 let root = PathBuf::from("/project");
1106 let mut results = AnalysisResults::default();
1107 results.duplicate_exports.push(DuplicateExport {
1108 export_name: "Config".to_string(),
1109 locations: vec![
1110 DuplicateLocation {
1111 path: root.join("src/a.ts"),
1112 line: 15,
1113 col: 0,
1114 },
1115 DuplicateLocation {
1116 path: root.join("src/b.ts"),
1117 line: 30,
1118 col: 0,
1119 },
1120 ],
1121 });
1122
1123 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1124 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1125 assert_eq!(entries.len(), 2);
1127 assert_eq!(entries[0]["ruleId"], "fallow/duplicate-export");
1128 assert_eq!(entries[1]["ruleId"], "fallow/duplicate-export");
1129 assert_eq!(
1130 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1131 "src/a.ts"
1132 );
1133 assert_eq!(
1134 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1135 "src/b.ts"
1136 );
1137 }
1138
1139 #[test]
1140 fn sarif_all_issue_types_produce_results() {
1141 let root = PathBuf::from("/project");
1142 let results = sample_results(&root);
1143 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1144
1145 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1146 assert_eq!(entries.len(), results.total_issues() + 1);
1148
1149 let rule_ids: Vec<&str> = entries
1150 .iter()
1151 .map(|e| e["ruleId"].as_str().unwrap())
1152 .collect();
1153 assert!(rule_ids.contains(&"fallow/unused-file"));
1154 assert!(rule_ids.contains(&"fallow/unused-export"));
1155 assert!(rule_ids.contains(&"fallow/unused-type"));
1156 assert!(rule_ids.contains(&"fallow/unused-dependency"));
1157 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
1158 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
1159 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
1160 assert!(rule_ids.contains(&"fallow/test-only-dependency"));
1161 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
1162 assert!(rule_ids.contains(&"fallow/unused-class-member"));
1163 assert!(rule_ids.contains(&"fallow/unresolved-import"));
1164 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
1165 assert!(rule_ids.contains(&"fallow/duplicate-export"));
1166 }
1167
1168 #[test]
1169 fn sarif_serializes_to_valid_json() {
1170 let root = PathBuf::from("/project");
1171 let results = sample_results(&root);
1172 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1173
1174 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
1175 let reparsed: serde_json::Value =
1176 serde_json::from_str(&json_str).expect("SARIF output should be valid JSON");
1177 assert_eq!(reparsed, sarif);
1178 }
1179
1180 #[test]
1181 fn sarif_file_write_produces_valid_sarif() {
1182 let root = PathBuf::from("/project");
1183 let results = sample_results(&root);
1184 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1185 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
1186
1187 let dir = std::env::temp_dir().join("fallow-test-sarif-file");
1188 let _ = std::fs::create_dir_all(&dir);
1189 let sarif_path = dir.join("results.sarif");
1190 std::fs::write(&sarif_path, &json_str).expect("should write SARIF file");
1191
1192 let contents = std::fs::read_to_string(&sarif_path).expect("should read SARIF file");
1193 let parsed: serde_json::Value =
1194 serde_json::from_str(&contents).expect("file should contain valid JSON");
1195
1196 assert_eq!(parsed["version"], "2.1.0");
1197 assert_eq!(
1198 parsed["$schema"],
1199 "https://json.schemastore.org/sarif-2.1.0.json"
1200 );
1201 let sarif_results = parsed["runs"][0]["results"]
1202 .as_array()
1203 .expect("results should be an array");
1204 assert!(!sarif_results.is_empty());
1205
1206 let _ = std::fs::remove_file(&sarif_path);
1208 let _ = std::fs::remove_dir(&dir);
1209 }
1210
1211 #[test]
1214 fn health_sarif_empty_no_results() {
1215 let root = PathBuf::from("/project");
1216 let report = crate::health_types::HealthReport {
1217 summary: crate::health_types::HealthSummary {
1218 files_analyzed: 10,
1219 functions_analyzed: 50,
1220 ..Default::default()
1221 },
1222 ..Default::default()
1223 };
1224 let sarif = build_health_sarif(&report, &root);
1225 assert_eq!(sarif["version"], "2.1.0");
1226 let results = sarif["runs"][0]["results"].as_array().unwrap();
1227 assert!(results.is_empty());
1228 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1229 .as_array()
1230 .unwrap();
1231 assert_eq!(rules.len(), 12);
1232 }
1233
1234 #[test]
1235 fn health_sarif_cyclomatic_only() {
1236 let root = PathBuf::from("/project");
1237 let report = crate::health_types::HealthReport {
1238 findings: vec![crate::health_types::HealthFinding {
1239 path: root.join("src/utils.ts"),
1240 name: "parseExpression".to_string(),
1241 line: 42,
1242 col: 0,
1243 cyclomatic: 25,
1244 cognitive: 10,
1245 line_count: 80,
1246 param_count: 0,
1247 exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
1248 severity: crate::health_types::FindingSeverity::High,
1249 crap: None,
1250 coverage_pct: None,
1251 }],
1252 summary: crate::health_types::HealthSummary {
1253 files_analyzed: 5,
1254 functions_analyzed: 20,
1255 functions_above_threshold: 1,
1256 ..Default::default()
1257 },
1258 ..Default::default()
1259 };
1260 let sarif = build_health_sarif(&report, &root);
1261 let entry = &sarif["runs"][0]["results"][0];
1262 assert_eq!(entry["ruleId"], "fallow/high-cyclomatic-complexity");
1263 assert_eq!(entry["level"], "warning");
1264 assert!(
1265 entry["message"]["text"]
1266 .as_str()
1267 .unwrap()
1268 .contains("cyclomatic complexity 25")
1269 );
1270 assert_eq!(
1271 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1272 "src/utils.ts"
1273 );
1274 let region = &entry["locations"][0]["physicalLocation"]["region"];
1275 assert_eq!(region["startLine"], 42);
1276 assert_eq!(region["startColumn"], 1);
1277 }
1278
1279 #[test]
1280 fn health_sarif_cognitive_only() {
1281 let root = PathBuf::from("/project");
1282 let report = crate::health_types::HealthReport {
1283 findings: vec![crate::health_types::HealthFinding {
1284 path: root.join("src/api.ts"),
1285 name: "handleRequest".to_string(),
1286 line: 10,
1287 col: 4,
1288 cyclomatic: 8,
1289 cognitive: 20,
1290 line_count: 40,
1291 param_count: 0,
1292 exceeded: crate::health_types::ExceededThreshold::Cognitive,
1293 severity: crate::health_types::FindingSeverity::High,
1294 crap: None,
1295 coverage_pct: None,
1296 }],
1297 summary: crate::health_types::HealthSummary {
1298 files_analyzed: 3,
1299 functions_analyzed: 10,
1300 functions_above_threshold: 1,
1301 ..Default::default()
1302 },
1303 ..Default::default()
1304 };
1305 let sarif = build_health_sarif(&report, &root);
1306 let entry = &sarif["runs"][0]["results"][0];
1307 assert_eq!(entry["ruleId"], "fallow/high-cognitive-complexity");
1308 assert!(
1309 entry["message"]["text"]
1310 .as_str()
1311 .unwrap()
1312 .contains("cognitive complexity 20")
1313 );
1314 let region = &entry["locations"][0]["physicalLocation"]["region"];
1315 assert_eq!(region["startColumn"], 5); }
1317
1318 #[test]
1319 fn health_sarif_both_thresholds() {
1320 let root = PathBuf::from("/project");
1321 let report = crate::health_types::HealthReport {
1322 findings: vec![crate::health_types::HealthFinding {
1323 path: root.join("src/complex.ts"),
1324 name: "doEverything".to_string(),
1325 line: 1,
1326 col: 0,
1327 cyclomatic: 30,
1328 cognitive: 45,
1329 line_count: 100,
1330 param_count: 0,
1331 exceeded: crate::health_types::ExceededThreshold::Both,
1332 severity: crate::health_types::FindingSeverity::High,
1333 crap: None,
1334 coverage_pct: None,
1335 }],
1336 summary: crate::health_types::HealthSummary {
1337 files_analyzed: 1,
1338 functions_analyzed: 1,
1339 functions_above_threshold: 1,
1340 ..Default::default()
1341 },
1342 ..Default::default()
1343 };
1344 let sarif = build_health_sarif(&report, &root);
1345 let entry = &sarif["runs"][0]["results"][0];
1346 assert_eq!(entry["ruleId"], "fallow/high-complexity");
1347 let msg = entry["message"]["text"].as_str().unwrap();
1348 assert!(msg.contains("cyclomatic complexity 30"));
1349 assert!(msg.contains("cognitive complexity 45"));
1350 }
1351
1352 #[test]
1353 fn health_sarif_crap_only_emits_crap_rule() {
1354 let root = PathBuf::from("/project");
1357 let report = crate::health_types::HealthReport {
1358 findings: vec![crate::health_types::HealthFinding {
1359 path: root.join("src/untested.ts"),
1360 name: "risky".to_string(),
1361 line: 8,
1362 col: 0,
1363 cyclomatic: 10,
1364 cognitive: 10,
1365 line_count: 20,
1366 param_count: 1,
1367 exceeded: crate::health_types::ExceededThreshold::Crap,
1368 severity: crate::health_types::FindingSeverity::High,
1369 crap: Some(82.2),
1370 coverage_pct: Some(12.0),
1371 }],
1372 summary: crate::health_types::HealthSummary {
1373 files_analyzed: 1,
1374 functions_analyzed: 1,
1375 functions_above_threshold: 1,
1376 ..Default::default()
1377 },
1378 ..Default::default()
1379 };
1380 let sarif = build_health_sarif(&report, &root);
1381 let entry = &sarif["runs"][0]["results"][0];
1382 assert_eq!(entry["ruleId"], "fallow/high-crap-score");
1383 let msg = entry["message"]["text"].as_str().unwrap();
1384 assert!(msg.contains("CRAP score 82.2"), "msg: {msg}");
1385 assert!(msg.contains("coverage 12%"), "msg: {msg}");
1386 }
1387
1388 #[test]
1389 fn health_sarif_cyclomatic_crap_uses_crap_rule() {
1390 let root = PathBuf::from("/project");
1393 let report = crate::health_types::HealthReport {
1394 findings: vec![crate::health_types::HealthFinding {
1395 path: root.join("src/hot.ts"),
1396 name: "branchy".to_string(),
1397 line: 1,
1398 col: 0,
1399 cyclomatic: 67,
1400 cognitive: 12,
1401 line_count: 80,
1402 param_count: 1,
1403 exceeded: crate::health_types::ExceededThreshold::CyclomaticCrap,
1404 severity: crate::health_types::FindingSeverity::Critical,
1405 crap: Some(182.0),
1406 coverage_pct: None,
1407 }],
1408 summary: crate::health_types::HealthSummary {
1409 files_analyzed: 1,
1410 functions_analyzed: 1,
1411 functions_above_threshold: 1,
1412 ..Default::default()
1413 },
1414 ..Default::default()
1415 };
1416 let sarif = build_health_sarif(&report, &root);
1417 let results = sarif["runs"][0]["results"].as_array().unwrap();
1418 assert_eq!(
1419 results.len(),
1420 1,
1421 "CyclomaticCrap should emit a single SARIF result under the CRAP rule"
1422 );
1423 assert_eq!(results[0]["ruleId"], "fallow/high-crap-score");
1424 let msg = results[0]["message"]["text"].as_str().unwrap();
1425 assert!(msg.contains("CRAP score 182"), "msg: {msg}");
1426 assert!(!msg.contains("coverage"), "msg: {msg}");
1428 }
1429
1430 #[test]
1433 fn severity_to_sarif_level_error() {
1434 assert_eq!(severity_to_sarif_level(Severity::Error), "error");
1435 }
1436
1437 #[test]
1438 fn severity_to_sarif_level_warn() {
1439 assert_eq!(severity_to_sarif_level(Severity::Warn), "warning");
1440 }
1441
1442 #[test]
1443 fn severity_to_sarif_level_off() {
1444 assert_eq!(severity_to_sarif_level(Severity::Off), "warning");
1445 }
1446
1447 #[test]
1450 fn sarif_re_export_has_properties() {
1451 let root = PathBuf::from("/project");
1452 let mut results = AnalysisResults::default();
1453 results.unused_exports.push(UnusedExport {
1454 path: root.join("src/index.ts"),
1455 export_name: "reExported".to_string(),
1456 is_type_only: false,
1457 line: 1,
1458 col: 0,
1459 span_start: 0,
1460 is_re_export: true,
1461 });
1462
1463 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1464 let entry = &sarif["runs"][0]["results"][0];
1465 assert_eq!(entry["properties"]["is_re_export"], true);
1466 let msg = entry["message"]["text"].as_str().unwrap();
1467 assert!(msg.starts_with("Re-export"));
1468 }
1469
1470 #[test]
1471 fn sarif_non_re_export_has_no_properties() {
1472 let root = PathBuf::from("/project");
1473 let mut results = AnalysisResults::default();
1474 results.unused_exports.push(UnusedExport {
1475 path: root.join("src/utils.ts"),
1476 export_name: "foo".to_string(),
1477 is_type_only: false,
1478 line: 5,
1479 col: 0,
1480 span_start: 0,
1481 is_re_export: false,
1482 });
1483
1484 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1485 let entry = &sarif["runs"][0]["results"][0];
1486 assert!(entry.get("properties").is_none());
1487 let msg = entry["message"]["text"].as_str().unwrap();
1488 assert!(msg.starts_with("Export"));
1489 }
1490
1491 #[test]
1494 fn sarif_type_re_export_message() {
1495 let root = PathBuf::from("/project");
1496 let mut results = AnalysisResults::default();
1497 results.unused_types.push(UnusedExport {
1498 path: root.join("src/index.ts"),
1499 export_name: "MyType".to_string(),
1500 is_type_only: true,
1501 line: 1,
1502 col: 0,
1503 span_start: 0,
1504 is_re_export: true,
1505 });
1506
1507 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1508 let entry = &sarif["runs"][0]["results"][0];
1509 assert_eq!(entry["ruleId"], "fallow/unused-type");
1510 let msg = entry["message"]["text"].as_str().unwrap();
1511 assert!(msg.starts_with("Type re-export"));
1512 assert_eq!(entry["properties"]["is_re_export"], true);
1513 }
1514
1515 #[test]
1518 fn sarif_dependency_line_zero_skips_region() {
1519 let root = PathBuf::from("/project");
1520 let mut results = AnalysisResults::default();
1521 results.unused_dependencies.push(UnusedDependency {
1522 package_name: "lodash".to_string(),
1523 location: DependencyLocation::Dependencies,
1524 path: root.join("package.json"),
1525 line: 0,
1526 });
1527
1528 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1529 let entry = &sarif["runs"][0]["results"][0];
1530 let phys = &entry["locations"][0]["physicalLocation"];
1531 assert!(phys.get("region").is_none());
1532 }
1533
1534 #[test]
1535 fn sarif_dependency_line_nonzero_has_region() {
1536 let root = PathBuf::from("/project");
1537 let mut results = AnalysisResults::default();
1538 results.unused_dependencies.push(UnusedDependency {
1539 package_name: "lodash".to_string(),
1540 location: DependencyLocation::Dependencies,
1541 path: root.join("package.json"),
1542 line: 7,
1543 });
1544
1545 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1546 let entry = &sarif["runs"][0]["results"][0];
1547 let region = &entry["locations"][0]["physicalLocation"]["region"];
1548 assert_eq!(region["startLine"], 7);
1549 assert_eq!(region["startColumn"], 1);
1550 }
1551
1552 #[test]
1555 fn sarif_type_only_dep_line_zero_skips_region() {
1556 let root = PathBuf::from("/project");
1557 let mut results = AnalysisResults::default();
1558 results.type_only_dependencies.push(TypeOnlyDependency {
1559 package_name: "zod".to_string(),
1560 path: root.join("package.json"),
1561 line: 0,
1562 });
1563
1564 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1565 let entry = &sarif["runs"][0]["results"][0];
1566 let phys = &entry["locations"][0]["physicalLocation"];
1567 assert!(phys.get("region").is_none());
1568 }
1569
1570 #[test]
1573 fn sarif_circular_dep_line_zero_skips_region() {
1574 let root = PathBuf::from("/project");
1575 let mut results = AnalysisResults::default();
1576 results.circular_dependencies.push(CircularDependency {
1577 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1578 length: 2,
1579 line: 0,
1580 col: 0,
1581 is_cross_package: false,
1582 });
1583
1584 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1585 let entry = &sarif["runs"][0]["results"][0];
1586 let phys = &entry["locations"][0]["physicalLocation"];
1587 assert!(phys.get("region").is_none());
1588 }
1589
1590 #[test]
1591 fn sarif_circular_dep_line_nonzero_has_region() {
1592 let root = PathBuf::from("/project");
1593 let mut results = AnalysisResults::default();
1594 results.circular_dependencies.push(CircularDependency {
1595 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1596 length: 2,
1597 line: 5,
1598 col: 2,
1599 is_cross_package: false,
1600 });
1601
1602 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1603 let entry = &sarif["runs"][0]["results"][0];
1604 let region = &entry["locations"][0]["physicalLocation"]["region"];
1605 assert_eq!(region["startLine"], 5);
1606 assert_eq!(region["startColumn"], 3);
1607 }
1608
1609 #[test]
1612 fn sarif_unused_optional_dependency_result() {
1613 let root = PathBuf::from("/project");
1614 let mut results = AnalysisResults::default();
1615 results.unused_optional_dependencies.push(UnusedDependency {
1616 package_name: "fsevents".to_string(),
1617 location: DependencyLocation::OptionalDependencies,
1618 path: root.join("package.json"),
1619 line: 12,
1620 });
1621
1622 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1623 let entry = &sarif["runs"][0]["results"][0];
1624 assert_eq!(entry["ruleId"], "fallow/unused-optional-dependency");
1625 let msg = entry["message"]["text"].as_str().unwrap();
1626 assert!(msg.contains("optionalDependencies"));
1627 }
1628
1629 #[test]
1632 fn sarif_enum_member_message_format() {
1633 let root = PathBuf::from("/project");
1634 let mut results = AnalysisResults::default();
1635 results
1636 .unused_enum_members
1637 .push(fallow_core::results::UnusedMember {
1638 path: root.join("src/enums.ts"),
1639 parent_name: "Color".to_string(),
1640 member_name: "Purple".to_string(),
1641 kind: fallow_core::extract::MemberKind::EnumMember,
1642 line: 5,
1643 col: 2,
1644 });
1645
1646 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1647 let entry = &sarif["runs"][0]["results"][0];
1648 assert_eq!(entry["ruleId"], "fallow/unused-enum-member");
1649 let msg = entry["message"]["text"].as_str().unwrap();
1650 assert!(msg.contains("Enum member 'Color.Purple'"));
1651 let region = &entry["locations"][0]["physicalLocation"]["region"];
1652 assert_eq!(region["startColumn"], 3); }
1654
1655 #[test]
1656 fn sarif_class_member_message_format() {
1657 let root = PathBuf::from("/project");
1658 let mut results = AnalysisResults::default();
1659 results
1660 .unused_class_members
1661 .push(fallow_core::results::UnusedMember {
1662 path: root.join("src/service.ts"),
1663 parent_name: "API".to_string(),
1664 member_name: "fetch".to_string(),
1665 kind: fallow_core::extract::MemberKind::ClassMethod,
1666 line: 10,
1667 col: 4,
1668 });
1669
1670 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1671 let entry = &sarif["runs"][0]["results"][0];
1672 assert_eq!(entry["ruleId"], "fallow/unused-class-member");
1673 let msg = entry["message"]["text"].as_str().unwrap();
1674 assert!(msg.contains("Class member 'API.fetch'"));
1675 }
1676
1677 #[test]
1680 #[expect(
1681 clippy::cast_possible_truncation,
1682 reason = "test line/col values are trivially small"
1683 )]
1684 fn duplication_sarif_structure() {
1685 use fallow_core::duplicates::*;
1686
1687 let root = PathBuf::from("/project");
1688 let report = DuplicationReport {
1689 clone_groups: vec![CloneGroup {
1690 instances: vec![
1691 CloneInstance {
1692 file: root.join("src/a.ts"),
1693 start_line: 1,
1694 end_line: 10,
1695 start_col: 0,
1696 end_col: 0,
1697 fragment: String::new(),
1698 },
1699 CloneInstance {
1700 file: root.join("src/b.ts"),
1701 start_line: 5,
1702 end_line: 14,
1703 start_col: 2,
1704 end_col: 0,
1705 fragment: String::new(),
1706 },
1707 ],
1708 token_count: 50,
1709 line_count: 10,
1710 }],
1711 clone_families: vec![],
1712 mirrored_directories: vec![],
1713 stats: DuplicationStats::default(),
1714 };
1715
1716 let sarif = serde_json::json!({
1717 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1718 "version": "2.1.0",
1719 "runs": [{
1720 "tool": {
1721 "driver": {
1722 "name": "fallow",
1723 "version": env!("CARGO_PKG_VERSION"),
1724 "informationUri": "https://github.com/fallow-rs/fallow",
1725 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
1726 }
1727 },
1728 "results": []
1729 }]
1730 });
1731 let _ = sarif;
1733
1734 let mut sarif_results = Vec::new();
1736 for (i, group) in report.clone_groups.iter().enumerate() {
1737 for instance in &group.instances {
1738 sarif_results.push(sarif_result(
1739 "fallow/code-duplication",
1740 "warning",
1741 &format!(
1742 "Code clone group {} ({} lines, {} instances)",
1743 i + 1,
1744 group.line_count,
1745 group.instances.len()
1746 ),
1747 &super::super::relative_uri(&instance.file, &root),
1748 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
1749 ));
1750 }
1751 }
1752 assert_eq!(sarif_results.len(), 2);
1753 assert_eq!(sarif_results[0]["ruleId"], "fallow/code-duplication");
1754 assert!(
1755 sarif_results[0]["message"]["text"]
1756 .as_str()
1757 .unwrap()
1758 .contains("10 lines")
1759 );
1760 let region0 = &sarif_results[0]["locations"][0]["physicalLocation"]["region"];
1761 assert_eq!(region0["startLine"], 1);
1762 assert_eq!(region0["startColumn"], 1); let region1 = &sarif_results[1]["locations"][0]["physicalLocation"]["region"];
1764 assert_eq!(region1["startLine"], 5);
1765 assert_eq!(region1["startColumn"], 3); }
1767
1768 #[test]
1771 fn sarif_rule_known_id_has_full_description() {
1772 let rule = sarif_rule("fallow/unused-file", "fallback text", "error");
1773 assert!(rule.get("fullDescription").is_some());
1774 assert!(rule.get("helpUri").is_some());
1775 }
1776
1777 #[test]
1778 fn sarif_rule_unknown_id_uses_fallback() {
1779 let rule = sarif_rule("fallow/nonexistent", "fallback text", "warning");
1780 assert_eq!(rule["shortDescription"]["text"], "fallback text");
1781 assert!(rule.get("fullDescription").is_none());
1782 assert!(rule.get("helpUri").is_none());
1783 assert_eq!(rule["defaultConfiguration"]["level"], "warning");
1784 }
1785
1786 #[test]
1789 fn sarif_result_no_region_omits_region_key() {
1790 let result = sarif_result("rule/test", "error", "test msg", "src/file.ts", None);
1791 let phys = &result["locations"][0]["physicalLocation"];
1792 assert!(phys.get("region").is_none());
1793 assert_eq!(phys["artifactLocation"]["uri"], "src/file.ts");
1794 }
1795
1796 #[test]
1797 fn sarif_result_with_region_includes_region() {
1798 let result = sarif_result(
1799 "rule/test",
1800 "error",
1801 "test msg",
1802 "src/file.ts",
1803 Some((10, 5)),
1804 );
1805 let region = &result["locations"][0]["physicalLocation"]["region"];
1806 assert_eq!(region["startLine"], 10);
1807 assert_eq!(region["startColumn"], 5);
1808 }
1809
1810 #[test]
1813 fn health_sarif_includes_refactoring_targets() {
1814 use crate::health_types::*;
1815
1816 let root = PathBuf::from("/project");
1817 let report = HealthReport {
1818 summary: HealthSummary {
1819 files_analyzed: 10,
1820 functions_analyzed: 50,
1821 ..Default::default()
1822 },
1823 targets: vec![RefactoringTarget {
1824 path: root.join("src/complex.ts"),
1825 priority: 85.0,
1826 efficiency: 42.5,
1827 recommendation: "Split high-impact file".into(),
1828 category: RecommendationCategory::SplitHighImpact,
1829 effort: EffortEstimate::Medium,
1830 confidence: Confidence::High,
1831 factors: vec![],
1832 evidence: None,
1833 }],
1834 ..Default::default()
1835 };
1836
1837 let sarif = build_health_sarif(&report, &root);
1838 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1839 assert_eq!(entries.len(), 1);
1840 assert_eq!(entries[0]["ruleId"], "fallow/refactoring-target");
1841 assert_eq!(entries[0]["level"], "warning");
1842 let msg = entries[0]["message"]["text"].as_str().unwrap();
1843 assert!(msg.contains("high impact"));
1844 assert!(msg.contains("Split high-impact file"));
1845 assert!(msg.contains("42.5"));
1846 }
1847
1848 #[test]
1849 fn health_sarif_includes_coverage_gaps() {
1850 use crate::health_types::*;
1851
1852 let root = PathBuf::from("/project");
1853 let report = HealthReport {
1854 summary: HealthSummary {
1855 files_analyzed: 10,
1856 functions_analyzed: 50,
1857 ..Default::default()
1858 },
1859 coverage_gaps: Some(CoverageGaps {
1860 summary: CoverageGapSummary {
1861 runtime_files: 2,
1862 covered_files: 0,
1863 file_coverage_pct: 0.0,
1864 untested_files: 1,
1865 untested_exports: 1,
1866 },
1867 files: vec![UntestedFile {
1868 path: root.join("src/app.ts"),
1869 value_export_count: 2,
1870 }],
1871 exports: vec![UntestedExport {
1872 path: root.join("src/app.ts"),
1873 export_name: "loader".into(),
1874 line: 12,
1875 col: 4,
1876 }],
1877 }),
1878 ..Default::default()
1879 };
1880
1881 let sarif = build_health_sarif(&report, &root);
1882 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1883 assert_eq!(entries.len(), 2);
1884 assert_eq!(entries[0]["ruleId"], "fallow/untested-file");
1885 assert_eq!(
1886 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1887 "src/app.ts"
1888 );
1889 assert!(
1890 entries[0]["message"]["text"]
1891 .as_str()
1892 .unwrap()
1893 .contains("2 value exports")
1894 );
1895 assert_eq!(entries[1]["ruleId"], "fallow/untested-export");
1896 assert_eq!(
1897 entries[1]["locations"][0]["physicalLocation"]["region"]["startLine"],
1898 12
1899 );
1900 assert_eq!(
1901 entries[1]["locations"][0]["physicalLocation"]["region"]["startColumn"],
1902 5
1903 );
1904 }
1905
1906 #[test]
1909 fn health_sarif_rules_have_full_descriptions() {
1910 let root = PathBuf::from("/project");
1911 let report = crate::health_types::HealthReport::default();
1912 let sarif = build_health_sarif(&report, &root);
1913 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1914 .as_array()
1915 .unwrap();
1916 for rule in rules {
1917 let id = rule["id"].as_str().unwrap();
1918 assert!(
1919 rule.get("fullDescription").is_some(),
1920 "health rule {id} should have fullDescription"
1921 );
1922 assert!(
1923 rule.get("helpUri").is_some(),
1924 "health rule {id} should have helpUri"
1925 );
1926 }
1927 }
1928
1929 #[test]
1932 fn sarif_warn_severity_produces_warning_level() {
1933 let root = PathBuf::from("/project");
1934 let mut results = AnalysisResults::default();
1935 results.unused_files.push(UnusedFile {
1936 path: root.join("src/dead.ts"),
1937 });
1938
1939 let rules = RulesConfig {
1940 unused_files: Severity::Warn,
1941 ..RulesConfig::default()
1942 };
1943
1944 let sarif = build_sarif(&results, &root, &rules);
1945 let entry = &sarif["runs"][0]["results"][0];
1946 assert_eq!(entry["level"], "warning");
1947 }
1948
1949 #[test]
1952 fn sarif_unused_file_has_no_region() {
1953 let root = PathBuf::from("/project");
1954 let mut results = AnalysisResults::default();
1955 results.unused_files.push(UnusedFile {
1956 path: root.join("src/dead.ts"),
1957 });
1958
1959 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1960 let entry = &sarif["runs"][0]["results"][0];
1961 let phys = &entry["locations"][0]["physicalLocation"];
1962 assert!(phys.get("region").is_none());
1963 }
1964
1965 #[test]
1968 fn sarif_unlisted_dep_multiple_import_sites() {
1969 let root = PathBuf::from("/project");
1970 let mut results = AnalysisResults::default();
1971 results.unlisted_dependencies.push(UnlistedDependency {
1972 package_name: "dotenv".to_string(),
1973 imported_from: vec![
1974 ImportSite {
1975 path: root.join("src/a.ts"),
1976 line: 1,
1977 col: 0,
1978 },
1979 ImportSite {
1980 path: root.join("src/b.ts"),
1981 line: 5,
1982 col: 0,
1983 },
1984 ],
1985 });
1986
1987 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1988 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1989 assert_eq!(entries.len(), 2);
1991 assert_eq!(
1992 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1993 "src/a.ts"
1994 );
1995 assert_eq!(
1996 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1997 "src/b.ts"
1998 );
1999 }
2000
2001 #[test]
2004 fn sarif_unlisted_dep_no_import_sites() {
2005 let root = PathBuf::from("/project");
2006 let mut results = AnalysisResults::default();
2007 results.unlisted_dependencies.push(UnlistedDependency {
2008 package_name: "phantom".to_string(),
2009 imported_from: vec![],
2010 });
2011
2012 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2013 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2014 assert!(entries.is_empty());
2016 }
2017}