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