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