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