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