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