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 ]
254}
255
256#[must_use]
257pub fn build_sarif(
258 results: &AnalysisResults,
259 root: &Path,
260 rules: &RulesConfig,
261) -> serde_json::Value {
262 let mut sarif_results = Vec::new();
263
264 push_sarif_results(&mut sarif_results, &results.unused_files, |file| {
265 SarifFields {
266 rule_id: "fallow/unused-file",
267 level: severity_to_sarif_level(rules.unused_files),
268 message: "File is not reachable from any entry point".to_string(),
269 uri: relative_uri(&file.path, root),
270 region: None,
271 properties: None,
272 }
273 });
274
275 push_sarif_results(&mut sarif_results, &results.unused_exports, |export| {
276 sarif_export_fields(
277 export,
278 root,
279 "fallow/unused-export",
280 severity_to_sarif_level(rules.unused_exports),
281 "Export",
282 "Re-export",
283 )
284 });
285
286 push_sarif_results(&mut sarif_results, &results.unused_types, |export| {
287 sarif_export_fields(
288 export,
289 root,
290 "fallow/unused-type",
291 severity_to_sarif_level(rules.unused_types),
292 "Type export",
293 "Type re-export",
294 )
295 });
296
297 push_sarif_results(&mut sarif_results, &results.unused_dependencies, |dep| {
298 sarif_dep_fields(
299 dep,
300 root,
301 "fallow/unused-dependency",
302 severity_to_sarif_level(rules.unused_dependencies),
303 "dependencies",
304 )
305 });
306
307 push_sarif_results(
308 &mut sarif_results,
309 &results.unused_dev_dependencies,
310 |dep| {
311 sarif_dep_fields(
312 dep,
313 root,
314 "fallow/unused-dev-dependency",
315 severity_to_sarif_level(rules.unused_dev_dependencies),
316 "devDependencies",
317 )
318 },
319 );
320
321 push_sarif_results(
322 &mut sarif_results,
323 &results.unused_optional_dependencies,
324 |dep| {
325 sarif_dep_fields(
326 dep,
327 root,
328 "fallow/unused-optional-dependency",
329 severity_to_sarif_level(rules.unused_optional_dependencies),
330 "optionalDependencies",
331 )
332 },
333 );
334
335 push_sarif_results(&mut sarif_results, &results.type_only_dependencies, |dep| {
336 SarifFields {
337 rule_id: "fallow/type-only-dependency",
338 level: severity_to_sarif_level(rules.type_only_dependencies),
339 message: format!(
340 "Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
341 dep.package_name
342 ),
343 uri: relative_uri(&dep.path, root),
344 region: if dep.line > 0 {
345 Some((dep.line, 1))
346 } else {
347 None
348 },
349 properties: None,
350 }
351 });
352
353 push_sarif_results(&mut sarif_results, &results.test_only_dependencies, |dep| {
354 SarifFields {
355 rule_id: "fallow/test-only-dependency",
356 level: severity_to_sarif_level(rules.test_only_dependencies),
357 message: format!(
358 "Package '{}' is only imported by test files (consider moving to devDependencies)",
359 dep.package_name
360 ),
361 uri: relative_uri(&dep.path, root),
362 region: if dep.line > 0 {
363 Some((dep.line, 1))
364 } else {
365 None
366 },
367 properties: None,
368 }
369 });
370
371 push_sarif_results(&mut sarif_results, &results.unused_enum_members, |member| {
372 sarif_member_fields(
373 member,
374 root,
375 "fallow/unused-enum-member",
376 severity_to_sarif_level(rules.unused_enum_members),
377 "Enum",
378 )
379 });
380
381 push_sarif_results(
382 &mut sarif_results,
383 &results.unused_class_members,
384 |member| {
385 sarif_member_fields(
386 member,
387 root,
388 "fallow/unused-class-member",
389 severity_to_sarif_level(rules.unused_class_members),
390 "Class",
391 )
392 },
393 );
394
395 push_sarif_results(&mut sarif_results, &results.unresolved_imports, |import| {
396 SarifFields {
397 rule_id: "fallow/unresolved-import",
398 level: severity_to_sarif_level(rules.unresolved_imports),
399 message: format!("Import '{}' could not be resolved", import.specifier),
400 uri: relative_uri(&import.path, root),
401 region: Some((import.line, import.col + 1)),
402 properties: None,
403 }
404 });
405
406 for dep in &results.unlisted_dependencies {
408 for site in &dep.imported_from {
409 sarif_results.push(sarif_result(
410 "fallow/unlisted-dependency",
411 severity_to_sarif_level(rules.unlisted_dependencies),
412 &format!(
413 "Package '{}' is imported but not listed in package.json",
414 dep.package_name
415 ),
416 &relative_uri(&site.path, root),
417 Some((site.line, site.col + 1)),
418 ));
419 }
420 }
421
422 for dup in &results.duplicate_exports {
424 for loc in &dup.locations {
425 sarif_results.push(sarif_result(
426 "fallow/duplicate-export",
427 severity_to_sarif_level(rules.duplicate_exports),
428 &format!("Export '{}' appears in multiple modules", dup.export_name),
429 &relative_uri(&loc.path, root),
430 Some((loc.line, loc.col + 1)),
431 ));
432 }
433 }
434
435 push_sarif_results(
436 &mut sarif_results,
437 &results.circular_dependencies,
438 |cycle| {
439 let chain: Vec<String> = cycle.files.iter().map(|p| relative_uri(p, root)).collect();
440 let mut display_chain = chain.clone();
441 if let Some(first) = chain.first() {
442 display_chain.push(first.clone());
443 }
444 let first_uri = chain.first().map_or_else(String::new, Clone::clone);
445 SarifFields {
446 rule_id: "fallow/circular-dependency",
447 level: severity_to_sarif_level(rules.circular_dependencies),
448 message: format!(
449 "Circular dependency{}: {}",
450 if cycle.is_cross_package {
451 " (cross-package)"
452 } else {
453 ""
454 },
455 display_chain.join(" \u{2192} ")
456 ),
457 uri: first_uri,
458 region: if cycle.line > 0 {
459 Some((cycle.line, cycle.col + 1))
460 } else {
461 None
462 },
463 properties: None,
464 }
465 },
466 );
467
468 push_sarif_results(
469 &mut sarif_results,
470 &results.boundary_violations,
471 |violation| {
472 let from_uri = relative_uri(&violation.from_path, root);
473 let to_uri = relative_uri(&violation.to_path, root);
474 SarifFields {
475 rule_id: "fallow/boundary-violation",
476 level: severity_to_sarif_level(rules.boundary_violation),
477 message: format!(
478 "Import from zone '{}' to zone '{}' is not allowed ({})",
479 violation.from_zone, violation.to_zone, to_uri,
480 ),
481 uri: from_uri,
482 region: if violation.line > 0 {
483 Some((violation.line, violation.col + 1))
484 } else {
485 None
486 },
487 properties: None,
488 }
489 },
490 );
491
492 serde_json::json!({
493 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
494 "version": "2.1.0",
495 "runs": [{
496 "tool": {
497 "driver": {
498 "name": "fallow",
499 "version": env!("CARGO_PKG_VERSION"),
500 "informationUri": "https://github.com/fallow-rs/fallow",
501 "rules": build_sarif_rules(rules)
502 }
503 },
504 "results": sarif_results
505 }]
506 })
507}
508
509pub(super) fn print_sarif(results: &AnalysisResults, root: &Path, rules: &RulesConfig) -> ExitCode {
510 let sarif = build_sarif(results, root, rules);
511 emit_json(&sarif, "SARIF")
512}
513
514pub(super) fn print_grouped_sarif(
520 results: &AnalysisResults,
521 root: &Path,
522 rules: &RulesConfig,
523 resolver: &OwnershipResolver,
524) -> ExitCode {
525 let mut sarif = build_sarif(results, root, rules);
526
527 if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
529 for run in runs {
530 if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
531 for result in results {
532 let uri = result
533 .pointer("/locations/0/physicalLocation/artifactLocation/uri")
534 .and_then(|v| v.as_str())
535 .unwrap_or("");
536 let decoded = uri.replace("%5B", "[").replace("%5D", "]");
539 let owner =
540 grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
541 let props = result
542 .as_object_mut()
543 .expect("SARIF result should be an object")
544 .entry("properties")
545 .or_insert_with(|| serde_json::json!({}));
546 props
547 .as_object_mut()
548 .expect("properties should be an object")
549 .insert("owner".to_string(), serde_json::Value::String(owner));
550 }
551 }
552 }
553 }
554
555 emit_json(&sarif, "SARIF")
556}
557
558#[expect(
559 clippy::cast_possible_truncation,
560 reason = "line/col numbers are bounded by source size"
561)]
562pub(super) fn print_duplication_sarif(report: &DuplicationReport, root: &Path) -> ExitCode {
563 let mut sarif_results = Vec::new();
564
565 for (i, group) in report.clone_groups.iter().enumerate() {
566 for instance in &group.instances {
567 sarif_results.push(sarif_result(
568 "fallow/code-duplication",
569 "warning",
570 &format!(
571 "Code clone group {} ({} lines, {} instances)",
572 i + 1,
573 group.line_count,
574 group.instances.len()
575 ),
576 &relative_uri(&instance.file, root),
577 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
578 ));
579 }
580 }
581
582 let sarif = serde_json::json!({
583 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
584 "version": "2.1.0",
585 "runs": [{
586 "tool": {
587 "driver": {
588 "name": "fallow",
589 "version": env!("CARGO_PKG_VERSION"),
590 "informationUri": "https://github.com/fallow-rs/fallow",
591 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
592 }
593 },
594 "results": sarif_results
595 }]
596 });
597
598 emit_json(&sarif, "SARIF")
599}
600
601#[must_use]
607pub fn build_health_sarif(
608 report: &crate::health_types::HealthReport,
609 root: &Path,
610) -> serde_json::Value {
611 use crate::health_types::ExceededThreshold;
612
613 let mut sarif_results = Vec::new();
614
615 for finding in &report.findings {
616 let uri = relative_uri(&finding.path, root);
617 let (rule_id, message) = match finding.exceeded {
618 ExceededThreshold::Cyclomatic => (
619 "fallow/high-cyclomatic-complexity",
620 format!(
621 "'{}' has cyclomatic complexity {} (threshold: {})",
622 finding.name, finding.cyclomatic, report.summary.max_cyclomatic_threshold,
623 ),
624 ),
625 ExceededThreshold::Cognitive => (
626 "fallow/high-cognitive-complexity",
627 format!(
628 "'{}' has cognitive complexity {} (threshold: {})",
629 finding.name, finding.cognitive, report.summary.max_cognitive_threshold,
630 ),
631 ),
632 ExceededThreshold::Both => (
633 "fallow/high-complexity",
634 format!(
635 "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
636 finding.name,
637 finding.cyclomatic,
638 report.summary.max_cyclomatic_threshold,
639 finding.cognitive,
640 report.summary.max_cognitive_threshold,
641 ),
642 ),
643 };
644
645 sarif_results.push(sarif_result(
646 rule_id,
647 "warning",
648 &message,
649 &uri,
650 Some((finding.line, finding.col + 1)),
651 ));
652 }
653
654 for target in &report.targets {
656 let uri = relative_uri(&target.path, root);
657 let message = format!(
658 "[{}] {} (priority: {:.1}, efficiency: {:.1}, effort: {}, confidence: {})",
659 target.category.label(),
660 target.recommendation,
661 target.priority,
662 target.efficiency,
663 target.effort.label(),
664 target.confidence.label(),
665 );
666 sarif_results.push(sarif_result(
667 "fallow/refactoring-target",
668 "warning",
669 &message,
670 &uri,
671 None,
672 ));
673 }
674
675 if let Some(ref gaps) = report.coverage_gaps {
676 for item in &gaps.files {
677 let uri = relative_uri(&item.path, root);
678 let message = format!(
679 "File is runtime-reachable but has no test dependency path ({} value export{})",
680 item.value_export_count,
681 if item.value_export_count == 1 {
682 ""
683 } else {
684 "s"
685 },
686 );
687 sarif_results.push(sarif_result(
688 "fallow/untested-file",
689 "warning",
690 &message,
691 &uri,
692 None,
693 ));
694 }
695
696 for item in &gaps.exports {
697 let uri = relative_uri(&item.path, root);
698 let message = format!(
699 "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
700 item.export_name
701 );
702 sarif_results.push(sarif_result(
703 "fallow/untested-export",
704 "warning",
705 &message,
706 &uri,
707 Some((item.line, item.col + 1)),
708 ));
709 }
710 }
711
712 let health_rules = vec![
713 sarif_rule(
714 "fallow/high-cyclomatic-complexity",
715 "Function has high cyclomatic complexity",
716 "warning",
717 ),
718 sarif_rule(
719 "fallow/high-cognitive-complexity",
720 "Function has high cognitive complexity",
721 "warning",
722 ),
723 sarif_rule(
724 "fallow/high-complexity",
725 "Function exceeds both complexity thresholds",
726 "warning",
727 ),
728 sarif_rule(
729 "fallow/refactoring-target",
730 "File identified as a high-priority refactoring candidate",
731 "warning",
732 ),
733 sarif_rule(
734 "fallow/untested-file",
735 "Runtime-reachable file has no test dependency path",
736 "warning",
737 ),
738 sarif_rule(
739 "fallow/untested-export",
740 "Runtime-reachable export has no test dependency path",
741 "warning",
742 ),
743 ];
744
745 serde_json::json!({
746 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
747 "version": "2.1.0",
748 "runs": [{
749 "tool": {
750 "driver": {
751 "name": "fallow",
752 "version": env!("CARGO_PKG_VERSION"),
753 "informationUri": "https://github.com/fallow-rs/fallow",
754 "rules": health_rules
755 }
756 },
757 "results": sarif_results
758 }]
759 })
760}
761
762pub(super) fn print_health_sarif(
763 report: &crate::health_types::HealthReport,
764 root: &Path,
765) -> ExitCode {
766 let sarif = build_health_sarif(report, root);
767 emit_json(&sarif, "SARIF")
768}
769
770#[cfg(test)]
771mod tests {
772 use super::*;
773 use crate::report::test_helpers::sample_results;
774 use fallow_core::results::*;
775 use std::path::PathBuf;
776
777 #[test]
778 fn sarif_has_required_top_level_fields() {
779 let root = PathBuf::from("/project");
780 let results = AnalysisResults::default();
781 let sarif = build_sarif(&results, &root, &RulesConfig::default());
782
783 assert_eq!(
784 sarif["$schema"],
785 "https://json.schemastore.org/sarif-2.1.0.json"
786 );
787 assert_eq!(sarif["version"], "2.1.0");
788 assert!(sarif["runs"].is_array());
789 }
790
791 #[test]
792 fn sarif_has_tool_driver_info() {
793 let root = PathBuf::from("/project");
794 let results = AnalysisResults::default();
795 let sarif = build_sarif(&results, &root, &RulesConfig::default());
796
797 let driver = &sarif["runs"][0]["tool"]["driver"];
798 assert_eq!(driver["name"], "fallow");
799 assert!(driver["version"].is_string());
800 assert_eq!(
801 driver["informationUri"],
802 "https://github.com/fallow-rs/fallow"
803 );
804 }
805
806 #[test]
807 fn sarif_declares_all_rules() {
808 let root = PathBuf::from("/project");
809 let results = AnalysisResults::default();
810 let sarif = build_sarif(&results, &root, &RulesConfig::default());
811
812 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
813 .as_array()
814 .expect("rules should be an array");
815 assert_eq!(rules.len(), 15);
816
817 let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
818 assert!(rule_ids.contains(&"fallow/unused-file"));
819 assert!(rule_ids.contains(&"fallow/unused-export"));
820 assert!(rule_ids.contains(&"fallow/unused-type"));
821 assert!(rule_ids.contains(&"fallow/unused-dependency"));
822 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
823 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
824 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
825 assert!(rule_ids.contains(&"fallow/test-only-dependency"));
826 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
827 assert!(rule_ids.contains(&"fallow/unused-class-member"));
828 assert!(rule_ids.contains(&"fallow/unresolved-import"));
829 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
830 assert!(rule_ids.contains(&"fallow/duplicate-export"));
831 assert!(rule_ids.contains(&"fallow/circular-dependency"));
832 assert!(rule_ids.contains(&"fallow/boundary-violation"));
833 }
834
835 #[test]
836 fn sarif_empty_results_no_results_entries() {
837 let root = PathBuf::from("/project");
838 let results = AnalysisResults::default();
839 let sarif = build_sarif(&results, &root, &RulesConfig::default());
840
841 let sarif_results = sarif["runs"][0]["results"]
842 .as_array()
843 .expect("results should be an array");
844 assert!(sarif_results.is_empty());
845 }
846
847 #[test]
848 fn sarif_unused_file_result() {
849 let root = PathBuf::from("/project");
850 let mut results = AnalysisResults::default();
851 results.unused_files.push(UnusedFile {
852 path: root.join("src/dead.ts"),
853 });
854
855 let sarif = build_sarif(&results, &root, &RulesConfig::default());
856 let entries = sarif["runs"][0]["results"].as_array().unwrap();
857 assert_eq!(entries.len(), 1);
858
859 let entry = &entries[0];
860 assert_eq!(entry["ruleId"], "fallow/unused-file");
861 assert_eq!(entry["level"], "error");
863 assert_eq!(
864 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
865 "src/dead.ts"
866 );
867 }
868
869 #[test]
870 fn sarif_unused_export_includes_region() {
871 let root = PathBuf::from("/project");
872 let mut results = AnalysisResults::default();
873 results.unused_exports.push(UnusedExport {
874 path: root.join("src/utils.ts"),
875 export_name: "helperFn".to_string(),
876 is_type_only: false,
877 line: 10,
878 col: 4,
879 span_start: 120,
880 is_re_export: false,
881 });
882
883 let sarif = build_sarif(&results, &root, &RulesConfig::default());
884 let entry = &sarif["runs"][0]["results"][0];
885 assert_eq!(entry["ruleId"], "fallow/unused-export");
886
887 let region = &entry["locations"][0]["physicalLocation"]["region"];
888 assert_eq!(region["startLine"], 10);
889 assert_eq!(region["startColumn"], 5);
891 }
892
893 #[test]
894 fn sarif_unresolved_import_is_error_level() {
895 let root = PathBuf::from("/project");
896 let mut results = AnalysisResults::default();
897 results.unresolved_imports.push(UnresolvedImport {
898 path: root.join("src/app.ts"),
899 specifier: "./missing".to_string(),
900 line: 1,
901 col: 0,
902 specifier_col: 0,
903 });
904
905 let sarif = build_sarif(&results, &root, &RulesConfig::default());
906 let entry = &sarif["runs"][0]["results"][0];
907 assert_eq!(entry["ruleId"], "fallow/unresolved-import");
908 assert_eq!(entry["level"], "error");
909 }
910
911 #[test]
912 fn sarif_unlisted_dependency_points_to_import_site() {
913 let root = PathBuf::from("/project");
914 let mut results = AnalysisResults::default();
915 results.unlisted_dependencies.push(UnlistedDependency {
916 package_name: "chalk".to_string(),
917 imported_from: vec![ImportSite {
918 path: root.join("src/cli.ts"),
919 line: 3,
920 col: 0,
921 }],
922 });
923
924 let sarif = build_sarif(&results, &root, &RulesConfig::default());
925 let entry = &sarif["runs"][0]["results"][0];
926 assert_eq!(entry["ruleId"], "fallow/unlisted-dependency");
927 assert_eq!(entry["level"], "error");
928 assert_eq!(
929 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
930 "src/cli.ts"
931 );
932 let region = &entry["locations"][0]["physicalLocation"]["region"];
933 assert_eq!(region["startLine"], 3);
934 assert_eq!(region["startColumn"], 1);
935 }
936
937 #[test]
938 fn sarif_dependency_issues_point_to_package_json() {
939 let root = PathBuf::from("/project");
940 let mut results = AnalysisResults::default();
941 results.unused_dependencies.push(UnusedDependency {
942 package_name: "lodash".to_string(),
943 location: DependencyLocation::Dependencies,
944 path: root.join("package.json"),
945 line: 5,
946 });
947 results.unused_dev_dependencies.push(UnusedDependency {
948 package_name: "jest".to_string(),
949 location: DependencyLocation::DevDependencies,
950 path: root.join("package.json"),
951 line: 5,
952 });
953
954 let sarif = build_sarif(&results, &root, &RulesConfig::default());
955 let entries = sarif["runs"][0]["results"].as_array().unwrap();
956 for entry in entries {
957 assert_eq!(
958 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
959 "package.json"
960 );
961 }
962 }
963
964 #[test]
965 fn sarif_duplicate_export_emits_one_result_per_location() {
966 let root = PathBuf::from("/project");
967 let mut results = AnalysisResults::default();
968 results.duplicate_exports.push(DuplicateExport {
969 export_name: "Config".to_string(),
970 locations: vec![
971 DuplicateLocation {
972 path: root.join("src/a.ts"),
973 line: 15,
974 col: 0,
975 },
976 DuplicateLocation {
977 path: root.join("src/b.ts"),
978 line: 30,
979 col: 0,
980 },
981 ],
982 });
983
984 let sarif = build_sarif(&results, &root, &RulesConfig::default());
985 let entries = sarif["runs"][0]["results"].as_array().unwrap();
986 assert_eq!(entries.len(), 2);
988 assert_eq!(entries[0]["ruleId"], "fallow/duplicate-export");
989 assert_eq!(entries[1]["ruleId"], "fallow/duplicate-export");
990 assert_eq!(
991 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
992 "src/a.ts"
993 );
994 assert_eq!(
995 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
996 "src/b.ts"
997 );
998 }
999
1000 #[test]
1001 fn sarif_all_issue_types_produce_results() {
1002 let root = PathBuf::from("/project");
1003 let results = sample_results(&root);
1004 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1005
1006 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1007 assert_eq!(entries.len(), results.total_issues() + 1);
1009
1010 let rule_ids: Vec<&str> = entries
1011 .iter()
1012 .map(|e| e["ruleId"].as_str().unwrap())
1013 .collect();
1014 assert!(rule_ids.contains(&"fallow/unused-file"));
1015 assert!(rule_ids.contains(&"fallow/unused-export"));
1016 assert!(rule_ids.contains(&"fallow/unused-type"));
1017 assert!(rule_ids.contains(&"fallow/unused-dependency"));
1018 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
1019 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
1020 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
1021 assert!(rule_ids.contains(&"fallow/test-only-dependency"));
1022 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
1023 assert!(rule_ids.contains(&"fallow/unused-class-member"));
1024 assert!(rule_ids.contains(&"fallow/unresolved-import"));
1025 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
1026 assert!(rule_ids.contains(&"fallow/duplicate-export"));
1027 }
1028
1029 #[test]
1030 fn sarif_serializes_to_valid_json() {
1031 let root = PathBuf::from("/project");
1032 let results = sample_results(&root);
1033 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1034
1035 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
1036 let reparsed: serde_json::Value =
1037 serde_json::from_str(&json_str).expect("SARIF output should be valid JSON");
1038 assert_eq!(reparsed, sarif);
1039 }
1040
1041 #[test]
1042 fn sarif_file_write_produces_valid_sarif() {
1043 let root = PathBuf::from("/project");
1044 let results = sample_results(&root);
1045 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1046 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
1047
1048 let dir = std::env::temp_dir().join("fallow-test-sarif-file");
1049 let _ = std::fs::create_dir_all(&dir);
1050 let sarif_path = dir.join("results.sarif");
1051 std::fs::write(&sarif_path, &json_str).expect("should write SARIF file");
1052
1053 let contents = std::fs::read_to_string(&sarif_path).expect("should read SARIF file");
1054 let parsed: serde_json::Value =
1055 serde_json::from_str(&contents).expect("file should contain valid JSON");
1056
1057 assert_eq!(parsed["version"], "2.1.0");
1058 assert_eq!(
1059 parsed["$schema"],
1060 "https://json.schemastore.org/sarif-2.1.0.json"
1061 );
1062 let sarif_results = parsed["runs"][0]["results"]
1063 .as_array()
1064 .expect("results should be an array");
1065 assert!(!sarif_results.is_empty());
1066
1067 let _ = std::fs::remove_file(&sarif_path);
1069 let _ = std::fs::remove_dir(&dir);
1070 }
1071
1072 #[test]
1075 fn health_sarif_empty_no_results() {
1076 let root = PathBuf::from("/project");
1077 let report = crate::health_types::HealthReport {
1078 findings: vec![],
1079 summary: crate::health_types::HealthSummary {
1080 files_analyzed: 10,
1081 functions_analyzed: 50,
1082 functions_above_threshold: 0,
1083 max_cyclomatic_threshold: 20,
1084 max_cognitive_threshold: 15,
1085 files_scored: None,
1086 average_maintainability: None,
1087 coverage_model: None,
1088 istanbul_matched: None,
1089 istanbul_total: None,
1090 },
1091 vital_signs: None,
1092 health_score: None,
1093 file_scores: vec![],
1094 coverage_gaps: None,
1095 hotspots: vec![],
1096 hotspot_summary: None,
1097 targets: vec![],
1098 target_thresholds: None,
1099 health_trend: None,
1100 };
1101 let sarif = build_health_sarif(&report, &root);
1102 assert_eq!(sarif["version"], "2.1.0");
1103 let results = sarif["runs"][0]["results"].as_array().unwrap();
1104 assert!(results.is_empty());
1105 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1106 .as_array()
1107 .unwrap();
1108 assert_eq!(rules.len(), 6);
1109 }
1110
1111 #[test]
1112 fn health_sarif_cyclomatic_only() {
1113 let root = PathBuf::from("/project");
1114 let report = crate::health_types::HealthReport {
1115 findings: vec![crate::health_types::HealthFinding {
1116 path: root.join("src/utils.ts"),
1117 name: "parseExpression".to_string(),
1118 line: 42,
1119 col: 0,
1120 cyclomatic: 25,
1121 cognitive: 10,
1122 line_count: 80,
1123 param_count: 0,
1124 exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
1125 }],
1126 summary: crate::health_types::HealthSummary {
1127 files_analyzed: 5,
1128 functions_analyzed: 20,
1129 functions_above_threshold: 1,
1130 max_cyclomatic_threshold: 20,
1131 max_cognitive_threshold: 15,
1132 files_scored: None,
1133 average_maintainability: None,
1134 coverage_model: None,
1135 istanbul_matched: None,
1136 istanbul_total: None,
1137 },
1138 vital_signs: None,
1139 health_score: None,
1140 file_scores: vec![],
1141 coverage_gaps: None,
1142 hotspots: vec![],
1143 hotspot_summary: None,
1144 targets: vec![],
1145 target_thresholds: None,
1146 health_trend: None,
1147 };
1148 let sarif = build_health_sarif(&report, &root);
1149 let entry = &sarif["runs"][0]["results"][0];
1150 assert_eq!(entry["ruleId"], "fallow/high-cyclomatic-complexity");
1151 assert_eq!(entry["level"], "warning");
1152 assert!(
1153 entry["message"]["text"]
1154 .as_str()
1155 .unwrap()
1156 .contains("cyclomatic complexity 25")
1157 );
1158 assert_eq!(
1159 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1160 "src/utils.ts"
1161 );
1162 let region = &entry["locations"][0]["physicalLocation"]["region"];
1163 assert_eq!(region["startLine"], 42);
1164 assert_eq!(region["startColumn"], 1);
1165 }
1166
1167 #[test]
1168 fn health_sarif_cognitive_only() {
1169 let root = PathBuf::from("/project");
1170 let report = crate::health_types::HealthReport {
1171 findings: vec![crate::health_types::HealthFinding {
1172 path: root.join("src/api.ts"),
1173 name: "handleRequest".to_string(),
1174 line: 10,
1175 col: 4,
1176 cyclomatic: 8,
1177 cognitive: 20,
1178 line_count: 40,
1179 param_count: 0,
1180 exceeded: crate::health_types::ExceededThreshold::Cognitive,
1181 }],
1182 summary: crate::health_types::HealthSummary {
1183 files_analyzed: 3,
1184 functions_analyzed: 10,
1185 functions_above_threshold: 1,
1186 max_cyclomatic_threshold: 20,
1187 max_cognitive_threshold: 15,
1188 files_scored: None,
1189 average_maintainability: None,
1190 coverage_model: None,
1191 istanbul_matched: None,
1192 istanbul_total: None,
1193 },
1194 vital_signs: None,
1195 health_score: None,
1196 file_scores: vec![],
1197 coverage_gaps: None,
1198 hotspots: vec![],
1199 hotspot_summary: None,
1200 targets: vec![],
1201 target_thresholds: None,
1202 health_trend: None,
1203 };
1204 let sarif = build_health_sarif(&report, &root);
1205 let entry = &sarif["runs"][0]["results"][0];
1206 assert_eq!(entry["ruleId"], "fallow/high-cognitive-complexity");
1207 assert!(
1208 entry["message"]["text"]
1209 .as_str()
1210 .unwrap()
1211 .contains("cognitive complexity 20")
1212 );
1213 let region = &entry["locations"][0]["physicalLocation"]["region"];
1214 assert_eq!(region["startColumn"], 5); }
1216
1217 #[test]
1218 fn health_sarif_both_thresholds() {
1219 let root = PathBuf::from("/project");
1220 let report = crate::health_types::HealthReport {
1221 findings: vec![crate::health_types::HealthFinding {
1222 path: root.join("src/complex.ts"),
1223 name: "doEverything".to_string(),
1224 line: 1,
1225 col: 0,
1226 cyclomatic: 30,
1227 cognitive: 45,
1228 line_count: 100,
1229 param_count: 0,
1230 exceeded: crate::health_types::ExceededThreshold::Both,
1231 }],
1232 summary: crate::health_types::HealthSummary {
1233 files_analyzed: 1,
1234 functions_analyzed: 1,
1235 functions_above_threshold: 1,
1236 max_cyclomatic_threshold: 20,
1237 max_cognitive_threshold: 15,
1238 files_scored: None,
1239 average_maintainability: None,
1240 coverage_model: None,
1241 istanbul_matched: None,
1242 istanbul_total: None,
1243 },
1244 vital_signs: None,
1245 health_score: None,
1246 file_scores: vec![],
1247 coverage_gaps: None,
1248 hotspots: vec![],
1249 hotspot_summary: None,
1250 targets: vec![],
1251 target_thresholds: None,
1252 health_trend: None,
1253 };
1254 let sarif = build_health_sarif(&report, &root);
1255 let entry = &sarif["runs"][0]["results"][0];
1256 assert_eq!(entry["ruleId"], "fallow/high-complexity");
1257 let msg = entry["message"]["text"].as_str().unwrap();
1258 assert!(msg.contains("cyclomatic complexity 30"));
1259 assert!(msg.contains("cognitive complexity 45"));
1260 }
1261
1262 #[test]
1265 fn severity_to_sarif_level_error() {
1266 assert_eq!(severity_to_sarif_level(Severity::Error), "error");
1267 }
1268
1269 #[test]
1270 fn severity_to_sarif_level_warn() {
1271 assert_eq!(severity_to_sarif_level(Severity::Warn), "warning");
1272 }
1273
1274 #[test]
1275 fn severity_to_sarif_level_off() {
1276 assert_eq!(severity_to_sarif_level(Severity::Off), "warning");
1277 }
1278
1279 #[test]
1282 fn sarif_re_export_has_properties() {
1283 let root = PathBuf::from("/project");
1284 let mut results = AnalysisResults::default();
1285 results.unused_exports.push(UnusedExport {
1286 path: root.join("src/index.ts"),
1287 export_name: "reExported".to_string(),
1288 is_type_only: false,
1289 line: 1,
1290 col: 0,
1291 span_start: 0,
1292 is_re_export: true,
1293 });
1294
1295 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1296 let entry = &sarif["runs"][0]["results"][0];
1297 assert_eq!(entry["properties"]["is_re_export"], true);
1298 let msg = entry["message"]["text"].as_str().unwrap();
1299 assert!(msg.starts_with("Re-export"));
1300 }
1301
1302 #[test]
1303 fn sarif_non_re_export_has_no_properties() {
1304 let root = PathBuf::from("/project");
1305 let mut results = AnalysisResults::default();
1306 results.unused_exports.push(UnusedExport {
1307 path: root.join("src/utils.ts"),
1308 export_name: "foo".to_string(),
1309 is_type_only: false,
1310 line: 5,
1311 col: 0,
1312 span_start: 0,
1313 is_re_export: false,
1314 });
1315
1316 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1317 let entry = &sarif["runs"][0]["results"][0];
1318 assert!(entry.get("properties").is_none());
1319 let msg = entry["message"]["text"].as_str().unwrap();
1320 assert!(msg.starts_with("Export"));
1321 }
1322
1323 #[test]
1326 fn sarif_type_re_export_message() {
1327 let root = PathBuf::from("/project");
1328 let mut results = AnalysisResults::default();
1329 results.unused_types.push(UnusedExport {
1330 path: root.join("src/index.ts"),
1331 export_name: "MyType".to_string(),
1332 is_type_only: true,
1333 line: 1,
1334 col: 0,
1335 span_start: 0,
1336 is_re_export: true,
1337 });
1338
1339 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1340 let entry = &sarif["runs"][0]["results"][0];
1341 assert_eq!(entry["ruleId"], "fallow/unused-type");
1342 let msg = entry["message"]["text"].as_str().unwrap();
1343 assert!(msg.starts_with("Type re-export"));
1344 assert_eq!(entry["properties"]["is_re_export"], true);
1345 }
1346
1347 #[test]
1350 fn sarif_dependency_line_zero_skips_region() {
1351 let root = PathBuf::from("/project");
1352 let mut results = AnalysisResults::default();
1353 results.unused_dependencies.push(UnusedDependency {
1354 package_name: "lodash".to_string(),
1355 location: DependencyLocation::Dependencies,
1356 path: root.join("package.json"),
1357 line: 0,
1358 });
1359
1360 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1361 let entry = &sarif["runs"][0]["results"][0];
1362 let phys = &entry["locations"][0]["physicalLocation"];
1363 assert!(phys.get("region").is_none());
1364 }
1365
1366 #[test]
1367 fn sarif_dependency_line_nonzero_has_region() {
1368 let root = PathBuf::from("/project");
1369 let mut results = AnalysisResults::default();
1370 results.unused_dependencies.push(UnusedDependency {
1371 package_name: "lodash".to_string(),
1372 location: DependencyLocation::Dependencies,
1373 path: root.join("package.json"),
1374 line: 7,
1375 });
1376
1377 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1378 let entry = &sarif["runs"][0]["results"][0];
1379 let region = &entry["locations"][0]["physicalLocation"]["region"];
1380 assert_eq!(region["startLine"], 7);
1381 assert_eq!(region["startColumn"], 1);
1382 }
1383
1384 #[test]
1387 fn sarif_type_only_dep_line_zero_skips_region() {
1388 let root = PathBuf::from("/project");
1389 let mut results = AnalysisResults::default();
1390 results.type_only_dependencies.push(TypeOnlyDependency {
1391 package_name: "zod".to_string(),
1392 path: root.join("package.json"),
1393 line: 0,
1394 });
1395
1396 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1397 let entry = &sarif["runs"][0]["results"][0];
1398 let phys = &entry["locations"][0]["physicalLocation"];
1399 assert!(phys.get("region").is_none());
1400 }
1401
1402 #[test]
1405 fn sarif_circular_dep_line_zero_skips_region() {
1406 let root = PathBuf::from("/project");
1407 let mut results = AnalysisResults::default();
1408 results.circular_dependencies.push(CircularDependency {
1409 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1410 length: 2,
1411 line: 0,
1412 col: 0,
1413 is_cross_package: false,
1414 });
1415
1416 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1417 let entry = &sarif["runs"][0]["results"][0];
1418 let phys = &entry["locations"][0]["physicalLocation"];
1419 assert!(phys.get("region").is_none());
1420 }
1421
1422 #[test]
1423 fn sarif_circular_dep_line_nonzero_has_region() {
1424 let root = PathBuf::from("/project");
1425 let mut results = AnalysisResults::default();
1426 results.circular_dependencies.push(CircularDependency {
1427 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1428 length: 2,
1429 line: 5,
1430 col: 2,
1431 is_cross_package: false,
1432 });
1433
1434 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1435 let entry = &sarif["runs"][0]["results"][0];
1436 let region = &entry["locations"][0]["physicalLocation"]["region"];
1437 assert_eq!(region["startLine"], 5);
1438 assert_eq!(region["startColumn"], 3);
1439 }
1440
1441 #[test]
1444 fn sarif_unused_optional_dependency_result() {
1445 let root = PathBuf::from("/project");
1446 let mut results = AnalysisResults::default();
1447 results.unused_optional_dependencies.push(UnusedDependency {
1448 package_name: "fsevents".to_string(),
1449 location: DependencyLocation::OptionalDependencies,
1450 path: root.join("package.json"),
1451 line: 12,
1452 });
1453
1454 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1455 let entry = &sarif["runs"][0]["results"][0];
1456 assert_eq!(entry["ruleId"], "fallow/unused-optional-dependency");
1457 let msg = entry["message"]["text"].as_str().unwrap();
1458 assert!(msg.contains("optionalDependencies"));
1459 }
1460
1461 #[test]
1464 fn sarif_enum_member_message_format() {
1465 let root = PathBuf::from("/project");
1466 let mut results = AnalysisResults::default();
1467 results
1468 .unused_enum_members
1469 .push(fallow_core::results::UnusedMember {
1470 path: root.join("src/enums.ts"),
1471 parent_name: "Color".to_string(),
1472 member_name: "Purple".to_string(),
1473 kind: fallow_core::extract::MemberKind::EnumMember,
1474 line: 5,
1475 col: 2,
1476 });
1477
1478 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1479 let entry = &sarif["runs"][0]["results"][0];
1480 assert_eq!(entry["ruleId"], "fallow/unused-enum-member");
1481 let msg = entry["message"]["text"].as_str().unwrap();
1482 assert!(msg.contains("Enum member 'Color.Purple'"));
1483 let region = &entry["locations"][0]["physicalLocation"]["region"];
1484 assert_eq!(region["startColumn"], 3); }
1486
1487 #[test]
1488 fn sarif_class_member_message_format() {
1489 let root = PathBuf::from("/project");
1490 let mut results = AnalysisResults::default();
1491 results
1492 .unused_class_members
1493 .push(fallow_core::results::UnusedMember {
1494 path: root.join("src/service.ts"),
1495 parent_name: "API".to_string(),
1496 member_name: "fetch".to_string(),
1497 kind: fallow_core::extract::MemberKind::ClassMethod,
1498 line: 10,
1499 col: 4,
1500 });
1501
1502 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1503 let entry = &sarif["runs"][0]["results"][0];
1504 assert_eq!(entry["ruleId"], "fallow/unused-class-member");
1505 let msg = entry["message"]["text"].as_str().unwrap();
1506 assert!(msg.contains("Class member 'API.fetch'"));
1507 }
1508
1509 #[test]
1512 #[expect(
1513 clippy::cast_possible_truncation,
1514 reason = "test line/col values are trivially small"
1515 )]
1516 fn duplication_sarif_structure() {
1517 use fallow_core::duplicates::*;
1518
1519 let root = PathBuf::from("/project");
1520 let report = DuplicationReport {
1521 clone_groups: vec![CloneGroup {
1522 instances: vec![
1523 CloneInstance {
1524 file: root.join("src/a.ts"),
1525 start_line: 1,
1526 end_line: 10,
1527 start_col: 0,
1528 end_col: 0,
1529 fragment: String::new(),
1530 },
1531 CloneInstance {
1532 file: root.join("src/b.ts"),
1533 start_line: 5,
1534 end_line: 14,
1535 start_col: 2,
1536 end_col: 0,
1537 fragment: String::new(),
1538 },
1539 ],
1540 token_count: 50,
1541 line_count: 10,
1542 }],
1543 clone_families: vec![],
1544 mirrored_directories: vec![],
1545 stats: DuplicationStats::default(),
1546 };
1547
1548 let sarif = serde_json::json!({
1549 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1550 "version": "2.1.0",
1551 "runs": [{
1552 "tool": {
1553 "driver": {
1554 "name": "fallow",
1555 "version": env!("CARGO_PKG_VERSION"),
1556 "informationUri": "https://github.com/fallow-rs/fallow",
1557 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
1558 }
1559 },
1560 "results": []
1561 }]
1562 });
1563 let _ = sarif;
1565
1566 let mut sarif_results = Vec::new();
1568 for (i, group) in report.clone_groups.iter().enumerate() {
1569 for instance in &group.instances {
1570 sarif_results.push(sarif_result(
1571 "fallow/code-duplication",
1572 "warning",
1573 &format!(
1574 "Code clone group {} ({} lines, {} instances)",
1575 i + 1,
1576 group.line_count,
1577 group.instances.len()
1578 ),
1579 &super::super::relative_uri(&instance.file, &root),
1580 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
1581 ));
1582 }
1583 }
1584 assert_eq!(sarif_results.len(), 2);
1585 assert_eq!(sarif_results[0]["ruleId"], "fallow/code-duplication");
1586 assert!(
1587 sarif_results[0]["message"]["text"]
1588 .as_str()
1589 .unwrap()
1590 .contains("10 lines")
1591 );
1592 let region0 = &sarif_results[0]["locations"][0]["physicalLocation"]["region"];
1593 assert_eq!(region0["startLine"], 1);
1594 assert_eq!(region0["startColumn"], 1); let region1 = &sarif_results[1]["locations"][0]["physicalLocation"]["region"];
1596 assert_eq!(region1["startLine"], 5);
1597 assert_eq!(region1["startColumn"], 3); }
1599
1600 #[test]
1603 fn sarif_rule_known_id_has_full_description() {
1604 let rule = sarif_rule("fallow/unused-file", "fallback text", "error");
1605 assert!(rule.get("fullDescription").is_some());
1606 assert!(rule.get("helpUri").is_some());
1607 }
1608
1609 #[test]
1610 fn sarif_rule_unknown_id_uses_fallback() {
1611 let rule = sarif_rule("fallow/nonexistent", "fallback text", "warning");
1612 assert_eq!(rule["shortDescription"]["text"], "fallback text");
1613 assert!(rule.get("fullDescription").is_none());
1614 assert!(rule.get("helpUri").is_none());
1615 assert_eq!(rule["defaultConfiguration"]["level"], "warning");
1616 }
1617
1618 #[test]
1621 fn sarif_result_no_region_omits_region_key() {
1622 let result = sarif_result("rule/test", "error", "test msg", "src/file.ts", None);
1623 let phys = &result["locations"][0]["physicalLocation"];
1624 assert!(phys.get("region").is_none());
1625 assert_eq!(phys["artifactLocation"]["uri"], "src/file.ts");
1626 }
1627
1628 #[test]
1629 fn sarif_result_with_region_includes_region() {
1630 let result = sarif_result(
1631 "rule/test",
1632 "error",
1633 "test msg",
1634 "src/file.ts",
1635 Some((10, 5)),
1636 );
1637 let region = &result["locations"][0]["physicalLocation"]["region"];
1638 assert_eq!(region["startLine"], 10);
1639 assert_eq!(region["startColumn"], 5);
1640 }
1641
1642 #[test]
1645 fn health_sarif_includes_refactoring_targets() {
1646 use crate::health_types::*;
1647
1648 let root = PathBuf::from("/project");
1649 let report = HealthReport {
1650 findings: vec![],
1651 summary: HealthSummary {
1652 files_analyzed: 10,
1653 functions_analyzed: 50,
1654 functions_above_threshold: 0,
1655 max_cyclomatic_threshold: 20,
1656 max_cognitive_threshold: 15,
1657 files_scored: None,
1658 average_maintainability: None,
1659 coverage_model: None,
1660 istanbul_matched: None,
1661 istanbul_total: None,
1662 },
1663 vital_signs: None,
1664 health_score: None,
1665 file_scores: vec![],
1666 coverage_gaps: None,
1667 hotspots: vec![],
1668 hotspot_summary: None,
1669 targets: vec![RefactoringTarget {
1670 path: root.join("src/complex.ts"),
1671 priority: 85.0,
1672 efficiency: 42.5,
1673 recommendation: "Split high-impact file".into(),
1674 category: RecommendationCategory::SplitHighImpact,
1675 effort: EffortEstimate::Medium,
1676 confidence: Confidence::High,
1677 factors: vec![],
1678 evidence: None,
1679 }],
1680 target_thresholds: None,
1681 health_trend: None,
1682 };
1683
1684 let sarif = build_health_sarif(&report, &root);
1685 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1686 assert_eq!(entries.len(), 1);
1687 assert_eq!(entries[0]["ruleId"], "fallow/refactoring-target");
1688 assert_eq!(entries[0]["level"], "warning");
1689 let msg = entries[0]["message"]["text"].as_str().unwrap();
1690 assert!(msg.contains("high impact"));
1691 assert!(msg.contains("Split high-impact file"));
1692 assert!(msg.contains("42.5"));
1693 }
1694
1695 #[test]
1696 fn health_sarif_includes_coverage_gaps() {
1697 use crate::health_types::*;
1698
1699 let root = PathBuf::from("/project");
1700 let report = HealthReport {
1701 findings: vec![],
1702 summary: HealthSummary {
1703 files_analyzed: 10,
1704 functions_analyzed: 50,
1705 functions_above_threshold: 0,
1706 max_cyclomatic_threshold: 20,
1707 max_cognitive_threshold: 15,
1708 files_scored: None,
1709 average_maintainability: None,
1710 coverage_model: None,
1711 istanbul_matched: None,
1712 istanbul_total: None,
1713 },
1714 vital_signs: None,
1715 health_score: None,
1716 file_scores: vec![],
1717 coverage_gaps: Some(CoverageGaps {
1718 summary: CoverageGapSummary {
1719 runtime_files: 2,
1720 covered_files: 0,
1721 file_coverage_pct: 0.0,
1722 untested_files: 1,
1723 untested_exports: 1,
1724 },
1725 files: vec![UntestedFile {
1726 path: root.join("src/app.ts"),
1727 value_export_count: 2,
1728 }],
1729 exports: vec![UntestedExport {
1730 path: root.join("src/app.ts"),
1731 export_name: "loader".into(),
1732 line: 12,
1733 col: 4,
1734 }],
1735 }),
1736 hotspots: vec![],
1737 hotspot_summary: None,
1738 targets: vec![],
1739 target_thresholds: None,
1740 health_trend: None,
1741 };
1742
1743 let sarif = build_health_sarif(&report, &root);
1744 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1745 assert_eq!(entries.len(), 2);
1746 assert_eq!(entries[0]["ruleId"], "fallow/untested-file");
1747 assert_eq!(
1748 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1749 "src/app.ts"
1750 );
1751 assert!(
1752 entries[0]["message"]["text"]
1753 .as_str()
1754 .unwrap()
1755 .contains("2 value exports")
1756 );
1757 assert_eq!(entries[1]["ruleId"], "fallow/untested-export");
1758 assert_eq!(
1759 entries[1]["locations"][0]["physicalLocation"]["region"]["startLine"],
1760 12
1761 );
1762 assert_eq!(
1763 entries[1]["locations"][0]["physicalLocation"]["region"]["startColumn"],
1764 5
1765 );
1766 }
1767
1768 #[test]
1771 fn health_sarif_rules_have_full_descriptions() {
1772 let root = PathBuf::from("/project");
1773 let report = crate::health_types::HealthReport {
1774 findings: vec![],
1775 summary: crate::health_types::HealthSummary {
1776 files_analyzed: 0,
1777 functions_analyzed: 0,
1778 functions_above_threshold: 0,
1779 max_cyclomatic_threshold: 20,
1780 max_cognitive_threshold: 15,
1781 files_scored: None,
1782 average_maintainability: None,
1783 coverage_model: None,
1784 istanbul_matched: None,
1785 istanbul_total: None,
1786 },
1787 vital_signs: None,
1788 health_score: None,
1789 file_scores: vec![],
1790 coverage_gaps: None,
1791 hotspots: vec![],
1792 hotspot_summary: None,
1793 targets: vec![],
1794 target_thresholds: None,
1795 health_trend: None,
1796 };
1797 let sarif = build_health_sarif(&report, &root);
1798 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1799 .as_array()
1800 .unwrap();
1801 for rule in rules {
1802 let id = rule["id"].as_str().unwrap();
1803 assert!(
1804 rule.get("fullDescription").is_some(),
1805 "health rule {id} should have fullDescription"
1806 );
1807 assert!(
1808 rule.get("helpUri").is_some(),
1809 "health rule {id} should have helpUri"
1810 );
1811 }
1812 }
1813
1814 #[test]
1817 fn sarif_warn_severity_produces_warning_level() {
1818 let root = PathBuf::from("/project");
1819 let mut results = AnalysisResults::default();
1820 results.unused_files.push(UnusedFile {
1821 path: root.join("src/dead.ts"),
1822 });
1823
1824 let rules = RulesConfig {
1825 unused_files: Severity::Warn,
1826 ..RulesConfig::default()
1827 };
1828
1829 let sarif = build_sarif(&results, &root, &rules);
1830 let entry = &sarif["runs"][0]["results"][0];
1831 assert_eq!(entry["level"], "warning");
1832 }
1833
1834 #[test]
1837 fn sarif_unused_file_has_no_region() {
1838 let root = PathBuf::from("/project");
1839 let mut results = AnalysisResults::default();
1840 results.unused_files.push(UnusedFile {
1841 path: root.join("src/dead.ts"),
1842 });
1843
1844 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1845 let entry = &sarif["runs"][0]["results"][0];
1846 let phys = &entry["locations"][0]["physicalLocation"];
1847 assert!(phys.get("region").is_none());
1848 }
1849
1850 #[test]
1853 fn sarif_unlisted_dep_multiple_import_sites() {
1854 let root = PathBuf::from("/project");
1855 let mut results = AnalysisResults::default();
1856 results.unlisted_dependencies.push(UnlistedDependency {
1857 package_name: "dotenv".to_string(),
1858 imported_from: vec![
1859 ImportSite {
1860 path: root.join("src/a.ts"),
1861 line: 1,
1862 col: 0,
1863 },
1864 ImportSite {
1865 path: root.join("src/b.ts"),
1866 line: 5,
1867 col: 0,
1868 },
1869 ],
1870 });
1871
1872 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1873 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1874 assert_eq!(entries.len(), 2);
1876 assert_eq!(
1877 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1878 "src/a.ts"
1879 );
1880 assert_eq!(
1881 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1882 "src/b.ts"
1883 );
1884 }
1885
1886 #[test]
1889 fn sarif_unlisted_dep_no_import_sites() {
1890 let root = PathBuf::from("/project");
1891 let mut results = AnalysisResults::default();
1892 results.unlisted_dependencies.push(UnlistedDependency {
1893 package_name: "phantom".to_string(),
1894 imported_from: vec![],
1895 });
1896
1897 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1898 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1899 assert!(entries.is_empty());
1901 }
1902}