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