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