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 {
644 ExceededThreshold::Cyclomatic => (
645 "fallow/high-cyclomatic-complexity",
646 format!(
647 "'{}' has cyclomatic complexity {} (threshold: {})",
648 finding.name, finding.cyclomatic, report.summary.max_cyclomatic_threshold,
649 ),
650 ),
651 ExceededThreshold::Cognitive => (
652 "fallow/high-cognitive-complexity",
653 format!(
654 "'{}' has cognitive complexity {} (threshold: {})",
655 finding.name, finding.cognitive, report.summary.max_cognitive_threshold,
656 ),
657 ),
658 ExceededThreshold::Both => (
659 "fallow/high-complexity",
660 format!(
661 "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
662 finding.name,
663 finding.cyclomatic,
664 report.summary.max_cyclomatic_threshold,
665 finding.cognitive,
666 report.summary.max_cognitive_threshold,
667 ),
668 ),
669 };
670
671 let level = match finding.severity {
672 crate::health_types::FindingSeverity::Critical => "error",
673 crate::health_types::FindingSeverity::High => "warning",
674 crate::health_types::FindingSeverity::Moderate => "note",
675 };
676 sarif_results.push(sarif_result(
677 rule_id,
678 level,
679 &message,
680 &uri,
681 Some((finding.line, finding.col + 1)),
682 ));
683 }
684
685 if let Some(ref production) = report.production_coverage {
686 append_production_coverage_sarif_results(&mut sarif_results, production, root);
687 }
688
689 for target in &report.targets {
691 let uri = relative_uri(&target.path, root);
692 let message = format!(
693 "[{}] {} (priority: {:.1}, efficiency: {:.1}, effort: {}, confidence: {})",
694 target.category.label(),
695 target.recommendation,
696 target.priority,
697 target.efficiency,
698 target.effort.label(),
699 target.confidence.label(),
700 );
701 sarif_results.push(sarif_result(
702 "fallow/refactoring-target",
703 "warning",
704 &message,
705 &uri,
706 None,
707 ));
708 }
709
710 if let Some(ref gaps) = report.coverage_gaps {
711 for item in &gaps.files {
712 let uri = relative_uri(&item.path, root);
713 let message = format!(
714 "File is runtime-reachable but has no test dependency path ({} value export{})",
715 item.value_export_count,
716 if item.value_export_count == 1 {
717 ""
718 } else {
719 "s"
720 },
721 );
722 sarif_results.push(sarif_result(
723 "fallow/untested-file",
724 "warning",
725 &message,
726 &uri,
727 None,
728 ));
729 }
730
731 for item in &gaps.exports {
732 let uri = relative_uri(&item.path, root);
733 let message = format!(
734 "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
735 item.export_name
736 );
737 sarif_results.push(sarif_result(
738 "fallow/untested-export",
739 "warning",
740 &message,
741 &uri,
742 Some((item.line, item.col + 1)),
743 ));
744 }
745 }
746
747 let health_rules = vec![
748 sarif_rule(
749 "fallow/high-cyclomatic-complexity",
750 "Function has high cyclomatic complexity",
751 "note",
752 ),
753 sarif_rule(
754 "fallow/high-cognitive-complexity",
755 "Function has high cognitive complexity",
756 "note",
757 ),
758 sarif_rule(
759 "fallow/high-complexity",
760 "Function exceeds both complexity thresholds",
761 "note",
762 ),
763 sarif_rule(
764 "fallow/refactoring-target",
765 "File identified as a high-priority refactoring candidate",
766 "warning",
767 ),
768 sarif_rule(
769 "fallow/untested-file",
770 "Runtime-reachable file has no test dependency path",
771 "warning",
772 ),
773 sarif_rule(
774 "fallow/untested-export",
775 "Runtime-reachable export has no test dependency path",
776 "warning",
777 ),
778 sarif_rule(
779 "fallow/production-safe-to-delete",
780 "Function is statically unused and was never invoked in production",
781 "warning",
782 ),
783 sarif_rule(
784 "fallow/production-review-required",
785 "Function is statically used but was never invoked in production",
786 "warning",
787 ),
788 sarif_rule(
789 "fallow/production-low-traffic",
790 "Function was invoked below the low-traffic threshold relative to total trace count",
791 "note",
792 ),
793 sarif_rule(
794 "fallow/production-coverage-unavailable",
795 "Production coverage could not be resolved for this function",
796 "note",
797 ),
798 sarif_rule(
799 "fallow/production-coverage",
800 "Production coverage finding",
801 "note",
802 ),
803 ];
804
805 serde_json::json!({
806 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
807 "version": "2.1.0",
808 "runs": [{
809 "tool": {
810 "driver": {
811 "name": "fallow",
812 "version": env!("CARGO_PKG_VERSION"),
813 "informationUri": "https://github.com/fallow-rs/fallow",
814 "rules": health_rules
815 }
816 },
817 "results": sarif_results
818 }]
819 })
820}
821
822fn append_production_coverage_sarif_results(
823 sarif_results: &mut Vec<serde_json::Value>,
824 production: &crate::health_types::ProductionCoverageReport,
825 root: &Path,
826) {
827 for finding in &production.findings {
828 let uri = relative_uri(&finding.path, root);
829 let rule_id = match finding.verdict {
830 crate::health_types::ProductionCoverageVerdict::SafeToDelete => {
831 "fallow/production-safe-to-delete"
832 }
833 crate::health_types::ProductionCoverageVerdict::ReviewRequired => {
834 "fallow/production-review-required"
835 }
836 crate::health_types::ProductionCoverageVerdict::LowTraffic => {
837 "fallow/production-low-traffic"
838 }
839 crate::health_types::ProductionCoverageVerdict::CoverageUnavailable => {
840 "fallow/production-coverage-unavailable"
841 }
842 crate::health_types::ProductionCoverageVerdict::Active
843 | crate::health_types::ProductionCoverageVerdict::Unknown => {
844 "fallow/production-coverage"
845 }
846 };
847 let level = match finding.verdict {
848 crate::health_types::ProductionCoverageVerdict::SafeToDelete
849 | crate::health_types::ProductionCoverageVerdict::ReviewRequired => "warning",
850 _ => "note",
851 };
852 let invocations_hint = finding.invocations.map_or_else(
853 || "untracked".to_owned(),
854 |hits| format!("{hits} invocations"),
855 );
856 let message = format!(
857 "'{}' production coverage verdict: {} ({})",
858 finding.function,
859 finding.verdict.human_label(),
860 invocations_hint,
861 );
862 sarif_results.push(sarif_result(
863 rule_id,
864 level,
865 &message,
866 &uri,
867 Some((finding.line, 1)),
868 ));
869 }
870}
871
872pub(super) fn print_health_sarif(
873 report: &crate::health_types::HealthReport,
874 root: &Path,
875) -> ExitCode {
876 let sarif = build_health_sarif(report, root);
877 emit_json(&sarif, "SARIF")
878}
879
880#[cfg(test)]
881mod tests {
882 use super::*;
883 use crate::report::test_helpers::sample_results;
884 use fallow_core::results::*;
885 use std::path::PathBuf;
886
887 #[test]
888 fn sarif_has_required_top_level_fields() {
889 let root = PathBuf::from("/project");
890 let results = AnalysisResults::default();
891 let sarif = build_sarif(&results, &root, &RulesConfig::default());
892
893 assert_eq!(
894 sarif["$schema"],
895 "https://json.schemastore.org/sarif-2.1.0.json"
896 );
897 assert_eq!(sarif["version"], "2.1.0");
898 assert!(sarif["runs"].is_array());
899 }
900
901 #[test]
902 fn sarif_has_tool_driver_info() {
903 let root = PathBuf::from("/project");
904 let results = AnalysisResults::default();
905 let sarif = build_sarif(&results, &root, &RulesConfig::default());
906
907 let driver = &sarif["runs"][0]["tool"]["driver"];
908 assert_eq!(driver["name"], "fallow");
909 assert!(driver["version"].is_string());
910 assert_eq!(
911 driver["informationUri"],
912 "https://github.com/fallow-rs/fallow"
913 );
914 }
915
916 #[test]
917 fn sarif_declares_all_rules() {
918 let root = PathBuf::from("/project");
919 let results = AnalysisResults::default();
920 let sarif = build_sarif(&results, &root, &RulesConfig::default());
921
922 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
923 .as_array()
924 .expect("rules should be an array");
925 assert_eq!(rules.len(), 16);
926
927 let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
928 assert!(rule_ids.contains(&"fallow/unused-file"));
929 assert!(rule_ids.contains(&"fallow/unused-export"));
930 assert!(rule_ids.contains(&"fallow/unused-type"));
931 assert!(rule_ids.contains(&"fallow/unused-dependency"));
932 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
933 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
934 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
935 assert!(rule_ids.contains(&"fallow/test-only-dependency"));
936 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
937 assert!(rule_ids.contains(&"fallow/unused-class-member"));
938 assert!(rule_ids.contains(&"fallow/unresolved-import"));
939 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
940 assert!(rule_ids.contains(&"fallow/duplicate-export"));
941 assert!(rule_ids.contains(&"fallow/circular-dependency"));
942 assert!(rule_ids.contains(&"fallow/boundary-violation"));
943 }
944
945 #[test]
946 fn sarif_empty_results_no_results_entries() {
947 let root = PathBuf::from("/project");
948 let results = AnalysisResults::default();
949 let sarif = build_sarif(&results, &root, &RulesConfig::default());
950
951 let sarif_results = sarif["runs"][0]["results"]
952 .as_array()
953 .expect("results should be an array");
954 assert!(sarif_results.is_empty());
955 }
956
957 #[test]
958 fn sarif_unused_file_result() {
959 let root = PathBuf::from("/project");
960 let mut results = AnalysisResults::default();
961 results.unused_files.push(UnusedFile {
962 path: root.join("src/dead.ts"),
963 });
964
965 let sarif = build_sarif(&results, &root, &RulesConfig::default());
966 let entries = sarif["runs"][0]["results"].as_array().unwrap();
967 assert_eq!(entries.len(), 1);
968
969 let entry = &entries[0];
970 assert_eq!(entry["ruleId"], "fallow/unused-file");
971 assert_eq!(entry["level"], "error");
973 assert_eq!(
974 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
975 "src/dead.ts"
976 );
977 }
978
979 #[test]
980 fn sarif_unused_export_includes_region() {
981 let root = PathBuf::from("/project");
982 let mut results = AnalysisResults::default();
983 results.unused_exports.push(UnusedExport {
984 path: root.join("src/utils.ts"),
985 export_name: "helperFn".to_string(),
986 is_type_only: false,
987 line: 10,
988 col: 4,
989 span_start: 120,
990 is_re_export: false,
991 });
992
993 let sarif = build_sarif(&results, &root, &RulesConfig::default());
994 let entry = &sarif["runs"][0]["results"][0];
995 assert_eq!(entry["ruleId"], "fallow/unused-export");
996
997 let region = &entry["locations"][0]["physicalLocation"]["region"];
998 assert_eq!(region["startLine"], 10);
999 assert_eq!(region["startColumn"], 5);
1001 }
1002
1003 #[test]
1004 fn sarif_unresolved_import_is_error_level() {
1005 let root = PathBuf::from("/project");
1006 let mut results = AnalysisResults::default();
1007 results.unresolved_imports.push(UnresolvedImport {
1008 path: root.join("src/app.ts"),
1009 specifier: "./missing".to_string(),
1010 line: 1,
1011 col: 0,
1012 specifier_col: 0,
1013 });
1014
1015 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1016 let entry = &sarif["runs"][0]["results"][0];
1017 assert_eq!(entry["ruleId"], "fallow/unresolved-import");
1018 assert_eq!(entry["level"], "error");
1019 }
1020
1021 #[test]
1022 fn sarif_unlisted_dependency_points_to_import_site() {
1023 let root = PathBuf::from("/project");
1024 let mut results = AnalysisResults::default();
1025 results.unlisted_dependencies.push(UnlistedDependency {
1026 package_name: "chalk".to_string(),
1027 imported_from: vec![ImportSite {
1028 path: root.join("src/cli.ts"),
1029 line: 3,
1030 col: 0,
1031 }],
1032 });
1033
1034 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1035 let entry = &sarif["runs"][0]["results"][0];
1036 assert_eq!(entry["ruleId"], "fallow/unlisted-dependency");
1037 assert_eq!(entry["level"], "error");
1038 assert_eq!(
1039 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1040 "src/cli.ts"
1041 );
1042 let region = &entry["locations"][0]["physicalLocation"]["region"];
1043 assert_eq!(region["startLine"], 3);
1044 assert_eq!(region["startColumn"], 1);
1045 }
1046
1047 #[test]
1048 fn sarif_dependency_issues_point_to_package_json() {
1049 let root = PathBuf::from("/project");
1050 let mut results = AnalysisResults::default();
1051 results.unused_dependencies.push(UnusedDependency {
1052 package_name: "lodash".to_string(),
1053 location: DependencyLocation::Dependencies,
1054 path: root.join("package.json"),
1055 line: 5,
1056 });
1057 results.unused_dev_dependencies.push(UnusedDependency {
1058 package_name: "jest".to_string(),
1059 location: DependencyLocation::DevDependencies,
1060 path: root.join("package.json"),
1061 line: 5,
1062 });
1063
1064 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1065 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1066 for entry in entries {
1067 assert_eq!(
1068 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1069 "package.json"
1070 );
1071 }
1072 }
1073
1074 #[test]
1075 fn sarif_duplicate_export_emits_one_result_per_location() {
1076 let root = PathBuf::from("/project");
1077 let mut results = AnalysisResults::default();
1078 results.duplicate_exports.push(DuplicateExport {
1079 export_name: "Config".to_string(),
1080 locations: vec![
1081 DuplicateLocation {
1082 path: root.join("src/a.ts"),
1083 line: 15,
1084 col: 0,
1085 },
1086 DuplicateLocation {
1087 path: root.join("src/b.ts"),
1088 line: 30,
1089 col: 0,
1090 },
1091 ],
1092 });
1093
1094 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1095 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1096 assert_eq!(entries.len(), 2);
1098 assert_eq!(entries[0]["ruleId"], "fallow/duplicate-export");
1099 assert_eq!(entries[1]["ruleId"], "fallow/duplicate-export");
1100 assert_eq!(
1101 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1102 "src/a.ts"
1103 );
1104 assert_eq!(
1105 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1106 "src/b.ts"
1107 );
1108 }
1109
1110 #[test]
1111 fn sarif_all_issue_types_produce_results() {
1112 let root = PathBuf::from("/project");
1113 let results = sample_results(&root);
1114 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1115
1116 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1117 assert_eq!(entries.len(), results.total_issues() + 1);
1119
1120 let rule_ids: Vec<&str> = entries
1121 .iter()
1122 .map(|e| e["ruleId"].as_str().unwrap())
1123 .collect();
1124 assert!(rule_ids.contains(&"fallow/unused-file"));
1125 assert!(rule_ids.contains(&"fallow/unused-export"));
1126 assert!(rule_ids.contains(&"fallow/unused-type"));
1127 assert!(rule_ids.contains(&"fallow/unused-dependency"));
1128 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
1129 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
1130 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
1131 assert!(rule_ids.contains(&"fallow/test-only-dependency"));
1132 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
1133 assert!(rule_ids.contains(&"fallow/unused-class-member"));
1134 assert!(rule_ids.contains(&"fallow/unresolved-import"));
1135 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
1136 assert!(rule_ids.contains(&"fallow/duplicate-export"));
1137 }
1138
1139 #[test]
1140 fn sarif_serializes_to_valid_json() {
1141 let root = PathBuf::from("/project");
1142 let results = sample_results(&root);
1143 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1144
1145 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
1146 let reparsed: serde_json::Value =
1147 serde_json::from_str(&json_str).expect("SARIF output should be valid JSON");
1148 assert_eq!(reparsed, sarif);
1149 }
1150
1151 #[test]
1152 fn sarif_file_write_produces_valid_sarif() {
1153 let root = PathBuf::from("/project");
1154 let results = sample_results(&root);
1155 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1156 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
1157
1158 let dir = std::env::temp_dir().join("fallow-test-sarif-file");
1159 let _ = std::fs::create_dir_all(&dir);
1160 let sarif_path = dir.join("results.sarif");
1161 std::fs::write(&sarif_path, &json_str).expect("should write SARIF file");
1162
1163 let contents = std::fs::read_to_string(&sarif_path).expect("should read SARIF file");
1164 let parsed: serde_json::Value =
1165 serde_json::from_str(&contents).expect("file should contain valid JSON");
1166
1167 assert_eq!(parsed["version"], "2.1.0");
1168 assert_eq!(
1169 parsed["$schema"],
1170 "https://json.schemastore.org/sarif-2.1.0.json"
1171 );
1172 let sarif_results = parsed["runs"][0]["results"]
1173 .as_array()
1174 .expect("results should be an array");
1175 assert!(!sarif_results.is_empty());
1176
1177 let _ = std::fs::remove_file(&sarif_path);
1179 let _ = std::fs::remove_dir(&dir);
1180 }
1181
1182 #[test]
1185 fn health_sarif_empty_no_results() {
1186 let root = PathBuf::from("/project");
1187 let report = crate::health_types::HealthReport {
1188 summary: crate::health_types::HealthSummary {
1189 files_analyzed: 10,
1190 functions_analyzed: 50,
1191 ..Default::default()
1192 },
1193 ..Default::default()
1194 };
1195 let sarif = build_health_sarif(&report, &root);
1196 assert_eq!(sarif["version"], "2.1.0");
1197 let results = sarif["runs"][0]["results"].as_array().unwrap();
1198 assert!(results.is_empty());
1199 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1200 .as_array()
1201 .unwrap();
1202 assert_eq!(rules.len(), 11);
1203 }
1204
1205 #[test]
1206 fn health_sarif_cyclomatic_only() {
1207 let root = PathBuf::from("/project");
1208 let report = crate::health_types::HealthReport {
1209 findings: vec![crate::health_types::HealthFinding {
1210 path: root.join("src/utils.ts"),
1211 name: "parseExpression".to_string(),
1212 line: 42,
1213 col: 0,
1214 cyclomatic: 25,
1215 cognitive: 10,
1216 line_count: 80,
1217 param_count: 0,
1218 exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
1219 severity: crate::health_types::FindingSeverity::High,
1220 }],
1221 summary: crate::health_types::HealthSummary {
1222 files_analyzed: 5,
1223 functions_analyzed: 20,
1224 functions_above_threshold: 1,
1225 ..Default::default()
1226 },
1227 ..Default::default()
1228 };
1229 let sarif = build_health_sarif(&report, &root);
1230 let entry = &sarif["runs"][0]["results"][0];
1231 assert_eq!(entry["ruleId"], "fallow/high-cyclomatic-complexity");
1232 assert_eq!(entry["level"], "warning");
1233 assert!(
1234 entry["message"]["text"]
1235 .as_str()
1236 .unwrap()
1237 .contains("cyclomatic complexity 25")
1238 );
1239 assert_eq!(
1240 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1241 "src/utils.ts"
1242 );
1243 let region = &entry["locations"][0]["physicalLocation"]["region"];
1244 assert_eq!(region["startLine"], 42);
1245 assert_eq!(region["startColumn"], 1);
1246 }
1247
1248 #[test]
1249 fn health_sarif_cognitive_only() {
1250 let root = PathBuf::from("/project");
1251 let report = crate::health_types::HealthReport {
1252 findings: vec![crate::health_types::HealthFinding {
1253 path: root.join("src/api.ts"),
1254 name: "handleRequest".to_string(),
1255 line: 10,
1256 col: 4,
1257 cyclomatic: 8,
1258 cognitive: 20,
1259 line_count: 40,
1260 param_count: 0,
1261 exceeded: crate::health_types::ExceededThreshold::Cognitive,
1262 severity: crate::health_types::FindingSeverity::High,
1263 }],
1264 summary: crate::health_types::HealthSummary {
1265 files_analyzed: 3,
1266 functions_analyzed: 10,
1267 functions_above_threshold: 1,
1268 ..Default::default()
1269 },
1270 ..Default::default()
1271 };
1272 let sarif = build_health_sarif(&report, &root);
1273 let entry = &sarif["runs"][0]["results"][0];
1274 assert_eq!(entry["ruleId"], "fallow/high-cognitive-complexity");
1275 assert!(
1276 entry["message"]["text"]
1277 .as_str()
1278 .unwrap()
1279 .contains("cognitive complexity 20")
1280 );
1281 let region = &entry["locations"][0]["physicalLocation"]["region"];
1282 assert_eq!(region["startColumn"], 5); }
1284
1285 #[test]
1286 fn health_sarif_both_thresholds() {
1287 let root = PathBuf::from("/project");
1288 let report = crate::health_types::HealthReport {
1289 findings: vec![crate::health_types::HealthFinding {
1290 path: root.join("src/complex.ts"),
1291 name: "doEverything".to_string(),
1292 line: 1,
1293 col: 0,
1294 cyclomatic: 30,
1295 cognitive: 45,
1296 line_count: 100,
1297 param_count: 0,
1298 exceeded: crate::health_types::ExceededThreshold::Both,
1299 severity: crate::health_types::FindingSeverity::High,
1300 }],
1301 summary: crate::health_types::HealthSummary {
1302 files_analyzed: 1,
1303 functions_analyzed: 1,
1304 functions_above_threshold: 1,
1305 ..Default::default()
1306 },
1307 ..Default::default()
1308 };
1309 let sarif = build_health_sarif(&report, &root);
1310 let entry = &sarif["runs"][0]["results"][0];
1311 assert_eq!(entry["ruleId"], "fallow/high-complexity");
1312 let msg = entry["message"]["text"].as_str().unwrap();
1313 assert!(msg.contains("cyclomatic complexity 30"));
1314 assert!(msg.contains("cognitive complexity 45"));
1315 }
1316
1317 #[test]
1320 fn severity_to_sarif_level_error() {
1321 assert_eq!(severity_to_sarif_level(Severity::Error), "error");
1322 }
1323
1324 #[test]
1325 fn severity_to_sarif_level_warn() {
1326 assert_eq!(severity_to_sarif_level(Severity::Warn), "warning");
1327 }
1328
1329 #[test]
1330 fn severity_to_sarif_level_off() {
1331 assert_eq!(severity_to_sarif_level(Severity::Off), "warning");
1332 }
1333
1334 #[test]
1337 fn sarif_re_export_has_properties() {
1338 let root = PathBuf::from("/project");
1339 let mut results = AnalysisResults::default();
1340 results.unused_exports.push(UnusedExport {
1341 path: root.join("src/index.ts"),
1342 export_name: "reExported".to_string(),
1343 is_type_only: false,
1344 line: 1,
1345 col: 0,
1346 span_start: 0,
1347 is_re_export: true,
1348 });
1349
1350 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1351 let entry = &sarif["runs"][0]["results"][0];
1352 assert_eq!(entry["properties"]["is_re_export"], true);
1353 let msg = entry["message"]["text"].as_str().unwrap();
1354 assert!(msg.starts_with("Re-export"));
1355 }
1356
1357 #[test]
1358 fn sarif_non_re_export_has_no_properties() {
1359 let root = PathBuf::from("/project");
1360 let mut results = AnalysisResults::default();
1361 results.unused_exports.push(UnusedExport {
1362 path: root.join("src/utils.ts"),
1363 export_name: "foo".to_string(),
1364 is_type_only: false,
1365 line: 5,
1366 col: 0,
1367 span_start: 0,
1368 is_re_export: false,
1369 });
1370
1371 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1372 let entry = &sarif["runs"][0]["results"][0];
1373 assert!(entry.get("properties").is_none());
1374 let msg = entry["message"]["text"].as_str().unwrap();
1375 assert!(msg.starts_with("Export"));
1376 }
1377
1378 #[test]
1381 fn sarif_type_re_export_message() {
1382 let root = PathBuf::from("/project");
1383 let mut results = AnalysisResults::default();
1384 results.unused_types.push(UnusedExport {
1385 path: root.join("src/index.ts"),
1386 export_name: "MyType".to_string(),
1387 is_type_only: true,
1388 line: 1,
1389 col: 0,
1390 span_start: 0,
1391 is_re_export: true,
1392 });
1393
1394 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1395 let entry = &sarif["runs"][0]["results"][0];
1396 assert_eq!(entry["ruleId"], "fallow/unused-type");
1397 let msg = entry["message"]["text"].as_str().unwrap();
1398 assert!(msg.starts_with("Type re-export"));
1399 assert_eq!(entry["properties"]["is_re_export"], true);
1400 }
1401
1402 #[test]
1405 fn sarif_dependency_line_zero_skips_region() {
1406 let root = PathBuf::from("/project");
1407 let mut results = AnalysisResults::default();
1408 results.unused_dependencies.push(UnusedDependency {
1409 package_name: "lodash".to_string(),
1410 location: DependencyLocation::Dependencies,
1411 path: root.join("package.json"),
1412 line: 0,
1413 });
1414
1415 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1416 let entry = &sarif["runs"][0]["results"][0];
1417 let phys = &entry["locations"][0]["physicalLocation"];
1418 assert!(phys.get("region").is_none());
1419 }
1420
1421 #[test]
1422 fn sarif_dependency_line_nonzero_has_region() {
1423 let root = PathBuf::from("/project");
1424 let mut results = AnalysisResults::default();
1425 results.unused_dependencies.push(UnusedDependency {
1426 package_name: "lodash".to_string(),
1427 location: DependencyLocation::Dependencies,
1428 path: root.join("package.json"),
1429 line: 7,
1430 });
1431
1432 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1433 let entry = &sarif["runs"][0]["results"][0];
1434 let region = &entry["locations"][0]["physicalLocation"]["region"];
1435 assert_eq!(region["startLine"], 7);
1436 assert_eq!(region["startColumn"], 1);
1437 }
1438
1439 #[test]
1442 fn sarif_type_only_dep_line_zero_skips_region() {
1443 let root = PathBuf::from("/project");
1444 let mut results = AnalysisResults::default();
1445 results.type_only_dependencies.push(TypeOnlyDependency {
1446 package_name: "zod".to_string(),
1447 path: root.join("package.json"),
1448 line: 0,
1449 });
1450
1451 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1452 let entry = &sarif["runs"][0]["results"][0];
1453 let phys = &entry["locations"][0]["physicalLocation"];
1454 assert!(phys.get("region").is_none());
1455 }
1456
1457 #[test]
1460 fn sarif_circular_dep_line_zero_skips_region() {
1461 let root = PathBuf::from("/project");
1462 let mut results = AnalysisResults::default();
1463 results.circular_dependencies.push(CircularDependency {
1464 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1465 length: 2,
1466 line: 0,
1467 col: 0,
1468 is_cross_package: false,
1469 });
1470
1471 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1472 let entry = &sarif["runs"][0]["results"][0];
1473 let phys = &entry["locations"][0]["physicalLocation"];
1474 assert!(phys.get("region").is_none());
1475 }
1476
1477 #[test]
1478 fn sarif_circular_dep_line_nonzero_has_region() {
1479 let root = PathBuf::from("/project");
1480 let mut results = AnalysisResults::default();
1481 results.circular_dependencies.push(CircularDependency {
1482 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1483 length: 2,
1484 line: 5,
1485 col: 2,
1486 is_cross_package: false,
1487 });
1488
1489 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1490 let entry = &sarif["runs"][0]["results"][0];
1491 let region = &entry["locations"][0]["physicalLocation"]["region"];
1492 assert_eq!(region["startLine"], 5);
1493 assert_eq!(region["startColumn"], 3);
1494 }
1495
1496 #[test]
1499 fn sarif_unused_optional_dependency_result() {
1500 let root = PathBuf::from("/project");
1501 let mut results = AnalysisResults::default();
1502 results.unused_optional_dependencies.push(UnusedDependency {
1503 package_name: "fsevents".to_string(),
1504 location: DependencyLocation::OptionalDependencies,
1505 path: root.join("package.json"),
1506 line: 12,
1507 });
1508
1509 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1510 let entry = &sarif["runs"][0]["results"][0];
1511 assert_eq!(entry["ruleId"], "fallow/unused-optional-dependency");
1512 let msg = entry["message"]["text"].as_str().unwrap();
1513 assert!(msg.contains("optionalDependencies"));
1514 }
1515
1516 #[test]
1519 fn sarif_enum_member_message_format() {
1520 let root = PathBuf::from("/project");
1521 let mut results = AnalysisResults::default();
1522 results
1523 .unused_enum_members
1524 .push(fallow_core::results::UnusedMember {
1525 path: root.join("src/enums.ts"),
1526 parent_name: "Color".to_string(),
1527 member_name: "Purple".to_string(),
1528 kind: fallow_core::extract::MemberKind::EnumMember,
1529 line: 5,
1530 col: 2,
1531 });
1532
1533 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1534 let entry = &sarif["runs"][0]["results"][0];
1535 assert_eq!(entry["ruleId"], "fallow/unused-enum-member");
1536 let msg = entry["message"]["text"].as_str().unwrap();
1537 assert!(msg.contains("Enum member 'Color.Purple'"));
1538 let region = &entry["locations"][0]["physicalLocation"]["region"];
1539 assert_eq!(region["startColumn"], 3); }
1541
1542 #[test]
1543 fn sarif_class_member_message_format() {
1544 let root = PathBuf::from("/project");
1545 let mut results = AnalysisResults::default();
1546 results
1547 .unused_class_members
1548 .push(fallow_core::results::UnusedMember {
1549 path: root.join("src/service.ts"),
1550 parent_name: "API".to_string(),
1551 member_name: "fetch".to_string(),
1552 kind: fallow_core::extract::MemberKind::ClassMethod,
1553 line: 10,
1554 col: 4,
1555 });
1556
1557 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1558 let entry = &sarif["runs"][0]["results"][0];
1559 assert_eq!(entry["ruleId"], "fallow/unused-class-member");
1560 let msg = entry["message"]["text"].as_str().unwrap();
1561 assert!(msg.contains("Class member 'API.fetch'"));
1562 }
1563
1564 #[test]
1567 #[expect(
1568 clippy::cast_possible_truncation,
1569 reason = "test line/col values are trivially small"
1570 )]
1571 fn duplication_sarif_structure() {
1572 use fallow_core::duplicates::*;
1573
1574 let root = PathBuf::from("/project");
1575 let report = DuplicationReport {
1576 clone_groups: vec![CloneGroup {
1577 instances: vec![
1578 CloneInstance {
1579 file: root.join("src/a.ts"),
1580 start_line: 1,
1581 end_line: 10,
1582 start_col: 0,
1583 end_col: 0,
1584 fragment: String::new(),
1585 },
1586 CloneInstance {
1587 file: root.join("src/b.ts"),
1588 start_line: 5,
1589 end_line: 14,
1590 start_col: 2,
1591 end_col: 0,
1592 fragment: String::new(),
1593 },
1594 ],
1595 token_count: 50,
1596 line_count: 10,
1597 }],
1598 clone_families: vec![],
1599 mirrored_directories: vec![],
1600 stats: DuplicationStats::default(),
1601 };
1602
1603 let sarif = serde_json::json!({
1604 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1605 "version": "2.1.0",
1606 "runs": [{
1607 "tool": {
1608 "driver": {
1609 "name": "fallow",
1610 "version": env!("CARGO_PKG_VERSION"),
1611 "informationUri": "https://github.com/fallow-rs/fallow",
1612 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
1613 }
1614 },
1615 "results": []
1616 }]
1617 });
1618 let _ = sarif;
1620
1621 let mut sarif_results = Vec::new();
1623 for (i, group) in report.clone_groups.iter().enumerate() {
1624 for instance in &group.instances {
1625 sarif_results.push(sarif_result(
1626 "fallow/code-duplication",
1627 "warning",
1628 &format!(
1629 "Code clone group {} ({} lines, {} instances)",
1630 i + 1,
1631 group.line_count,
1632 group.instances.len()
1633 ),
1634 &super::super::relative_uri(&instance.file, &root),
1635 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
1636 ));
1637 }
1638 }
1639 assert_eq!(sarif_results.len(), 2);
1640 assert_eq!(sarif_results[0]["ruleId"], "fallow/code-duplication");
1641 assert!(
1642 sarif_results[0]["message"]["text"]
1643 .as_str()
1644 .unwrap()
1645 .contains("10 lines")
1646 );
1647 let region0 = &sarif_results[0]["locations"][0]["physicalLocation"]["region"];
1648 assert_eq!(region0["startLine"], 1);
1649 assert_eq!(region0["startColumn"], 1); let region1 = &sarif_results[1]["locations"][0]["physicalLocation"]["region"];
1651 assert_eq!(region1["startLine"], 5);
1652 assert_eq!(region1["startColumn"], 3); }
1654
1655 #[test]
1658 fn sarif_rule_known_id_has_full_description() {
1659 let rule = sarif_rule("fallow/unused-file", "fallback text", "error");
1660 assert!(rule.get("fullDescription").is_some());
1661 assert!(rule.get("helpUri").is_some());
1662 }
1663
1664 #[test]
1665 fn sarif_rule_unknown_id_uses_fallback() {
1666 let rule = sarif_rule("fallow/nonexistent", "fallback text", "warning");
1667 assert_eq!(rule["shortDescription"]["text"], "fallback text");
1668 assert!(rule.get("fullDescription").is_none());
1669 assert!(rule.get("helpUri").is_none());
1670 assert_eq!(rule["defaultConfiguration"]["level"], "warning");
1671 }
1672
1673 #[test]
1676 fn sarif_result_no_region_omits_region_key() {
1677 let result = sarif_result("rule/test", "error", "test msg", "src/file.ts", None);
1678 let phys = &result["locations"][0]["physicalLocation"];
1679 assert!(phys.get("region").is_none());
1680 assert_eq!(phys["artifactLocation"]["uri"], "src/file.ts");
1681 }
1682
1683 #[test]
1684 fn sarif_result_with_region_includes_region() {
1685 let result = sarif_result(
1686 "rule/test",
1687 "error",
1688 "test msg",
1689 "src/file.ts",
1690 Some((10, 5)),
1691 );
1692 let region = &result["locations"][0]["physicalLocation"]["region"];
1693 assert_eq!(region["startLine"], 10);
1694 assert_eq!(region["startColumn"], 5);
1695 }
1696
1697 #[test]
1700 fn health_sarif_includes_refactoring_targets() {
1701 use crate::health_types::*;
1702
1703 let root = PathBuf::from("/project");
1704 let report = HealthReport {
1705 summary: HealthSummary {
1706 files_analyzed: 10,
1707 functions_analyzed: 50,
1708 ..Default::default()
1709 },
1710 targets: vec![RefactoringTarget {
1711 path: root.join("src/complex.ts"),
1712 priority: 85.0,
1713 efficiency: 42.5,
1714 recommendation: "Split high-impact file".into(),
1715 category: RecommendationCategory::SplitHighImpact,
1716 effort: EffortEstimate::Medium,
1717 confidence: Confidence::High,
1718 factors: vec![],
1719 evidence: None,
1720 }],
1721 ..Default::default()
1722 };
1723
1724 let sarif = build_health_sarif(&report, &root);
1725 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1726 assert_eq!(entries.len(), 1);
1727 assert_eq!(entries[0]["ruleId"], "fallow/refactoring-target");
1728 assert_eq!(entries[0]["level"], "warning");
1729 let msg = entries[0]["message"]["text"].as_str().unwrap();
1730 assert!(msg.contains("high impact"));
1731 assert!(msg.contains("Split high-impact file"));
1732 assert!(msg.contains("42.5"));
1733 }
1734
1735 #[test]
1736 fn health_sarif_includes_coverage_gaps() {
1737 use crate::health_types::*;
1738
1739 let root = PathBuf::from("/project");
1740 let report = HealthReport {
1741 summary: HealthSummary {
1742 files_analyzed: 10,
1743 functions_analyzed: 50,
1744 ..Default::default()
1745 },
1746 coverage_gaps: Some(CoverageGaps {
1747 summary: CoverageGapSummary {
1748 runtime_files: 2,
1749 covered_files: 0,
1750 file_coverage_pct: 0.0,
1751 untested_files: 1,
1752 untested_exports: 1,
1753 },
1754 files: vec![UntestedFile {
1755 path: root.join("src/app.ts"),
1756 value_export_count: 2,
1757 }],
1758 exports: vec![UntestedExport {
1759 path: root.join("src/app.ts"),
1760 export_name: "loader".into(),
1761 line: 12,
1762 col: 4,
1763 }],
1764 }),
1765 ..Default::default()
1766 };
1767
1768 let sarif = build_health_sarif(&report, &root);
1769 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1770 assert_eq!(entries.len(), 2);
1771 assert_eq!(entries[0]["ruleId"], "fallow/untested-file");
1772 assert_eq!(
1773 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1774 "src/app.ts"
1775 );
1776 assert!(
1777 entries[0]["message"]["text"]
1778 .as_str()
1779 .unwrap()
1780 .contains("2 value exports")
1781 );
1782 assert_eq!(entries[1]["ruleId"], "fallow/untested-export");
1783 assert_eq!(
1784 entries[1]["locations"][0]["physicalLocation"]["region"]["startLine"],
1785 12
1786 );
1787 assert_eq!(
1788 entries[1]["locations"][0]["physicalLocation"]["region"]["startColumn"],
1789 5
1790 );
1791 }
1792
1793 #[test]
1796 fn health_sarif_rules_have_full_descriptions() {
1797 let root = PathBuf::from("/project");
1798 let report = crate::health_types::HealthReport::default();
1799 let sarif = build_health_sarif(&report, &root);
1800 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1801 .as_array()
1802 .unwrap();
1803 for rule in rules {
1804 let id = rule["id"].as_str().unwrap();
1805 assert!(
1806 rule.get("fullDescription").is_some(),
1807 "health rule {id} should have fullDescription"
1808 );
1809 assert!(
1810 rule.get("helpUri").is_some(),
1811 "health rule {id} should have helpUri"
1812 );
1813 }
1814 }
1815
1816 #[test]
1819 fn sarif_warn_severity_produces_warning_level() {
1820 let root = PathBuf::from("/project");
1821 let mut results = AnalysisResults::default();
1822 results.unused_files.push(UnusedFile {
1823 path: root.join("src/dead.ts"),
1824 });
1825
1826 let rules = RulesConfig {
1827 unused_files: Severity::Warn,
1828 ..RulesConfig::default()
1829 };
1830
1831 let sarif = build_sarif(&results, &root, &rules);
1832 let entry = &sarif["runs"][0]["results"][0];
1833 assert_eq!(entry["level"], "warning");
1834 }
1835
1836 #[test]
1839 fn sarif_unused_file_has_no_region() {
1840 let root = PathBuf::from("/project");
1841 let mut results = AnalysisResults::default();
1842 results.unused_files.push(UnusedFile {
1843 path: root.join("src/dead.ts"),
1844 });
1845
1846 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1847 let entry = &sarif["runs"][0]["results"][0];
1848 let phys = &entry["locations"][0]["physicalLocation"];
1849 assert!(phys.get("region").is_none());
1850 }
1851
1852 #[test]
1855 fn sarif_unlisted_dep_multiple_import_sites() {
1856 let root = PathBuf::from("/project");
1857 let mut results = AnalysisResults::default();
1858 results.unlisted_dependencies.push(UnlistedDependency {
1859 package_name: "dotenv".to_string(),
1860 imported_from: vec![
1861 ImportSite {
1862 path: root.join("src/a.ts"),
1863 line: 1,
1864 col: 0,
1865 },
1866 ImportSite {
1867 path: root.join("src/b.ts"),
1868 line: 5,
1869 col: 0,
1870 },
1871 ],
1872 });
1873
1874 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1875 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1876 assert_eq!(entries.len(), 2);
1878 assert_eq!(
1879 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1880 "src/a.ts"
1881 );
1882 assert_eq!(
1883 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1884 "src/b.ts"
1885 );
1886 }
1887
1888 #[test]
1891 fn sarif_unlisted_dep_no_import_sites() {
1892 let root = PathBuf::from("/project");
1893 let mut results = AnalysisResults::default();
1894 results.unlisted_dependencies.push(UnlistedDependency {
1895 package_name: "phantom".to_string(),
1896 imported_from: vec![],
1897 });
1898
1899 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1900 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1901 assert!(entries.is_empty());
1903 }
1904}