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;
9
10struct SarifFields {
12 rule_id: &'static str,
13 level: &'static str,
14 message: String,
15 uri: String,
16 region: Option<(u32, u32)>,
17 properties: Option<serde_json::Value>,
18}
19
20const fn severity_to_sarif_level(s: Severity) -> &'static str {
21 match s {
22 Severity::Error => "error",
23 Severity::Warn | Severity::Off => "warning",
24 }
25}
26
27fn sarif_result(
32 rule_id: &str,
33 level: &str,
34 message: &str,
35 uri: &str,
36 region: Option<(u32, u32)>,
37) -> serde_json::Value {
38 let mut physical_location = serde_json::json!({
39 "artifactLocation": { "uri": uri }
40 });
41 if let Some((line, col)) = region {
42 physical_location["region"] = serde_json::json!({
43 "startLine": line,
44 "startColumn": col
45 });
46 }
47 serde_json::json!({
48 "ruleId": rule_id,
49 "level": level,
50 "message": { "text": message },
51 "locations": [{ "physicalLocation": physical_location }]
52 })
53}
54
55fn push_sarif_results<T>(
57 sarif_results: &mut Vec<serde_json::Value>,
58 items: &[T],
59 extract: impl Fn(&T) -> SarifFields,
60) {
61 for item in items {
62 let fields = extract(item);
63 let mut result = sarif_result(
64 fields.rule_id,
65 fields.level,
66 &fields.message,
67 &fields.uri,
68 fields.region,
69 );
70 if let Some(props) = fields.properties {
71 result["properties"] = props;
72 }
73 sarif_results.push(result);
74 }
75}
76
77pub fn build_sarif(
78 results: &AnalysisResults,
79 root: &Path,
80 rules: &RulesConfig,
81) -> serde_json::Value {
82 let mut sarif_results = Vec::new();
83
84 push_sarif_results(&mut sarif_results, &results.unused_files, |file| {
85 SarifFields {
86 rule_id: "fallow/unused-file",
87 level: severity_to_sarif_level(rules.unused_files),
88 message: "File is not reachable from any entry point".to_string(),
89 uri: relative_uri(&file.path, root),
90 region: None,
91 properties: None,
92 }
93 });
94
95 let sarif_export = |export: &UnusedExport,
96 rule_id: &'static str,
97 level: &'static str,
98 kind: &str,
99 re_kind: &str|
100 -> SarifFields {
101 let label = if export.is_re_export { re_kind } else { kind };
102 SarifFields {
103 rule_id,
104 level,
105 message: format!(
106 "{} '{}' is never imported by other modules",
107 label, export.export_name
108 ),
109 uri: relative_uri(&export.path, root),
110 region: Some((export.line, export.col + 1)),
111 properties: if export.is_re_export {
112 Some(serde_json::json!({ "is_re_export": true }))
113 } else {
114 None
115 },
116 }
117 };
118
119 push_sarif_results(&mut sarif_results, &results.unused_exports, |export| {
120 sarif_export(
121 export,
122 "fallow/unused-export",
123 severity_to_sarif_level(rules.unused_exports),
124 "Export",
125 "Re-export",
126 )
127 });
128
129 push_sarif_results(&mut sarif_results, &results.unused_types, |export| {
130 sarif_export(
131 export,
132 "fallow/unused-type",
133 severity_to_sarif_level(rules.unused_types),
134 "Type export",
135 "Type re-export",
136 )
137 });
138
139 let sarif_dep = |dep: &UnusedDependency,
140 rule_id: &'static str,
141 level: &'static str,
142 section: &str|
143 -> SarifFields {
144 SarifFields {
145 rule_id,
146 level,
147 message: format!(
148 "Package '{}' is in {} but never imported",
149 dep.package_name, section
150 ),
151 uri: relative_uri(&dep.path, root),
152 region: if dep.line > 0 {
153 Some((dep.line, 1))
154 } else {
155 None
156 },
157 properties: None,
158 }
159 };
160
161 push_sarif_results(&mut sarif_results, &results.unused_dependencies, |dep| {
162 sarif_dep(
163 dep,
164 "fallow/unused-dependency",
165 severity_to_sarif_level(rules.unused_dependencies),
166 "dependencies",
167 )
168 });
169
170 push_sarif_results(
171 &mut sarif_results,
172 &results.unused_dev_dependencies,
173 |dep| {
174 sarif_dep(
175 dep,
176 "fallow/unused-dev-dependency",
177 severity_to_sarif_level(rules.unused_dev_dependencies),
178 "devDependencies",
179 )
180 },
181 );
182
183 push_sarif_results(
184 &mut sarif_results,
185 &results.unused_optional_dependencies,
186 |dep| {
187 sarif_dep(
188 dep,
189 "fallow/unused-optional-dependency",
190 severity_to_sarif_level(rules.unused_optional_dependencies),
191 "optionalDependencies",
192 )
193 },
194 );
195
196 push_sarif_results(&mut sarif_results, &results.type_only_dependencies, |dep| {
197 SarifFields {
198 rule_id: "fallow/type-only-dependency",
199 level: severity_to_sarif_level(rules.type_only_dependencies),
200 message: format!(
201 "Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
202 dep.package_name
203 ),
204 uri: relative_uri(&dep.path, root),
205 region: if dep.line > 0 {
206 Some((dep.line, 1))
207 } else {
208 None
209 },
210 properties: None,
211 }
212 });
213
214 let sarif_member = |member: &UnusedMember,
215 rule_id: &'static str,
216 level: &'static str,
217 kind: &str|
218 -> SarifFields {
219 SarifFields {
220 rule_id,
221 level,
222 message: format!(
223 "{} member '{}.{}' is never referenced",
224 kind, member.parent_name, member.member_name
225 ),
226 uri: relative_uri(&member.path, root),
227 region: Some((member.line, member.col + 1)),
228 properties: None,
229 }
230 };
231
232 push_sarif_results(&mut sarif_results, &results.unused_enum_members, |member| {
233 sarif_member(
234 member,
235 "fallow/unused-enum-member",
236 severity_to_sarif_level(rules.unused_enum_members),
237 "Enum",
238 )
239 });
240
241 push_sarif_results(
242 &mut sarif_results,
243 &results.unused_class_members,
244 |member| {
245 sarif_member(
246 member,
247 "fallow/unused-class-member",
248 severity_to_sarif_level(rules.unused_class_members),
249 "Class",
250 )
251 },
252 );
253
254 push_sarif_results(&mut sarif_results, &results.unresolved_imports, |import| {
255 SarifFields {
256 rule_id: "fallow/unresolved-import",
257 level: severity_to_sarif_level(rules.unresolved_imports),
258 message: format!("Import '{}' could not be resolved", import.specifier),
259 uri: relative_uri(&import.path, root),
260 region: Some((import.line, import.col + 1)),
261 properties: None,
262 }
263 });
264
265 for dep in &results.unlisted_dependencies {
267 for site in &dep.imported_from {
268 sarif_results.push(sarif_result(
269 "fallow/unlisted-dependency",
270 severity_to_sarif_level(rules.unlisted_dependencies),
271 &format!(
272 "Package '{}' is imported but not listed in package.json",
273 dep.package_name
274 ),
275 &relative_uri(&site.path, root),
276 Some((site.line, site.col + 1)),
277 ));
278 }
279 }
280
281 for dup in &results.duplicate_exports {
283 for loc in &dup.locations {
284 sarif_results.push(sarif_result(
285 "fallow/duplicate-export",
286 severity_to_sarif_level(rules.duplicate_exports),
287 &format!("Export '{}' appears in multiple modules", dup.export_name),
288 &relative_uri(&loc.path, root),
289 Some((loc.line, loc.col + 1)),
290 ));
291 }
292 }
293
294 push_sarif_results(
295 &mut sarif_results,
296 &results.circular_dependencies,
297 |cycle| {
298 let chain: Vec<String> = cycle.files.iter().map(|p| relative_uri(p, root)).collect();
299 let mut display_chain = chain.clone();
300 if let Some(first) = chain.first() {
301 display_chain.push(first.clone());
302 }
303 let first_uri = chain.first().map_or_else(String::new, Clone::clone);
304 SarifFields {
305 rule_id: "fallow/circular-dependency",
306 level: severity_to_sarif_level(rules.circular_dependencies),
307 message: format!("Circular dependency: {}", display_chain.join(" \u{2192} ")),
308 uri: first_uri,
309 region: if cycle.line > 0 {
310 Some((cycle.line, cycle.col + 1))
311 } else {
312 None
313 },
314 properties: None,
315 }
316 },
317 );
318
319 serde_json::json!({
320 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
321 "version": "2.1.0",
322 "runs": [{
323 "tool": {
324 "driver": {
325 "name": "fallow",
326 "version": env!("CARGO_PKG_VERSION"),
327 "informationUri": "https://github.com/fallow-rs/fallow",
328 "rules": [
329 {
330 "id": "fallow/unused-file",
331 "shortDescription": { "text": "File is not reachable from any entry point" },
332 "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_files) }
333 },
334 {
335 "id": "fallow/unused-export",
336 "shortDescription": { "text": "Export is never imported" },
337 "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_exports) }
338 },
339 {
340 "id": "fallow/unused-type",
341 "shortDescription": { "text": "Type export is never imported" },
342 "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_types) }
343 },
344 {
345 "id": "fallow/unused-dependency",
346 "shortDescription": { "text": "Dependency listed but never imported" },
347 "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_dependencies) }
348 },
349 {
350 "id": "fallow/unused-dev-dependency",
351 "shortDescription": { "text": "Dev dependency listed but never imported" },
352 "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_dev_dependencies) }
353 },
354 {
355 "id": "fallow/unused-optional-dependency",
356 "shortDescription": { "text": "Optional dependency listed but never imported" },
357 "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_optional_dependencies) }
358 },
359 {
360 "id": "fallow/type-only-dependency",
361 "shortDescription": { "text": "Production dependency only used via type-only imports" },
362 "defaultConfiguration": { "level": severity_to_sarif_level(rules.type_only_dependencies) }
363 },
364 {
365 "id": "fallow/unused-enum-member",
366 "shortDescription": { "text": "Enum member is never referenced" },
367 "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_enum_members) }
368 },
369 {
370 "id": "fallow/unused-class-member",
371 "shortDescription": { "text": "Class member is never referenced" },
372 "defaultConfiguration": { "level": severity_to_sarif_level(rules.unused_class_members) }
373 },
374 {
375 "id": "fallow/unresolved-import",
376 "shortDescription": { "text": "Import could not be resolved" },
377 "defaultConfiguration": { "level": severity_to_sarif_level(rules.unresolved_imports) }
378 },
379 {
380 "id": "fallow/unlisted-dependency",
381 "shortDescription": { "text": "Dependency used but not in package.json" },
382 "defaultConfiguration": { "level": severity_to_sarif_level(rules.unlisted_dependencies) }
383 },
384 {
385 "id": "fallow/duplicate-export",
386 "shortDescription": { "text": "Export name appears in multiple modules" },
387 "defaultConfiguration": { "level": severity_to_sarif_level(rules.duplicate_exports) }
388 },
389 {
390 "id": "fallow/circular-dependency",
391 "shortDescription": { "text": "Circular dependency chain detected" },
392 "defaultConfiguration": { "level": severity_to_sarif_level(rules.circular_dependencies) }
393 }
394 ]
395 }
396 },
397 "results": sarif_results
398 }]
399 })
400}
401
402pub(super) fn print_sarif(results: &AnalysisResults, root: &Path, rules: &RulesConfig) -> ExitCode {
403 let sarif = build_sarif(results, root, rules);
404 match serde_json::to_string_pretty(&sarif) {
405 Ok(json) => {
406 println!("{json}");
407 ExitCode::SUCCESS
408 }
409 Err(e) => {
410 eprintln!("Error: failed to serialize SARIF output: {e}");
411 ExitCode::from(2)
412 }
413 }
414}
415
416pub(super) fn print_duplication_sarif(report: &DuplicationReport, root: &Path) -> ExitCode {
417 let mut sarif_results = Vec::new();
418
419 for (i, group) in report.clone_groups.iter().enumerate() {
420 for instance in &group.instances {
421 sarif_results.push(sarif_result(
422 "fallow/code-duplication",
423 "warning",
424 &format!(
425 "Code clone group {} ({} lines, {} instances)",
426 i + 1,
427 group.line_count,
428 group.instances.len()
429 ),
430 &relative_uri(&instance.file, root),
431 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
432 ));
433 }
434 }
435
436 let sarif = serde_json::json!({
437 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
438 "version": "2.1.0",
439 "runs": [{
440 "tool": {
441 "driver": {
442 "name": "fallow",
443 "version": env!("CARGO_PKG_VERSION"),
444 "informationUri": "https://github.com/fallow-rs/fallow",
445 "rules": [{
446 "id": "fallow/code-duplication",
447 "shortDescription": { "text": "Duplicated code block" },
448 "defaultConfiguration": { "level": "warning" }
449 }]
450 }
451 },
452 "results": sarif_results
453 }]
454 });
455
456 match serde_json::to_string_pretty(&sarif) {
457 Ok(json) => {
458 println!("{json}");
459 ExitCode::SUCCESS
460 }
461 Err(e) => {
462 eprintln!("Error: failed to serialize SARIF output: {e}");
463 ExitCode::from(2)
464 }
465 }
466}
467
468pub fn build_health_sarif(
474 report: &crate::health_types::HealthReport,
475 root: &Path,
476) -> serde_json::Value {
477 use crate::health_types::ExceededThreshold;
478
479 let mut sarif_results = Vec::new();
480
481 for finding in &report.findings {
482 let uri = relative_uri(&finding.path, root);
483 let (rule_id, message) = match finding.exceeded {
484 ExceededThreshold::Cyclomatic => (
485 "fallow/high-cyclomatic-complexity",
486 format!(
487 "'{}' has cyclomatic complexity {} (threshold: {})",
488 finding.name, finding.cyclomatic, report.summary.max_cyclomatic_threshold,
489 ),
490 ),
491 ExceededThreshold::Cognitive => (
492 "fallow/high-cognitive-complexity",
493 format!(
494 "'{}' has cognitive complexity {} (threshold: {})",
495 finding.name, finding.cognitive, report.summary.max_cognitive_threshold,
496 ),
497 ),
498 ExceededThreshold::Both => (
499 "fallow/high-complexity",
500 format!(
501 "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
502 finding.name,
503 finding.cyclomatic,
504 report.summary.max_cyclomatic_threshold,
505 finding.cognitive,
506 report.summary.max_cognitive_threshold,
507 ),
508 ),
509 };
510
511 sarif_results.push(sarif_result(
512 rule_id,
513 "warning",
514 &message,
515 &uri,
516 Some((finding.line, finding.col + 1)),
517 ));
518 }
519
520 serde_json::json!({
521 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
522 "version": "2.1.0",
523 "runs": [{
524 "tool": {
525 "driver": {
526 "name": "fallow",
527 "version": env!("CARGO_PKG_VERSION"),
528 "informationUri": "https://github.com/fallow-rs/fallow",
529 "rules": [
530 {
531 "id": "fallow/high-cyclomatic-complexity",
532 "shortDescription": { "text": "Function exceeds cyclomatic complexity threshold" },
533 "defaultConfiguration": { "level": "warning" }
534 },
535 {
536 "id": "fallow/high-cognitive-complexity",
537 "shortDescription": { "text": "Function exceeds cognitive complexity threshold" },
538 "defaultConfiguration": { "level": "warning" }
539 },
540 {
541 "id": "fallow/high-complexity",
542 "shortDescription": { "text": "Function exceeds both cyclomatic and cognitive complexity thresholds" },
543 "defaultConfiguration": { "level": "warning" }
544 }
545 ]
546 }
547 },
548 "results": sarif_results
549 }]
550 })
551}
552
553pub(super) fn print_health_sarif(
554 report: &crate::health_types::HealthReport,
555 root: &Path,
556) -> ExitCode {
557 let sarif = build_health_sarif(report, root);
558 match serde_json::to_string_pretty(&sarif) {
559 Ok(json) => {
560 println!("{json}");
561 ExitCode::SUCCESS
562 }
563 Err(e) => {
564 eprintln!("Error: failed to serialize SARIF output: {e}");
565 ExitCode::from(2)
566 }
567 }
568}
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573 use fallow_core::extract::MemberKind;
574 use fallow_core::results::*;
575 use std::path::PathBuf;
576
577 fn sample_results(root: &Path) -> AnalysisResults {
579 let mut r = AnalysisResults::default();
580
581 r.unused_files.push(UnusedFile {
582 path: root.join("src/dead.ts"),
583 });
584 r.unused_exports.push(UnusedExport {
585 path: root.join("src/utils.ts"),
586 export_name: "helperFn".to_string(),
587 is_type_only: false,
588 line: 10,
589 col: 4,
590 span_start: 120,
591 is_re_export: false,
592 });
593 r.unused_types.push(UnusedExport {
594 path: root.join("src/types.ts"),
595 export_name: "OldType".to_string(),
596 is_type_only: true,
597 line: 5,
598 col: 0,
599 span_start: 60,
600 is_re_export: false,
601 });
602 r.unused_dependencies.push(UnusedDependency {
603 package_name: "lodash".to_string(),
604 location: DependencyLocation::Dependencies,
605 path: root.join("package.json"),
606 line: 5,
607 });
608 r.unused_dev_dependencies.push(UnusedDependency {
609 package_name: "jest".to_string(),
610 location: DependencyLocation::DevDependencies,
611 path: root.join("package.json"),
612 line: 5,
613 });
614 r.unused_enum_members.push(UnusedMember {
615 path: root.join("src/enums.ts"),
616 parent_name: "Status".to_string(),
617 member_name: "Deprecated".to_string(),
618 kind: MemberKind::EnumMember,
619 line: 8,
620 col: 2,
621 });
622 r.unused_class_members.push(UnusedMember {
623 path: root.join("src/service.ts"),
624 parent_name: "UserService".to_string(),
625 member_name: "legacyMethod".to_string(),
626 kind: MemberKind::ClassMethod,
627 line: 42,
628 col: 4,
629 });
630 r.unresolved_imports.push(UnresolvedImport {
631 path: root.join("src/app.ts"),
632 specifier: "./missing-module".to_string(),
633 line: 3,
634 col: 0,
635 });
636 r.unlisted_dependencies.push(UnlistedDependency {
637 package_name: "chalk".to_string(),
638 imported_from: vec![ImportSite {
639 path: root.join("src/cli.ts"),
640 line: 2,
641 col: 0,
642 }],
643 });
644 r.duplicate_exports.push(DuplicateExport {
645 export_name: "Config".to_string(),
646 locations: vec![
647 DuplicateLocation {
648 path: root.join("src/config.ts"),
649 line: 15,
650 col: 0,
651 },
652 DuplicateLocation {
653 path: root.join("src/types.ts"),
654 line: 30,
655 col: 0,
656 },
657 ],
658 });
659 r.type_only_dependencies.push(TypeOnlyDependency {
660 package_name: "zod".to_string(),
661 path: root.join("package.json"),
662 line: 8,
663 });
664 r.circular_dependencies.push(CircularDependency {
665 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
666 length: 2,
667 line: 3,
668 col: 0,
669 });
670
671 r
672 }
673
674 #[test]
675 fn sarif_has_required_top_level_fields() {
676 let root = PathBuf::from("/project");
677 let results = AnalysisResults::default();
678 let sarif = build_sarif(&results, &root, &RulesConfig::default());
679
680 assert_eq!(
681 sarif["$schema"],
682 "https://json.schemastore.org/sarif-2.1.0.json"
683 );
684 assert_eq!(sarif["version"], "2.1.0");
685 assert!(sarif["runs"].is_array());
686 }
687
688 #[test]
689 fn sarif_has_tool_driver_info() {
690 let root = PathBuf::from("/project");
691 let results = AnalysisResults::default();
692 let sarif = build_sarif(&results, &root, &RulesConfig::default());
693
694 let driver = &sarif["runs"][0]["tool"]["driver"];
695 assert_eq!(driver["name"], "fallow");
696 assert!(driver["version"].is_string());
697 assert_eq!(
698 driver["informationUri"],
699 "https://github.com/fallow-rs/fallow"
700 );
701 }
702
703 #[test]
704 fn sarif_declares_all_rules() {
705 let root = PathBuf::from("/project");
706 let results = AnalysisResults::default();
707 let sarif = build_sarif(&results, &root, &RulesConfig::default());
708
709 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
710 .as_array()
711 .expect("rules should be an array");
712 assert_eq!(rules.len(), 13);
713
714 let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
715 assert!(rule_ids.contains(&"fallow/unused-file"));
716 assert!(rule_ids.contains(&"fallow/unused-export"));
717 assert!(rule_ids.contains(&"fallow/unused-type"));
718 assert!(rule_ids.contains(&"fallow/unused-dependency"));
719 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
720 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
721 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
722 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
723 assert!(rule_ids.contains(&"fallow/unused-class-member"));
724 assert!(rule_ids.contains(&"fallow/unresolved-import"));
725 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
726 assert!(rule_ids.contains(&"fallow/duplicate-export"));
727 assert!(rule_ids.contains(&"fallow/circular-dependency"));
728 }
729
730 #[test]
731 fn sarif_empty_results_no_results_entries() {
732 let root = PathBuf::from("/project");
733 let results = AnalysisResults::default();
734 let sarif = build_sarif(&results, &root, &RulesConfig::default());
735
736 let sarif_results = sarif["runs"][0]["results"]
737 .as_array()
738 .expect("results should be an array");
739 assert!(sarif_results.is_empty());
740 }
741
742 #[test]
743 fn sarif_unused_file_result() {
744 let root = PathBuf::from("/project");
745 let mut results = AnalysisResults::default();
746 results.unused_files.push(UnusedFile {
747 path: root.join("src/dead.ts"),
748 });
749
750 let sarif = build_sarif(&results, &root, &RulesConfig::default());
751 let entries = sarif["runs"][0]["results"].as_array().unwrap();
752 assert_eq!(entries.len(), 1);
753
754 let entry = &entries[0];
755 assert_eq!(entry["ruleId"], "fallow/unused-file");
756 assert_eq!(entry["level"], "error");
758 assert_eq!(
759 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
760 "src/dead.ts"
761 );
762 }
763
764 #[test]
765 fn sarif_unused_export_includes_region() {
766 let root = PathBuf::from("/project");
767 let mut results = AnalysisResults::default();
768 results.unused_exports.push(UnusedExport {
769 path: root.join("src/utils.ts"),
770 export_name: "helperFn".to_string(),
771 is_type_only: false,
772 line: 10,
773 col: 4,
774 span_start: 120,
775 is_re_export: false,
776 });
777
778 let sarif = build_sarif(&results, &root, &RulesConfig::default());
779 let entry = &sarif["runs"][0]["results"][0];
780 assert_eq!(entry["ruleId"], "fallow/unused-export");
781
782 let region = &entry["locations"][0]["physicalLocation"]["region"];
783 assert_eq!(region["startLine"], 10);
784 assert_eq!(region["startColumn"], 5);
786 }
787
788 #[test]
789 fn sarif_unresolved_import_is_error_level() {
790 let root = PathBuf::from("/project");
791 let mut results = AnalysisResults::default();
792 results.unresolved_imports.push(UnresolvedImport {
793 path: root.join("src/app.ts"),
794 specifier: "./missing".to_string(),
795 line: 1,
796 col: 0,
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/unresolved-import");
802 assert_eq!(entry["level"], "error");
803 }
804
805 #[test]
806 fn sarif_unlisted_dependency_points_to_import_site() {
807 let root = PathBuf::from("/project");
808 let mut results = AnalysisResults::default();
809 results.unlisted_dependencies.push(UnlistedDependency {
810 package_name: "chalk".to_string(),
811 imported_from: vec![ImportSite {
812 path: root.join("src/cli.ts"),
813 line: 3,
814 col: 0,
815 }],
816 });
817
818 let sarif = build_sarif(&results, &root, &RulesConfig::default());
819 let entry = &sarif["runs"][0]["results"][0];
820 assert_eq!(entry["ruleId"], "fallow/unlisted-dependency");
821 assert_eq!(entry["level"], "error");
822 assert_eq!(
823 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
824 "src/cli.ts"
825 );
826 let region = &entry["locations"][0]["physicalLocation"]["region"];
827 assert_eq!(region["startLine"], 3);
828 assert_eq!(region["startColumn"], 1);
829 }
830
831 #[test]
832 fn sarif_dependency_issues_point_to_package_json() {
833 let root = PathBuf::from("/project");
834 let mut results = AnalysisResults::default();
835 results.unused_dependencies.push(UnusedDependency {
836 package_name: "lodash".to_string(),
837 location: DependencyLocation::Dependencies,
838 path: root.join("package.json"),
839 line: 5,
840 });
841 results.unused_dev_dependencies.push(UnusedDependency {
842 package_name: "jest".to_string(),
843 location: DependencyLocation::DevDependencies,
844 path: root.join("package.json"),
845 line: 5,
846 });
847
848 let sarif = build_sarif(&results, &root, &RulesConfig::default());
849 let entries = sarif["runs"][0]["results"].as_array().unwrap();
850 for entry in entries {
851 assert_eq!(
852 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
853 "package.json"
854 );
855 }
856 }
857
858 #[test]
859 fn sarif_duplicate_export_emits_one_result_per_location() {
860 let root = PathBuf::from("/project");
861 let mut results = AnalysisResults::default();
862 results.duplicate_exports.push(DuplicateExport {
863 export_name: "Config".to_string(),
864 locations: vec![
865 DuplicateLocation {
866 path: root.join("src/a.ts"),
867 line: 15,
868 col: 0,
869 },
870 DuplicateLocation {
871 path: root.join("src/b.ts"),
872 line: 30,
873 col: 0,
874 },
875 ],
876 });
877
878 let sarif = build_sarif(&results, &root, &RulesConfig::default());
879 let entries = sarif["runs"][0]["results"].as_array().unwrap();
880 assert_eq!(entries.len(), 2);
882 assert_eq!(entries[0]["ruleId"], "fallow/duplicate-export");
883 assert_eq!(entries[1]["ruleId"], "fallow/duplicate-export");
884 assert_eq!(
885 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
886 "src/a.ts"
887 );
888 assert_eq!(
889 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
890 "src/b.ts"
891 );
892 }
893
894 #[test]
895 fn sarif_all_issue_types_produce_results() {
896 let root = PathBuf::from("/project");
897 let results = sample_results(&root);
898 let sarif = build_sarif(&results, &root, &RulesConfig::default());
899
900 let entries = sarif["runs"][0]["results"].as_array().unwrap();
901 assert_eq!(entries.len(), 13);
903
904 let rule_ids: Vec<&str> = entries
905 .iter()
906 .map(|e| e["ruleId"].as_str().unwrap())
907 .collect();
908 assert!(rule_ids.contains(&"fallow/unused-file"));
909 assert!(rule_ids.contains(&"fallow/unused-export"));
910 assert!(rule_ids.contains(&"fallow/unused-type"));
911 assert!(rule_ids.contains(&"fallow/unused-dependency"));
912 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
913 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
914 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
915 assert!(rule_ids.contains(&"fallow/unused-class-member"));
916 assert!(rule_ids.contains(&"fallow/unresolved-import"));
917 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
918 assert!(rule_ids.contains(&"fallow/duplicate-export"));
919 }
920
921 #[test]
922 fn sarif_serializes_to_valid_json() {
923 let root = PathBuf::from("/project");
924 let results = sample_results(&root);
925 let sarif = build_sarif(&results, &root, &RulesConfig::default());
926
927 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
928 let reparsed: serde_json::Value =
929 serde_json::from_str(&json_str).expect("SARIF output should be valid JSON");
930 assert_eq!(reparsed, sarif);
931 }
932
933 #[test]
934 fn sarif_file_write_produces_valid_sarif() {
935 let root = PathBuf::from("/project");
936 let results = sample_results(&root);
937 let sarif = build_sarif(&results, &root, &RulesConfig::default());
938 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
939
940 let dir = std::env::temp_dir().join("fallow-test-sarif-file");
941 let _ = std::fs::create_dir_all(&dir);
942 let sarif_path = dir.join("results.sarif");
943 std::fs::write(&sarif_path, &json_str).expect("should write SARIF file");
944
945 let contents = std::fs::read_to_string(&sarif_path).expect("should read SARIF file");
946 let parsed: serde_json::Value =
947 serde_json::from_str(&contents).expect("file should contain valid JSON");
948
949 assert_eq!(parsed["version"], "2.1.0");
950 assert_eq!(
951 parsed["$schema"],
952 "https://json.schemastore.org/sarif-2.1.0.json"
953 );
954 let sarif_results = parsed["runs"][0]["results"]
955 .as_array()
956 .expect("results should be an array");
957 assert!(!sarif_results.is_empty());
958
959 let _ = std::fs::remove_file(&sarif_path);
961 let _ = std::fs::remove_dir(&dir);
962 }
963
964 #[test]
967 fn health_sarif_empty_no_results() {
968 let root = PathBuf::from("/project");
969 let report = crate::health_types::HealthReport {
970 findings: vec![],
971 summary: crate::health_types::HealthSummary {
972 files_analyzed: 10,
973 functions_analyzed: 50,
974 functions_above_threshold: 0,
975 max_cyclomatic_threshold: 20,
976 max_cognitive_threshold: 15,
977 files_scored: None,
978 average_maintainability: None,
979 },
980 file_scores: vec![],
981 hotspots: vec![],
982 hotspot_summary: None,
983 };
984 let sarif = build_health_sarif(&report, &root);
985 assert_eq!(sarif["version"], "2.1.0");
986 let results = sarif["runs"][0]["results"].as_array().unwrap();
987 assert!(results.is_empty());
988 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
989 .as_array()
990 .unwrap();
991 assert_eq!(rules.len(), 3);
992 }
993
994 #[test]
995 fn health_sarif_cyclomatic_only() {
996 let root = PathBuf::from("/project");
997 let report = crate::health_types::HealthReport {
998 findings: vec![crate::health_types::HealthFinding {
999 path: root.join("src/utils.ts"),
1000 name: "parseExpression".to_string(),
1001 line: 42,
1002 col: 0,
1003 cyclomatic: 25,
1004 cognitive: 10,
1005 line_count: 80,
1006 exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
1007 }],
1008 summary: crate::health_types::HealthSummary {
1009 files_analyzed: 5,
1010 functions_analyzed: 20,
1011 functions_above_threshold: 1,
1012 max_cyclomatic_threshold: 20,
1013 max_cognitive_threshold: 15,
1014 files_scored: None,
1015 average_maintainability: None,
1016 },
1017 file_scores: vec![],
1018 hotspots: vec![],
1019 hotspot_summary: None,
1020 };
1021 let sarif = build_health_sarif(&report, &root);
1022 let entry = &sarif["runs"][0]["results"][0];
1023 assert_eq!(entry["ruleId"], "fallow/high-cyclomatic-complexity");
1024 assert_eq!(entry["level"], "warning");
1025 assert!(
1026 entry["message"]["text"]
1027 .as_str()
1028 .unwrap()
1029 .contains("cyclomatic complexity 25")
1030 );
1031 assert_eq!(
1032 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1033 "src/utils.ts"
1034 );
1035 let region = &entry["locations"][0]["physicalLocation"]["region"];
1036 assert_eq!(region["startLine"], 42);
1037 assert_eq!(region["startColumn"], 1);
1038 }
1039
1040 #[test]
1041 fn health_sarif_cognitive_only() {
1042 let root = PathBuf::from("/project");
1043 let report = crate::health_types::HealthReport {
1044 findings: vec![crate::health_types::HealthFinding {
1045 path: root.join("src/api.ts"),
1046 name: "handleRequest".to_string(),
1047 line: 10,
1048 col: 4,
1049 cyclomatic: 8,
1050 cognitive: 20,
1051 line_count: 40,
1052 exceeded: crate::health_types::ExceededThreshold::Cognitive,
1053 }],
1054 summary: crate::health_types::HealthSummary {
1055 files_analyzed: 3,
1056 functions_analyzed: 10,
1057 functions_above_threshold: 1,
1058 max_cyclomatic_threshold: 20,
1059 max_cognitive_threshold: 15,
1060 files_scored: None,
1061 average_maintainability: None,
1062 },
1063 file_scores: vec![],
1064 hotspots: vec![],
1065 hotspot_summary: None,
1066 };
1067 let sarif = build_health_sarif(&report, &root);
1068 let entry = &sarif["runs"][0]["results"][0];
1069 assert_eq!(entry["ruleId"], "fallow/high-cognitive-complexity");
1070 assert!(
1071 entry["message"]["text"]
1072 .as_str()
1073 .unwrap()
1074 .contains("cognitive complexity 20")
1075 );
1076 let region = &entry["locations"][0]["physicalLocation"]["region"];
1077 assert_eq!(region["startColumn"], 5); }
1079
1080 #[test]
1081 fn health_sarif_both_thresholds() {
1082 let root = PathBuf::from("/project");
1083 let report = crate::health_types::HealthReport {
1084 findings: vec![crate::health_types::HealthFinding {
1085 path: root.join("src/complex.ts"),
1086 name: "doEverything".to_string(),
1087 line: 1,
1088 col: 0,
1089 cyclomatic: 30,
1090 cognitive: 45,
1091 line_count: 100,
1092 exceeded: crate::health_types::ExceededThreshold::Both,
1093 }],
1094 summary: crate::health_types::HealthSummary {
1095 files_analyzed: 1,
1096 functions_analyzed: 1,
1097 functions_above_threshold: 1,
1098 max_cyclomatic_threshold: 20,
1099 max_cognitive_threshold: 15,
1100 files_scored: None,
1101 average_maintainability: None,
1102 },
1103 file_scores: vec![],
1104 hotspots: vec![],
1105 hotspot_summary: None,
1106 };
1107 let sarif = build_health_sarif(&report, &root);
1108 let entry = &sarif["runs"][0]["results"][0];
1109 assert_eq!(entry["ruleId"], "fallow/high-complexity");
1110 let msg = entry["message"]["text"].as_str().unwrap();
1111 assert!(msg.contains("cyclomatic complexity 30"));
1112 assert!(msg.contains("cognitive complexity 45"));
1113 }
1114}