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::{emit_json, relative_uri};
9use crate::explain;
10
11struct SarifFields {
13 rule_id: &'static str,
14 level: &'static str,
15 message: String,
16 uri: String,
17 region: Option<(u32, u32)>,
18 properties: Option<serde_json::Value>,
19}
20
21const fn severity_to_sarif_level(s: Severity) -> &'static str {
22 match s {
23 Severity::Error => "error",
24 Severity::Warn | Severity::Off => "warning",
25 }
26}
27
28fn sarif_result(
33 rule_id: &str,
34 level: &str,
35 message: &str,
36 uri: &str,
37 region: Option<(u32, u32)>,
38) -> serde_json::Value {
39 let mut physical_location = serde_json::json!({
40 "artifactLocation": { "uri": uri }
41 });
42 if let Some((line, col)) = region {
43 physical_location["region"] = serde_json::json!({
44 "startLine": line,
45 "startColumn": col
46 });
47 }
48 serde_json::json!({
49 "ruleId": rule_id,
50 "level": level,
51 "message": { "text": message },
52 "locations": [{ "physicalLocation": physical_location }]
53 })
54}
55
56fn push_sarif_results<T>(
58 sarif_results: &mut Vec<serde_json::Value>,
59 items: &[T],
60 extract: impl Fn(&T) -> SarifFields,
61) {
62 for item in items {
63 let fields = extract(item);
64 let mut result = sarif_result(
65 fields.rule_id,
66 fields.level,
67 &fields.message,
68 &fields.uri,
69 fields.region,
70 );
71 if let Some(props) = fields.properties {
72 result["properties"] = props;
73 }
74 sarif_results.push(result);
75 }
76}
77
78fn sarif_rule(id: &str, fallback_short: &str, level: &str) -> serde_json::Value {
81 explain::rule_by_id(id).map_or_else(
82 || {
83 serde_json::json!({
84 "id": id,
85 "shortDescription": { "text": fallback_short },
86 "defaultConfiguration": { "level": level }
87 })
88 },
89 |def| {
90 serde_json::json!({
91 "id": id,
92 "shortDescription": { "text": def.short },
93 "fullDescription": { "text": def.full },
94 "helpUri": explain::rule_docs_url(def),
95 "defaultConfiguration": { "level": level }
96 })
97 },
98 )
99}
100
101fn sarif_export_fields(
103 export: &UnusedExport,
104 root: &Path,
105 rule_id: &'static str,
106 level: &'static str,
107 kind: &str,
108 re_kind: &str,
109) -> SarifFields {
110 let label = if export.is_re_export { re_kind } else { kind };
111 SarifFields {
112 rule_id,
113 level,
114 message: format!(
115 "{} '{}' is never imported by other modules",
116 label, export.export_name
117 ),
118 uri: relative_uri(&export.path, root),
119 region: Some((export.line, export.col + 1)),
120 properties: if export.is_re_export {
121 Some(serde_json::json!({ "is_re_export": true }))
122 } else {
123 None
124 },
125 }
126}
127
128fn sarif_dep_fields(
130 dep: &UnusedDependency,
131 root: &Path,
132 rule_id: &'static str,
133 level: &'static str,
134 section: &str,
135) -> SarifFields {
136 SarifFields {
137 rule_id,
138 level,
139 message: format!(
140 "Package '{}' is in {} but never imported",
141 dep.package_name, section
142 ),
143 uri: relative_uri(&dep.path, root),
144 region: if dep.line > 0 {
145 Some((dep.line, 1))
146 } else {
147 None
148 },
149 properties: None,
150 }
151}
152
153fn sarif_member_fields(
155 member: &UnusedMember,
156 root: &Path,
157 rule_id: &'static str,
158 level: &'static str,
159 kind: &str,
160) -> SarifFields {
161 SarifFields {
162 rule_id,
163 level,
164 message: format!(
165 "{} member '{}.{}' is never referenced",
166 kind, member.parent_name, member.member_name
167 ),
168 uri: relative_uri(&member.path, root),
169 region: Some((member.line, member.col + 1)),
170 properties: None,
171 }
172}
173
174fn build_sarif_rules(rules: &RulesConfig) -> Vec<serde_json::Value> {
176 vec![
177 sarif_rule(
178 "fallow/unused-file",
179 "File is not reachable from any entry point",
180 severity_to_sarif_level(rules.unused_files),
181 ),
182 sarif_rule(
183 "fallow/unused-export",
184 "Export is never imported",
185 severity_to_sarif_level(rules.unused_exports),
186 ),
187 sarif_rule(
188 "fallow/unused-type",
189 "Type export is never imported",
190 severity_to_sarif_level(rules.unused_types),
191 ),
192 sarif_rule(
193 "fallow/unused-dependency",
194 "Dependency listed but never imported",
195 severity_to_sarif_level(rules.unused_dependencies),
196 ),
197 sarif_rule(
198 "fallow/unused-dev-dependency",
199 "Dev dependency listed but never imported",
200 severity_to_sarif_level(rules.unused_dev_dependencies),
201 ),
202 sarif_rule(
203 "fallow/unused-optional-dependency",
204 "Optional dependency listed but never imported",
205 severity_to_sarif_level(rules.unused_optional_dependencies),
206 ),
207 sarif_rule(
208 "fallow/type-only-dependency",
209 "Production dependency only used via type-only imports",
210 severity_to_sarif_level(rules.type_only_dependencies),
211 ),
212 sarif_rule(
213 "fallow/test-only-dependency",
214 "Production dependency only imported by test files",
215 severity_to_sarif_level(rules.test_only_dependencies),
216 ),
217 sarif_rule(
218 "fallow/unused-enum-member",
219 "Enum member is never referenced",
220 severity_to_sarif_level(rules.unused_enum_members),
221 ),
222 sarif_rule(
223 "fallow/unused-class-member",
224 "Class member is never referenced",
225 severity_to_sarif_level(rules.unused_class_members),
226 ),
227 sarif_rule(
228 "fallow/unresolved-import",
229 "Import could not be resolved",
230 severity_to_sarif_level(rules.unresolved_imports),
231 ),
232 sarif_rule(
233 "fallow/unlisted-dependency",
234 "Dependency used but not in package.json",
235 severity_to_sarif_level(rules.unlisted_dependencies),
236 ),
237 sarif_rule(
238 "fallow/duplicate-export",
239 "Export name appears in multiple modules",
240 severity_to_sarif_level(rules.duplicate_exports),
241 ),
242 sarif_rule(
243 "fallow/circular-dependency",
244 "Circular dependency chain detected",
245 severity_to_sarif_level(rules.circular_dependencies),
246 ),
247 sarif_rule(
248 "fallow/boundary-violation",
249 "Import crosses an architecture boundary",
250 severity_to_sarif_level(rules.boundary_violation),
251 ),
252 ]
253}
254
255#[must_use]
256pub fn build_sarif(
257 results: &AnalysisResults,
258 root: &Path,
259 rules: &RulesConfig,
260) -> serde_json::Value {
261 let mut sarif_results = Vec::new();
262
263 push_sarif_results(&mut sarif_results, &results.unused_files, |file| {
264 SarifFields {
265 rule_id: "fallow/unused-file",
266 level: severity_to_sarif_level(rules.unused_files),
267 message: "File is not reachable from any entry point".to_string(),
268 uri: relative_uri(&file.path, root),
269 region: None,
270 properties: None,
271 }
272 });
273
274 push_sarif_results(&mut sarif_results, &results.unused_exports, |export| {
275 sarif_export_fields(
276 export,
277 root,
278 "fallow/unused-export",
279 severity_to_sarif_level(rules.unused_exports),
280 "Export",
281 "Re-export",
282 )
283 });
284
285 push_sarif_results(&mut sarif_results, &results.unused_types, |export| {
286 sarif_export_fields(
287 export,
288 root,
289 "fallow/unused-type",
290 severity_to_sarif_level(rules.unused_types),
291 "Type export",
292 "Type re-export",
293 )
294 });
295
296 push_sarif_results(&mut sarif_results, &results.unused_dependencies, |dep| {
297 sarif_dep_fields(
298 dep,
299 root,
300 "fallow/unused-dependency",
301 severity_to_sarif_level(rules.unused_dependencies),
302 "dependencies",
303 )
304 });
305
306 push_sarif_results(
307 &mut sarif_results,
308 &results.unused_dev_dependencies,
309 |dep| {
310 sarif_dep_fields(
311 dep,
312 root,
313 "fallow/unused-dev-dependency",
314 severity_to_sarif_level(rules.unused_dev_dependencies),
315 "devDependencies",
316 )
317 },
318 );
319
320 push_sarif_results(
321 &mut sarif_results,
322 &results.unused_optional_dependencies,
323 |dep| {
324 sarif_dep_fields(
325 dep,
326 root,
327 "fallow/unused-optional-dependency",
328 severity_to_sarif_level(rules.unused_optional_dependencies),
329 "optionalDependencies",
330 )
331 },
332 );
333
334 push_sarif_results(&mut sarif_results, &results.type_only_dependencies, |dep| {
335 SarifFields {
336 rule_id: "fallow/type-only-dependency",
337 level: severity_to_sarif_level(rules.type_only_dependencies),
338 message: format!(
339 "Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
340 dep.package_name
341 ),
342 uri: relative_uri(&dep.path, root),
343 region: if dep.line > 0 {
344 Some((dep.line, 1))
345 } else {
346 None
347 },
348 properties: None,
349 }
350 });
351
352 push_sarif_results(&mut sarif_results, &results.test_only_dependencies, |dep| {
353 SarifFields {
354 rule_id: "fallow/test-only-dependency",
355 level: severity_to_sarif_level(rules.test_only_dependencies),
356 message: format!(
357 "Package '{}' is only imported by test files (consider moving to devDependencies)",
358 dep.package_name
359 ),
360 uri: relative_uri(&dep.path, root),
361 region: if dep.line > 0 {
362 Some((dep.line, 1))
363 } else {
364 None
365 },
366 properties: None,
367 }
368 });
369
370 push_sarif_results(&mut sarif_results, &results.unused_enum_members, |member| {
371 sarif_member_fields(
372 member,
373 root,
374 "fallow/unused-enum-member",
375 severity_to_sarif_level(rules.unused_enum_members),
376 "Enum",
377 )
378 });
379
380 push_sarif_results(
381 &mut sarif_results,
382 &results.unused_class_members,
383 |member| {
384 sarif_member_fields(
385 member,
386 root,
387 "fallow/unused-class-member",
388 severity_to_sarif_level(rules.unused_class_members),
389 "Class",
390 )
391 },
392 );
393
394 push_sarif_results(&mut sarif_results, &results.unresolved_imports, |import| {
395 SarifFields {
396 rule_id: "fallow/unresolved-import",
397 level: severity_to_sarif_level(rules.unresolved_imports),
398 message: format!("Import '{}' could not be resolved", import.specifier),
399 uri: relative_uri(&import.path, root),
400 region: Some((import.line, import.col + 1)),
401 properties: None,
402 }
403 });
404
405 for dep in &results.unlisted_dependencies {
407 for site in &dep.imported_from {
408 sarif_results.push(sarif_result(
409 "fallow/unlisted-dependency",
410 severity_to_sarif_level(rules.unlisted_dependencies),
411 &format!(
412 "Package '{}' is imported but not listed in package.json",
413 dep.package_name
414 ),
415 &relative_uri(&site.path, root),
416 Some((site.line, site.col + 1)),
417 ));
418 }
419 }
420
421 for dup in &results.duplicate_exports {
423 for loc in &dup.locations {
424 sarif_results.push(sarif_result(
425 "fallow/duplicate-export",
426 severity_to_sarif_level(rules.duplicate_exports),
427 &format!("Export '{}' appears in multiple modules", dup.export_name),
428 &relative_uri(&loc.path, root),
429 Some((loc.line, loc.col + 1)),
430 ));
431 }
432 }
433
434 push_sarif_results(
435 &mut sarif_results,
436 &results.circular_dependencies,
437 |cycle| {
438 let chain: Vec<String> = cycle.files.iter().map(|p| relative_uri(p, root)).collect();
439 let mut display_chain = chain.clone();
440 if let Some(first) = chain.first() {
441 display_chain.push(first.clone());
442 }
443 let first_uri = chain.first().map_or_else(String::new, Clone::clone);
444 SarifFields {
445 rule_id: "fallow/circular-dependency",
446 level: severity_to_sarif_level(rules.circular_dependencies),
447 message: format!("Circular dependency: {}", display_chain.join(" \u{2192} ")),
448 uri: first_uri,
449 region: if cycle.line > 0 {
450 Some((cycle.line, cycle.col + 1))
451 } else {
452 None
453 },
454 properties: None,
455 }
456 },
457 );
458
459 push_sarif_results(
460 &mut sarif_results,
461 &results.boundary_violations,
462 |violation| {
463 let from_uri = relative_uri(&violation.from_path, root);
464 let to_uri = relative_uri(&violation.to_path, root);
465 SarifFields {
466 rule_id: "fallow/boundary-violation",
467 level: severity_to_sarif_level(rules.boundary_violation),
468 message: format!(
469 "Import from zone '{}' to zone '{}' is not allowed ({})",
470 violation.from_zone, violation.to_zone, to_uri,
471 ),
472 uri: from_uri,
473 region: if violation.line > 0 {
474 Some((violation.line, violation.col + 1))
475 } else {
476 None
477 },
478 properties: None,
479 }
480 },
481 );
482
483 serde_json::json!({
484 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
485 "version": "2.1.0",
486 "runs": [{
487 "tool": {
488 "driver": {
489 "name": "fallow",
490 "version": env!("CARGO_PKG_VERSION"),
491 "informationUri": "https://github.com/fallow-rs/fallow",
492 "rules": build_sarif_rules(rules)
493 }
494 },
495 "results": sarif_results
496 }]
497 })
498}
499
500pub(super) fn print_sarif(results: &AnalysisResults, root: &Path, rules: &RulesConfig) -> ExitCode {
501 let sarif = build_sarif(results, root, rules);
502 emit_json(&sarif, "SARIF")
503}
504
505#[expect(
506 clippy::cast_possible_truncation,
507 reason = "line/col numbers are bounded by source size"
508)]
509pub(super) fn print_duplication_sarif(report: &DuplicationReport, root: &Path) -> ExitCode {
510 let mut sarif_results = Vec::new();
511
512 for (i, group) in report.clone_groups.iter().enumerate() {
513 for instance in &group.instances {
514 sarif_results.push(sarif_result(
515 "fallow/code-duplication",
516 "warning",
517 &format!(
518 "Code clone group {} ({} lines, {} instances)",
519 i + 1,
520 group.line_count,
521 group.instances.len()
522 ),
523 &relative_uri(&instance.file, root),
524 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
525 ));
526 }
527 }
528
529 let sarif = serde_json::json!({
530 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
531 "version": "2.1.0",
532 "runs": [{
533 "tool": {
534 "driver": {
535 "name": "fallow",
536 "version": env!("CARGO_PKG_VERSION"),
537 "informationUri": "https://github.com/fallow-rs/fallow",
538 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
539 }
540 },
541 "results": sarif_results
542 }]
543 });
544
545 emit_json(&sarif, "SARIF")
546}
547
548#[must_use]
554pub fn build_health_sarif(
555 report: &crate::health_types::HealthReport,
556 root: &Path,
557) -> serde_json::Value {
558 use crate::health_types::ExceededThreshold;
559
560 let mut sarif_results = Vec::new();
561
562 for finding in &report.findings {
563 let uri = relative_uri(&finding.path, root);
564 let (rule_id, message) = match finding.exceeded {
565 ExceededThreshold::Cyclomatic => (
566 "fallow/high-cyclomatic-complexity",
567 format!(
568 "'{}' has cyclomatic complexity {} (threshold: {})",
569 finding.name, finding.cyclomatic, report.summary.max_cyclomatic_threshold,
570 ),
571 ),
572 ExceededThreshold::Cognitive => (
573 "fallow/high-cognitive-complexity",
574 format!(
575 "'{}' has cognitive complexity {} (threshold: {})",
576 finding.name, finding.cognitive, report.summary.max_cognitive_threshold,
577 ),
578 ),
579 ExceededThreshold::Both => (
580 "fallow/high-complexity",
581 format!(
582 "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
583 finding.name,
584 finding.cyclomatic,
585 report.summary.max_cyclomatic_threshold,
586 finding.cognitive,
587 report.summary.max_cognitive_threshold,
588 ),
589 ),
590 };
591
592 sarif_results.push(sarif_result(
593 rule_id,
594 "warning",
595 &message,
596 &uri,
597 Some((finding.line, finding.col + 1)),
598 ));
599 }
600
601 for target in &report.targets {
603 let uri = relative_uri(&target.path, root);
604 let message = format!(
605 "[{}] {} (priority: {:.1}, efficiency: {:.1}, effort: {}, confidence: {})",
606 target.category.label(),
607 target.recommendation,
608 target.priority,
609 target.efficiency,
610 target.effort.label(),
611 target.confidence.label(),
612 );
613 sarif_results.push(sarif_result(
614 "fallow/refactoring-target",
615 "warning",
616 &message,
617 &uri,
618 None,
619 ));
620 }
621
622 let health_rules = vec![
623 sarif_rule(
624 "fallow/high-cyclomatic-complexity",
625 "Function has high cyclomatic complexity",
626 "warning",
627 ),
628 sarif_rule(
629 "fallow/high-cognitive-complexity",
630 "Function has high cognitive complexity",
631 "warning",
632 ),
633 sarif_rule(
634 "fallow/high-complexity",
635 "Function exceeds both complexity thresholds",
636 "warning",
637 ),
638 sarif_rule(
639 "fallow/refactoring-target",
640 "File identified as a high-priority refactoring candidate",
641 "warning",
642 ),
643 ];
644
645 serde_json::json!({
646 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
647 "version": "2.1.0",
648 "runs": [{
649 "tool": {
650 "driver": {
651 "name": "fallow",
652 "version": env!("CARGO_PKG_VERSION"),
653 "informationUri": "https://github.com/fallow-rs/fallow",
654 "rules": health_rules
655 }
656 },
657 "results": sarif_results
658 }]
659 })
660}
661
662pub(super) fn print_health_sarif(
663 report: &crate::health_types::HealthReport,
664 root: &Path,
665) -> ExitCode {
666 let sarif = build_health_sarif(report, root);
667 emit_json(&sarif, "SARIF")
668}
669
670#[cfg(test)]
671mod tests {
672 use super::*;
673 use crate::report::test_helpers::sample_results;
674 use fallow_core::results::*;
675 use std::path::PathBuf;
676
677 #[test]
678 fn sarif_has_required_top_level_fields() {
679 let root = PathBuf::from("/project");
680 let results = AnalysisResults::default();
681 let sarif = build_sarif(&results, &root, &RulesConfig::default());
682
683 assert_eq!(
684 sarif["$schema"],
685 "https://json.schemastore.org/sarif-2.1.0.json"
686 );
687 assert_eq!(sarif["version"], "2.1.0");
688 assert!(sarif["runs"].is_array());
689 }
690
691 #[test]
692 fn sarif_has_tool_driver_info() {
693 let root = PathBuf::from("/project");
694 let results = AnalysisResults::default();
695 let sarif = build_sarif(&results, &root, &RulesConfig::default());
696
697 let driver = &sarif["runs"][0]["tool"]["driver"];
698 assert_eq!(driver["name"], "fallow");
699 assert!(driver["version"].is_string());
700 assert_eq!(
701 driver["informationUri"],
702 "https://github.com/fallow-rs/fallow"
703 );
704 }
705
706 #[test]
707 fn sarif_declares_all_rules() {
708 let root = PathBuf::from("/project");
709 let results = AnalysisResults::default();
710 let sarif = build_sarif(&results, &root, &RulesConfig::default());
711
712 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
713 .as_array()
714 .expect("rules should be an array");
715 assert_eq!(rules.len(), 15);
716
717 let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
718 assert!(rule_ids.contains(&"fallow/unused-file"));
719 assert!(rule_ids.contains(&"fallow/unused-export"));
720 assert!(rule_ids.contains(&"fallow/unused-type"));
721 assert!(rule_ids.contains(&"fallow/unused-dependency"));
722 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
723 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
724 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
725 assert!(rule_ids.contains(&"fallow/test-only-dependency"));
726 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
727 assert!(rule_ids.contains(&"fallow/unused-class-member"));
728 assert!(rule_ids.contains(&"fallow/unresolved-import"));
729 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
730 assert!(rule_ids.contains(&"fallow/duplicate-export"));
731 assert!(rule_ids.contains(&"fallow/circular-dependency"));
732 assert!(rule_ids.contains(&"fallow/boundary-violation"));
733 }
734
735 #[test]
736 fn sarif_empty_results_no_results_entries() {
737 let root = PathBuf::from("/project");
738 let results = AnalysisResults::default();
739 let sarif = build_sarif(&results, &root, &RulesConfig::default());
740
741 let sarif_results = sarif["runs"][0]["results"]
742 .as_array()
743 .expect("results should be an array");
744 assert!(sarif_results.is_empty());
745 }
746
747 #[test]
748 fn sarif_unused_file_result() {
749 let root = PathBuf::from("/project");
750 let mut results = AnalysisResults::default();
751 results.unused_files.push(UnusedFile {
752 path: root.join("src/dead.ts"),
753 });
754
755 let sarif = build_sarif(&results, &root, &RulesConfig::default());
756 let entries = sarif["runs"][0]["results"].as_array().unwrap();
757 assert_eq!(entries.len(), 1);
758
759 let entry = &entries[0];
760 assert_eq!(entry["ruleId"], "fallow/unused-file");
761 assert_eq!(entry["level"], "error");
763 assert_eq!(
764 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
765 "src/dead.ts"
766 );
767 }
768
769 #[test]
770 fn sarif_unused_export_includes_region() {
771 let root = PathBuf::from("/project");
772 let mut results = AnalysisResults::default();
773 results.unused_exports.push(UnusedExport {
774 path: root.join("src/utils.ts"),
775 export_name: "helperFn".to_string(),
776 is_type_only: false,
777 line: 10,
778 col: 4,
779 span_start: 120,
780 is_re_export: false,
781 });
782
783 let sarif = build_sarif(&results, &root, &RulesConfig::default());
784 let entry = &sarif["runs"][0]["results"][0];
785 assert_eq!(entry["ruleId"], "fallow/unused-export");
786
787 let region = &entry["locations"][0]["physicalLocation"]["region"];
788 assert_eq!(region["startLine"], 10);
789 assert_eq!(region["startColumn"], 5);
791 }
792
793 #[test]
794 fn sarif_unresolved_import_is_error_level() {
795 let root = PathBuf::from("/project");
796 let mut results = AnalysisResults::default();
797 results.unresolved_imports.push(UnresolvedImport {
798 path: root.join("src/app.ts"),
799 specifier: "./missing".to_string(),
800 line: 1,
801 col: 0,
802 specifier_col: 0,
803 });
804
805 let sarif = build_sarif(&results, &root, &RulesConfig::default());
806 let entry = &sarif["runs"][0]["results"][0];
807 assert_eq!(entry["ruleId"], "fallow/unresolved-import");
808 assert_eq!(entry["level"], "error");
809 }
810
811 #[test]
812 fn sarif_unlisted_dependency_points_to_import_site() {
813 let root = PathBuf::from("/project");
814 let mut results = AnalysisResults::default();
815 results.unlisted_dependencies.push(UnlistedDependency {
816 package_name: "chalk".to_string(),
817 imported_from: vec![ImportSite {
818 path: root.join("src/cli.ts"),
819 line: 3,
820 col: 0,
821 }],
822 });
823
824 let sarif = build_sarif(&results, &root, &RulesConfig::default());
825 let entry = &sarif["runs"][0]["results"][0];
826 assert_eq!(entry["ruleId"], "fallow/unlisted-dependency");
827 assert_eq!(entry["level"], "error");
828 assert_eq!(
829 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
830 "src/cli.ts"
831 );
832 let region = &entry["locations"][0]["physicalLocation"]["region"];
833 assert_eq!(region["startLine"], 3);
834 assert_eq!(region["startColumn"], 1);
835 }
836
837 #[test]
838 fn sarif_dependency_issues_point_to_package_json() {
839 let root = PathBuf::from("/project");
840 let mut results = AnalysisResults::default();
841 results.unused_dependencies.push(UnusedDependency {
842 package_name: "lodash".to_string(),
843 location: DependencyLocation::Dependencies,
844 path: root.join("package.json"),
845 line: 5,
846 });
847 results.unused_dev_dependencies.push(UnusedDependency {
848 package_name: "jest".to_string(),
849 location: DependencyLocation::DevDependencies,
850 path: root.join("package.json"),
851 line: 5,
852 });
853
854 let sarif = build_sarif(&results, &root, &RulesConfig::default());
855 let entries = sarif["runs"][0]["results"].as_array().unwrap();
856 for entry in entries {
857 assert_eq!(
858 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
859 "package.json"
860 );
861 }
862 }
863
864 #[test]
865 fn sarif_duplicate_export_emits_one_result_per_location() {
866 let root = PathBuf::from("/project");
867 let mut results = AnalysisResults::default();
868 results.duplicate_exports.push(DuplicateExport {
869 export_name: "Config".to_string(),
870 locations: vec![
871 DuplicateLocation {
872 path: root.join("src/a.ts"),
873 line: 15,
874 col: 0,
875 },
876 DuplicateLocation {
877 path: root.join("src/b.ts"),
878 line: 30,
879 col: 0,
880 },
881 ],
882 });
883
884 let sarif = build_sarif(&results, &root, &RulesConfig::default());
885 let entries = sarif["runs"][0]["results"].as_array().unwrap();
886 assert_eq!(entries.len(), 2);
888 assert_eq!(entries[0]["ruleId"], "fallow/duplicate-export");
889 assert_eq!(entries[1]["ruleId"], "fallow/duplicate-export");
890 assert_eq!(
891 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
892 "src/a.ts"
893 );
894 assert_eq!(
895 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
896 "src/b.ts"
897 );
898 }
899
900 #[test]
901 fn sarif_all_issue_types_produce_results() {
902 let root = PathBuf::from("/project");
903 let results = sample_results(&root);
904 let sarif = build_sarif(&results, &root, &RulesConfig::default());
905
906 let entries = sarif["runs"][0]["results"].as_array().unwrap();
907 assert_eq!(entries.len(), results.total_issues() + 1);
909
910 let rule_ids: Vec<&str> = entries
911 .iter()
912 .map(|e| e["ruleId"].as_str().unwrap())
913 .collect();
914 assert!(rule_ids.contains(&"fallow/unused-file"));
915 assert!(rule_ids.contains(&"fallow/unused-export"));
916 assert!(rule_ids.contains(&"fallow/unused-type"));
917 assert!(rule_ids.contains(&"fallow/unused-dependency"));
918 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
919 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
920 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
921 assert!(rule_ids.contains(&"fallow/test-only-dependency"));
922 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
923 assert!(rule_ids.contains(&"fallow/unused-class-member"));
924 assert!(rule_ids.contains(&"fallow/unresolved-import"));
925 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
926 assert!(rule_ids.contains(&"fallow/duplicate-export"));
927 }
928
929 #[test]
930 fn sarif_serializes_to_valid_json() {
931 let root = PathBuf::from("/project");
932 let results = sample_results(&root);
933 let sarif = build_sarif(&results, &root, &RulesConfig::default());
934
935 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
936 let reparsed: serde_json::Value =
937 serde_json::from_str(&json_str).expect("SARIF output should be valid JSON");
938 assert_eq!(reparsed, sarif);
939 }
940
941 #[test]
942 fn sarif_file_write_produces_valid_sarif() {
943 let root = PathBuf::from("/project");
944 let results = sample_results(&root);
945 let sarif = build_sarif(&results, &root, &RulesConfig::default());
946 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
947
948 let dir = std::env::temp_dir().join("fallow-test-sarif-file");
949 let _ = std::fs::create_dir_all(&dir);
950 let sarif_path = dir.join("results.sarif");
951 std::fs::write(&sarif_path, &json_str).expect("should write SARIF file");
952
953 let contents = std::fs::read_to_string(&sarif_path).expect("should read SARIF file");
954 let parsed: serde_json::Value =
955 serde_json::from_str(&contents).expect("file should contain valid JSON");
956
957 assert_eq!(parsed["version"], "2.1.0");
958 assert_eq!(
959 parsed["$schema"],
960 "https://json.schemastore.org/sarif-2.1.0.json"
961 );
962 let sarif_results = parsed["runs"][0]["results"]
963 .as_array()
964 .expect("results should be an array");
965 assert!(!sarif_results.is_empty());
966
967 let _ = std::fs::remove_file(&sarif_path);
969 let _ = std::fs::remove_dir(&dir);
970 }
971
972 #[test]
975 fn health_sarif_empty_no_results() {
976 let root = PathBuf::from("/project");
977 let report = crate::health_types::HealthReport {
978 findings: vec![],
979 summary: crate::health_types::HealthSummary {
980 files_analyzed: 10,
981 functions_analyzed: 50,
982 functions_above_threshold: 0,
983 max_cyclomatic_threshold: 20,
984 max_cognitive_threshold: 15,
985 files_scored: None,
986 average_maintainability: None,
987 },
988 vital_signs: None,
989 health_score: None,
990 file_scores: vec![],
991 hotspots: vec![],
992 hotspot_summary: None,
993 targets: vec![],
994 target_thresholds: None,
995 health_trend: None,
996 };
997 let sarif = build_health_sarif(&report, &root);
998 assert_eq!(sarif["version"], "2.1.0");
999 let results = sarif["runs"][0]["results"].as_array().unwrap();
1000 assert!(results.is_empty());
1001 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1002 .as_array()
1003 .unwrap();
1004 assert_eq!(rules.len(), 4);
1005 }
1006
1007 #[test]
1008 fn health_sarif_cyclomatic_only() {
1009 let root = PathBuf::from("/project");
1010 let report = crate::health_types::HealthReport {
1011 findings: vec![crate::health_types::HealthFinding {
1012 path: root.join("src/utils.ts"),
1013 name: "parseExpression".to_string(),
1014 line: 42,
1015 col: 0,
1016 cyclomatic: 25,
1017 cognitive: 10,
1018 line_count: 80,
1019 exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
1020 }],
1021 summary: crate::health_types::HealthSummary {
1022 files_analyzed: 5,
1023 functions_analyzed: 20,
1024 functions_above_threshold: 1,
1025 max_cyclomatic_threshold: 20,
1026 max_cognitive_threshold: 15,
1027 files_scored: None,
1028 average_maintainability: None,
1029 },
1030 vital_signs: None,
1031 health_score: None,
1032 file_scores: vec![],
1033 hotspots: vec![],
1034 hotspot_summary: None,
1035 targets: vec![],
1036 target_thresholds: None,
1037 health_trend: None,
1038 };
1039 let sarif = build_health_sarif(&report, &root);
1040 let entry = &sarif["runs"][0]["results"][0];
1041 assert_eq!(entry["ruleId"], "fallow/high-cyclomatic-complexity");
1042 assert_eq!(entry["level"], "warning");
1043 assert!(
1044 entry["message"]["text"]
1045 .as_str()
1046 .unwrap()
1047 .contains("cyclomatic complexity 25")
1048 );
1049 assert_eq!(
1050 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1051 "src/utils.ts"
1052 );
1053 let region = &entry["locations"][0]["physicalLocation"]["region"];
1054 assert_eq!(region["startLine"], 42);
1055 assert_eq!(region["startColumn"], 1);
1056 }
1057
1058 #[test]
1059 fn health_sarif_cognitive_only() {
1060 let root = PathBuf::from("/project");
1061 let report = crate::health_types::HealthReport {
1062 findings: vec![crate::health_types::HealthFinding {
1063 path: root.join("src/api.ts"),
1064 name: "handleRequest".to_string(),
1065 line: 10,
1066 col: 4,
1067 cyclomatic: 8,
1068 cognitive: 20,
1069 line_count: 40,
1070 exceeded: crate::health_types::ExceededThreshold::Cognitive,
1071 }],
1072 summary: crate::health_types::HealthSummary {
1073 files_analyzed: 3,
1074 functions_analyzed: 10,
1075 functions_above_threshold: 1,
1076 max_cyclomatic_threshold: 20,
1077 max_cognitive_threshold: 15,
1078 files_scored: None,
1079 average_maintainability: None,
1080 },
1081 vital_signs: None,
1082 health_score: None,
1083 file_scores: vec![],
1084 hotspots: vec![],
1085 hotspot_summary: None,
1086 targets: vec![],
1087 target_thresholds: None,
1088 health_trend: None,
1089 };
1090 let sarif = build_health_sarif(&report, &root);
1091 let entry = &sarif["runs"][0]["results"][0];
1092 assert_eq!(entry["ruleId"], "fallow/high-cognitive-complexity");
1093 assert!(
1094 entry["message"]["text"]
1095 .as_str()
1096 .unwrap()
1097 .contains("cognitive complexity 20")
1098 );
1099 let region = &entry["locations"][0]["physicalLocation"]["region"];
1100 assert_eq!(region["startColumn"], 5); }
1102
1103 #[test]
1104 fn health_sarif_both_thresholds() {
1105 let root = PathBuf::from("/project");
1106 let report = crate::health_types::HealthReport {
1107 findings: vec![crate::health_types::HealthFinding {
1108 path: root.join("src/complex.ts"),
1109 name: "doEverything".to_string(),
1110 line: 1,
1111 col: 0,
1112 cyclomatic: 30,
1113 cognitive: 45,
1114 line_count: 100,
1115 exceeded: crate::health_types::ExceededThreshold::Both,
1116 }],
1117 summary: crate::health_types::HealthSummary {
1118 files_analyzed: 1,
1119 functions_analyzed: 1,
1120 functions_above_threshold: 1,
1121 max_cyclomatic_threshold: 20,
1122 max_cognitive_threshold: 15,
1123 files_scored: None,
1124 average_maintainability: None,
1125 },
1126 vital_signs: None,
1127 health_score: None,
1128 file_scores: vec![],
1129 hotspots: vec![],
1130 hotspot_summary: None,
1131 targets: vec![],
1132 target_thresholds: None,
1133 health_trend: None,
1134 };
1135 let sarif = build_health_sarif(&report, &root);
1136 let entry = &sarif["runs"][0]["results"][0];
1137 assert_eq!(entry["ruleId"], "fallow/high-complexity");
1138 let msg = entry["message"]["text"].as_str().unwrap();
1139 assert!(msg.contains("cyclomatic complexity 30"));
1140 assert!(msg.contains("cognitive complexity 45"));
1141 }
1142
1143 #[test]
1146 fn severity_to_sarif_level_error() {
1147 assert_eq!(severity_to_sarif_level(Severity::Error), "error");
1148 }
1149
1150 #[test]
1151 fn severity_to_sarif_level_warn() {
1152 assert_eq!(severity_to_sarif_level(Severity::Warn), "warning");
1153 }
1154
1155 #[test]
1156 fn severity_to_sarif_level_off() {
1157 assert_eq!(severity_to_sarif_level(Severity::Off), "warning");
1158 }
1159
1160 #[test]
1163 fn sarif_re_export_has_properties() {
1164 let root = PathBuf::from("/project");
1165 let mut results = AnalysisResults::default();
1166 results.unused_exports.push(UnusedExport {
1167 path: root.join("src/index.ts"),
1168 export_name: "reExported".to_string(),
1169 is_type_only: false,
1170 line: 1,
1171 col: 0,
1172 span_start: 0,
1173 is_re_export: true,
1174 });
1175
1176 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1177 let entry = &sarif["runs"][0]["results"][0];
1178 assert_eq!(entry["properties"]["is_re_export"], true);
1179 let msg = entry["message"]["text"].as_str().unwrap();
1180 assert!(msg.starts_with("Re-export"));
1181 }
1182
1183 #[test]
1184 fn sarif_non_re_export_has_no_properties() {
1185 let root = PathBuf::from("/project");
1186 let mut results = AnalysisResults::default();
1187 results.unused_exports.push(UnusedExport {
1188 path: root.join("src/utils.ts"),
1189 export_name: "foo".to_string(),
1190 is_type_only: false,
1191 line: 5,
1192 col: 0,
1193 span_start: 0,
1194 is_re_export: false,
1195 });
1196
1197 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1198 let entry = &sarif["runs"][0]["results"][0];
1199 assert!(entry.get("properties").is_none());
1200 let msg = entry["message"]["text"].as_str().unwrap();
1201 assert!(msg.starts_with("Export"));
1202 }
1203
1204 #[test]
1207 fn sarif_type_re_export_message() {
1208 let root = PathBuf::from("/project");
1209 let mut results = AnalysisResults::default();
1210 results.unused_types.push(UnusedExport {
1211 path: root.join("src/index.ts"),
1212 export_name: "MyType".to_string(),
1213 is_type_only: true,
1214 line: 1,
1215 col: 0,
1216 span_start: 0,
1217 is_re_export: true,
1218 });
1219
1220 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1221 let entry = &sarif["runs"][0]["results"][0];
1222 assert_eq!(entry["ruleId"], "fallow/unused-type");
1223 let msg = entry["message"]["text"].as_str().unwrap();
1224 assert!(msg.starts_with("Type re-export"));
1225 assert_eq!(entry["properties"]["is_re_export"], true);
1226 }
1227
1228 #[test]
1231 fn sarif_dependency_line_zero_skips_region() {
1232 let root = PathBuf::from("/project");
1233 let mut results = AnalysisResults::default();
1234 results.unused_dependencies.push(UnusedDependency {
1235 package_name: "lodash".to_string(),
1236 location: DependencyLocation::Dependencies,
1237 path: root.join("package.json"),
1238 line: 0,
1239 });
1240
1241 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1242 let entry = &sarif["runs"][0]["results"][0];
1243 let phys = &entry["locations"][0]["physicalLocation"];
1244 assert!(phys.get("region").is_none());
1245 }
1246
1247 #[test]
1248 fn sarif_dependency_line_nonzero_has_region() {
1249 let root = PathBuf::from("/project");
1250 let mut results = AnalysisResults::default();
1251 results.unused_dependencies.push(UnusedDependency {
1252 package_name: "lodash".to_string(),
1253 location: DependencyLocation::Dependencies,
1254 path: root.join("package.json"),
1255 line: 7,
1256 });
1257
1258 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1259 let entry = &sarif["runs"][0]["results"][0];
1260 let region = &entry["locations"][0]["physicalLocation"]["region"];
1261 assert_eq!(region["startLine"], 7);
1262 assert_eq!(region["startColumn"], 1);
1263 }
1264
1265 #[test]
1268 fn sarif_type_only_dep_line_zero_skips_region() {
1269 let root = PathBuf::from("/project");
1270 let mut results = AnalysisResults::default();
1271 results.type_only_dependencies.push(TypeOnlyDependency {
1272 package_name: "zod".to_string(),
1273 path: root.join("package.json"),
1274 line: 0,
1275 });
1276
1277 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1278 let entry = &sarif["runs"][0]["results"][0];
1279 let phys = &entry["locations"][0]["physicalLocation"];
1280 assert!(phys.get("region").is_none());
1281 }
1282
1283 #[test]
1286 fn sarif_circular_dep_line_zero_skips_region() {
1287 let root = PathBuf::from("/project");
1288 let mut results = AnalysisResults::default();
1289 results.circular_dependencies.push(CircularDependency {
1290 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1291 length: 2,
1292 line: 0,
1293 col: 0,
1294 });
1295
1296 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1297 let entry = &sarif["runs"][0]["results"][0];
1298 let phys = &entry["locations"][0]["physicalLocation"];
1299 assert!(phys.get("region").is_none());
1300 }
1301
1302 #[test]
1303 fn sarif_circular_dep_line_nonzero_has_region() {
1304 let root = PathBuf::from("/project");
1305 let mut results = AnalysisResults::default();
1306 results.circular_dependencies.push(CircularDependency {
1307 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1308 length: 2,
1309 line: 5,
1310 col: 2,
1311 });
1312
1313 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1314 let entry = &sarif["runs"][0]["results"][0];
1315 let region = &entry["locations"][0]["physicalLocation"]["region"];
1316 assert_eq!(region["startLine"], 5);
1317 assert_eq!(region["startColumn"], 3);
1318 }
1319
1320 #[test]
1323 fn sarif_unused_optional_dependency_result() {
1324 let root = PathBuf::from("/project");
1325 let mut results = AnalysisResults::default();
1326 results.unused_optional_dependencies.push(UnusedDependency {
1327 package_name: "fsevents".to_string(),
1328 location: DependencyLocation::OptionalDependencies,
1329 path: root.join("package.json"),
1330 line: 12,
1331 });
1332
1333 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1334 let entry = &sarif["runs"][0]["results"][0];
1335 assert_eq!(entry["ruleId"], "fallow/unused-optional-dependency");
1336 let msg = entry["message"]["text"].as_str().unwrap();
1337 assert!(msg.contains("optionalDependencies"));
1338 }
1339
1340 #[test]
1343 fn sarif_enum_member_message_format() {
1344 let root = PathBuf::from("/project");
1345 let mut results = AnalysisResults::default();
1346 results
1347 .unused_enum_members
1348 .push(fallow_core::results::UnusedMember {
1349 path: root.join("src/enums.ts"),
1350 parent_name: "Color".to_string(),
1351 member_name: "Purple".to_string(),
1352 kind: fallow_core::extract::MemberKind::EnumMember,
1353 line: 5,
1354 col: 2,
1355 });
1356
1357 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1358 let entry = &sarif["runs"][0]["results"][0];
1359 assert_eq!(entry["ruleId"], "fallow/unused-enum-member");
1360 let msg = entry["message"]["text"].as_str().unwrap();
1361 assert!(msg.contains("Enum member 'Color.Purple'"));
1362 let region = &entry["locations"][0]["physicalLocation"]["region"];
1363 assert_eq!(region["startColumn"], 3); }
1365
1366 #[test]
1367 fn sarif_class_member_message_format() {
1368 let root = PathBuf::from("/project");
1369 let mut results = AnalysisResults::default();
1370 results
1371 .unused_class_members
1372 .push(fallow_core::results::UnusedMember {
1373 path: root.join("src/service.ts"),
1374 parent_name: "API".to_string(),
1375 member_name: "fetch".to_string(),
1376 kind: fallow_core::extract::MemberKind::ClassMethod,
1377 line: 10,
1378 col: 4,
1379 });
1380
1381 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1382 let entry = &sarif["runs"][0]["results"][0];
1383 assert_eq!(entry["ruleId"], "fallow/unused-class-member");
1384 let msg = entry["message"]["text"].as_str().unwrap();
1385 assert!(msg.contains("Class member 'API.fetch'"));
1386 }
1387
1388 #[test]
1391 #[expect(
1392 clippy::cast_possible_truncation,
1393 reason = "test line/col values are trivially small"
1394 )]
1395 fn duplication_sarif_structure() {
1396 use fallow_core::duplicates::*;
1397
1398 let root = PathBuf::from("/project");
1399 let report = DuplicationReport {
1400 clone_groups: vec![CloneGroup {
1401 instances: vec![
1402 CloneInstance {
1403 file: root.join("src/a.ts"),
1404 start_line: 1,
1405 end_line: 10,
1406 start_col: 0,
1407 end_col: 0,
1408 fragment: String::new(),
1409 },
1410 CloneInstance {
1411 file: root.join("src/b.ts"),
1412 start_line: 5,
1413 end_line: 14,
1414 start_col: 2,
1415 end_col: 0,
1416 fragment: String::new(),
1417 },
1418 ],
1419 token_count: 50,
1420 line_count: 10,
1421 }],
1422 clone_families: vec![],
1423 stats: DuplicationStats::default(),
1424 };
1425
1426 let sarif = serde_json::json!({
1427 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1428 "version": "2.1.0",
1429 "runs": [{
1430 "tool": {
1431 "driver": {
1432 "name": "fallow",
1433 "version": env!("CARGO_PKG_VERSION"),
1434 "informationUri": "https://github.com/fallow-rs/fallow",
1435 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
1436 }
1437 },
1438 "results": []
1439 }]
1440 });
1441 let _ = sarif;
1443
1444 let mut sarif_results = Vec::new();
1446 for (i, group) in report.clone_groups.iter().enumerate() {
1447 for instance in &group.instances {
1448 sarif_results.push(sarif_result(
1449 "fallow/code-duplication",
1450 "warning",
1451 &format!(
1452 "Code clone group {} ({} lines, {} instances)",
1453 i + 1,
1454 group.line_count,
1455 group.instances.len()
1456 ),
1457 &super::super::relative_uri(&instance.file, &root),
1458 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
1459 ));
1460 }
1461 }
1462 assert_eq!(sarif_results.len(), 2);
1463 assert_eq!(sarif_results[0]["ruleId"], "fallow/code-duplication");
1464 assert!(
1465 sarif_results[0]["message"]["text"]
1466 .as_str()
1467 .unwrap()
1468 .contains("10 lines")
1469 );
1470 let region0 = &sarif_results[0]["locations"][0]["physicalLocation"]["region"];
1471 assert_eq!(region0["startLine"], 1);
1472 assert_eq!(region0["startColumn"], 1); let region1 = &sarif_results[1]["locations"][0]["physicalLocation"]["region"];
1474 assert_eq!(region1["startLine"], 5);
1475 assert_eq!(region1["startColumn"], 3); }
1477
1478 #[test]
1481 fn sarif_rule_known_id_has_full_description() {
1482 let rule = sarif_rule("fallow/unused-file", "fallback text", "error");
1483 assert!(rule.get("fullDescription").is_some());
1484 assert!(rule.get("helpUri").is_some());
1485 }
1486
1487 #[test]
1488 fn sarif_rule_unknown_id_uses_fallback() {
1489 let rule = sarif_rule("fallow/nonexistent", "fallback text", "warning");
1490 assert_eq!(rule["shortDescription"]["text"], "fallback text");
1491 assert!(rule.get("fullDescription").is_none());
1492 assert!(rule.get("helpUri").is_none());
1493 assert_eq!(rule["defaultConfiguration"]["level"], "warning");
1494 }
1495
1496 #[test]
1499 fn sarif_result_no_region_omits_region_key() {
1500 let result = sarif_result("rule/test", "error", "test msg", "src/file.ts", None);
1501 let phys = &result["locations"][0]["physicalLocation"];
1502 assert!(phys.get("region").is_none());
1503 assert_eq!(phys["artifactLocation"]["uri"], "src/file.ts");
1504 }
1505
1506 #[test]
1507 fn sarif_result_with_region_includes_region() {
1508 let result = sarif_result(
1509 "rule/test",
1510 "error",
1511 "test msg",
1512 "src/file.ts",
1513 Some((10, 5)),
1514 );
1515 let region = &result["locations"][0]["physicalLocation"]["region"];
1516 assert_eq!(region["startLine"], 10);
1517 assert_eq!(region["startColumn"], 5);
1518 }
1519
1520 #[test]
1523 fn health_sarif_includes_refactoring_targets() {
1524 use crate::health_types::*;
1525
1526 let root = PathBuf::from("/project");
1527 let report = HealthReport {
1528 findings: vec![],
1529 summary: HealthSummary {
1530 files_analyzed: 10,
1531 functions_analyzed: 50,
1532 functions_above_threshold: 0,
1533 max_cyclomatic_threshold: 20,
1534 max_cognitive_threshold: 15,
1535 files_scored: None,
1536 average_maintainability: None,
1537 },
1538 vital_signs: None,
1539 health_score: None,
1540 file_scores: vec![],
1541 hotspots: vec![],
1542 hotspot_summary: None,
1543 targets: vec![RefactoringTarget {
1544 path: root.join("src/complex.ts"),
1545 priority: 85.0,
1546 efficiency: 42.5,
1547 recommendation: "Split high-impact file".into(),
1548 category: RecommendationCategory::SplitHighImpact,
1549 effort: EffortEstimate::Medium,
1550 confidence: Confidence::High,
1551 factors: vec![],
1552 evidence: None,
1553 }],
1554 target_thresholds: None,
1555 health_trend: None,
1556 };
1557
1558 let sarif = build_health_sarif(&report, &root);
1559 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1560 assert_eq!(entries.len(), 1);
1561 assert_eq!(entries[0]["ruleId"], "fallow/refactoring-target");
1562 assert_eq!(entries[0]["level"], "warning");
1563 let msg = entries[0]["message"]["text"].as_str().unwrap();
1564 assert!(msg.contains("high impact"));
1565 assert!(msg.contains("Split high-impact file"));
1566 assert!(msg.contains("42.5"));
1567 }
1568
1569 #[test]
1572 fn health_sarif_rules_have_full_descriptions() {
1573 let root = PathBuf::from("/project");
1574 let report = crate::health_types::HealthReport {
1575 findings: vec![],
1576 summary: crate::health_types::HealthSummary {
1577 files_analyzed: 0,
1578 functions_analyzed: 0,
1579 functions_above_threshold: 0,
1580 max_cyclomatic_threshold: 20,
1581 max_cognitive_threshold: 15,
1582 files_scored: None,
1583 average_maintainability: None,
1584 },
1585 vital_signs: None,
1586 health_score: None,
1587 file_scores: vec![],
1588 hotspots: vec![],
1589 hotspot_summary: None,
1590 targets: vec![],
1591 target_thresholds: None,
1592 health_trend: None,
1593 };
1594 let sarif = build_health_sarif(&report, &root);
1595 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1596 .as_array()
1597 .unwrap();
1598 for rule in rules {
1599 let id = rule["id"].as_str().unwrap();
1600 assert!(
1601 rule.get("fullDescription").is_some(),
1602 "health rule {id} should have fullDescription"
1603 );
1604 assert!(
1605 rule.get("helpUri").is_some(),
1606 "health rule {id} should have helpUri"
1607 );
1608 }
1609 }
1610
1611 #[test]
1614 fn sarif_warn_severity_produces_warning_level() {
1615 let root = PathBuf::from("/project");
1616 let mut results = AnalysisResults::default();
1617 results.unused_files.push(UnusedFile {
1618 path: root.join("src/dead.ts"),
1619 });
1620
1621 let rules = RulesConfig {
1622 unused_files: Severity::Warn,
1623 ..RulesConfig::default()
1624 };
1625
1626 let sarif = build_sarif(&results, &root, &rules);
1627 let entry = &sarif["runs"][0]["results"][0];
1628 assert_eq!(entry["level"], "warning");
1629 }
1630
1631 #[test]
1634 fn sarif_unused_file_has_no_region() {
1635 let root = PathBuf::from("/project");
1636 let mut results = AnalysisResults::default();
1637 results.unused_files.push(UnusedFile {
1638 path: root.join("src/dead.ts"),
1639 });
1640
1641 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1642 let entry = &sarif["runs"][0]["results"][0];
1643 let phys = &entry["locations"][0]["physicalLocation"];
1644 assert!(phys.get("region").is_none());
1645 }
1646
1647 #[test]
1650 fn sarif_unlisted_dep_multiple_import_sites() {
1651 let root = PathBuf::from("/project");
1652 let mut results = AnalysisResults::default();
1653 results.unlisted_dependencies.push(UnlistedDependency {
1654 package_name: "dotenv".to_string(),
1655 imported_from: vec![
1656 ImportSite {
1657 path: root.join("src/a.ts"),
1658 line: 1,
1659 col: 0,
1660 },
1661 ImportSite {
1662 path: root.join("src/b.ts"),
1663 line: 5,
1664 col: 0,
1665 },
1666 ],
1667 });
1668
1669 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1670 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1671 assert_eq!(entries.len(), 2);
1673 assert_eq!(
1674 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1675 "src/a.ts"
1676 );
1677 assert_eq!(
1678 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1679 "src/b.ts"
1680 );
1681 }
1682
1683 #[test]
1686 fn sarif_unlisted_dep_no_import_sites() {
1687 let root = PathBuf::from("/project");
1688 let mut results = AnalysisResults::default();
1689 results.unlisted_dependencies.push(UnlistedDependency {
1690 package_name: "phantom".to_string(),
1691 imported_from: vec![],
1692 });
1693
1694 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1695 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1696 assert!(entries.is_empty());
1698 }
1699}