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::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 match serde_json::to_string_pretty(&sarif) {
428 Ok(json) => {
429 println!("{json}");
430 ExitCode::SUCCESS
431 }
432 Err(e) => {
433 eprintln!("Error: failed to serialize SARIF output: {e}");
434 ExitCode::from(2)
435 }
436 }
437}
438
439pub(super) fn print_duplication_sarif(report: &DuplicationReport, root: &Path) -> ExitCode {
440 let mut sarif_results = Vec::new();
441
442 for (i, group) in report.clone_groups.iter().enumerate() {
443 for instance in &group.instances {
444 sarif_results.push(sarif_result(
445 "fallow/code-duplication",
446 "warning",
447 &format!(
448 "Code clone group {} ({} lines, {} instances)",
449 i + 1,
450 group.line_count,
451 group.instances.len()
452 ),
453 &relative_uri(&instance.file, root),
454 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
455 ));
456 }
457 }
458
459 let sarif = serde_json::json!({
460 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
461 "version": "2.1.0",
462 "runs": [{
463 "tool": {
464 "driver": {
465 "name": "fallow",
466 "version": env!("CARGO_PKG_VERSION"),
467 "informationUri": "https://github.com/fallow-rs/fallow",
468 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
469 }
470 },
471 "results": sarif_results
472 }]
473 });
474
475 match serde_json::to_string_pretty(&sarif) {
476 Ok(json) => {
477 println!("{json}");
478 ExitCode::SUCCESS
479 }
480 Err(e) => {
481 eprintln!("Error: failed to serialize SARIF output: {e}");
482 ExitCode::from(2)
483 }
484 }
485}
486
487pub fn build_health_sarif(
493 report: &crate::health_types::HealthReport,
494 root: &Path,
495) -> serde_json::Value {
496 use crate::health_types::ExceededThreshold;
497
498 let mut sarif_results = Vec::new();
499
500 for finding in &report.findings {
501 let uri = relative_uri(&finding.path, root);
502 let (rule_id, message) = match finding.exceeded {
503 ExceededThreshold::Cyclomatic => (
504 "fallow/high-cyclomatic-complexity",
505 format!(
506 "'{}' has cyclomatic complexity {} (threshold: {})",
507 finding.name, finding.cyclomatic, report.summary.max_cyclomatic_threshold,
508 ),
509 ),
510 ExceededThreshold::Cognitive => (
511 "fallow/high-cognitive-complexity",
512 format!(
513 "'{}' has cognitive complexity {} (threshold: {})",
514 finding.name, finding.cognitive, report.summary.max_cognitive_threshold,
515 ),
516 ),
517 ExceededThreshold::Both => (
518 "fallow/high-complexity",
519 format!(
520 "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
521 finding.name,
522 finding.cyclomatic,
523 report.summary.max_cyclomatic_threshold,
524 finding.cognitive,
525 report.summary.max_cognitive_threshold,
526 ),
527 ),
528 };
529
530 sarif_results.push(sarif_result(
531 rule_id,
532 "warning",
533 &message,
534 &uri,
535 Some((finding.line, finding.col + 1)),
536 ));
537 }
538
539 for target in &report.targets {
541 let uri = relative_uri(&target.path, root);
542 let message = format!(
543 "[{}] {} (priority: {:.1}, efficiency: {:.1}, effort: {}, confidence: {})",
544 target.category.label(),
545 target.recommendation,
546 target.priority,
547 target.efficiency,
548 target.effort.label(),
549 target.confidence.label(),
550 );
551 sarif_results.push(sarif_result(
552 "fallow/refactoring-target",
553 "warning",
554 &message,
555 &uri,
556 None,
557 ));
558 }
559
560 let health_rules = vec![
561 sarif_rule(
562 "fallow/high-cyclomatic-complexity",
563 "Function has high cyclomatic complexity",
564 "warning",
565 ),
566 sarif_rule(
567 "fallow/high-cognitive-complexity",
568 "Function has high cognitive complexity",
569 "warning",
570 ),
571 sarif_rule(
572 "fallow/high-complexity",
573 "Function exceeds both complexity thresholds",
574 "warning",
575 ),
576 sarif_rule(
577 "fallow/refactoring-target",
578 "File identified as a high-priority refactoring candidate",
579 "warning",
580 ),
581 ];
582
583 serde_json::json!({
584 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
585 "version": "2.1.0",
586 "runs": [{
587 "tool": {
588 "driver": {
589 "name": "fallow",
590 "version": env!("CARGO_PKG_VERSION"),
591 "informationUri": "https://github.com/fallow-rs/fallow",
592 "rules": health_rules
593 }
594 },
595 "results": sarif_results
596 }]
597 })
598}
599
600pub(super) fn print_health_sarif(
601 report: &crate::health_types::HealthReport,
602 root: &Path,
603) -> ExitCode {
604 let sarif = build_health_sarif(report, root);
605 match serde_json::to_string_pretty(&sarif) {
606 Ok(json) => {
607 println!("{json}");
608 ExitCode::SUCCESS
609 }
610 Err(e) => {
611 eprintln!("Error: failed to serialize SARIF output: {e}");
612 ExitCode::from(2)
613 }
614 }
615}
616
617#[cfg(test)]
618mod tests {
619 use super::*;
620 use fallow_core::extract::MemberKind;
621 use fallow_core::results::*;
622 use std::path::PathBuf;
623
624 fn sample_results(root: &Path) -> AnalysisResults {
626 let mut r = AnalysisResults::default();
627
628 r.unused_files.push(UnusedFile {
629 path: root.join("src/dead.ts"),
630 });
631 r.unused_exports.push(UnusedExport {
632 path: root.join("src/utils.ts"),
633 export_name: "helperFn".to_string(),
634 is_type_only: false,
635 line: 10,
636 col: 4,
637 span_start: 120,
638 is_re_export: false,
639 });
640 r.unused_types.push(UnusedExport {
641 path: root.join("src/types.ts"),
642 export_name: "OldType".to_string(),
643 is_type_only: true,
644 line: 5,
645 col: 0,
646 span_start: 60,
647 is_re_export: false,
648 });
649 r.unused_dependencies.push(UnusedDependency {
650 package_name: "lodash".to_string(),
651 location: DependencyLocation::Dependencies,
652 path: root.join("package.json"),
653 line: 5,
654 });
655 r.unused_dev_dependencies.push(UnusedDependency {
656 package_name: "jest".to_string(),
657 location: DependencyLocation::DevDependencies,
658 path: root.join("package.json"),
659 line: 5,
660 });
661 r.unused_enum_members.push(UnusedMember {
662 path: root.join("src/enums.ts"),
663 parent_name: "Status".to_string(),
664 member_name: "Deprecated".to_string(),
665 kind: MemberKind::EnumMember,
666 line: 8,
667 col: 2,
668 });
669 r.unused_class_members.push(UnusedMember {
670 path: root.join("src/service.ts"),
671 parent_name: "UserService".to_string(),
672 member_name: "legacyMethod".to_string(),
673 kind: MemberKind::ClassMethod,
674 line: 42,
675 col: 4,
676 });
677 r.unresolved_imports.push(UnresolvedImport {
678 path: root.join("src/app.ts"),
679 specifier: "./missing-module".to_string(),
680 line: 3,
681 col: 0,
682 specifier_col: 0,
683 });
684 r.unlisted_dependencies.push(UnlistedDependency {
685 package_name: "chalk".to_string(),
686 imported_from: vec![ImportSite {
687 path: root.join("src/cli.ts"),
688 line: 2,
689 col: 0,
690 }],
691 });
692 r.duplicate_exports.push(DuplicateExport {
693 export_name: "Config".to_string(),
694 locations: vec![
695 DuplicateLocation {
696 path: root.join("src/config.ts"),
697 line: 15,
698 col: 0,
699 },
700 DuplicateLocation {
701 path: root.join("src/types.ts"),
702 line: 30,
703 col: 0,
704 },
705 ],
706 });
707 r.type_only_dependencies.push(TypeOnlyDependency {
708 package_name: "zod".to_string(),
709 path: root.join("package.json"),
710 line: 8,
711 });
712 r.circular_dependencies.push(CircularDependency {
713 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
714 length: 2,
715 line: 3,
716 col: 0,
717 });
718
719 r
720 }
721
722 #[test]
723 fn sarif_has_required_top_level_fields() {
724 let root = PathBuf::from("/project");
725 let results = AnalysisResults::default();
726 let sarif = build_sarif(&results, &root, &RulesConfig::default());
727
728 assert_eq!(
729 sarif["$schema"],
730 "https://json.schemastore.org/sarif-2.1.0.json"
731 );
732 assert_eq!(sarif["version"], "2.1.0");
733 assert!(sarif["runs"].is_array());
734 }
735
736 #[test]
737 fn sarif_has_tool_driver_info() {
738 let root = PathBuf::from("/project");
739 let results = AnalysisResults::default();
740 let sarif = build_sarif(&results, &root, &RulesConfig::default());
741
742 let driver = &sarif["runs"][0]["tool"]["driver"];
743 assert_eq!(driver["name"], "fallow");
744 assert!(driver["version"].is_string());
745 assert_eq!(
746 driver["informationUri"],
747 "https://github.com/fallow-rs/fallow"
748 );
749 }
750
751 #[test]
752 fn sarif_declares_all_rules() {
753 let root = PathBuf::from("/project");
754 let results = AnalysisResults::default();
755 let sarif = build_sarif(&results, &root, &RulesConfig::default());
756
757 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
758 .as_array()
759 .expect("rules should be an array");
760 assert_eq!(rules.len(), 13);
761
762 let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
763 assert!(rule_ids.contains(&"fallow/unused-file"));
764 assert!(rule_ids.contains(&"fallow/unused-export"));
765 assert!(rule_ids.contains(&"fallow/unused-type"));
766 assert!(rule_ids.contains(&"fallow/unused-dependency"));
767 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
768 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
769 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
770 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
771 assert!(rule_ids.contains(&"fallow/unused-class-member"));
772 assert!(rule_ids.contains(&"fallow/unresolved-import"));
773 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
774 assert!(rule_ids.contains(&"fallow/duplicate-export"));
775 assert!(rule_ids.contains(&"fallow/circular-dependency"));
776 }
777
778 #[test]
779 fn sarif_empty_results_no_results_entries() {
780 let root = PathBuf::from("/project");
781 let results = AnalysisResults::default();
782 let sarif = build_sarif(&results, &root, &RulesConfig::default());
783
784 let sarif_results = sarif["runs"][0]["results"]
785 .as_array()
786 .expect("results should be an array");
787 assert!(sarif_results.is_empty());
788 }
789
790 #[test]
791 fn sarif_unused_file_result() {
792 let root = PathBuf::from("/project");
793 let mut results = AnalysisResults::default();
794 results.unused_files.push(UnusedFile {
795 path: root.join("src/dead.ts"),
796 });
797
798 let sarif = build_sarif(&results, &root, &RulesConfig::default());
799 let entries = sarif["runs"][0]["results"].as_array().unwrap();
800 assert_eq!(entries.len(), 1);
801
802 let entry = &entries[0];
803 assert_eq!(entry["ruleId"], "fallow/unused-file");
804 assert_eq!(entry["level"], "error");
806 assert_eq!(
807 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
808 "src/dead.ts"
809 );
810 }
811
812 #[test]
813 fn sarif_unused_export_includes_region() {
814 let root = PathBuf::from("/project");
815 let mut results = AnalysisResults::default();
816 results.unused_exports.push(UnusedExport {
817 path: root.join("src/utils.ts"),
818 export_name: "helperFn".to_string(),
819 is_type_only: false,
820 line: 10,
821 col: 4,
822 span_start: 120,
823 is_re_export: false,
824 });
825
826 let sarif = build_sarif(&results, &root, &RulesConfig::default());
827 let entry = &sarif["runs"][0]["results"][0];
828 assert_eq!(entry["ruleId"], "fallow/unused-export");
829
830 let region = &entry["locations"][0]["physicalLocation"]["region"];
831 assert_eq!(region["startLine"], 10);
832 assert_eq!(region["startColumn"], 5);
834 }
835
836 #[test]
837 fn sarif_unresolved_import_is_error_level() {
838 let root = PathBuf::from("/project");
839 let mut results = AnalysisResults::default();
840 results.unresolved_imports.push(UnresolvedImport {
841 path: root.join("src/app.ts"),
842 specifier: "./missing".to_string(),
843 line: 1,
844 col: 0,
845 specifier_col: 0,
846 });
847
848 let sarif = build_sarif(&results, &root, &RulesConfig::default());
849 let entry = &sarif["runs"][0]["results"][0];
850 assert_eq!(entry["ruleId"], "fallow/unresolved-import");
851 assert_eq!(entry["level"], "error");
852 }
853
854 #[test]
855 fn sarif_unlisted_dependency_points_to_import_site() {
856 let root = PathBuf::from("/project");
857 let mut results = AnalysisResults::default();
858 results.unlisted_dependencies.push(UnlistedDependency {
859 package_name: "chalk".to_string(),
860 imported_from: vec![ImportSite {
861 path: root.join("src/cli.ts"),
862 line: 3,
863 col: 0,
864 }],
865 });
866
867 let sarif = build_sarif(&results, &root, &RulesConfig::default());
868 let entry = &sarif["runs"][0]["results"][0];
869 assert_eq!(entry["ruleId"], "fallow/unlisted-dependency");
870 assert_eq!(entry["level"], "error");
871 assert_eq!(
872 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
873 "src/cli.ts"
874 );
875 let region = &entry["locations"][0]["physicalLocation"]["region"];
876 assert_eq!(region["startLine"], 3);
877 assert_eq!(region["startColumn"], 1);
878 }
879
880 #[test]
881 fn sarif_dependency_issues_point_to_package_json() {
882 let root = PathBuf::from("/project");
883 let mut results = AnalysisResults::default();
884 results.unused_dependencies.push(UnusedDependency {
885 package_name: "lodash".to_string(),
886 location: DependencyLocation::Dependencies,
887 path: root.join("package.json"),
888 line: 5,
889 });
890 results.unused_dev_dependencies.push(UnusedDependency {
891 package_name: "jest".to_string(),
892 location: DependencyLocation::DevDependencies,
893 path: root.join("package.json"),
894 line: 5,
895 });
896
897 let sarif = build_sarif(&results, &root, &RulesConfig::default());
898 let entries = sarif["runs"][0]["results"].as_array().unwrap();
899 for entry in entries {
900 assert_eq!(
901 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
902 "package.json"
903 );
904 }
905 }
906
907 #[test]
908 fn sarif_duplicate_export_emits_one_result_per_location() {
909 let root = PathBuf::from("/project");
910 let mut results = AnalysisResults::default();
911 results.duplicate_exports.push(DuplicateExport {
912 export_name: "Config".to_string(),
913 locations: vec![
914 DuplicateLocation {
915 path: root.join("src/a.ts"),
916 line: 15,
917 col: 0,
918 },
919 DuplicateLocation {
920 path: root.join("src/b.ts"),
921 line: 30,
922 col: 0,
923 },
924 ],
925 });
926
927 let sarif = build_sarif(&results, &root, &RulesConfig::default());
928 let entries = sarif["runs"][0]["results"].as_array().unwrap();
929 assert_eq!(entries.len(), 2);
931 assert_eq!(entries[0]["ruleId"], "fallow/duplicate-export");
932 assert_eq!(entries[1]["ruleId"], "fallow/duplicate-export");
933 assert_eq!(
934 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
935 "src/a.ts"
936 );
937 assert_eq!(
938 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
939 "src/b.ts"
940 );
941 }
942
943 #[test]
944 fn sarif_all_issue_types_produce_results() {
945 let root = PathBuf::from("/project");
946 let results = sample_results(&root);
947 let sarif = build_sarif(&results, &root, &RulesConfig::default());
948
949 let entries = sarif["runs"][0]["results"].as_array().unwrap();
950 assert_eq!(entries.len(), 13);
952
953 let rule_ids: Vec<&str> = entries
954 .iter()
955 .map(|e| e["ruleId"].as_str().unwrap())
956 .collect();
957 assert!(rule_ids.contains(&"fallow/unused-file"));
958 assert!(rule_ids.contains(&"fallow/unused-export"));
959 assert!(rule_ids.contains(&"fallow/unused-type"));
960 assert!(rule_ids.contains(&"fallow/unused-dependency"));
961 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
962 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
963 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
964 assert!(rule_ids.contains(&"fallow/unused-class-member"));
965 assert!(rule_ids.contains(&"fallow/unresolved-import"));
966 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
967 assert!(rule_ids.contains(&"fallow/duplicate-export"));
968 }
969
970 #[test]
971 fn sarif_serializes_to_valid_json() {
972 let root = PathBuf::from("/project");
973 let results = sample_results(&root);
974 let sarif = build_sarif(&results, &root, &RulesConfig::default());
975
976 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
977 let reparsed: serde_json::Value =
978 serde_json::from_str(&json_str).expect("SARIF output should be valid JSON");
979 assert_eq!(reparsed, sarif);
980 }
981
982 #[test]
983 fn sarif_file_write_produces_valid_sarif() {
984 let root = PathBuf::from("/project");
985 let results = sample_results(&root);
986 let sarif = build_sarif(&results, &root, &RulesConfig::default());
987 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
988
989 let dir = std::env::temp_dir().join("fallow-test-sarif-file");
990 let _ = std::fs::create_dir_all(&dir);
991 let sarif_path = dir.join("results.sarif");
992 std::fs::write(&sarif_path, &json_str).expect("should write SARIF file");
993
994 let contents = std::fs::read_to_string(&sarif_path).expect("should read SARIF file");
995 let parsed: serde_json::Value =
996 serde_json::from_str(&contents).expect("file should contain valid JSON");
997
998 assert_eq!(parsed["version"], "2.1.0");
999 assert_eq!(
1000 parsed["$schema"],
1001 "https://json.schemastore.org/sarif-2.1.0.json"
1002 );
1003 let sarif_results = parsed["runs"][0]["results"]
1004 .as_array()
1005 .expect("results should be an array");
1006 assert!(!sarif_results.is_empty());
1007
1008 let _ = std::fs::remove_file(&sarif_path);
1010 let _ = std::fs::remove_dir(&dir);
1011 }
1012
1013 #[test]
1016 fn health_sarif_empty_no_results() {
1017 let root = PathBuf::from("/project");
1018 let report = crate::health_types::HealthReport {
1019 findings: vec![],
1020 summary: crate::health_types::HealthSummary {
1021 files_analyzed: 10,
1022 functions_analyzed: 50,
1023 functions_above_threshold: 0,
1024 max_cyclomatic_threshold: 20,
1025 max_cognitive_threshold: 15,
1026 files_scored: None,
1027 average_maintainability: None,
1028 },
1029 vital_signs: None,
1030 file_scores: vec![],
1031 hotspots: vec![],
1032 hotspot_summary: None,
1033 targets: vec![],
1034 target_thresholds: None,
1035 };
1036 let sarif = build_health_sarif(&report, &root);
1037 assert_eq!(sarif["version"], "2.1.0");
1038 let results = sarif["runs"][0]["results"].as_array().unwrap();
1039 assert!(results.is_empty());
1040 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1041 .as_array()
1042 .unwrap();
1043 assert_eq!(rules.len(), 4);
1044 }
1045
1046 #[test]
1047 fn health_sarif_cyclomatic_only() {
1048 let root = PathBuf::from("/project");
1049 let report = crate::health_types::HealthReport {
1050 findings: vec![crate::health_types::HealthFinding {
1051 path: root.join("src/utils.ts"),
1052 name: "parseExpression".to_string(),
1053 line: 42,
1054 col: 0,
1055 cyclomatic: 25,
1056 cognitive: 10,
1057 line_count: 80,
1058 exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
1059 }],
1060 summary: crate::health_types::HealthSummary {
1061 files_analyzed: 5,
1062 functions_analyzed: 20,
1063 functions_above_threshold: 1,
1064 max_cyclomatic_threshold: 20,
1065 max_cognitive_threshold: 15,
1066 files_scored: None,
1067 average_maintainability: None,
1068 },
1069 vital_signs: None,
1070 file_scores: vec![],
1071 hotspots: vec![],
1072 hotspot_summary: None,
1073 targets: vec![],
1074 target_thresholds: None,
1075 };
1076 let sarif = build_health_sarif(&report, &root);
1077 let entry = &sarif["runs"][0]["results"][0];
1078 assert_eq!(entry["ruleId"], "fallow/high-cyclomatic-complexity");
1079 assert_eq!(entry["level"], "warning");
1080 assert!(
1081 entry["message"]["text"]
1082 .as_str()
1083 .unwrap()
1084 .contains("cyclomatic complexity 25")
1085 );
1086 assert_eq!(
1087 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1088 "src/utils.ts"
1089 );
1090 let region = &entry["locations"][0]["physicalLocation"]["region"];
1091 assert_eq!(region["startLine"], 42);
1092 assert_eq!(region["startColumn"], 1);
1093 }
1094
1095 #[test]
1096 fn health_sarif_cognitive_only() {
1097 let root = PathBuf::from("/project");
1098 let report = crate::health_types::HealthReport {
1099 findings: vec![crate::health_types::HealthFinding {
1100 path: root.join("src/api.ts"),
1101 name: "handleRequest".to_string(),
1102 line: 10,
1103 col: 4,
1104 cyclomatic: 8,
1105 cognitive: 20,
1106 line_count: 40,
1107 exceeded: crate::health_types::ExceededThreshold::Cognitive,
1108 }],
1109 summary: crate::health_types::HealthSummary {
1110 files_analyzed: 3,
1111 functions_analyzed: 10,
1112 functions_above_threshold: 1,
1113 max_cyclomatic_threshold: 20,
1114 max_cognitive_threshold: 15,
1115 files_scored: None,
1116 average_maintainability: None,
1117 },
1118 vital_signs: None,
1119 file_scores: vec![],
1120 hotspots: vec![],
1121 hotspot_summary: None,
1122 targets: vec![],
1123 target_thresholds: None,
1124 };
1125 let sarif = build_health_sarif(&report, &root);
1126 let entry = &sarif["runs"][0]["results"][0];
1127 assert_eq!(entry["ruleId"], "fallow/high-cognitive-complexity");
1128 assert!(
1129 entry["message"]["text"]
1130 .as_str()
1131 .unwrap()
1132 .contains("cognitive complexity 20")
1133 );
1134 let region = &entry["locations"][0]["physicalLocation"]["region"];
1135 assert_eq!(region["startColumn"], 5); }
1137
1138 #[test]
1139 fn health_sarif_both_thresholds() {
1140 let root = PathBuf::from("/project");
1141 let report = crate::health_types::HealthReport {
1142 findings: vec![crate::health_types::HealthFinding {
1143 path: root.join("src/complex.ts"),
1144 name: "doEverything".to_string(),
1145 line: 1,
1146 col: 0,
1147 cyclomatic: 30,
1148 cognitive: 45,
1149 line_count: 100,
1150 exceeded: crate::health_types::ExceededThreshold::Both,
1151 }],
1152 summary: crate::health_types::HealthSummary {
1153 files_analyzed: 1,
1154 functions_analyzed: 1,
1155 functions_above_threshold: 1,
1156 max_cyclomatic_threshold: 20,
1157 max_cognitive_threshold: 15,
1158 files_scored: None,
1159 average_maintainability: None,
1160 },
1161 vital_signs: None,
1162 file_scores: vec![],
1163 hotspots: vec![],
1164 hotspot_summary: None,
1165 targets: vec![],
1166 target_thresholds: None,
1167 };
1168 let sarif = build_health_sarif(&report, &root);
1169 let entry = &sarif["runs"][0]["results"][0];
1170 assert_eq!(entry["ruleId"], "fallow/high-complexity");
1171 let msg = entry["message"]["text"].as_str().unwrap();
1172 assert!(msg.contains("cyclomatic complexity 30"));
1173 assert!(msg.contains("cognitive complexity 45"));
1174 }
1175}