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