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