1use std::path::Path;
2use std::process::ExitCode;
3
4use fallow_config::{RulesConfig, Severity};
5use fallow_core::duplicates::DuplicationReport;
6use fallow_core::results::{
7 AnalysisResults, BoundaryViolation, CircularDependency, DuplicateExport, StaleSuppression,
8 TestOnlyDependency, TypeOnlyDependency, UnlistedDependency, UnresolvedImport, UnusedDependency,
9 UnusedExport, UnusedFile, UnusedMember,
10};
11
12use super::grouping::{self, OwnershipResolver};
13use super::{emit_json, relative_uri};
14use crate::explain;
15
16struct SarifFields {
18 rule_id: &'static str,
19 level: &'static str,
20 message: String,
21 uri: String,
22 region: Option<(u32, u32)>,
23 properties: Option<serde_json::Value>,
24}
25
26const fn severity_to_sarif_level(s: Severity) -> &'static str {
27 match s {
28 Severity::Error => "error",
29 Severity::Warn | Severity::Off => "warning",
30 }
31}
32
33fn sarif_result(
38 rule_id: &str,
39 level: &str,
40 message: &str,
41 uri: &str,
42 region: Option<(u32, u32)>,
43) -> serde_json::Value {
44 let mut physical_location = serde_json::json!({
45 "artifactLocation": { "uri": uri }
46 });
47 if let Some((line, col)) = region {
48 physical_location["region"] = serde_json::json!({
49 "startLine": line,
50 "startColumn": col
51 });
52 }
53 serde_json::json!({
54 "ruleId": rule_id,
55 "level": level,
56 "message": { "text": message },
57 "locations": [{ "physicalLocation": physical_location }]
58 })
59}
60
61fn push_sarif_results<T>(
63 sarif_results: &mut Vec<serde_json::Value>,
64 items: &[T],
65 extract: impl Fn(&T) -> SarifFields,
66) {
67 for item in items {
68 let fields = extract(item);
69 let mut result = sarif_result(
70 fields.rule_id,
71 fields.level,
72 &fields.message,
73 &fields.uri,
74 fields.region,
75 );
76 if let Some(props) = fields.properties {
77 result["properties"] = props;
78 }
79 sarif_results.push(result);
80 }
81}
82
83fn sarif_rule(id: &str, fallback_short: &str, level: &str) -> serde_json::Value {
86 explain::rule_by_id(id).map_or_else(
87 || {
88 serde_json::json!({
89 "id": id,
90 "shortDescription": { "text": fallback_short },
91 "defaultConfiguration": { "level": level }
92 })
93 },
94 |def| {
95 serde_json::json!({
96 "id": id,
97 "shortDescription": { "text": def.short },
98 "fullDescription": { "text": def.full },
99 "helpUri": explain::rule_docs_url(def),
100 "defaultConfiguration": { "level": level }
101 })
102 },
103 )
104}
105
106fn sarif_export_fields(
108 export: &UnusedExport,
109 root: &Path,
110 rule_id: &'static str,
111 level: &'static str,
112 kind: &str,
113 re_kind: &str,
114) -> SarifFields {
115 let label = if export.is_re_export { re_kind } else { kind };
116 SarifFields {
117 rule_id,
118 level,
119 message: format!(
120 "{} '{}' is never imported by other modules",
121 label, export.export_name
122 ),
123 uri: relative_uri(&export.path, root),
124 region: Some((export.line, export.col + 1)),
125 properties: if export.is_re_export {
126 Some(serde_json::json!({ "is_re_export": true }))
127 } else {
128 None
129 },
130 }
131}
132
133fn sarif_dep_fields(
135 dep: &UnusedDependency,
136 root: &Path,
137 rule_id: &'static str,
138 level: &'static str,
139 section: &str,
140) -> SarifFields {
141 let workspace_context = if dep.used_in_workspaces.is_empty() {
142 String::new()
143 } else {
144 let workspaces = dep
145 .used_in_workspaces
146 .iter()
147 .map(|path| relative_uri(path, root))
148 .collect::<Vec<_>>()
149 .join(", ");
150 format!("; imported in other workspaces: {workspaces}")
151 };
152 SarifFields {
153 rule_id,
154 level,
155 message: format!(
156 "Package '{}' is in {} but never imported{}",
157 dep.package_name, section, workspace_context
158 ),
159 uri: relative_uri(&dep.path, root),
160 region: if dep.line > 0 {
161 Some((dep.line, 1))
162 } else {
163 None
164 },
165 properties: None,
166 }
167}
168
169fn sarif_member_fields(
171 member: &UnusedMember,
172 root: &Path,
173 rule_id: &'static str,
174 level: &'static str,
175 kind: &str,
176) -> SarifFields {
177 SarifFields {
178 rule_id,
179 level,
180 message: format!(
181 "{} member '{}.{}' is never referenced",
182 kind, member.parent_name, member.member_name
183 ),
184 uri: relative_uri(&member.path, root),
185 region: Some((member.line, member.col + 1)),
186 properties: None,
187 }
188}
189
190fn sarif_unused_file_fields(file: &UnusedFile, root: &Path, level: &'static str) -> SarifFields {
191 SarifFields {
192 rule_id: "fallow/unused-file",
193 level,
194 message: "File is not reachable from any entry point".to_string(),
195 uri: relative_uri(&file.path, root),
196 region: None,
197 properties: None,
198 }
199}
200
201fn sarif_type_only_dep_fields(
202 dep: &TypeOnlyDependency,
203 root: &Path,
204 level: &'static str,
205) -> SarifFields {
206 SarifFields {
207 rule_id: "fallow/type-only-dependency",
208 level,
209 message: format!(
210 "Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
211 dep.package_name
212 ),
213 uri: relative_uri(&dep.path, root),
214 region: if dep.line > 0 {
215 Some((dep.line, 1))
216 } else {
217 None
218 },
219 properties: None,
220 }
221}
222
223fn sarif_test_only_dep_fields(
224 dep: &TestOnlyDependency,
225 root: &Path,
226 level: &'static str,
227) -> SarifFields {
228 SarifFields {
229 rule_id: "fallow/test-only-dependency",
230 level,
231 message: format!(
232 "Package '{}' is only imported by test files (consider moving to devDependencies)",
233 dep.package_name
234 ),
235 uri: relative_uri(&dep.path, root),
236 region: if dep.line > 0 {
237 Some((dep.line, 1))
238 } else {
239 None
240 },
241 properties: None,
242 }
243}
244
245fn sarif_unresolved_import_fields(
246 import: &UnresolvedImport,
247 root: &Path,
248 level: &'static str,
249) -> SarifFields {
250 SarifFields {
251 rule_id: "fallow/unresolved-import",
252 level,
253 message: format!("Import '{}' could not be resolved", import.specifier),
254 uri: relative_uri(&import.path, root),
255 region: Some((import.line, import.col + 1)),
256 properties: None,
257 }
258}
259
260fn sarif_circular_dep_fields(
261 cycle: &CircularDependency,
262 root: &Path,
263 level: &'static str,
264) -> SarifFields {
265 let chain: Vec<String> = cycle.files.iter().map(|p| relative_uri(p, root)).collect();
266 let mut display_chain = chain.clone();
267 if let Some(first) = chain.first() {
268 display_chain.push(first.clone());
269 }
270 let first_uri = chain.first().map_or_else(String::new, Clone::clone);
271 SarifFields {
272 rule_id: "fallow/circular-dependency",
273 level,
274 message: format!(
275 "Circular dependency{}: {}",
276 if cycle.is_cross_package {
277 " (cross-package)"
278 } else {
279 ""
280 },
281 display_chain.join(" \u{2192} ")
282 ),
283 uri: first_uri,
284 region: if cycle.line > 0 {
285 Some((cycle.line, cycle.col + 1))
286 } else {
287 None
288 },
289 properties: None,
290 }
291}
292
293fn sarif_boundary_violation_fields(
294 violation: &BoundaryViolation,
295 root: &Path,
296 level: &'static str,
297) -> SarifFields {
298 let from_uri = relative_uri(&violation.from_path, root);
299 let to_uri = relative_uri(&violation.to_path, root);
300 SarifFields {
301 rule_id: "fallow/boundary-violation",
302 level,
303 message: format!(
304 "Import from zone '{}' to zone '{}' is not allowed ({})",
305 violation.from_zone, violation.to_zone, to_uri,
306 ),
307 uri: from_uri,
308 region: if violation.line > 0 {
309 Some((violation.line, violation.col + 1))
310 } else {
311 None
312 },
313 properties: None,
314 }
315}
316
317fn sarif_stale_suppression_fields(
318 suppression: &StaleSuppression,
319 root: &Path,
320 level: &'static str,
321) -> SarifFields {
322 SarifFields {
323 rule_id: "fallow/stale-suppression",
324 level,
325 message: suppression.description(),
326 uri: relative_uri(&suppression.path, root),
327 region: Some((suppression.line, suppression.col + 1)),
328 properties: None,
329 }
330}
331
332fn push_sarif_unlisted_deps(
335 sarif_results: &mut Vec<serde_json::Value>,
336 deps: &[UnlistedDependency],
337 root: &Path,
338 level: &'static str,
339) {
340 for dep in deps {
341 for site in &dep.imported_from {
342 sarif_results.push(sarif_result(
343 "fallow/unlisted-dependency",
344 level,
345 &format!(
346 "Package '{}' is imported but not listed in package.json",
347 dep.package_name
348 ),
349 &relative_uri(&site.path, root),
350 Some((site.line, site.col + 1)),
351 ));
352 }
353 }
354}
355
356fn push_sarif_duplicate_exports(
359 sarif_results: &mut Vec<serde_json::Value>,
360 dups: &[DuplicateExport],
361 root: &Path,
362 level: &'static str,
363) {
364 for dup in dups {
365 for loc in &dup.locations {
366 sarif_results.push(sarif_result(
367 "fallow/duplicate-export",
368 level,
369 &format!("Export '{}' appears in multiple modules", dup.export_name),
370 &relative_uri(&loc.path, root),
371 Some((loc.line, loc.col + 1)),
372 ));
373 }
374 }
375}
376
377fn build_sarif_rules(rules: &RulesConfig) -> Vec<serde_json::Value> {
379 vec![
380 sarif_rule(
381 "fallow/unused-file",
382 "File is not reachable from any entry point",
383 severity_to_sarif_level(rules.unused_files),
384 ),
385 sarif_rule(
386 "fallow/unused-export",
387 "Export is never imported",
388 severity_to_sarif_level(rules.unused_exports),
389 ),
390 sarif_rule(
391 "fallow/unused-type",
392 "Type export is never imported",
393 severity_to_sarif_level(rules.unused_types),
394 ),
395 sarif_rule(
396 "fallow/unused-dependency",
397 "Dependency listed but never imported",
398 severity_to_sarif_level(rules.unused_dependencies),
399 ),
400 sarif_rule(
401 "fallow/unused-dev-dependency",
402 "Dev dependency listed but never imported",
403 severity_to_sarif_level(rules.unused_dev_dependencies),
404 ),
405 sarif_rule(
406 "fallow/unused-optional-dependency",
407 "Optional dependency listed but never imported",
408 severity_to_sarif_level(rules.unused_optional_dependencies),
409 ),
410 sarif_rule(
411 "fallow/type-only-dependency",
412 "Production dependency only used via type-only imports",
413 severity_to_sarif_level(rules.type_only_dependencies),
414 ),
415 sarif_rule(
416 "fallow/test-only-dependency",
417 "Production dependency only imported by test files",
418 severity_to_sarif_level(rules.test_only_dependencies),
419 ),
420 sarif_rule(
421 "fallow/unused-enum-member",
422 "Enum member is never referenced",
423 severity_to_sarif_level(rules.unused_enum_members),
424 ),
425 sarif_rule(
426 "fallow/unused-class-member",
427 "Class member is never referenced",
428 severity_to_sarif_level(rules.unused_class_members),
429 ),
430 sarif_rule(
431 "fallow/unresolved-import",
432 "Import could not be resolved",
433 severity_to_sarif_level(rules.unresolved_imports),
434 ),
435 sarif_rule(
436 "fallow/unlisted-dependency",
437 "Dependency used but not in package.json",
438 severity_to_sarif_level(rules.unlisted_dependencies),
439 ),
440 sarif_rule(
441 "fallow/duplicate-export",
442 "Export name appears in multiple modules",
443 severity_to_sarif_level(rules.duplicate_exports),
444 ),
445 sarif_rule(
446 "fallow/circular-dependency",
447 "Circular dependency chain detected",
448 severity_to_sarif_level(rules.circular_dependencies),
449 ),
450 sarif_rule(
451 "fallow/boundary-violation",
452 "Import crosses an architecture boundary",
453 severity_to_sarif_level(rules.boundary_violation),
454 ),
455 sarif_rule(
456 "fallow/stale-suppression",
457 "Suppression comment or tag no longer matches any issue",
458 severity_to_sarif_level(rules.stale_suppressions),
459 ),
460 ]
461}
462
463#[must_use]
464pub fn build_sarif(
465 results: &AnalysisResults,
466 root: &Path,
467 rules: &RulesConfig,
468) -> serde_json::Value {
469 let mut sarif_results = Vec::new();
470 let lvl_files = severity_to_sarif_level(rules.unused_files);
471 let lvl_exports = severity_to_sarif_level(rules.unused_exports);
472 let lvl_types = severity_to_sarif_level(rules.unused_types);
473 let lvl_deps = severity_to_sarif_level(rules.unused_dependencies);
474 let lvl_dev_deps = severity_to_sarif_level(rules.unused_dev_dependencies);
475 let lvl_opt_deps = severity_to_sarif_level(rules.unused_optional_dependencies);
476 let lvl_type_only = severity_to_sarif_level(rules.type_only_dependencies);
477 let lvl_test_only = severity_to_sarif_level(rules.test_only_dependencies);
478 let lvl_enum_members = severity_to_sarif_level(rules.unused_enum_members);
479 let lvl_class_members = severity_to_sarif_level(rules.unused_class_members);
480 let lvl_unresolved = severity_to_sarif_level(rules.unresolved_imports);
481 let lvl_unlisted = severity_to_sarif_level(rules.unlisted_dependencies);
482 let lvl_duplicate = severity_to_sarif_level(rules.duplicate_exports);
483 let lvl_circular = severity_to_sarif_level(rules.circular_dependencies);
484 let lvl_boundary = severity_to_sarif_level(rules.boundary_violation);
485 let lvl_stale = severity_to_sarif_level(rules.stale_suppressions);
486
487 push_sarif_results(&mut sarif_results, &results.unused_files, |f| {
488 sarif_unused_file_fields(f, root, lvl_files)
489 });
490 push_sarif_results(&mut sarif_results, &results.unused_exports, |e| {
491 sarif_export_fields(
492 e,
493 root,
494 "fallow/unused-export",
495 lvl_exports,
496 "Export",
497 "Re-export",
498 )
499 });
500 push_sarif_results(&mut sarif_results, &results.unused_types, |e| {
501 sarif_export_fields(
502 e,
503 root,
504 "fallow/unused-type",
505 lvl_types,
506 "Type export",
507 "Type re-export",
508 )
509 });
510 push_sarif_results(&mut sarif_results, &results.unused_dependencies, |d| {
511 sarif_dep_fields(
512 d,
513 root,
514 "fallow/unused-dependency",
515 lvl_deps,
516 "dependencies",
517 )
518 });
519 push_sarif_results(&mut sarif_results, &results.unused_dev_dependencies, |d| {
520 sarif_dep_fields(
521 d,
522 root,
523 "fallow/unused-dev-dependency",
524 lvl_dev_deps,
525 "devDependencies",
526 )
527 });
528 push_sarif_results(
529 &mut sarif_results,
530 &results.unused_optional_dependencies,
531 |d| {
532 sarif_dep_fields(
533 d,
534 root,
535 "fallow/unused-optional-dependency",
536 lvl_opt_deps,
537 "optionalDependencies",
538 )
539 },
540 );
541 push_sarif_results(&mut sarif_results, &results.type_only_dependencies, |d| {
542 sarif_type_only_dep_fields(d, root, lvl_type_only)
543 });
544 push_sarif_results(&mut sarif_results, &results.test_only_dependencies, |d| {
545 sarif_test_only_dep_fields(d, root, lvl_test_only)
546 });
547 push_sarif_results(&mut sarif_results, &results.unused_enum_members, |m| {
548 sarif_member_fields(
549 m,
550 root,
551 "fallow/unused-enum-member",
552 lvl_enum_members,
553 "Enum",
554 )
555 });
556 push_sarif_results(&mut sarif_results, &results.unused_class_members, |m| {
557 sarif_member_fields(
558 m,
559 root,
560 "fallow/unused-class-member",
561 lvl_class_members,
562 "Class",
563 )
564 });
565 push_sarif_results(&mut sarif_results, &results.unresolved_imports, |i| {
566 sarif_unresolved_import_fields(i, root, lvl_unresolved)
567 });
568 push_sarif_unlisted_deps(
569 &mut sarif_results,
570 &results.unlisted_dependencies,
571 root,
572 lvl_unlisted,
573 );
574 push_sarif_duplicate_exports(
575 &mut sarif_results,
576 &results.duplicate_exports,
577 root,
578 lvl_duplicate,
579 );
580 push_sarif_results(&mut sarif_results, &results.circular_dependencies, |c| {
581 sarif_circular_dep_fields(c, root, lvl_circular)
582 });
583 push_sarif_results(&mut sarif_results, &results.boundary_violations, |v| {
584 sarif_boundary_violation_fields(v, root, lvl_boundary)
585 });
586 push_sarif_results(&mut sarif_results, &results.stale_suppressions, |s| {
587 sarif_stale_suppression_fields(s, root, lvl_stale)
588 });
589
590 serde_json::json!({
591 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
592 "version": "2.1.0",
593 "runs": [{
594 "tool": {
595 "driver": {
596 "name": "fallow",
597 "version": env!("CARGO_PKG_VERSION"),
598 "informationUri": "https://github.com/fallow-rs/fallow",
599 "rules": build_sarif_rules(rules)
600 }
601 },
602 "results": sarif_results
603 }]
604 })
605}
606
607pub(super) fn print_sarif(results: &AnalysisResults, root: &Path, rules: &RulesConfig) -> ExitCode {
608 let sarif = build_sarif(results, root, rules);
609 emit_json(&sarif, "SARIF")
610}
611
612pub(super) fn print_grouped_sarif(
618 results: &AnalysisResults,
619 root: &Path,
620 rules: &RulesConfig,
621 resolver: &OwnershipResolver,
622) -> ExitCode {
623 let mut sarif = build_sarif(results, root, rules);
624
625 if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
627 for run in runs {
628 if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
629 for result in results {
630 let uri = result
631 .pointer("/locations/0/physicalLocation/artifactLocation/uri")
632 .and_then(|v| v.as_str())
633 .unwrap_or("");
634 let decoded = uri.replace("%5B", "[").replace("%5D", "]");
637 let owner =
638 grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
639 let props = result
640 .as_object_mut()
641 .expect("SARIF result should be an object")
642 .entry("properties")
643 .or_insert_with(|| serde_json::json!({}));
644 props
645 .as_object_mut()
646 .expect("properties should be an object")
647 .insert("owner".to_string(), serde_json::Value::String(owner));
648 }
649 }
650 }
651 }
652
653 emit_json(&sarif, "SARIF")
654}
655
656#[expect(
657 clippy::cast_possible_truncation,
658 reason = "line/col numbers are bounded by source size"
659)]
660pub(super) fn print_duplication_sarif(report: &DuplicationReport, root: &Path) -> ExitCode {
661 let mut sarif_results = Vec::new();
662
663 for (i, group) in report.clone_groups.iter().enumerate() {
664 for instance in &group.instances {
665 sarif_results.push(sarif_result(
666 "fallow/code-duplication",
667 "warning",
668 &format!(
669 "Code clone group {} ({} lines, {} instances)",
670 i + 1,
671 group.line_count,
672 group.instances.len()
673 ),
674 &relative_uri(&instance.file, root),
675 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
676 ));
677 }
678 }
679
680 let sarif = serde_json::json!({
681 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
682 "version": "2.1.0",
683 "runs": [{
684 "tool": {
685 "driver": {
686 "name": "fallow",
687 "version": env!("CARGO_PKG_VERSION"),
688 "informationUri": "https://github.com/fallow-rs/fallow",
689 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
690 }
691 },
692 "results": sarif_results
693 }]
694 });
695
696 emit_json(&sarif, "SARIF")
697}
698
699#[expect(
710 clippy::cast_possible_truncation,
711 reason = "line/col numbers are bounded by source size"
712)]
713pub(super) fn print_grouped_duplication_sarif(
714 report: &DuplicationReport,
715 root: &Path,
716 resolver: &OwnershipResolver,
717) -> ExitCode {
718 let mut sarif_results = Vec::new();
719
720 for (i, group) in report.clone_groups.iter().enumerate() {
721 let primary_owner = super::dupes_grouping::largest_owner(group, root, resolver);
725 for instance in &group.instances {
726 let mut result = sarif_result(
727 "fallow/code-duplication",
728 "warning",
729 &format!(
730 "Code clone group {} ({} lines, {} instances)",
731 i + 1,
732 group.line_count,
733 group.instances.len()
734 ),
735 &relative_uri(&instance.file, root),
736 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
737 );
738 let props = result
739 .as_object_mut()
740 .expect("SARIF result should be an object")
741 .entry("properties")
742 .or_insert_with(|| serde_json::json!({}));
743 props
744 .as_object_mut()
745 .expect("properties should be an object")
746 .insert(
747 "group".to_string(),
748 serde_json::Value::String(primary_owner.clone()),
749 );
750 sarif_results.push(result);
751 }
752 }
753
754 let sarif = serde_json::json!({
755 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
756 "version": "2.1.0",
757 "runs": [{
758 "tool": {
759 "driver": {
760 "name": "fallow",
761 "version": env!("CARGO_PKG_VERSION"),
762 "informationUri": "https://github.com/fallow-rs/fallow",
763 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
764 }
765 },
766 "results": sarif_results
767 }]
768 });
769
770 emit_json(&sarif, "SARIF")
771}
772
773#[must_use]
779#[expect(
780 clippy::too_many_lines,
781 reason = "flat rules + results table: adding runtime-coverage rules pushed past the 150 line threshold but each section is a straightforward sequence of sarif_rule / sarif_result calls"
782)]
783pub fn build_health_sarif(
784 report: &crate::health_types::HealthReport,
785 root: &Path,
786) -> serde_json::Value {
787 use crate::health_types::ExceededThreshold;
788
789 let mut sarif_results = Vec::new();
790
791 for finding in &report.findings {
792 let uri = relative_uri(&finding.path, root);
793 let (rule_id, message) = match finding.exceeded {
797 ExceededThreshold::Cyclomatic => (
798 "fallow/high-cyclomatic-complexity",
799 format!(
800 "'{}' has cyclomatic complexity {} (threshold: {})",
801 finding.name, finding.cyclomatic, report.summary.max_cyclomatic_threshold,
802 ),
803 ),
804 ExceededThreshold::Cognitive => (
805 "fallow/high-cognitive-complexity",
806 format!(
807 "'{}' has cognitive complexity {} (threshold: {})",
808 finding.name, finding.cognitive, report.summary.max_cognitive_threshold,
809 ),
810 ),
811 ExceededThreshold::Both => (
812 "fallow/high-complexity",
813 format!(
814 "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
815 finding.name,
816 finding.cyclomatic,
817 report.summary.max_cyclomatic_threshold,
818 finding.cognitive,
819 report.summary.max_cognitive_threshold,
820 ),
821 ),
822 ExceededThreshold::Crap
823 | ExceededThreshold::CyclomaticCrap
824 | ExceededThreshold::CognitiveCrap
825 | ExceededThreshold::All => {
826 let crap = finding.crap.unwrap_or(0.0);
827 let coverage = finding
828 .coverage_pct
829 .map(|pct| format!(", coverage {pct:.0}%"))
830 .unwrap_or_default();
831 (
832 "fallow/high-crap-score",
833 format!(
834 "'{}' has CRAP score {:.1} (threshold: {:.1}, cyclomatic {}{})",
835 finding.name,
836 crap,
837 report.summary.max_crap_threshold,
838 finding.cyclomatic,
839 coverage,
840 ),
841 )
842 }
843 };
844
845 let level = match finding.severity {
846 crate::health_types::FindingSeverity::Critical => "error",
847 crate::health_types::FindingSeverity::High => "warning",
848 crate::health_types::FindingSeverity::Moderate => "note",
849 };
850 sarif_results.push(sarif_result(
851 rule_id,
852 level,
853 &message,
854 &uri,
855 Some((finding.line, finding.col + 1)),
856 ));
857 }
858
859 if let Some(ref production) = report.runtime_coverage {
860 append_runtime_coverage_sarif_results(&mut sarif_results, production, root);
861 }
862
863 for target in &report.targets {
865 let uri = relative_uri(&target.path, root);
866 let message = format!(
867 "[{}] {} (priority: {:.1}, efficiency: {:.1}, effort: {}, confidence: {})",
868 target.category.label(),
869 target.recommendation,
870 target.priority,
871 target.efficiency,
872 target.effort.label(),
873 target.confidence.label(),
874 );
875 sarif_results.push(sarif_result(
876 "fallow/refactoring-target",
877 "warning",
878 &message,
879 &uri,
880 None,
881 ));
882 }
883
884 if let Some(ref gaps) = report.coverage_gaps {
885 for item in &gaps.files {
886 let uri = relative_uri(&item.path, root);
887 let message = format!(
888 "File is runtime-reachable but has no test dependency path ({} value export{})",
889 item.value_export_count,
890 if item.value_export_count == 1 {
891 ""
892 } else {
893 "s"
894 },
895 );
896 sarif_results.push(sarif_result(
897 "fallow/untested-file",
898 "warning",
899 &message,
900 &uri,
901 None,
902 ));
903 }
904
905 for item in &gaps.exports {
906 let uri = relative_uri(&item.path, root);
907 let message = format!(
908 "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
909 item.export_name
910 );
911 sarif_results.push(sarif_result(
912 "fallow/untested-export",
913 "warning",
914 &message,
915 &uri,
916 Some((item.line, item.col + 1)),
917 ));
918 }
919 }
920
921 let health_rules = vec![
922 sarif_rule(
923 "fallow/high-cyclomatic-complexity",
924 "Function has high cyclomatic complexity",
925 "note",
926 ),
927 sarif_rule(
928 "fallow/high-cognitive-complexity",
929 "Function has high cognitive complexity",
930 "note",
931 ),
932 sarif_rule(
933 "fallow/high-complexity",
934 "Function exceeds both complexity thresholds",
935 "note",
936 ),
937 sarif_rule(
938 "fallow/high-crap-score",
939 "Function has a high CRAP score (high complexity combined with low coverage)",
940 "warning",
941 ),
942 sarif_rule(
943 "fallow/refactoring-target",
944 "File identified as a high-priority refactoring candidate",
945 "warning",
946 ),
947 sarif_rule(
948 "fallow/untested-file",
949 "Runtime-reachable file has no test dependency path",
950 "warning",
951 ),
952 sarif_rule(
953 "fallow/untested-export",
954 "Runtime-reachable export has no test dependency path",
955 "warning",
956 ),
957 sarif_rule(
958 "fallow/runtime-safe-to-delete",
959 "Function is statically unused and was never invoked in production",
960 "warning",
961 ),
962 sarif_rule(
963 "fallow/runtime-review-required",
964 "Function is statically used but was never invoked in production",
965 "warning",
966 ),
967 sarif_rule(
968 "fallow/runtime-low-traffic",
969 "Function was invoked below the low-traffic threshold relative to total trace count",
970 "note",
971 ),
972 sarif_rule(
973 "fallow/runtime-coverage-unavailable",
974 "Runtime coverage could not be resolved for this function",
975 "note",
976 ),
977 sarif_rule(
978 "fallow/runtime-coverage",
979 "Runtime coverage finding",
980 "note",
981 ),
982 ];
983
984 serde_json::json!({
985 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
986 "version": "2.1.0",
987 "runs": [{
988 "tool": {
989 "driver": {
990 "name": "fallow",
991 "version": env!("CARGO_PKG_VERSION"),
992 "informationUri": "https://github.com/fallow-rs/fallow",
993 "rules": health_rules
994 }
995 },
996 "results": sarif_results
997 }]
998 })
999}
1000
1001fn append_runtime_coverage_sarif_results(
1002 sarif_results: &mut Vec<serde_json::Value>,
1003 production: &crate::health_types::RuntimeCoverageReport,
1004 root: &Path,
1005) {
1006 for finding in &production.findings {
1007 let uri = relative_uri(&finding.path, root);
1008 let rule_id = match finding.verdict {
1009 crate::health_types::RuntimeCoverageVerdict::SafeToDelete => {
1010 "fallow/runtime-safe-to-delete"
1011 }
1012 crate::health_types::RuntimeCoverageVerdict::ReviewRequired => {
1013 "fallow/runtime-review-required"
1014 }
1015 crate::health_types::RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
1016 crate::health_types::RuntimeCoverageVerdict::CoverageUnavailable => {
1017 "fallow/runtime-coverage-unavailable"
1018 }
1019 crate::health_types::RuntimeCoverageVerdict::Active
1020 | crate::health_types::RuntimeCoverageVerdict::Unknown => "fallow/runtime-coverage",
1021 };
1022 let level = match finding.verdict {
1023 crate::health_types::RuntimeCoverageVerdict::SafeToDelete
1024 | crate::health_types::RuntimeCoverageVerdict::ReviewRequired => "warning",
1025 _ => "note",
1026 };
1027 let invocations_hint = finding.invocations.map_or_else(
1028 || "untracked".to_owned(),
1029 |hits| format!("{hits} invocations"),
1030 );
1031 let message = format!(
1032 "'{}' runtime coverage verdict: {} ({})",
1033 finding.function,
1034 finding.verdict.human_label(),
1035 invocations_hint,
1036 );
1037 sarif_results.push(sarif_result(
1038 rule_id,
1039 level,
1040 &message,
1041 &uri,
1042 Some((finding.line, 1)),
1043 ));
1044 }
1045}
1046
1047pub(super) fn print_health_sarif(
1048 report: &crate::health_types::HealthReport,
1049 root: &Path,
1050) -> ExitCode {
1051 let sarif = build_health_sarif(report, root);
1052 emit_json(&sarif, "SARIF")
1053}
1054
1055pub(super) fn print_grouped_health_sarif(
1066 report: &crate::health_types::HealthReport,
1067 root: &Path,
1068 resolver: &OwnershipResolver,
1069) -> ExitCode {
1070 let mut sarif = build_health_sarif(report, root);
1071
1072 if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
1073 for run in runs {
1074 if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
1075 for result in results {
1076 let uri = result
1077 .pointer("/locations/0/physicalLocation/artifactLocation/uri")
1078 .and_then(|v| v.as_str())
1079 .unwrap_or("");
1080 let decoded = uri.replace("%5B", "[").replace("%5D", "]");
1081 let group =
1082 grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
1083 let props = result
1084 .as_object_mut()
1085 .expect("SARIF result should be an object")
1086 .entry("properties")
1087 .or_insert_with(|| serde_json::json!({}));
1088 props
1089 .as_object_mut()
1090 .expect("properties should be an object")
1091 .insert("group".to_string(), serde_json::Value::String(group));
1092 }
1093 }
1094 }
1095 }
1096
1097 emit_json(&sarif, "SARIF")
1098}
1099
1100#[cfg(test)]
1101mod tests {
1102 use super::*;
1103 use crate::report::test_helpers::sample_results;
1104 use fallow_core::results::*;
1105 use std::path::PathBuf;
1106
1107 #[test]
1108 fn sarif_has_required_top_level_fields() {
1109 let root = PathBuf::from("/project");
1110 let results = AnalysisResults::default();
1111 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1112
1113 assert_eq!(
1114 sarif["$schema"],
1115 "https://json.schemastore.org/sarif-2.1.0.json"
1116 );
1117 assert_eq!(sarif["version"], "2.1.0");
1118 assert!(sarif["runs"].is_array());
1119 }
1120
1121 #[test]
1122 fn sarif_has_tool_driver_info() {
1123 let root = PathBuf::from("/project");
1124 let results = AnalysisResults::default();
1125 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1126
1127 let driver = &sarif["runs"][0]["tool"]["driver"];
1128 assert_eq!(driver["name"], "fallow");
1129 assert!(driver["version"].is_string());
1130 assert_eq!(
1131 driver["informationUri"],
1132 "https://github.com/fallow-rs/fallow"
1133 );
1134 }
1135
1136 #[test]
1137 fn sarif_declares_all_rules() {
1138 let root = PathBuf::from("/project");
1139 let results = AnalysisResults::default();
1140 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1141
1142 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1143 .as_array()
1144 .expect("rules should be an array");
1145 assert_eq!(rules.len(), 16);
1146
1147 let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
1148 assert!(rule_ids.contains(&"fallow/unused-file"));
1149 assert!(rule_ids.contains(&"fallow/unused-export"));
1150 assert!(rule_ids.contains(&"fallow/unused-type"));
1151 assert!(rule_ids.contains(&"fallow/unused-dependency"));
1152 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
1153 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
1154 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
1155 assert!(rule_ids.contains(&"fallow/test-only-dependency"));
1156 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
1157 assert!(rule_ids.contains(&"fallow/unused-class-member"));
1158 assert!(rule_ids.contains(&"fallow/unresolved-import"));
1159 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
1160 assert!(rule_ids.contains(&"fallow/duplicate-export"));
1161 assert!(rule_ids.contains(&"fallow/circular-dependency"));
1162 assert!(rule_ids.contains(&"fallow/boundary-violation"));
1163 }
1164
1165 #[test]
1166 fn sarif_empty_results_no_results_entries() {
1167 let root = PathBuf::from("/project");
1168 let results = AnalysisResults::default();
1169 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1170
1171 let sarif_results = sarif["runs"][0]["results"]
1172 .as_array()
1173 .expect("results should be an array");
1174 assert!(sarif_results.is_empty());
1175 }
1176
1177 #[test]
1178 fn sarif_unused_file_result() {
1179 let root = PathBuf::from("/project");
1180 let mut results = AnalysisResults::default();
1181 results.unused_files.push(UnusedFile {
1182 path: root.join("src/dead.ts"),
1183 });
1184
1185 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1186 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1187 assert_eq!(entries.len(), 1);
1188
1189 let entry = &entries[0];
1190 assert_eq!(entry["ruleId"], "fallow/unused-file");
1191 assert_eq!(entry["level"], "error");
1193 assert_eq!(
1194 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1195 "src/dead.ts"
1196 );
1197 }
1198
1199 #[test]
1200 fn sarif_unused_export_includes_region() {
1201 let root = PathBuf::from("/project");
1202 let mut results = AnalysisResults::default();
1203 results.unused_exports.push(UnusedExport {
1204 path: root.join("src/utils.ts"),
1205 export_name: "helperFn".to_string(),
1206 is_type_only: false,
1207 line: 10,
1208 col: 4,
1209 span_start: 120,
1210 is_re_export: false,
1211 });
1212
1213 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1214 let entry = &sarif["runs"][0]["results"][0];
1215 assert_eq!(entry["ruleId"], "fallow/unused-export");
1216
1217 let region = &entry["locations"][0]["physicalLocation"]["region"];
1218 assert_eq!(region["startLine"], 10);
1219 assert_eq!(region["startColumn"], 5);
1221 }
1222
1223 #[test]
1224 fn sarif_unresolved_import_is_error_level() {
1225 let root = PathBuf::from("/project");
1226 let mut results = AnalysisResults::default();
1227 results.unresolved_imports.push(UnresolvedImport {
1228 path: root.join("src/app.ts"),
1229 specifier: "./missing".to_string(),
1230 line: 1,
1231 col: 0,
1232 specifier_col: 0,
1233 });
1234
1235 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1236 let entry = &sarif["runs"][0]["results"][0];
1237 assert_eq!(entry["ruleId"], "fallow/unresolved-import");
1238 assert_eq!(entry["level"], "error");
1239 }
1240
1241 #[test]
1242 fn sarif_unlisted_dependency_points_to_import_site() {
1243 let root = PathBuf::from("/project");
1244 let mut results = AnalysisResults::default();
1245 results.unlisted_dependencies.push(UnlistedDependency {
1246 package_name: "chalk".to_string(),
1247 imported_from: vec![ImportSite {
1248 path: root.join("src/cli.ts"),
1249 line: 3,
1250 col: 0,
1251 }],
1252 });
1253
1254 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1255 let entry = &sarif["runs"][0]["results"][0];
1256 assert_eq!(entry["ruleId"], "fallow/unlisted-dependency");
1257 assert_eq!(entry["level"], "error");
1258 assert_eq!(
1259 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1260 "src/cli.ts"
1261 );
1262 let region = &entry["locations"][0]["physicalLocation"]["region"];
1263 assert_eq!(region["startLine"], 3);
1264 assert_eq!(region["startColumn"], 1);
1265 }
1266
1267 #[test]
1268 fn sarif_dependency_issues_point_to_package_json() {
1269 let root = PathBuf::from("/project");
1270 let mut results = AnalysisResults::default();
1271 results.unused_dependencies.push(UnusedDependency {
1272 package_name: "lodash".to_string(),
1273 location: DependencyLocation::Dependencies,
1274 path: root.join("package.json"),
1275 line: 5,
1276 used_in_workspaces: Vec::new(),
1277 });
1278 results.unused_dev_dependencies.push(UnusedDependency {
1279 package_name: "jest".to_string(),
1280 location: DependencyLocation::DevDependencies,
1281 path: root.join("package.json"),
1282 line: 5,
1283 used_in_workspaces: Vec::new(),
1284 });
1285
1286 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1287 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1288 for entry in entries {
1289 assert_eq!(
1290 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1291 "package.json"
1292 );
1293 }
1294 }
1295
1296 #[test]
1297 fn sarif_duplicate_export_emits_one_result_per_location() {
1298 let root = PathBuf::from("/project");
1299 let mut results = AnalysisResults::default();
1300 results.duplicate_exports.push(DuplicateExport {
1301 export_name: "Config".to_string(),
1302 locations: vec![
1303 DuplicateLocation {
1304 path: root.join("src/a.ts"),
1305 line: 15,
1306 col: 0,
1307 },
1308 DuplicateLocation {
1309 path: root.join("src/b.ts"),
1310 line: 30,
1311 col: 0,
1312 },
1313 ],
1314 });
1315
1316 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1317 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1318 assert_eq!(entries.len(), 2);
1320 assert_eq!(entries[0]["ruleId"], "fallow/duplicate-export");
1321 assert_eq!(entries[1]["ruleId"], "fallow/duplicate-export");
1322 assert_eq!(
1323 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1324 "src/a.ts"
1325 );
1326 assert_eq!(
1327 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1328 "src/b.ts"
1329 );
1330 }
1331
1332 #[test]
1333 fn sarif_all_issue_types_produce_results() {
1334 let root = PathBuf::from("/project");
1335 let results = sample_results(&root);
1336 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1337
1338 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1339 assert_eq!(entries.len(), results.total_issues() + 1);
1341
1342 let rule_ids: Vec<&str> = entries
1343 .iter()
1344 .map(|e| e["ruleId"].as_str().unwrap())
1345 .collect();
1346 assert!(rule_ids.contains(&"fallow/unused-file"));
1347 assert!(rule_ids.contains(&"fallow/unused-export"));
1348 assert!(rule_ids.contains(&"fallow/unused-type"));
1349 assert!(rule_ids.contains(&"fallow/unused-dependency"));
1350 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
1351 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
1352 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
1353 assert!(rule_ids.contains(&"fallow/test-only-dependency"));
1354 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
1355 assert!(rule_ids.contains(&"fallow/unused-class-member"));
1356 assert!(rule_ids.contains(&"fallow/unresolved-import"));
1357 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
1358 assert!(rule_ids.contains(&"fallow/duplicate-export"));
1359 }
1360
1361 #[test]
1362 fn sarif_serializes_to_valid_json() {
1363 let root = PathBuf::from("/project");
1364 let results = sample_results(&root);
1365 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1366
1367 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
1368 let reparsed: serde_json::Value =
1369 serde_json::from_str(&json_str).expect("SARIF output should be valid JSON");
1370 assert_eq!(reparsed, sarif);
1371 }
1372
1373 #[test]
1374 fn sarif_file_write_produces_valid_sarif() {
1375 let root = PathBuf::from("/project");
1376 let results = sample_results(&root);
1377 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1378 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
1379
1380 let dir = std::env::temp_dir().join("fallow-test-sarif-file");
1381 let _ = std::fs::create_dir_all(&dir);
1382 let sarif_path = dir.join("results.sarif");
1383 std::fs::write(&sarif_path, &json_str).expect("should write SARIF file");
1384
1385 let contents = std::fs::read_to_string(&sarif_path).expect("should read SARIF file");
1386 let parsed: serde_json::Value =
1387 serde_json::from_str(&contents).expect("file should contain valid JSON");
1388
1389 assert_eq!(parsed["version"], "2.1.0");
1390 assert_eq!(
1391 parsed["$schema"],
1392 "https://json.schemastore.org/sarif-2.1.0.json"
1393 );
1394 let sarif_results = parsed["runs"][0]["results"]
1395 .as_array()
1396 .expect("results should be an array");
1397 assert!(!sarif_results.is_empty());
1398
1399 let _ = std::fs::remove_file(&sarif_path);
1401 let _ = std::fs::remove_dir(&dir);
1402 }
1403
1404 #[test]
1407 fn health_sarif_empty_no_results() {
1408 let root = PathBuf::from("/project");
1409 let report = crate::health_types::HealthReport {
1410 summary: crate::health_types::HealthSummary {
1411 files_analyzed: 10,
1412 functions_analyzed: 50,
1413 ..Default::default()
1414 },
1415 ..Default::default()
1416 };
1417 let sarif = build_health_sarif(&report, &root);
1418 assert_eq!(sarif["version"], "2.1.0");
1419 let results = sarif["runs"][0]["results"].as_array().unwrap();
1420 assert!(results.is_empty());
1421 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1422 .as_array()
1423 .unwrap();
1424 assert_eq!(rules.len(), 12);
1425 }
1426
1427 #[test]
1428 fn health_sarif_cyclomatic_only() {
1429 let root = PathBuf::from("/project");
1430 let report = crate::health_types::HealthReport {
1431 findings: vec![crate::health_types::HealthFinding {
1432 path: root.join("src/utils.ts"),
1433 name: "parseExpression".to_string(),
1434 line: 42,
1435 col: 0,
1436 cyclomatic: 25,
1437 cognitive: 10,
1438 line_count: 80,
1439 param_count: 0,
1440 exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
1441 severity: crate::health_types::FindingSeverity::High,
1442 crap: None,
1443 coverage_pct: None,
1444 coverage_tier: None,
1445 }],
1446 summary: crate::health_types::HealthSummary {
1447 files_analyzed: 5,
1448 functions_analyzed: 20,
1449 functions_above_threshold: 1,
1450 ..Default::default()
1451 },
1452 ..Default::default()
1453 };
1454 let sarif = build_health_sarif(&report, &root);
1455 let entry = &sarif["runs"][0]["results"][0];
1456 assert_eq!(entry["ruleId"], "fallow/high-cyclomatic-complexity");
1457 assert_eq!(entry["level"], "warning");
1458 assert!(
1459 entry["message"]["text"]
1460 .as_str()
1461 .unwrap()
1462 .contains("cyclomatic complexity 25")
1463 );
1464 assert_eq!(
1465 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1466 "src/utils.ts"
1467 );
1468 let region = &entry["locations"][0]["physicalLocation"]["region"];
1469 assert_eq!(region["startLine"], 42);
1470 assert_eq!(region["startColumn"], 1);
1471 }
1472
1473 #[test]
1474 fn health_sarif_cognitive_only() {
1475 let root = PathBuf::from("/project");
1476 let report = crate::health_types::HealthReport {
1477 findings: vec![crate::health_types::HealthFinding {
1478 path: root.join("src/api.ts"),
1479 name: "handleRequest".to_string(),
1480 line: 10,
1481 col: 4,
1482 cyclomatic: 8,
1483 cognitive: 20,
1484 line_count: 40,
1485 param_count: 0,
1486 exceeded: crate::health_types::ExceededThreshold::Cognitive,
1487 severity: crate::health_types::FindingSeverity::High,
1488 crap: None,
1489 coverage_pct: None,
1490 coverage_tier: None,
1491 }],
1492 summary: crate::health_types::HealthSummary {
1493 files_analyzed: 3,
1494 functions_analyzed: 10,
1495 functions_above_threshold: 1,
1496 ..Default::default()
1497 },
1498 ..Default::default()
1499 };
1500 let sarif = build_health_sarif(&report, &root);
1501 let entry = &sarif["runs"][0]["results"][0];
1502 assert_eq!(entry["ruleId"], "fallow/high-cognitive-complexity");
1503 assert!(
1504 entry["message"]["text"]
1505 .as_str()
1506 .unwrap()
1507 .contains("cognitive complexity 20")
1508 );
1509 let region = &entry["locations"][0]["physicalLocation"]["region"];
1510 assert_eq!(region["startColumn"], 5); }
1512
1513 #[test]
1514 fn health_sarif_both_thresholds() {
1515 let root = PathBuf::from("/project");
1516 let report = crate::health_types::HealthReport {
1517 findings: vec![crate::health_types::HealthFinding {
1518 path: root.join("src/complex.ts"),
1519 name: "doEverything".to_string(),
1520 line: 1,
1521 col: 0,
1522 cyclomatic: 30,
1523 cognitive: 45,
1524 line_count: 100,
1525 param_count: 0,
1526 exceeded: crate::health_types::ExceededThreshold::Both,
1527 severity: crate::health_types::FindingSeverity::High,
1528 crap: None,
1529 coverage_pct: None,
1530 coverage_tier: None,
1531 }],
1532 summary: crate::health_types::HealthSummary {
1533 files_analyzed: 1,
1534 functions_analyzed: 1,
1535 functions_above_threshold: 1,
1536 ..Default::default()
1537 },
1538 ..Default::default()
1539 };
1540 let sarif = build_health_sarif(&report, &root);
1541 let entry = &sarif["runs"][0]["results"][0];
1542 assert_eq!(entry["ruleId"], "fallow/high-complexity");
1543 let msg = entry["message"]["text"].as_str().unwrap();
1544 assert!(msg.contains("cyclomatic complexity 30"));
1545 assert!(msg.contains("cognitive complexity 45"));
1546 }
1547
1548 #[test]
1549 fn health_sarif_crap_only_emits_crap_rule() {
1550 let root = PathBuf::from("/project");
1553 let report = crate::health_types::HealthReport {
1554 findings: vec![crate::health_types::HealthFinding {
1555 path: root.join("src/untested.ts"),
1556 name: "risky".to_string(),
1557 line: 8,
1558 col: 0,
1559 cyclomatic: 10,
1560 cognitive: 10,
1561 line_count: 20,
1562 param_count: 1,
1563 exceeded: crate::health_types::ExceededThreshold::Crap,
1564 severity: crate::health_types::FindingSeverity::High,
1565 crap: Some(82.2),
1566 coverage_pct: Some(12.0),
1567 coverage_tier: None,
1568 }],
1569 summary: crate::health_types::HealthSummary {
1570 files_analyzed: 1,
1571 functions_analyzed: 1,
1572 functions_above_threshold: 1,
1573 ..Default::default()
1574 },
1575 ..Default::default()
1576 };
1577 let sarif = build_health_sarif(&report, &root);
1578 let entry = &sarif["runs"][0]["results"][0];
1579 assert_eq!(entry["ruleId"], "fallow/high-crap-score");
1580 let msg = entry["message"]["text"].as_str().unwrap();
1581 assert!(msg.contains("CRAP score 82.2"), "msg: {msg}");
1582 assert!(msg.contains("coverage 12%"), "msg: {msg}");
1583 }
1584
1585 #[test]
1586 fn health_sarif_cyclomatic_crap_uses_crap_rule() {
1587 let root = PathBuf::from("/project");
1590 let report = crate::health_types::HealthReport {
1591 findings: vec![crate::health_types::HealthFinding {
1592 path: root.join("src/hot.ts"),
1593 name: "branchy".to_string(),
1594 line: 1,
1595 col: 0,
1596 cyclomatic: 67,
1597 cognitive: 12,
1598 line_count: 80,
1599 param_count: 1,
1600 exceeded: crate::health_types::ExceededThreshold::CyclomaticCrap,
1601 severity: crate::health_types::FindingSeverity::Critical,
1602 crap: Some(182.0),
1603 coverage_pct: None,
1604 coverage_tier: None,
1605 }],
1606 summary: crate::health_types::HealthSummary {
1607 files_analyzed: 1,
1608 functions_analyzed: 1,
1609 functions_above_threshold: 1,
1610 ..Default::default()
1611 },
1612 ..Default::default()
1613 };
1614 let sarif = build_health_sarif(&report, &root);
1615 let results = sarif["runs"][0]["results"].as_array().unwrap();
1616 assert_eq!(
1617 results.len(),
1618 1,
1619 "CyclomaticCrap should emit a single SARIF result under the CRAP rule"
1620 );
1621 assert_eq!(results[0]["ruleId"], "fallow/high-crap-score");
1622 let msg = results[0]["message"]["text"].as_str().unwrap();
1623 assert!(msg.contains("CRAP score 182"), "msg: {msg}");
1624 assert!(!msg.contains("coverage"), "msg: {msg}");
1626 }
1627
1628 #[test]
1631 fn severity_to_sarif_level_error() {
1632 assert_eq!(severity_to_sarif_level(Severity::Error), "error");
1633 }
1634
1635 #[test]
1636 fn severity_to_sarif_level_warn() {
1637 assert_eq!(severity_to_sarif_level(Severity::Warn), "warning");
1638 }
1639
1640 #[test]
1641 fn severity_to_sarif_level_off() {
1642 assert_eq!(severity_to_sarif_level(Severity::Off), "warning");
1643 }
1644
1645 #[test]
1648 fn sarif_re_export_has_properties() {
1649 let root = PathBuf::from("/project");
1650 let mut results = AnalysisResults::default();
1651 results.unused_exports.push(UnusedExport {
1652 path: root.join("src/index.ts"),
1653 export_name: "reExported".to_string(),
1654 is_type_only: false,
1655 line: 1,
1656 col: 0,
1657 span_start: 0,
1658 is_re_export: true,
1659 });
1660
1661 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1662 let entry = &sarif["runs"][0]["results"][0];
1663 assert_eq!(entry["properties"]["is_re_export"], true);
1664 let msg = entry["message"]["text"].as_str().unwrap();
1665 assert!(msg.starts_with("Re-export"));
1666 }
1667
1668 #[test]
1669 fn sarif_non_re_export_has_no_properties() {
1670 let root = PathBuf::from("/project");
1671 let mut results = AnalysisResults::default();
1672 results.unused_exports.push(UnusedExport {
1673 path: root.join("src/utils.ts"),
1674 export_name: "foo".to_string(),
1675 is_type_only: false,
1676 line: 5,
1677 col: 0,
1678 span_start: 0,
1679 is_re_export: false,
1680 });
1681
1682 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1683 let entry = &sarif["runs"][0]["results"][0];
1684 assert!(entry.get("properties").is_none());
1685 let msg = entry["message"]["text"].as_str().unwrap();
1686 assert!(msg.starts_with("Export"));
1687 }
1688
1689 #[test]
1692 fn sarif_type_re_export_message() {
1693 let root = PathBuf::from("/project");
1694 let mut results = AnalysisResults::default();
1695 results.unused_types.push(UnusedExport {
1696 path: root.join("src/index.ts"),
1697 export_name: "MyType".to_string(),
1698 is_type_only: true,
1699 line: 1,
1700 col: 0,
1701 span_start: 0,
1702 is_re_export: true,
1703 });
1704
1705 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1706 let entry = &sarif["runs"][0]["results"][0];
1707 assert_eq!(entry["ruleId"], "fallow/unused-type");
1708 let msg = entry["message"]["text"].as_str().unwrap();
1709 assert!(msg.starts_with("Type re-export"));
1710 assert_eq!(entry["properties"]["is_re_export"], true);
1711 }
1712
1713 #[test]
1716 fn sarif_dependency_line_zero_skips_region() {
1717 let root = PathBuf::from("/project");
1718 let mut results = AnalysisResults::default();
1719 results.unused_dependencies.push(UnusedDependency {
1720 package_name: "lodash".to_string(),
1721 location: DependencyLocation::Dependencies,
1722 path: root.join("package.json"),
1723 line: 0,
1724 used_in_workspaces: Vec::new(),
1725 });
1726
1727 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1728 let entry = &sarif["runs"][0]["results"][0];
1729 let phys = &entry["locations"][0]["physicalLocation"];
1730 assert!(phys.get("region").is_none());
1731 }
1732
1733 #[test]
1734 fn sarif_dependency_line_nonzero_has_region() {
1735 let root = PathBuf::from("/project");
1736 let mut results = AnalysisResults::default();
1737 results.unused_dependencies.push(UnusedDependency {
1738 package_name: "lodash".to_string(),
1739 location: DependencyLocation::Dependencies,
1740 path: root.join("package.json"),
1741 line: 7,
1742 used_in_workspaces: Vec::new(),
1743 });
1744
1745 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1746 let entry = &sarif["runs"][0]["results"][0];
1747 let region = &entry["locations"][0]["physicalLocation"]["region"];
1748 assert_eq!(region["startLine"], 7);
1749 assert_eq!(region["startColumn"], 1);
1750 }
1751
1752 #[test]
1755 fn sarif_type_only_dep_line_zero_skips_region() {
1756 let root = PathBuf::from("/project");
1757 let mut results = AnalysisResults::default();
1758 results.type_only_dependencies.push(TypeOnlyDependency {
1759 package_name: "zod".to_string(),
1760 path: root.join("package.json"),
1761 line: 0,
1762 });
1763
1764 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1765 let entry = &sarif["runs"][0]["results"][0];
1766 let phys = &entry["locations"][0]["physicalLocation"];
1767 assert!(phys.get("region").is_none());
1768 }
1769
1770 #[test]
1773 fn sarif_circular_dep_line_zero_skips_region() {
1774 let root = PathBuf::from("/project");
1775 let mut results = AnalysisResults::default();
1776 results.circular_dependencies.push(CircularDependency {
1777 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1778 length: 2,
1779 line: 0,
1780 col: 0,
1781 is_cross_package: false,
1782 });
1783
1784 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1785 let entry = &sarif["runs"][0]["results"][0];
1786 let phys = &entry["locations"][0]["physicalLocation"];
1787 assert!(phys.get("region").is_none());
1788 }
1789
1790 #[test]
1791 fn sarif_circular_dep_line_nonzero_has_region() {
1792 let root = PathBuf::from("/project");
1793 let mut results = AnalysisResults::default();
1794 results.circular_dependencies.push(CircularDependency {
1795 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1796 length: 2,
1797 line: 5,
1798 col: 2,
1799 is_cross_package: false,
1800 });
1801
1802 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1803 let entry = &sarif["runs"][0]["results"][0];
1804 let region = &entry["locations"][0]["physicalLocation"]["region"];
1805 assert_eq!(region["startLine"], 5);
1806 assert_eq!(region["startColumn"], 3);
1807 }
1808
1809 #[test]
1812 fn sarif_unused_optional_dependency_result() {
1813 let root = PathBuf::from("/project");
1814 let mut results = AnalysisResults::default();
1815 results.unused_optional_dependencies.push(UnusedDependency {
1816 package_name: "fsevents".to_string(),
1817 location: DependencyLocation::OptionalDependencies,
1818 path: root.join("package.json"),
1819 line: 12,
1820 used_in_workspaces: Vec::new(),
1821 });
1822
1823 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1824 let entry = &sarif["runs"][0]["results"][0];
1825 assert_eq!(entry["ruleId"], "fallow/unused-optional-dependency");
1826 let msg = entry["message"]["text"].as_str().unwrap();
1827 assert!(msg.contains("optionalDependencies"));
1828 }
1829
1830 #[test]
1833 fn sarif_enum_member_message_format() {
1834 let root = PathBuf::from("/project");
1835 let mut results = AnalysisResults::default();
1836 results
1837 .unused_enum_members
1838 .push(fallow_core::results::UnusedMember {
1839 path: root.join("src/enums.ts"),
1840 parent_name: "Color".to_string(),
1841 member_name: "Purple".to_string(),
1842 kind: fallow_core::extract::MemberKind::EnumMember,
1843 line: 5,
1844 col: 2,
1845 });
1846
1847 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1848 let entry = &sarif["runs"][0]["results"][0];
1849 assert_eq!(entry["ruleId"], "fallow/unused-enum-member");
1850 let msg = entry["message"]["text"].as_str().unwrap();
1851 assert!(msg.contains("Enum member 'Color.Purple'"));
1852 let region = &entry["locations"][0]["physicalLocation"]["region"];
1853 assert_eq!(region["startColumn"], 3); }
1855
1856 #[test]
1857 fn sarif_class_member_message_format() {
1858 let root = PathBuf::from("/project");
1859 let mut results = AnalysisResults::default();
1860 results
1861 .unused_class_members
1862 .push(fallow_core::results::UnusedMember {
1863 path: root.join("src/service.ts"),
1864 parent_name: "API".to_string(),
1865 member_name: "fetch".to_string(),
1866 kind: fallow_core::extract::MemberKind::ClassMethod,
1867 line: 10,
1868 col: 4,
1869 });
1870
1871 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1872 let entry = &sarif["runs"][0]["results"][0];
1873 assert_eq!(entry["ruleId"], "fallow/unused-class-member");
1874 let msg = entry["message"]["text"].as_str().unwrap();
1875 assert!(msg.contains("Class member 'API.fetch'"));
1876 }
1877
1878 #[test]
1881 #[expect(
1882 clippy::cast_possible_truncation,
1883 reason = "test line/col values are trivially small"
1884 )]
1885 fn duplication_sarif_structure() {
1886 use fallow_core::duplicates::*;
1887
1888 let root = PathBuf::from("/project");
1889 let report = DuplicationReport {
1890 clone_groups: vec![CloneGroup {
1891 instances: vec![
1892 CloneInstance {
1893 file: root.join("src/a.ts"),
1894 start_line: 1,
1895 end_line: 10,
1896 start_col: 0,
1897 end_col: 0,
1898 fragment: String::new(),
1899 },
1900 CloneInstance {
1901 file: root.join("src/b.ts"),
1902 start_line: 5,
1903 end_line: 14,
1904 start_col: 2,
1905 end_col: 0,
1906 fragment: String::new(),
1907 },
1908 ],
1909 token_count: 50,
1910 line_count: 10,
1911 }],
1912 clone_families: vec![],
1913 mirrored_directories: vec![],
1914 stats: DuplicationStats::default(),
1915 };
1916
1917 let sarif = serde_json::json!({
1918 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1919 "version": "2.1.0",
1920 "runs": [{
1921 "tool": {
1922 "driver": {
1923 "name": "fallow",
1924 "version": env!("CARGO_PKG_VERSION"),
1925 "informationUri": "https://github.com/fallow-rs/fallow",
1926 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
1927 }
1928 },
1929 "results": []
1930 }]
1931 });
1932 let _ = sarif;
1934
1935 let mut sarif_results = Vec::new();
1937 for (i, group) in report.clone_groups.iter().enumerate() {
1938 for instance in &group.instances {
1939 sarif_results.push(sarif_result(
1940 "fallow/code-duplication",
1941 "warning",
1942 &format!(
1943 "Code clone group {} ({} lines, {} instances)",
1944 i + 1,
1945 group.line_count,
1946 group.instances.len()
1947 ),
1948 &super::super::relative_uri(&instance.file, &root),
1949 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
1950 ));
1951 }
1952 }
1953 assert_eq!(sarif_results.len(), 2);
1954 assert_eq!(sarif_results[0]["ruleId"], "fallow/code-duplication");
1955 assert!(
1956 sarif_results[0]["message"]["text"]
1957 .as_str()
1958 .unwrap()
1959 .contains("10 lines")
1960 );
1961 let region0 = &sarif_results[0]["locations"][0]["physicalLocation"]["region"];
1962 assert_eq!(region0["startLine"], 1);
1963 assert_eq!(region0["startColumn"], 1); let region1 = &sarif_results[1]["locations"][0]["physicalLocation"]["region"];
1965 assert_eq!(region1["startLine"], 5);
1966 assert_eq!(region1["startColumn"], 3); }
1968
1969 #[test]
1972 fn sarif_rule_known_id_has_full_description() {
1973 let rule = sarif_rule("fallow/unused-file", "fallback text", "error");
1974 assert!(rule.get("fullDescription").is_some());
1975 assert!(rule.get("helpUri").is_some());
1976 }
1977
1978 #[test]
1979 fn sarif_rule_unknown_id_uses_fallback() {
1980 let rule = sarif_rule("fallow/nonexistent", "fallback text", "warning");
1981 assert_eq!(rule["shortDescription"]["text"], "fallback text");
1982 assert!(rule.get("fullDescription").is_none());
1983 assert!(rule.get("helpUri").is_none());
1984 assert_eq!(rule["defaultConfiguration"]["level"], "warning");
1985 }
1986
1987 #[test]
1990 fn sarif_result_no_region_omits_region_key() {
1991 let result = sarif_result("rule/test", "error", "test msg", "src/file.ts", None);
1992 let phys = &result["locations"][0]["physicalLocation"];
1993 assert!(phys.get("region").is_none());
1994 assert_eq!(phys["artifactLocation"]["uri"], "src/file.ts");
1995 }
1996
1997 #[test]
1998 fn sarif_result_with_region_includes_region() {
1999 let result = sarif_result(
2000 "rule/test",
2001 "error",
2002 "test msg",
2003 "src/file.ts",
2004 Some((10, 5)),
2005 );
2006 let region = &result["locations"][0]["physicalLocation"]["region"];
2007 assert_eq!(region["startLine"], 10);
2008 assert_eq!(region["startColumn"], 5);
2009 }
2010
2011 #[test]
2014 fn health_sarif_includes_refactoring_targets() {
2015 use crate::health_types::*;
2016
2017 let root = PathBuf::from("/project");
2018 let report = HealthReport {
2019 summary: HealthSummary {
2020 files_analyzed: 10,
2021 functions_analyzed: 50,
2022 ..Default::default()
2023 },
2024 targets: vec![RefactoringTarget {
2025 path: root.join("src/complex.ts"),
2026 priority: 85.0,
2027 efficiency: 42.5,
2028 recommendation: "Split high-impact file".into(),
2029 category: RecommendationCategory::SplitHighImpact,
2030 effort: EffortEstimate::Medium,
2031 confidence: Confidence::High,
2032 factors: vec![],
2033 evidence: None,
2034 }],
2035 ..Default::default()
2036 };
2037
2038 let sarif = build_health_sarif(&report, &root);
2039 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2040 assert_eq!(entries.len(), 1);
2041 assert_eq!(entries[0]["ruleId"], "fallow/refactoring-target");
2042 assert_eq!(entries[0]["level"], "warning");
2043 let msg = entries[0]["message"]["text"].as_str().unwrap();
2044 assert!(msg.contains("high impact"));
2045 assert!(msg.contains("Split high-impact file"));
2046 assert!(msg.contains("42.5"));
2047 }
2048
2049 #[test]
2050 fn health_sarif_includes_coverage_gaps() {
2051 use crate::health_types::*;
2052
2053 let root = PathBuf::from("/project");
2054 let report = HealthReport {
2055 summary: HealthSummary {
2056 files_analyzed: 10,
2057 functions_analyzed: 50,
2058 ..Default::default()
2059 },
2060 coverage_gaps: Some(CoverageGaps {
2061 summary: CoverageGapSummary {
2062 runtime_files: 2,
2063 covered_files: 0,
2064 file_coverage_pct: 0.0,
2065 untested_files: 1,
2066 untested_exports: 1,
2067 },
2068 files: vec![UntestedFile {
2069 path: root.join("src/app.ts"),
2070 value_export_count: 2,
2071 }],
2072 exports: vec![UntestedExport {
2073 path: root.join("src/app.ts"),
2074 export_name: "loader".into(),
2075 line: 12,
2076 col: 4,
2077 }],
2078 }),
2079 ..Default::default()
2080 };
2081
2082 let sarif = build_health_sarif(&report, &root);
2083 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2084 assert_eq!(entries.len(), 2);
2085 assert_eq!(entries[0]["ruleId"], "fallow/untested-file");
2086 assert_eq!(
2087 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2088 "src/app.ts"
2089 );
2090 assert!(
2091 entries[0]["message"]["text"]
2092 .as_str()
2093 .unwrap()
2094 .contains("2 value exports")
2095 );
2096 assert_eq!(entries[1]["ruleId"], "fallow/untested-export");
2097 assert_eq!(
2098 entries[1]["locations"][0]["physicalLocation"]["region"]["startLine"],
2099 12
2100 );
2101 assert_eq!(
2102 entries[1]["locations"][0]["physicalLocation"]["region"]["startColumn"],
2103 5
2104 );
2105 }
2106
2107 #[test]
2110 fn health_sarif_rules_have_full_descriptions() {
2111 let root = PathBuf::from("/project");
2112 let report = crate::health_types::HealthReport::default();
2113 let sarif = build_health_sarif(&report, &root);
2114 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
2115 .as_array()
2116 .unwrap();
2117 for rule in rules {
2118 let id = rule["id"].as_str().unwrap();
2119 assert!(
2120 rule.get("fullDescription").is_some(),
2121 "health rule {id} should have fullDescription"
2122 );
2123 assert!(
2124 rule.get("helpUri").is_some(),
2125 "health rule {id} should have helpUri"
2126 );
2127 }
2128 }
2129
2130 #[test]
2133 fn sarif_warn_severity_produces_warning_level() {
2134 let root = PathBuf::from("/project");
2135 let mut results = AnalysisResults::default();
2136 results.unused_files.push(UnusedFile {
2137 path: root.join("src/dead.ts"),
2138 });
2139
2140 let rules = RulesConfig {
2141 unused_files: Severity::Warn,
2142 ..RulesConfig::default()
2143 };
2144
2145 let sarif = build_sarif(&results, &root, &rules);
2146 let entry = &sarif["runs"][0]["results"][0];
2147 assert_eq!(entry["level"], "warning");
2148 }
2149
2150 #[test]
2153 fn sarif_unused_file_has_no_region() {
2154 let root = PathBuf::from("/project");
2155 let mut results = AnalysisResults::default();
2156 results.unused_files.push(UnusedFile {
2157 path: root.join("src/dead.ts"),
2158 });
2159
2160 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2161 let entry = &sarif["runs"][0]["results"][0];
2162 let phys = &entry["locations"][0]["physicalLocation"];
2163 assert!(phys.get("region").is_none());
2164 }
2165
2166 #[test]
2169 fn sarif_unlisted_dep_multiple_import_sites() {
2170 let root = PathBuf::from("/project");
2171 let mut results = AnalysisResults::default();
2172 results.unlisted_dependencies.push(UnlistedDependency {
2173 package_name: "dotenv".to_string(),
2174 imported_from: vec![
2175 ImportSite {
2176 path: root.join("src/a.ts"),
2177 line: 1,
2178 col: 0,
2179 },
2180 ImportSite {
2181 path: root.join("src/b.ts"),
2182 line: 5,
2183 col: 0,
2184 },
2185 ],
2186 });
2187
2188 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2189 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2190 assert_eq!(entries.len(), 2);
2192 assert_eq!(
2193 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2194 "src/a.ts"
2195 );
2196 assert_eq!(
2197 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2198 "src/b.ts"
2199 );
2200 }
2201
2202 #[test]
2205 fn sarif_unlisted_dep_no_import_sites() {
2206 let root = PathBuf::from("/project");
2207 let mut results = AnalysisResults::default();
2208 results.unlisted_dependencies.push(UnlistedDependency {
2209 package_name: "phantom".to_string(),
2210 imported_from: vec![],
2211 });
2212
2213 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2214 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2215 assert!(entries.is_empty());
2217 }
2218}