1use std::path::{Path, PathBuf};
2use std::process::ExitCode;
3
4use fallow_config::{RulesConfig, Severity};
5use fallow_core::duplicates::DuplicationReport;
6use fallow_core::results::{
7 AnalysisResults, BoundaryCallViolation, BoundaryCoverageViolation, BoundaryViolation,
8 CircularDependency, DuplicateExportFinding, DuplicatePropShape, DynamicSegmentNameConflict,
9 EmptyCatalogGroupFinding, InvalidClientExport, MisconfiguredDependencyOverrideFinding,
10 MisplacedDirective, MixedClientServerBarrel, PolicyViolation, PolicyViolationSeverity,
11 PrivateTypeLeak, PropDrillingChain, RouteCollision, StaleSuppression, TestOnlyDependency,
12 ThinWrapper, TypeOnlyDependency, UnlistedDependencyFinding, UnprovidedInject,
13 UnrenderedComponent, UnresolvedCatalogReferenceFinding, UnresolvedImport,
14 UnusedCatalogEntryFinding, UnusedComponentEmit, UnusedComponentInput, UnusedComponentOutput,
15 UnusedComponentProp, UnusedDependency, UnusedDependencyOverrideFinding, UnusedExport,
16 UnusedFile, UnusedMember, UnusedServerAction, UnusedSvelteEvent,
17};
18use rustc_hash::FxHashMap;
19
20use super::ci::{fingerprint, severity};
21use super::grouping::{self, OwnershipResolver};
22use super::{emit_json, relative_uri};
23use crate::explain;
24
25struct SarifFields {
27 rule_id: &'static str,
28 level: &'static str,
29 message: String,
30 uri: String,
31 region: Option<(u32, u32)>,
32 source_path: Option<PathBuf>,
33 properties: Option<serde_json::Value>,
34}
35
36#[derive(Default)]
37struct SourceSnippetCache {
38 files: FxHashMap<PathBuf, Vec<String>>,
39}
40
41impl SourceSnippetCache {
42 fn line(&mut self, path: &Path, line: u32) -> Option<String> {
43 if line == 0 {
44 return None;
45 }
46 if !self.files.contains_key(path) {
47 let lines = std::fs::read_to_string(path)
48 .ok()
49 .map(|source| source.lines().map(str::to_owned).collect())
50 .unwrap_or_default();
51 self.files.insert(path.to_path_buf(), lines);
52 }
53 self.files
54 .get(path)
55 .and_then(|lines| lines.get(line.saturating_sub(1) as usize))
56 .cloned()
57 }
58}
59
60fn severity_to_sarif_level(s: Severity) -> &'static str {
61 severity::sarif_level(s)
62}
63
64fn configured_sarif_level(s: Severity) -> &'static str {
65 match s {
66 Severity::Error | Severity::Warn => severity_to_sarif_level(s),
67 Severity::Off => "none",
68 }
69}
70
71fn sarif_result(
76 rule_id: &str,
77 level: &str,
78 message: &str,
79 uri: &str,
80 region: Option<(u32, u32)>,
81) -> serde_json::Value {
82 sarif_result_with_snippet(rule_id, level, message, uri, region, None)
83}
84
85fn sarif_result_with_snippet(
86 rule_id: &str,
87 level: &str,
88 message: &str,
89 uri: &str,
90 region: Option<(u32, u32)>,
91 snippet: Option<&str>,
92) -> serde_json::Value {
93 let mut physical_location = serde_json::json!({
94 "artifactLocation": { "uri": uri }
95 });
96 if let Some((line, col)) = region {
97 physical_location["region"] = serde_json::json!({
98 "startLine": line,
99 "startColumn": col
100 });
101 }
102 let line = region.map_or_else(String::new, |(line, _)| line.to_string());
103 let col = region.map_or_else(String::new, |(_, col)| col.to_string());
104 let normalized_snippet = snippet
105 .map(fingerprint::normalize_snippet)
106 .filter(|snippet| !snippet.is_empty());
107 let partial_fingerprint = normalized_snippet.as_ref().map_or_else(
108 || fingerprint::fingerprint_hash(&[rule_id, uri, &line, &col]),
109 |snippet| fingerprint::finding_fingerprint(rule_id, uri, snippet),
110 );
111 let partial_fingerprint_ghas = partial_fingerprint.clone();
112 serde_json::json!({
113 "ruleId": rule_id,
114 "level": level,
115 "message": { "text": message },
116 "locations": [{ "physicalLocation": physical_location }],
117 "partialFingerprints": {
118 fingerprint::FINGERPRINT_KEY: partial_fingerprint,
119 fingerprint::GHAS_FINGERPRINT_KEY: partial_fingerprint_ghas
120 }
121 })
122}
123
124fn push_sarif_results<T>(
126 sarif_results: &mut Vec<serde_json::Value>,
127 items: &[T],
128 snippets: &mut SourceSnippetCache,
129 mut extract: impl FnMut(&T) -> SarifFields,
130) {
131 for item in items {
132 let fields = extract(item);
133 let source_snippet = fields
134 .source_path
135 .as_deref()
136 .zip(fields.region)
137 .and_then(|(path, (line, _))| snippets.line(path, line));
138 let mut result = sarif_result_with_snippet(
139 fields.rule_id,
140 fields.level,
141 &fields.message,
142 &fields.uri,
143 fields.region,
144 source_snippet.as_deref(),
145 );
146 if let Some(props) = fields.properties {
147 result["properties"] = props;
148 }
149 sarif_results.push(result);
150 }
151}
152
153fn sarif_rule(id: &str, fallback_short: &str, level: &str) -> serde_json::Value {
156 explain::rule_by_id(id).map_or_else(
157 || {
158 serde_json::json!({
159 "id": id,
160 "shortDescription": { "text": fallback_short },
161 "defaultConfiguration": { "level": level }
162 })
163 },
164 |def| {
165 serde_json::json!({
166 "id": id,
167 "shortDescription": { "text": def.short },
168 "fullDescription": { "text": def.full },
169 "helpUri": explain::rule_docs_url(def),
170 "defaultConfiguration": { "level": level }
171 })
172 },
173 )
174}
175
176fn sarif_export_fields(
178 export: &UnusedExport,
179 root: &Path,
180 rule_id: &'static str,
181 level: &'static str,
182 kind: &str,
183 re_kind: &str,
184) -> SarifFields {
185 let label = if export.is_re_export { re_kind } else { kind };
186 SarifFields {
187 rule_id,
188 level,
189 message: format!(
190 "{} '{}' is never imported by other modules",
191 label, export.export_name
192 ),
193 uri: relative_uri(&export.path, root),
194 region: Some((export.line, export.col + 1)),
195 source_path: Some(export.path.clone()),
196 properties: if export.is_re_export {
197 Some(serde_json::json!({ "is_re_export": true }))
198 } else {
199 None
200 },
201 }
202}
203
204fn sarif_private_type_leak_fields(
205 leak: &PrivateTypeLeak,
206 root: &Path,
207 level: &'static str,
208) -> SarifFields {
209 SarifFields {
210 rule_id: "fallow/private-type-leak",
211 level,
212 message: format!(
213 "Export '{}' references private type '{}'",
214 leak.export_name, leak.type_name
215 ),
216 uri: relative_uri(&leak.path, root),
217 region: Some((leak.line, leak.col + 1)),
218 source_path: Some(leak.path.clone()),
219 properties: None,
220 }
221}
222
223fn sarif_dep_fields(
225 dep: &UnusedDependency,
226 root: &Path,
227 rule_id: &'static str,
228 level: &'static str,
229 section: &str,
230) -> SarifFields {
231 let workspace_context = if dep.used_in_workspaces.is_empty() {
232 String::new()
233 } else {
234 let workspaces = dep
235 .used_in_workspaces
236 .iter()
237 .map(|path| relative_uri(path, root))
238 .collect::<Vec<_>>()
239 .join(", ");
240 format!("; imported in other workspaces: {workspaces}")
241 };
242 SarifFields {
243 rule_id,
244 level,
245 message: format!(
246 "Package '{}' is in {} but never imported{}",
247 dep.package_name, section, workspace_context
248 ),
249 uri: relative_uri(&dep.path, root),
250 region: if dep.line > 0 {
251 Some((dep.line, 1))
252 } else {
253 None
254 },
255 source_path: (dep.line > 0).then(|| dep.path.clone()),
256 properties: None,
257 }
258}
259
260fn sarif_member_fields(
262 member: &UnusedMember,
263 root: &Path,
264 rule_id: &'static str,
265 level: &'static str,
266 kind: &str,
267) -> SarifFields {
268 SarifFields {
269 rule_id,
270 level,
271 message: format!(
272 "{} member '{}.{}' is never referenced",
273 kind, member.parent_name, member.member_name
274 ),
275 uri: relative_uri(&member.path, root),
276 region: Some((member.line, member.col + 1)),
277 source_path: Some(member.path.clone()),
278 properties: None,
279 }
280}
281
282fn sarif_unused_file_fields(file: &UnusedFile, root: &Path, level: &'static str) -> SarifFields {
283 SarifFields {
284 rule_id: "fallow/unused-file",
285 level,
286 message: "File is not reachable from any entry point".to_string(),
287 uri: relative_uri(&file.path, root),
288 region: None,
289 source_path: None,
290 properties: None,
291 }
292}
293
294fn sarif_type_only_dep_fields(
295 dep: &TypeOnlyDependency,
296 root: &Path,
297 level: &'static str,
298) -> SarifFields {
299 SarifFields {
300 rule_id: "fallow/type-only-dependency",
301 level,
302 message: format!(
303 "Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
304 dep.package_name
305 ),
306 uri: relative_uri(&dep.path, root),
307 region: if dep.line > 0 {
308 Some((dep.line, 1))
309 } else {
310 None
311 },
312 source_path: (dep.line > 0).then(|| dep.path.clone()),
313 properties: None,
314 }
315}
316
317fn sarif_test_only_dep_fields(
318 dep: &TestOnlyDependency,
319 root: &Path,
320 level: &'static str,
321) -> SarifFields {
322 SarifFields {
323 rule_id: "fallow/test-only-dependency",
324 level,
325 message: format!(
326 "Package '{}' is only imported by test files (consider moving to devDependencies)",
327 dep.package_name
328 ),
329 uri: relative_uri(&dep.path, root),
330 region: if dep.line > 0 {
331 Some((dep.line, 1))
332 } else {
333 None
334 },
335 source_path: (dep.line > 0).then(|| dep.path.clone()),
336 properties: None,
337 }
338}
339
340fn sarif_unresolved_import_fields(
341 import: &UnresolvedImport,
342 root: &Path,
343 level: &'static str,
344) -> SarifFields {
345 SarifFields {
346 rule_id: "fallow/unresolved-import",
347 level,
348 message: format!("Import '{}' could not be resolved", import.specifier),
349 uri: relative_uri(&import.path, root),
350 region: Some((import.line, import.col + 1)),
351 source_path: Some(import.path.clone()),
352 properties: None,
353 }
354}
355
356fn sarif_circular_dep_fields(
357 cycle: &CircularDependency,
358 root: &Path,
359 level: &'static str,
360) -> SarifFields {
361 let chain: Vec<String> = cycle.files.iter().map(|p| relative_uri(p, root)).collect();
362 let mut display_chain = chain.clone();
363 if let Some(first) = chain.first() {
364 display_chain.push(first.clone());
365 }
366 let first_uri = chain.first().map_or_else(String::new, Clone::clone);
367 let first_path = cycle.files.first().cloned();
368 SarifFields {
369 rule_id: "fallow/circular-dependency",
370 level,
371 message: format!(
372 "Circular dependency{}: {}",
373 if cycle.is_cross_package {
374 " (cross-package)"
375 } else {
376 ""
377 },
378 display_chain.join(" \u{2192} ")
379 ),
380 uri: first_uri,
381 region: if cycle.line > 0 {
382 Some((cycle.line, cycle.col + 1))
383 } else {
384 None
385 },
386 source_path: (cycle.line > 0).then_some(first_path).flatten(),
387 properties: None,
388 }
389}
390
391fn sarif_re_export_cycle_fields(
392 cycle: &fallow_core::results::ReExportCycle,
393 root: &Path,
394 level: &'static str,
395) -> SarifFields {
396 let chain: Vec<String> = cycle.files.iter().map(|p| relative_uri(p, root)).collect();
397 let first_uri = chain.first().map_or_else(String::new, Clone::clone);
398 let first_path = cycle.files.first().cloned();
399 let kind_tag = match cycle.kind {
400 fallow_core::results::ReExportCycleKind::SelfLoop => " (self-loop)",
401 fallow_core::results::ReExportCycleKind::MultiNode => "",
402 };
403 SarifFields {
404 rule_id: "fallow/re-export-cycle",
405 level,
406 message: format!("Re-export cycle{}: {}", kind_tag, chain.join(" <-> ")),
407 uri: first_uri,
408 region: None,
409 source_path: first_path,
410 properties: None,
411 }
412}
413
414fn sarif_boundary_violation_fields(
415 violation: &BoundaryViolation,
416 root: &Path,
417 level: &'static str,
418) -> SarifFields {
419 let from_uri = relative_uri(&violation.from_path, root);
420 let to_uri = relative_uri(&violation.to_path, root);
421 SarifFields {
422 rule_id: "fallow/boundary-violation",
423 level,
424 message: format!(
425 "Import from zone '{}' to zone '{}' is not allowed ({})",
426 violation.from_zone, violation.to_zone, to_uri,
427 ),
428 uri: from_uri,
429 region: if violation.line > 0 {
430 Some((violation.line, violation.col + 1))
431 } else {
432 None
433 },
434 source_path: (violation.line > 0).then(|| violation.from_path.clone()),
435 properties: None,
436 }
437}
438
439fn sarif_boundary_coverage_fields(
440 violation: &BoundaryCoverageViolation,
441 root: &Path,
442 level: &'static str,
443) -> SarifFields {
444 SarifFields {
445 rule_id: "fallow/boundary-coverage",
446 level,
447 message: "File does not match any configured architecture boundary zone".to_string(),
448 uri: relative_uri(&violation.path, root),
449 region: Some((violation.line, violation.col + 1)),
450 source_path: Some(violation.path.clone()),
451 properties: None,
452 }
453}
454
455fn sarif_boundary_call_fields(
456 violation: &BoundaryCallViolation,
457 root: &Path,
458 level: &'static str,
459) -> SarifFields {
460 SarifFields {
461 rule_id: "fallow/boundary-call-violation",
462 level,
463 message: format!(
464 "Call to `{}` matches forbidden pattern `{}` in zone '{}'",
465 violation.callee, violation.pattern, violation.zone
466 ),
467 uri: relative_uri(&violation.path, root),
468 region: Some((violation.line, violation.col + 1)),
469 source_path: Some(violation.path.clone()),
470 properties: None,
471 }
472}
473
474fn sarif_policy_violation_fields(violation: &PolicyViolation, root: &Path) -> SarifFields {
475 let level = match violation.severity {
476 PolicyViolationSeverity::Error => "error",
477 PolicyViolationSeverity::Warn => "warning",
478 };
479 let message = match &violation.message {
480 Some(message) => format!(
481 "Policy violation `{}/{}`: `{}` is banned. {message}",
482 violation.pack, violation.rule_id, violation.matched
483 ),
484 None => format!(
485 "Policy violation `{}/{}`: `{}` is banned",
486 violation.pack, violation.rule_id, violation.matched
487 ),
488 };
489 SarifFields {
490 rule_id: "fallow/policy-violation",
491 level,
492 message,
493 uri: relative_uri(&violation.path, root),
494 region: Some((violation.line, violation.col + 1)),
495 source_path: Some(violation.path.clone()),
496 properties: Some(serde_json::json!({
502 "policyRule": format!("{}/{}", violation.pack, violation.rule_id),
503 })),
504 }
505}
506
507fn sarif_invalid_client_export_fields(
508 export: &InvalidClientExport,
509 root: &Path,
510 level: &'static str,
511) -> SarifFields {
512 SarifFields {
513 rule_id: "fallow/invalid-client-export",
514 level,
515 message: format!(
516 "Export '{}' is not allowed in a \"{}\" file (Next.js server-only / route-config name)",
517 export.export_name, export.directive
518 ),
519 uri: relative_uri(&export.path, root),
520 region: Some((export.line, export.col + 1)),
521 source_path: Some(export.path.clone()),
522 properties: None,
523 }
524}
525
526fn sarif_mixed_client_server_barrel_fields(
527 barrel: &MixedClientServerBarrel,
528 root: &Path,
529 level: &'static str,
530) -> SarifFields {
531 SarifFields {
532 rule_id: "fallow/mixed-client-server-barrel",
533 level,
534 message: format!(
535 "Barrel re-exports both a \"use client\" module ('{}') and a server-only module ('{}'); one import drags the other's directive across the boundary",
536 barrel.client_origin, barrel.server_origin
537 ),
538 uri: relative_uri(&barrel.path, root),
539 region: Some((barrel.line, barrel.col + 1)),
540 source_path: Some(barrel.path.clone()),
541 properties: None,
542 }
543}
544
545fn sarif_misplaced_directive_fields(
546 directive_site: &MisplacedDirective,
547 root: &Path,
548 level: &'static str,
549) -> SarifFields {
550 SarifFields {
551 rule_id: "fallow/misplaced-directive",
552 level,
553 message: format!(
554 "Directive \"{}\" is not in the leading position, so the RSC bundler ignores it; move it to the top of the file",
555 directive_site.directive
556 ),
557 uri: relative_uri(&directive_site.path, root),
558 region: Some((directive_site.line, directive_site.col + 1)),
559 source_path: Some(directive_site.path.clone()),
560 properties: None,
561 }
562}
563
564fn sarif_unprovided_inject_fields(
565 inject: &UnprovidedInject,
566 root: &Path,
567 level: &'static str,
568) -> SarifFields {
569 SarifFields {
570 rule_id: "fallow/unprovided-inject",
571 level,
572 message: format!(
573 "inject(\"{}\") has no matching provide(\"{}\") in this project; at runtime it returns undefined; provide the key or remove this inject",
574 inject.key_name, inject.key_name
575 ),
576 uri: relative_uri(&inject.path, root),
577 region: Some((inject.line, inject.col + 1)),
578 source_path: Some(inject.path.clone()),
579 properties: None,
580 }
581}
582
583fn sarif_unrendered_component_fields(
584 component: &UnrenderedComponent,
585 root: &Path,
586 level: &'static str,
587) -> SarifFields {
588 SarifFields {
589 rule_id: "fallow/unrendered-component",
590 level,
591 message: format!(
592 "component \"{}\" is reachable but rendered nowhere in this project; render it somewhere or remove it",
593 component.component_name
594 ),
595 uri: relative_uri(&component.path, root),
596 region: Some((component.line, component.col + 1)),
597 source_path: Some(component.path.clone()),
598 properties: None,
599 }
600}
601
602fn sarif_unused_component_prop_fields(
603 prop: &UnusedComponentProp,
604 root: &Path,
605 level: &'static str,
606) -> SarifFields {
607 SarifFields {
608 rule_id: "fallow/unused-component-prop",
609 level,
610 message: format!(
611 "prop \"{}\" is declared but referenced nowhere inside component \"{}\"; remove it or use it",
612 prop.prop_name, prop.component_name
613 ),
614 uri: relative_uri(&prop.path, root),
615 region: Some((prop.line, prop.col + 1)),
616 source_path: Some(prop.path.clone()),
617 properties: None,
618 }
619}
620
621fn sarif_unused_component_emit_fields(
622 emit: &UnusedComponentEmit,
623 root: &Path,
624 level: &'static str,
625) -> SarifFields {
626 SarifFields {
627 rule_id: "fallow/unused-component-emit",
628 level,
629 message: format!(
630 "emit \"{}\" is declared but emitted nowhere inside component \"{}\"; remove it or emit it",
631 emit.emit_name, emit.component_name
632 ),
633 uri: relative_uri(&emit.path, root),
634 region: Some((emit.line, emit.col + 1)),
635 source_path: Some(emit.path.clone()),
636 properties: None,
637 }
638}
639
640fn sarif_unused_svelte_event_fields(
641 event: &UnusedSvelteEvent,
642 root: &Path,
643 level: &'static str,
644) -> SarifFields {
645 SarifFields {
646 rule_id: "fallow/unused-svelte-event",
647 level,
648 message: format!(
649 "event \"{}\" is dispatched by component \"{}\" but listened to nowhere in the project; remove it or listen for it",
650 event.event_name, event.component_name
651 ),
652 uri: relative_uri(&event.path, root),
653 region: Some((event.line, event.col + 1)),
654 source_path: Some(event.path.clone()),
655 properties: None,
656 }
657}
658
659fn sarif_unused_component_input_fields(
660 input: &UnusedComponentInput,
661 root: &Path,
662 level: &'static str,
663) -> SarifFields {
664 SarifFields {
665 rule_id: "fallow/unused-component-input",
666 level,
667 message: format!(
668 "input \"{}\" is declared but read nowhere inside component \"{}\"; remove it or use it",
669 input.input_name, input.component_name
670 ),
671 uri: relative_uri(&input.path, root),
672 region: Some((input.line, input.col + 1)),
673 source_path: Some(input.path.clone()),
674 properties: None,
675 }
676}
677
678fn sarif_unused_component_output_fields(
679 output: &UnusedComponentOutput,
680 root: &Path,
681 level: &'static str,
682) -> SarifFields {
683 SarifFields {
684 rule_id: "fallow/unused-component-output",
685 level,
686 message: format!(
687 "output \"{}\" is declared but emitted nowhere inside component \"{}\"; remove it or emit it",
688 output.output_name, output.component_name
689 ),
690 uri: relative_uri(&output.path, root),
691 region: Some((output.line, output.col + 1)),
692 source_path: Some(output.path.clone()),
693 properties: None,
694 }
695}
696
697fn sarif_unused_server_action_fields(
698 action: &UnusedServerAction,
699 root: &Path,
700 level: &'static str,
701) -> SarifFields {
702 SarifFields {
703 rule_id: "fallow/unused-server-action",
704 level,
705 message: format!(
706 "server action \"{}\" is exported from a \"use server\" file but no code in this project references it; wire it to a consumer or remove it",
707 action.action_name
708 ),
709 uri: relative_uri(&action.path, root),
710 region: Some((action.line, action.col + 1)),
711 source_path: Some(action.path.clone()),
712 properties: None,
713 }
714}
715
716fn sarif_unused_load_data_key_fields(
717 key: &fallow_core::results::UnusedLoadDataKey,
718 root: &Path,
719 level: &'static str,
720) -> SarifFields {
721 SarifFields {
722 rule_id: "fallow/unused-load-data-key",
723 level,
724 message: format!(
725 "load() return key \"{}\" is read by no consumer (sibling +page.svelte data.<key> or project-wide page.data.<key>); delete the key or wire a consumer",
726 key.key_name
727 ),
728 uri: relative_uri(&key.path, root),
729 region: Some((key.line, key.col + 1)),
730 source_path: Some(key.path.clone()),
731 properties: None,
732 }
733}
734
735fn sarif_prop_drilling_fields(
736 chain: &PropDrillingChain,
737 root: &Path,
738 level: &'static str,
739) -> SarifFields {
740 let source = chain.hops.first();
743 let consumer = chain.hops.last();
744 let (path, line) = source.map_or((std::path::PathBuf::new(), 1), |h| (h.file.clone(), h.line));
745 let consumer_name = consumer.map_or("a distant component", |h| h.component.as_str());
746 SarifFields {
747 rule_id: "fallow/prop-drilling",
748 level,
749 message: format!(
750 "prop \"{}\" is forwarded unchanged through {} component(s) before \"{}\" consumes it; colocate, lift to context, or compose",
751 chain.prop, chain.depth, consumer_name
752 ),
753 uri: relative_uri(&path, root),
754 region: Some((line, 1)),
755 source_path: Some(path),
756 properties: None,
757 }
758}
759
760fn sarif_thin_wrapper_fields(
761 wrapper: &ThinWrapper,
762 root: &Path,
763 level: &'static str,
764) -> SarifFields {
765 SarifFields {
766 rule_id: "fallow/thin-wrapper",
767 level,
768 message: format!(
769 "\"{}\" is a thin wrapper: its whole body forwards props to \"{}\"; inline it at call sites or delete it",
770 wrapper.component, wrapper.child_component
771 ),
772 uri: relative_uri(&wrapper.file, root),
773 region: Some((wrapper.line, 1)),
774 source_path: Some(wrapper.file.clone()),
775 properties: None,
776 }
777}
778
779fn sarif_duplicate_prop_shape_fields(
780 shape: &DuplicatePropShape,
781 root: &Path,
782 level: &'static str,
783) -> SarifFields {
784 SarifFields {
785 rule_id: "fallow/duplicate-prop-shape",
786 level,
787 message: format!(
788 "\"{}\" shares an identical prop shape {{{}}} with {} other component(s); extract a shared Props type or base component",
789 shape.component,
790 shape.shape.join(", "),
791 shape.group_size.saturating_sub(1)
792 ),
793 uri: relative_uri(&shape.file, root),
794 region: Some((shape.line, 1)),
795 source_path: Some(shape.file.clone()),
796 properties: None,
797 }
798}
799
800fn sarif_route_collision_fields(
801 collision: &RouteCollision,
802 root: &Path,
803 level: &'static str,
804) -> SarifFields {
805 SarifFields {
806 rule_id: "fallow/route-collision",
807 level,
808 message: format!(
809 "Route file resolves to '{}', which is also owned by {} other file(s); Next.js fails the build because a URL can have only one owner",
810 collision.url,
811 collision.conflicting_paths.len()
812 ),
813 uri: relative_uri(&collision.path, root),
814 region: Some((collision.line, collision.col + 1)),
815 source_path: Some(collision.path.clone()),
816 properties: None,
817 }
818}
819
820fn sarif_dynamic_segment_name_conflict_fields(
821 conflict: &DynamicSegmentNameConflict,
822 root: &Path,
823 level: &'static str,
824) -> SarifFields {
825 SarifFields {
826 rule_id: "fallow/dynamic-segment-name-conflict",
827 level,
828 message: format!(
829 "Dynamic segments at '{}' use different slug names ({}); Next.js requires one consistent name per dynamic path",
830 conflict.position,
831 conflict.conflicting_segments.join(", ")
832 ),
833 uri: relative_uri(&conflict.path, root),
834 region: Some((conflict.line, conflict.col + 1)),
835 source_path: Some(conflict.path.clone()),
836 properties: None,
837 }
838}
839
840fn sarif_stale_suppression_fields(
841 suppression: &StaleSuppression,
842 root: &Path,
843 level: &'static str,
844) -> SarifFields {
845 SarifFields {
846 rule_id: if suppression.missing_reason {
847 "fallow/missing-suppression-reason"
848 } else {
849 "fallow/stale-suppression"
850 },
851 level,
852 message: suppression.display_message(),
853 uri: relative_uri(&suppression.path, root),
854 region: Some((suppression.line, suppression.col + 1)),
855 source_path: Some(suppression.path.clone()),
856 properties: None,
857 }
858}
859
860fn stale_suppression_severity(suppression: &StaleSuppression, rules: &RulesConfig) -> Severity {
861 if suppression.missing_reason {
862 rules.require_suppression_reason
863 } else {
864 rules.stale_suppressions
865 }
866}
867
868fn sarif_unused_catalog_entry_fields(
869 entry: &UnusedCatalogEntryFinding,
870 root: &Path,
871 level: &'static str,
872) -> SarifFields {
873 let entry = &entry.entry;
874 let message = if entry.catalog_name == "default" {
875 format!(
876 "Catalog entry '{}' is not referenced by any workspace package",
877 entry.entry_name
878 )
879 } else {
880 format!(
881 "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package",
882 entry.entry_name, entry.catalog_name
883 )
884 };
885 SarifFields {
886 rule_id: "fallow/unused-catalog-entry",
887 level,
888 message,
889 uri: relative_uri(&entry.path, root),
890 region: Some((entry.line, 1)),
891 source_path: Some(entry.path.clone()),
892 properties: None,
893 }
894}
895
896fn sarif_unused_dependency_override_fields(
897 finding: &UnusedDependencyOverrideFinding,
898 root: &Path,
899 level: &'static str,
900) -> SarifFields {
901 let finding = &finding.entry;
902 let mut message = format!(
903 "Override `{}` forces version `{}` but `{}` is not declared by any workspace package or resolved in pnpm-lock.yaml",
904 finding.raw_key, finding.version_range, finding.target_package,
905 );
906 if let Some(hint) = &finding.hint {
907 use std::fmt::Write as _;
908 let _ = write!(message, " ({hint})");
909 }
910 SarifFields {
911 rule_id: "fallow/unused-dependency-override",
912 level,
913 message,
914 uri: relative_uri(&finding.path, root),
915 region: Some((finding.line, 1)),
916 source_path: Some(finding.path.clone()),
917 properties: None,
918 }
919}
920
921fn sarif_misconfigured_dependency_override_fields(
922 finding: &MisconfiguredDependencyOverrideFinding,
923 root: &Path,
924 level: &'static str,
925) -> SarifFields {
926 let finding = &finding.entry;
927 let message = format!(
928 "Override `{}` -> `{}` is malformed: {}",
929 finding.raw_key,
930 finding.raw_value,
931 finding.reason.describe(),
932 );
933 SarifFields {
934 rule_id: "fallow/misconfigured-dependency-override",
935 level,
936 message,
937 uri: relative_uri(&finding.path, root),
938 region: Some((finding.line, 1)),
939 source_path: Some(finding.path.clone()),
940 properties: None,
941 }
942}
943
944fn sarif_unresolved_catalog_reference_fields(
945 finding: &UnresolvedCatalogReferenceFinding,
946 root: &Path,
947 level: &'static str,
948) -> SarifFields {
949 let finding = &finding.reference;
950 let catalog_phrase = if finding.catalog_name == "default" {
951 "the default catalog".to_string()
952 } else {
953 format!("catalog '{}'", finding.catalog_name)
954 };
955 let mut message = format!(
956 "Package '{}' is referenced via `catalog:{}` but {} does not declare it",
957 finding.entry_name,
958 if finding.catalog_name == "default" {
959 ""
960 } else {
961 finding.catalog_name.as_str()
962 },
963 catalog_phrase,
964 );
965 if !finding.available_in_catalogs.is_empty() {
966 use std::fmt::Write as _;
967 let _ = write!(
968 message,
969 " (available in: {})",
970 finding.available_in_catalogs.join(", ")
971 );
972 }
973 SarifFields {
974 rule_id: "fallow/unresolved-catalog-reference",
975 level,
976 message,
977 uri: relative_uri(&finding.path, root),
978 region: Some((finding.line, 1)),
979 source_path: Some(finding.path.clone()),
980 properties: None,
981 }
982}
983
984fn sarif_empty_catalog_group_fields(
985 group: &EmptyCatalogGroupFinding,
986 root: &Path,
987 level: &'static str,
988) -> SarifFields {
989 let group = &group.group;
990 SarifFields {
991 rule_id: "fallow/empty-catalog-group",
992 level,
993 message: format!("Catalog group '{}' has no entries", group.catalog_name),
994 uri: relative_uri(&group.path, root),
995 region: Some((group.line, 1)),
996 source_path: Some(group.path.clone()),
997 properties: None,
998 }
999}
1000
1001fn push_sarif_unlisted_deps(
1004 sarif_results: &mut Vec<serde_json::Value>,
1005 deps: &[UnlistedDependencyFinding],
1006 root: &Path,
1007 level: &'static str,
1008 snippets: &mut SourceSnippetCache,
1009) {
1010 for entry in deps {
1011 let dep = &entry.dep;
1012 for site in &dep.imported_from {
1013 let uri = relative_uri(&site.path, root);
1014 let source_snippet = snippets.line(&site.path, site.line);
1015 sarif_results.push(sarif_result_with_snippet(
1016 "fallow/unlisted-dependency",
1017 level,
1018 &format!(
1019 "Package '{}' is imported but not listed in package.json",
1020 dep.package_name
1021 ),
1022 &uri,
1023 Some((site.line, site.col + 1)),
1024 source_snippet.as_deref(),
1025 ));
1026 }
1027 }
1028}
1029
1030fn push_sarif_duplicate_exports(
1033 sarif_results: &mut Vec<serde_json::Value>,
1034 dups: &[DuplicateExportFinding],
1035 root: &Path,
1036 level: &'static str,
1037 snippets: &mut SourceSnippetCache,
1038) {
1039 for dup in dups {
1040 let dup = &dup.export;
1041 for loc in &dup.locations {
1042 let uri = relative_uri(&loc.path, root);
1043 let source_snippet = snippets.line(&loc.path, loc.line);
1044 sarif_results.push(sarif_result_with_snippet(
1045 "fallow/duplicate-export",
1046 level,
1047 &format!("Export '{}' appears in multiple modules", dup.export_name),
1048 &uri,
1049 Some((loc.line, loc.col + 1)),
1050 source_snippet.as_deref(),
1051 ));
1052 }
1053 }
1054}
1055
1056fn build_sarif_rules(rules: &RulesConfig) -> Vec<serde_json::Value> {
1058 let mut specs = Vec::new();
1059 specs.extend(sarif_core_rule_specs(rules));
1060 specs.extend(sarif_dependency_rule_specs(rules));
1061 specs.extend(sarif_member_import_rule_specs(rules));
1062 specs.extend(sarif_graph_rule_specs(rules));
1063 specs.extend(sarif_workspace_rule_specs(rules));
1064 specs
1065 .into_iter()
1066 .map(|(id, description, rule_severity)| {
1067 sarif_rule(id, description, configured_sarif_level(rule_severity))
1068 })
1069 .collect()
1070}
1071
1072type SarifRuleSpec = (&'static str, &'static str, Severity);
1073
1074fn sarif_core_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1075 [
1076 (
1077 "fallow/unused-file",
1078 "File is not reachable from any entry point",
1079 rules.unused_files,
1080 ),
1081 (
1082 "fallow/unused-export",
1083 "Export is never imported",
1084 rules.unused_exports,
1085 ),
1086 (
1087 "fallow/unused-type",
1088 "Type export is never imported",
1089 rules.unused_types,
1090 ),
1091 (
1092 "fallow/private-type-leak",
1093 "Exported signature references a same-file private type",
1094 rules.private_type_leaks,
1095 ),
1096 ]
1097 .into()
1098}
1099
1100fn sarif_dependency_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1101 [
1102 (
1103 "fallow/unused-dependency",
1104 "Dependency listed but never imported",
1105 rules.unused_dependencies,
1106 ),
1107 (
1108 "fallow/unused-dev-dependency",
1109 "Dev dependency listed but never imported",
1110 rules.unused_dev_dependencies,
1111 ),
1112 (
1113 "fallow/unused-optional-dependency",
1114 "Optional dependency listed but never imported",
1115 rules.unused_optional_dependencies,
1116 ),
1117 (
1118 "fallow/type-only-dependency",
1119 "Production dependency only used via type-only imports",
1120 rules.type_only_dependencies,
1121 ),
1122 (
1123 "fallow/test-only-dependency",
1124 "Production dependency only imported by test files",
1125 rules.test_only_dependencies,
1126 ),
1127 ]
1128 .into()
1129}
1130
1131fn sarif_member_import_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1132 [
1133 (
1134 "fallow/unused-enum-member",
1135 "Enum member is never referenced",
1136 rules.unused_enum_members,
1137 ),
1138 (
1139 "fallow/unused-class-member",
1140 "Class member is never referenced",
1141 rules.unused_class_members,
1142 ),
1143 (
1144 "fallow/unused-store-member",
1145 "Store member is never referenced",
1146 rules.unused_store_members,
1147 ),
1148 (
1149 "fallow/unresolved-import",
1150 "Import could not be resolved",
1151 rules.unresolved_imports,
1152 ),
1153 (
1154 "fallow/unlisted-dependency",
1155 "Dependency used but not in package.json",
1156 rules.unlisted_dependencies,
1157 ),
1158 (
1159 "fallow/duplicate-export",
1160 "Export name appears in multiple modules",
1161 rules.duplicate_exports,
1162 ),
1163 ]
1164 .into()
1165}
1166
1167fn sarif_graph_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1168 let mut specs = sarif_cycle_rule_specs(rules);
1169 specs.extend(sarif_boundary_rule_specs(rules));
1170 specs.extend(sarif_framework_rule_specs(rules));
1171 specs.extend(sarif_component_rule_specs(rules));
1172 specs.push((
1173 "fallow/stale-suppression",
1174 "Suppression comment or tag no longer matches any issue",
1175 rules.stale_suppressions,
1176 ));
1177 specs.push((
1178 "fallow/missing-suppression-reason",
1179 "Suppression comment or tag is missing a required reason",
1180 rules.require_suppression_reason,
1181 ));
1182 specs
1183}
1184
1185fn sarif_cycle_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1186 vec![
1187 (
1188 "fallow/circular-dependency",
1189 "Circular dependency chain detected",
1190 rules.circular_dependencies,
1191 ),
1192 (
1193 "fallow/re-export-cycle",
1194 "Two or more barrel files re-export from each other in a loop",
1195 rules.re_export_cycle,
1196 ),
1197 ]
1198}
1199
1200fn sarif_boundary_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1201 vec![
1202 (
1203 "fallow/boundary-violation",
1204 "Import crosses an architecture boundary",
1205 rules.boundary_violation,
1206 ),
1207 (
1208 "fallow/boundary-coverage",
1209 "Source file matches no architecture boundary zone",
1210 rules.boundary_violation,
1211 ),
1212 (
1213 "fallow/boundary-call-violation",
1214 "Zoned file calls a callee its zone forbids",
1215 rules.boundary_violation,
1216 ),
1217 (
1218 "fallow/policy-violation",
1219 "Banned usage matched a rule-pack rule",
1220 rules.policy_violation,
1221 ),
1222 ]
1223}
1224
1225fn sarif_framework_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1226 vec![
1227 (
1228 "fallow/invalid-client-export",
1229 "\"use client\" file exports a server-only / route-config name",
1230 rules.invalid_client_export,
1231 ),
1232 (
1233 "fallow/mixed-client-server-barrel",
1234 "Barrel re-exports both a \"use client\" module and a server-only module",
1235 rules.mixed_client_server_barrel,
1236 ),
1237 (
1238 "fallow/misplaced-directive",
1239 "\"use client\" / \"use server\" directive is not in the leading position and is ignored",
1240 rules.misplaced_directive,
1241 ),
1242 ]
1243}
1244
1245fn sarif_component_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1246 vec![
1247 (
1248 "fallow/unprovided-inject",
1249 "A Vue inject / Svelte getContext whose key is provided nowhere in the project",
1250 rules.unprovided_injects,
1251 ),
1252 (
1253 "fallow/unrendered-component",
1254 "A Vue / Svelte component reachable through a barrel but rendered nowhere in the project",
1255 rules.unrendered_components,
1256 ),
1257 (
1258 "fallow/unused-component-prop",
1259 "A Vue <script setup> defineProps prop referenced nowhere inside its own component",
1260 rules.unused_component_props,
1261 ),
1262 (
1263 "fallow/unused-component-emit",
1264 "A Vue <script setup> defineEmits event emitted nowhere inside its own component",
1265 rules.unused_component_emits,
1266 ),
1267 (
1268 "fallow/unused-component-input",
1269 "An Angular @Input() / signal input() / model() input read nowhere inside its own component",
1270 rules.unused_component_inputs,
1271 ),
1272 (
1273 "fallow/unused-component-output",
1274 "An Angular @Output() / signal output() output emitted nowhere inside its own component",
1275 rules.unused_component_outputs,
1276 ),
1277 (
1278 "fallow/unused-svelte-event",
1279 "A Svelte component dispatching a createEventDispatcher event whose name is listened to nowhere in the project",
1280 rules.unused_svelte_events,
1281 ),
1282 (
1283 "fallow/unused-server-action",
1284 "A Next.js Server Action exported from a \"use server\" file that no code in the project references",
1285 rules.unused_server_actions,
1286 ),
1287 (
1288 "fallow/unused-load-data-key",
1289 "A SvelteKit load() return-object key that no consumer reads (sibling +page.svelte data.<key> or project-wide page.data.<key>)",
1290 rules.unused_load_data_keys,
1291 ),
1292 (
1293 "fallow/prop-drilling",
1294 "A React/Preact prop forwarded unchanged through 3+ pass-through components to a distant consumer",
1295 rules.prop_drilling,
1296 ),
1297 (
1298 "fallow/thin-wrapper",
1299 "A React/Preact component whose whole body is a single spread-forwarded child render (a candidate for inlining)",
1300 rules.thin_wrapper,
1301 ),
1302 (
1303 "fallow/duplicate-prop-shape",
1304 "Three or more React/Preact components across two or more files declare an identical prop-name set (a missing shared Props type)",
1305 rules.duplicate_prop_shape,
1306 ),
1307 (
1308 "fallow/route-collision",
1309 "Two or more Next.js App Router route files resolve to the same URL",
1310 rules.route_collision,
1311 ),
1312 (
1313 "fallow/dynamic-segment-name-conflict",
1314 "Sibling Next.js dynamic route segments use different slug names at the same position",
1315 rules.dynamic_segment_name_conflict,
1316 ),
1317 ]
1318}
1319
1320fn sarif_workspace_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1321 [
1322 (
1323 "fallow/unused-catalog-entry",
1324 "pnpm catalog entry not referenced by any workspace package",
1325 rules.unused_catalog_entries,
1326 ),
1327 (
1328 "fallow/empty-catalog-group",
1329 "pnpm named catalog group has no entries",
1330 rules.empty_catalog_groups,
1331 ),
1332 (
1333 "fallow/unresolved-catalog-reference",
1334 "package.json catalog reference points at a catalog that does not declare the package",
1335 rules.unresolved_catalog_references,
1336 ),
1337 (
1338 "fallow/unused-dependency-override",
1339 "pnpm dependency override target is not declared or lockfile-resolved",
1340 rules.unused_dependency_overrides,
1341 ),
1342 (
1343 "fallow/misconfigured-dependency-override",
1344 "pnpm dependency override key or value is malformed",
1345 rules.misconfigured_dependency_overrides,
1346 ),
1347 ]
1348 .into()
1349}
1350
1351#[must_use]
1352pub fn build_sarif(
1353 results: &AnalysisResults,
1354 root: &Path,
1355 rules: &RulesConfig,
1356) -> serde_json::Value {
1357 let mut sarif_results = Vec::new();
1358 let mut snippets = SourceSnippetCache::default();
1359
1360 push_primary_dead_code_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
1361 push_dependency_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
1362 push_member_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
1363 push_sarif_results(
1364 &mut sarif_results,
1365 &results.unresolved_imports,
1366 &mut snippets,
1367 |i| {
1368 sarif_unresolved_import_fields(
1369 &i.import,
1370 root,
1371 severity_to_sarif_level(rules.unresolved_imports),
1372 )
1373 },
1374 );
1375 push_misc_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
1376 push_graph_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
1377 push_catalog_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
1378
1379 let sarif_rules = build_sarif_rules(rules);
1380 sarif_document(&sarif_results, &sarif_rules)
1381}
1382
1383fn push_primary_dead_code_sarif_results(
1384 sarif_results: &mut Vec<serde_json::Value>,
1385 results: &AnalysisResults,
1386 root: &Path,
1387 rules: &RulesConfig,
1388 snippets: &mut SourceSnippetCache,
1389) {
1390 push_sarif_results(sarif_results, &results.unused_files, snippets, |finding| {
1391 sarif_unused_file_fields(
1392 &finding.file,
1393 root,
1394 severity_to_sarif_level(rules.unused_files),
1395 )
1396 });
1397 push_sarif_results(
1398 sarif_results,
1399 &results.unused_exports,
1400 snippets,
1401 |finding| {
1402 sarif_export_fields(
1403 &finding.export,
1404 root,
1405 "fallow/unused-export",
1406 severity_to_sarif_level(rules.unused_exports),
1407 "Export",
1408 "Re-export",
1409 )
1410 },
1411 );
1412 push_sarif_results(sarif_results, &results.unused_types, snippets, |finding| {
1413 sarif_export_fields(
1414 &finding.export,
1415 root,
1416 "fallow/unused-type",
1417 severity_to_sarif_level(rules.unused_types),
1418 "Type export",
1419 "Type re-export",
1420 )
1421 });
1422 push_sarif_results(
1423 sarif_results,
1424 &results.private_type_leaks,
1425 snippets,
1426 |finding| {
1427 sarif_private_type_leak_fields(
1428 &finding.leak,
1429 root,
1430 severity_to_sarif_level(rules.private_type_leaks),
1431 )
1432 },
1433 );
1434}
1435
1436fn sarif_document(
1437 sarif_results: &[serde_json::Value],
1438 sarif_rules: &[serde_json::Value],
1439) -> serde_json::Value {
1440 serde_json::json!({
1441 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1442 "version": "2.1.0",
1443 "runs": [{
1444 "tool": {
1445 "driver": {
1446 "name": "fallow",
1447 "version": env!("CARGO_PKG_VERSION"),
1448 "informationUri": "https://github.com/fallow-rs/fallow",
1449 "rules": sarif_rules
1450 }
1451 },
1452 "results": sarif_results
1453 }]
1454 })
1455}
1456
1457fn push_dependency_sarif_results(
1458 sarif_results: &mut Vec<serde_json::Value>,
1459 results: &AnalysisResults,
1460 root: &Path,
1461 rules: &RulesConfig,
1462 snippets: &mut SourceSnippetCache,
1463) {
1464 push_unused_dependency_sarif_results(sarif_results, results, root, rules, snippets);
1465 push_classified_dependency_sarif_results(sarif_results, results, root, rules, snippets);
1466}
1467
1468fn push_unused_dependency_sarif_results(
1470 sarif_results: &mut Vec<serde_json::Value>,
1471 results: &AnalysisResults,
1472 root: &Path,
1473 rules: &RulesConfig,
1474 snippets: &mut SourceSnippetCache,
1475) {
1476 push_sarif_results(sarif_results, &results.unused_dependencies, snippets, |d| {
1477 sarif_dep_fields(
1478 &d.dep,
1479 root,
1480 "fallow/unused-dependency",
1481 severity_to_sarif_level(rules.unused_dependencies),
1482 "dependencies",
1483 )
1484 });
1485 push_sarif_results(
1486 sarif_results,
1487 &results.unused_dev_dependencies,
1488 snippets,
1489 |d| {
1490 sarif_dep_fields(
1491 &d.dep,
1492 root,
1493 "fallow/unused-dev-dependency",
1494 severity_to_sarif_level(rules.unused_dev_dependencies),
1495 "devDependencies",
1496 )
1497 },
1498 );
1499 push_sarif_results(
1500 sarif_results,
1501 &results.unused_optional_dependencies,
1502 snippets,
1503 |d| {
1504 sarif_dep_fields(
1505 &d.dep,
1506 root,
1507 "fallow/unused-optional-dependency",
1508 severity_to_sarif_level(rules.unused_optional_dependencies),
1509 "optionalDependencies",
1510 )
1511 },
1512 );
1513}
1514
1515fn push_classified_dependency_sarif_results(
1517 sarif_results: &mut Vec<serde_json::Value>,
1518 results: &AnalysisResults,
1519 root: &Path,
1520 rules: &RulesConfig,
1521 snippets: &mut SourceSnippetCache,
1522) {
1523 push_sarif_results(
1524 sarif_results,
1525 &results.type_only_dependencies,
1526 snippets,
1527 |d| {
1528 sarif_type_only_dep_fields(
1529 &d.dep,
1530 root,
1531 severity_to_sarif_level(rules.type_only_dependencies),
1532 )
1533 },
1534 );
1535 push_sarif_results(
1536 sarif_results,
1537 &results.test_only_dependencies,
1538 snippets,
1539 |d| {
1540 sarif_test_only_dep_fields(
1541 &d.dep,
1542 root,
1543 severity_to_sarif_level(rules.test_only_dependencies),
1544 )
1545 },
1546 );
1547}
1548
1549fn push_member_sarif_results(
1550 sarif_results: &mut Vec<serde_json::Value>,
1551 results: &AnalysisResults,
1552 root: &Path,
1553 rules: &RulesConfig,
1554 snippets: &mut SourceSnippetCache,
1555) {
1556 push_sarif_results(sarif_results, &results.unused_enum_members, snippets, |m| {
1557 sarif_member_fields(
1558 &m.member,
1559 root,
1560 "fallow/unused-enum-member",
1561 severity_to_sarif_level(rules.unused_enum_members),
1562 "Enum",
1563 )
1564 });
1565 push_sarif_results(
1566 sarif_results,
1567 &results.unused_class_members,
1568 snippets,
1569 |m| {
1570 sarif_member_fields(
1571 &m.member,
1572 root,
1573 "fallow/unused-class-member",
1574 severity_to_sarif_level(rules.unused_class_members),
1575 "Class",
1576 )
1577 },
1578 );
1579 push_sarif_results(
1580 sarif_results,
1581 &results.unused_store_members,
1582 snippets,
1583 |m| {
1584 sarif_member_fields(
1585 &m.member,
1586 root,
1587 "fallow/unused-store-member",
1588 severity_to_sarif_level(rules.unused_store_members),
1589 "Store",
1590 )
1591 },
1592 );
1593}
1594
1595fn push_misc_sarif_results(
1596 sarif_results: &mut Vec<serde_json::Value>,
1597 results: &AnalysisResults,
1598 root: &Path,
1599 rules: &RulesConfig,
1600 snippets: &mut SourceSnippetCache,
1601) {
1602 if !results.unlisted_dependencies.is_empty() {
1603 push_sarif_unlisted_deps(
1604 sarif_results,
1605 &results.unlisted_dependencies,
1606 root,
1607 severity_to_sarif_level(rules.unlisted_dependencies),
1608 snippets,
1609 );
1610 }
1611 if !results.duplicate_exports.is_empty() {
1612 push_sarif_duplicate_exports(
1613 sarif_results,
1614 &results.duplicate_exports,
1615 root,
1616 severity_to_sarif_level(rules.duplicate_exports),
1617 snippets,
1618 );
1619 }
1620}
1621
1622fn push_component_contract_sarif_results(
1626 sarif_results: &mut Vec<serde_json::Value>,
1627 results: &AnalysisResults,
1628 root: &Path,
1629 rules: &RulesConfig,
1630 snippets: &mut SourceSnippetCache,
1631) {
1632 push_component_member_sarif_results(sarif_results, results, root, rules, snippets);
1633 push_component_framework_sarif_results(sarif_results, results, root, rules, snippets);
1634 push_component_shape_sarif_results(sarif_results, results, root, rules, snippets);
1635}
1636
1637fn push_component_member_sarif_results(
1639 sarif_results: &mut Vec<serde_json::Value>,
1640 results: &AnalysisResults,
1641 root: &Path,
1642 rules: &RulesConfig,
1643 snippets: &mut SourceSnippetCache,
1644) {
1645 push_sarif_results(
1646 sarif_results,
1647 &results.unused_component_props,
1648 snippets,
1649 |p| {
1650 sarif_unused_component_prop_fields(
1651 &p.prop,
1652 root,
1653 severity_to_sarif_level(rules.unused_component_props),
1654 )
1655 },
1656 );
1657 push_sarif_results(
1658 sarif_results,
1659 &results.unused_component_emits,
1660 snippets,
1661 |e| {
1662 sarif_unused_component_emit_fields(
1663 &e.emit,
1664 root,
1665 severity_to_sarif_level(rules.unused_component_emits),
1666 )
1667 },
1668 );
1669 push_sarif_results(
1670 sarif_results,
1671 &results.unused_component_inputs,
1672 snippets,
1673 |i| {
1674 sarif_unused_component_input_fields(
1675 &i.input,
1676 root,
1677 severity_to_sarif_level(rules.unused_component_inputs),
1678 )
1679 },
1680 );
1681 push_sarif_results(
1682 sarif_results,
1683 &results.unused_component_outputs,
1684 snippets,
1685 |o| {
1686 sarif_unused_component_output_fields(
1687 &o.output,
1688 root,
1689 severity_to_sarif_level(rules.unused_component_outputs),
1690 )
1691 },
1692 );
1693}
1694
1695fn push_component_framework_sarif_results(
1697 sarif_results: &mut Vec<serde_json::Value>,
1698 results: &AnalysisResults,
1699 root: &Path,
1700 rules: &RulesConfig,
1701 snippets: &mut SourceSnippetCache,
1702) {
1703 push_sarif_results(
1704 sarif_results,
1705 &results.unused_svelte_events,
1706 snippets,
1707 |e| {
1708 sarif_unused_svelte_event_fields(
1709 &e.event,
1710 root,
1711 severity_to_sarif_level(rules.unused_svelte_events),
1712 )
1713 },
1714 );
1715 push_sarif_results(
1716 sarif_results,
1717 &results.unused_server_actions,
1718 snippets,
1719 |a| {
1720 sarif_unused_server_action_fields(
1721 &a.action,
1722 root,
1723 severity_to_sarif_level(rules.unused_server_actions),
1724 )
1725 },
1726 );
1727 push_sarif_results(
1728 sarif_results,
1729 &results.unused_load_data_keys,
1730 snippets,
1731 |k| {
1732 sarif_unused_load_data_key_fields(
1733 &k.key,
1734 root,
1735 severity_to_sarif_level(rules.unused_load_data_keys),
1736 )
1737 },
1738 );
1739}
1740
1741fn push_component_shape_sarif_results(
1743 sarif_results: &mut Vec<serde_json::Value>,
1744 results: &AnalysisResults,
1745 root: &Path,
1746 rules: &RulesConfig,
1747 snippets: &mut SourceSnippetCache,
1748) {
1749 push_sarif_results(
1750 sarif_results,
1751 &results.prop_drilling_chains,
1752 snippets,
1753 |c| {
1754 sarif_prop_drilling_fields(&c.chain, root, severity_to_sarif_level(rules.prop_drilling))
1755 },
1756 );
1757 push_sarif_results(sarif_results, &results.thin_wrappers, snippets, |w| {
1758 sarif_thin_wrapper_fields(
1759 &w.wrapper,
1760 root,
1761 severity_to_sarif_level(rules.thin_wrapper),
1762 )
1763 });
1764 push_sarif_results(
1765 sarif_results,
1766 &results.duplicate_prop_shapes,
1767 snippets,
1768 |d| {
1769 sarif_duplicate_prop_shape_fields(
1770 &d.shape,
1771 root,
1772 severity_to_sarif_level(rules.duplicate_prop_shape),
1773 )
1774 },
1775 );
1776}
1777
1778fn push_graph_sarif_results(
1779 sarif_results: &mut Vec<serde_json::Value>,
1780 results: &AnalysisResults,
1781 root: &Path,
1782 rules: &RulesConfig,
1783 snippets: &mut SourceSnippetCache,
1784) {
1785 push_structure_sarif_results(sarif_results, results, root, rules, snippets);
1786 push_framework_sarif_results(sarif_results, results, root, rules, snippets);
1787 push_route_sarif_results(sarif_results, results, root, rules, snippets);
1788 push_suppression_sarif_results(sarif_results, results, root, rules, snippets);
1789}
1790
1791fn push_structure_sarif_results(
1792 sarif_results: &mut Vec<serde_json::Value>,
1793 results: &AnalysisResults,
1794 root: &Path,
1795 rules: &RulesConfig,
1796 snippets: &mut SourceSnippetCache,
1797) {
1798 push_cycle_sarif_results(sarif_results, results, root, rules, snippets);
1799 push_boundary_sarif_results(sarif_results, results, root, rules, snippets);
1800}
1801
1802fn push_cycle_sarif_results(
1804 sarif_results: &mut Vec<serde_json::Value>,
1805 results: &AnalysisResults,
1806 root: &Path,
1807 rules: &RulesConfig,
1808 snippets: &mut SourceSnippetCache,
1809) {
1810 push_sarif_results(
1811 sarif_results,
1812 &results.circular_dependencies,
1813 snippets,
1814 |c| {
1815 sarif_circular_dep_fields(
1816 &c.cycle,
1817 root,
1818 severity_to_sarif_level(rules.circular_dependencies),
1819 )
1820 },
1821 );
1822 push_sarif_results(sarif_results, &results.re_export_cycles, snippets, |c| {
1823 sarif_re_export_cycle_fields(
1824 &c.cycle,
1825 root,
1826 severity_to_sarif_level(rules.re_export_cycle),
1827 )
1828 });
1829}
1830
1831fn push_boundary_sarif_results(
1833 sarif_results: &mut Vec<serde_json::Value>,
1834 results: &AnalysisResults,
1835 root: &Path,
1836 rules: &RulesConfig,
1837 snippets: &mut SourceSnippetCache,
1838) {
1839 push_sarif_results(sarif_results, &results.boundary_violations, snippets, |v| {
1840 sarif_boundary_violation_fields(
1841 &v.violation,
1842 root,
1843 severity_to_sarif_level(rules.boundary_violation),
1844 )
1845 });
1846 push_sarif_results(
1847 sarif_results,
1848 &results.boundary_coverage_violations,
1849 snippets,
1850 |v| {
1851 sarif_boundary_coverage_fields(
1852 &v.violation,
1853 root,
1854 severity_to_sarif_level(rules.boundary_violation),
1855 )
1856 },
1857 );
1858 push_sarif_results(
1859 sarif_results,
1860 &results.boundary_call_violations,
1861 snippets,
1862 |v| {
1863 sarif_boundary_call_fields(
1864 &v.violation,
1865 root,
1866 severity_to_sarif_level(rules.boundary_violation),
1867 )
1868 },
1869 );
1870 push_sarif_results(sarif_results, &results.policy_violations, snippets, |v| {
1871 sarif_policy_violation_fields(&v.violation, root)
1872 });
1873}
1874
1875fn push_framework_sarif_results(
1876 sarif_results: &mut Vec<serde_json::Value>,
1877 results: &AnalysisResults,
1878 root: &Path,
1879 rules: &RulesConfig,
1880 snippets: &mut SourceSnippetCache,
1881) {
1882 push_framework_boundary_sarif_results(sarif_results, results, root, rules, snippets);
1883 push_component_contract_sarif_results(sarif_results, results, root, rules, snippets);
1884}
1885
1886fn push_framework_boundary_sarif_results(
1888 sarif_results: &mut Vec<serde_json::Value>,
1889 results: &AnalysisResults,
1890 root: &Path,
1891 rules: &RulesConfig,
1892 snippets: &mut SourceSnippetCache,
1893) {
1894 push_sarif_results(
1895 sarif_results,
1896 &results.invalid_client_exports,
1897 snippets,
1898 |e| {
1899 sarif_invalid_client_export_fields(
1900 &e.export,
1901 root,
1902 severity_to_sarif_level(rules.invalid_client_export),
1903 )
1904 },
1905 );
1906 push_sarif_results(
1907 sarif_results,
1908 &results.mixed_client_server_barrels,
1909 snippets,
1910 |b| {
1911 sarif_mixed_client_server_barrel_fields(
1912 &b.barrel,
1913 root,
1914 severity_to_sarif_level(rules.mixed_client_server_barrel),
1915 )
1916 },
1917 );
1918 push_sarif_results(
1919 sarif_results,
1920 &results.misplaced_directives,
1921 snippets,
1922 |d| {
1923 sarif_misplaced_directive_fields(
1924 &d.directive_site,
1925 root,
1926 severity_to_sarif_level(rules.misplaced_directive),
1927 )
1928 },
1929 );
1930 push_sarif_results(sarif_results, &results.unprovided_injects, snippets, |i| {
1931 sarif_unprovided_inject_fields(
1932 &i.inject,
1933 root,
1934 severity_to_sarif_level(rules.unprovided_injects),
1935 )
1936 });
1937 push_sarif_results(
1938 sarif_results,
1939 &results.unrendered_components,
1940 snippets,
1941 |c| {
1942 sarif_unrendered_component_fields(
1943 &c.component,
1944 root,
1945 severity_to_sarif_level(rules.unrendered_components),
1946 )
1947 },
1948 );
1949}
1950
1951fn push_route_sarif_results(
1952 sarif_results: &mut Vec<serde_json::Value>,
1953 results: &AnalysisResults,
1954 root: &Path,
1955 rules: &RulesConfig,
1956 snippets: &mut SourceSnippetCache,
1957) {
1958 push_sarif_results(sarif_results, &results.route_collisions, snippets, |c| {
1959 sarif_route_collision_fields(
1960 &c.collision,
1961 root,
1962 severity_to_sarif_level(rules.route_collision),
1963 )
1964 });
1965 push_sarif_results(
1966 sarif_results,
1967 &results.dynamic_segment_name_conflicts,
1968 snippets,
1969 |c| {
1970 sarif_dynamic_segment_name_conflict_fields(
1971 &c.conflict,
1972 root,
1973 severity_to_sarif_level(rules.dynamic_segment_name_conflict),
1974 )
1975 },
1976 );
1977}
1978
1979fn push_suppression_sarif_results(
1980 sarif_results: &mut Vec<serde_json::Value>,
1981 results: &AnalysisResults,
1982 root: &Path,
1983 rules: &RulesConfig,
1984 snippets: &mut SourceSnippetCache,
1985) {
1986 push_sarif_results(sarif_results, &results.stale_suppressions, snippets, |s| {
1987 sarif_stale_suppression_fields(
1988 s,
1989 root,
1990 severity_to_sarif_level(stale_suppression_severity(s, rules)),
1991 )
1992 });
1993}
1994
1995fn push_catalog_sarif_results(
1996 sarif_results: &mut Vec<serde_json::Value>,
1997 results: &AnalysisResults,
1998 root: &Path,
1999 rules: &RulesConfig,
2000 snippets: &mut SourceSnippetCache,
2001) {
2002 push_catalog_entry_sarif_results(sarif_results, results, root, rules, snippets);
2003 push_dependency_override_sarif_results(sarif_results, results, root, rules, snippets);
2004}
2005
2006fn push_catalog_entry_sarif_results(
2008 sarif_results: &mut Vec<serde_json::Value>,
2009 results: &AnalysisResults,
2010 root: &Path,
2011 rules: &RulesConfig,
2012 snippets: &mut SourceSnippetCache,
2013) {
2014 push_sarif_results(
2015 sarif_results,
2016 &results.unused_catalog_entries,
2017 snippets,
2018 |e| {
2019 sarif_unused_catalog_entry_fields(
2020 e,
2021 root,
2022 severity_to_sarif_level(rules.unused_catalog_entries),
2023 )
2024 },
2025 );
2026 push_sarif_results(
2027 sarif_results,
2028 &results.empty_catalog_groups,
2029 snippets,
2030 |g| {
2031 sarif_empty_catalog_group_fields(
2032 g,
2033 root,
2034 severity_to_sarif_level(rules.empty_catalog_groups),
2035 )
2036 },
2037 );
2038 push_sarif_results(
2039 sarif_results,
2040 &results.unresolved_catalog_references,
2041 snippets,
2042 |f| {
2043 sarif_unresolved_catalog_reference_fields(
2044 f,
2045 root,
2046 severity_to_sarif_level(rules.unresolved_catalog_references),
2047 )
2048 },
2049 );
2050}
2051
2052fn push_dependency_override_sarif_results(
2054 sarif_results: &mut Vec<serde_json::Value>,
2055 results: &AnalysisResults,
2056 root: &Path,
2057 rules: &RulesConfig,
2058 snippets: &mut SourceSnippetCache,
2059) {
2060 push_sarif_results(
2061 sarif_results,
2062 &results.unused_dependency_overrides,
2063 snippets,
2064 |f| {
2065 sarif_unused_dependency_override_fields(
2066 f,
2067 root,
2068 severity_to_sarif_level(rules.unused_dependency_overrides),
2069 )
2070 },
2071 );
2072 push_sarif_results(
2073 sarif_results,
2074 &results.misconfigured_dependency_overrides,
2075 snippets,
2076 |f| {
2077 sarif_misconfigured_dependency_override_fields(
2078 f,
2079 root,
2080 severity_to_sarif_level(rules.misconfigured_dependency_overrides),
2081 )
2082 },
2083 );
2084}
2085
2086pub(super) fn print_sarif(results: &AnalysisResults, root: &Path, rules: &RulesConfig) -> ExitCode {
2087 let sarif = build_sarif(results, root, rules);
2088 emit_json(&sarif, "SARIF")
2089}
2090
2091#[expect(
2097 clippy::expect_used,
2098 reason = "grouped SARIF entries are JSON objects created by build_sarif"
2099)]
2100pub(super) fn print_grouped_sarif(
2101 results: &AnalysisResults,
2102 root: &Path,
2103 rules: &RulesConfig,
2104 resolver: &OwnershipResolver,
2105) -> ExitCode {
2106 let mut sarif = build_sarif(results, root, rules);
2107
2108 if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
2109 for run in runs {
2110 if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
2111 for result in results {
2112 let uri = result
2113 .pointer("/locations/0/physicalLocation/artifactLocation/uri")
2114 .and_then(|v| v.as_str())
2115 .unwrap_or("");
2116 let decoded = uri.replace("%5B", "[").replace("%5D", "]");
2117 let owner =
2118 grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
2119 let props = result
2120 .as_object_mut()
2121 .expect("SARIF result should be an object")
2122 .entry("properties")
2123 .or_insert_with(|| serde_json::json!({}));
2124 props
2125 .as_object_mut()
2126 .expect("properties should be an object")
2127 .insert("owner".to_string(), serde_json::Value::String(owner));
2128 }
2129 }
2130 }
2131 }
2132
2133 emit_json(&sarif, "SARIF")
2134}
2135
2136#[expect(
2137 clippy::cast_possible_truncation,
2138 reason = "line/col numbers are bounded by source size"
2139)]
2140pub(super) fn print_duplication_sarif(report: &DuplicationReport, root: &Path) -> ExitCode {
2141 let mut sarif_results = Vec::new();
2142 let mut snippets = SourceSnippetCache::default();
2143
2144 for (i, group) in report.clone_groups.iter().enumerate() {
2145 for instance in &group.instances {
2146 let uri = relative_uri(&instance.file, root);
2147 let source_snippet = snippets.line(&instance.file, instance.start_line as u32);
2148 sarif_results.push(sarif_result_with_snippet(
2149 "fallow/code-duplication",
2150 "warning",
2151 &format!(
2152 "Code clone group {} ({} lines, {} instances)",
2153 i + 1,
2154 group.line_count,
2155 group.instances.len()
2156 ),
2157 &uri,
2158 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
2159 source_snippet.as_deref(),
2160 ));
2161 }
2162 }
2163
2164 let sarif = serde_json::json!({
2165 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
2166 "version": "2.1.0",
2167 "runs": [{
2168 "tool": {
2169 "driver": {
2170 "name": "fallow",
2171 "version": env!("CARGO_PKG_VERSION"),
2172 "informationUri": "https://github.com/fallow-rs/fallow",
2173 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
2174 }
2175 },
2176 "results": sarif_results
2177 }]
2178 });
2179
2180 emit_json(&sarif, "SARIF")
2181}
2182
2183#[expect(
2194 clippy::cast_possible_truncation,
2195 reason = "line/col numbers are bounded by source size"
2196)]
2197#[expect(
2198 clippy::expect_used,
2199 reason = "duplication SARIF entries are JSON objects created by sarif_result_with_snippet"
2200)]
2201pub(super) fn print_grouped_duplication_sarif(
2202 report: &DuplicationReport,
2203 root: &Path,
2204 resolver: &OwnershipResolver,
2205) -> ExitCode {
2206 let mut sarif_results = Vec::new();
2207 let mut snippets = SourceSnippetCache::default();
2208
2209 for (i, group) in report.clone_groups.iter().enumerate() {
2210 let primary_owner = super::dupes_grouping::largest_owner(group, root, resolver);
2211 for instance in &group.instances {
2212 let uri = relative_uri(&instance.file, root);
2213 let source_snippet = snippets.line(&instance.file, instance.start_line as u32);
2214 let mut result = sarif_result_with_snippet(
2215 "fallow/code-duplication",
2216 "warning",
2217 &format!(
2218 "Code clone group {} ({} lines, {} instances)",
2219 i + 1,
2220 group.line_count,
2221 group.instances.len()
2222 ),
2223 &uri,
2224 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
2225 source_snippet.as_deref(),
2226 );
2227 let props = result
2228 .as_object_mut()
2229 .expect("SARIF result should be an object")
2230 .entry("properties")
2231 .or_insert_with(|| serde_json::json!({}));
2232 props
2233 .as_object_mut()
2234 .expect("properties should be an object")
2235 .insert(
2236 "group".to_string(),
2237 serde_json::Value::String(primary_owner.clone()),
2238 );
2239 sarif_results.push(result);
2240 }
2241 }
2242
2243 let sarif = serde_json::json!({
2244 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
2245 "version": "2.1.0",
2246 "runs": [{
2247 "tool": {
2248 "driver": {
2249 "name": "fallow",
2250 "version": env!("CARGO_PKG_VERSION"),
2251 "informationUri": "https://github.com/fallow-rs/fallow",
2252 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
2253 }
2254 },
2255 "results": sarif_results
2256 }]
2257 });
2258
2259 emit_json(&sarif, "SARIF")
2260}
2261
2262#[must_use]
2263pub fn build_health_sarif(
2264 report: &crate::health_types::HealthReport,
2265 root: &Path,
2266) -> serde_json::Value {
2267 let mut sarif_results = Vec::new();
2268 let mut snippets = SourceSnippetCache::default();
2269
2270 append_health_sarif_results(report, root, &mut sarif_results, &mut snippets);
2271 let health_rules = health_sarif_rules();
2272 health_sarif_document(&sarif_results, &health_rules)
2273}
2274
2275fn append_health_sarif_results(
2276 report: &crate::health_types::HealthReport,
2277 root: &Path,
2278 sarif_results: &mut Vec<serde_json::Value>,
2279 snippets: &mut SourceSnippetCache,
2280) {
2281 append_complexity_sarif_results(sarif_results, report, root, snippets);
2282
2283 if let Some(ref production) = report.runtime_coverage {
2284 append_runtime_coverage_sarif_results(sarif_results, production, root, snippets);
2285 }
2286 if let Some(ref intelligence) = report.coverage_intelligence {
2287 append_coverage_intelligence_sarif_results(sarif_results, intelligence, root, snippets);
2288 }
2289
2290 append_refactoring_target_sarif_results(sarif_results, report, root);
2291 append_coverage_gap_sarif_results(sarif_results, report, root, snippets);
2292}
2293
2294fn health_sarif_rules() -> Vec<serde_json::Value> {
2295 let mut rules = health_complexity_sarif_rules();
2296 rules.extend(health_runtime_sarif_rules());
2297 rules.extend(health_coverage_intelligence_sarif_rules());
2298 rules
2299}
2300
2301fn health_complexity_sarif_rules() -> Vec<serde_json::Value> {
2302 vec![
2303 sarif_rule(
2304 "fallow/high-cyclomatic-complexity",
2305 "Function has high cyclomatic complexity",
2306 "note",
2307 ),
2308 sarif_rule(
2309 "fallow/high-cognitive-complexity",
2310 "Function has high cognitive complexity",
2311 "note",
2312 ),
2313 sarif_rule(
2314 "fallow/high-complexity",
2315 "Function exceeds both complexity thresholds",
2316 "note",
2317 ),
2318 sarif_rule(
2319 "fallow/high-crap-score",
2320 "Function has a high CRAP score (high complexity combined with low coverage)",
2321 "warning",
2322 ),
2323 sarif_rule(
2324 "fallow/refactoring-target",
2325 "File identified as a high-priority refactoring candidate",
2326 "warning",
2327 ),
2328 ]
2329}
2330
2331fn health_runtime_sarif_rules() -> Vec<serde_json::Value> {
2332 vec![
2333 sarif_rule(
2334 "fallow/untested-file",
2335 "Runtime-reachable file has no test dependency path",
2336 "warning",
2337 ),
2338 sarif_rule(
2339 "fallow/untested-export",
2340 "Runtime-reachable export has no test dependency path",
2341 "warning",
2342 ),
2343 sarif_rule(
2344 "fallow/runtime-safe-to-delete",
2345 "Function is statically unused and was never invoked in production",
2346 "warning",
2347 ),
2348 sarif_rule(
2349 "fallow/runtime-review-required",
2350 "Function is statically used but was never invoked in production",
2351 "warning",
2352 ),
2353 sarif_rule(
2354 "fallow/runtime-low-traffic",
2355 "Function was invoked below the low-traffic threshold relative to total trace count",
2356 "note",
2357 ),
2358 sarif_rule(
2359 "fallow/runtime-coverage-unavailable",
2360 "Runtime coverage could not be resolved for this function",
2361 "note",
2362 ),
2363 sarif_rule(
2364 "fallow/runtime-coverage",
2365 "Runtime coverage finding",
2366 "note",
2367 ),
2368 ]
2369}
2370
2371fn health_coverage_intelligence_sarif_rules() -> Vec<serde_json::Value> {
2372 vec![
2373 sarif_rule(
2374 "fallow/coverage-intelligence-risky-change",
2375 "Changed hot path combines high CRAP and low test coverage",
2376 "warning",
2377 ),
2378 sarif_rule(
2379 "fallow/coverage-intelligence-delete",
2380 "Static and runtime evidence indicate code can be deleted",
2381 "warning",
2382 ),
2383 sarif_rule(
2384 "fallow/coverage-intelligence-review",
2385 "Cold reachable uncovered code needs owner review",
2386 "warning",
2387 ),
2388 sarif_rule(
2389 "fallow/coverage-intelligence-refactor",
2390 "Hot covered code has high CRAP and should be refactored carefully",
2391 "warning",
2392 ),
2393 ]
2394}
2395
2396fn health_sarif_document(
2397 sarif_results: &[serde_json::Value],
2398 health_rules: &[serde_json::Value],
2399) -> serde_json::Value {
2400 serde_json::json!({
2401 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
2402 "version": "2.1.0",
2403 "runs": [{
2404 "tool": {
2405 "driver": {
2406 "name": "fallow",
2407 "version": env!("CARGO_PKG_VERSION"),
2408 "informationUri": "https://github.com/fallow-rs/fallow",
2409 "rules": health_rules
2410 }
2411 },
2412 "results": sarif_results
2413 }]
2414 })
2415}
2416
2417fn append_complexity_sarif_results(
2418 sarif_results: &mut Vec<serde_json::Value>,
2419 report: &crate::health_types::HealthReport,
2420 root: &Path,
2421 snippets: &mut SourceSnippetCache,
2422) {
2423 for finding in &report.findings {
2424 let uri = relative_uri(&finding.path, root);
2425 let (rule_id, message) = health_complexity_sarif_message(finding, report);
2426 let level = match finding.severity {
2427 crate::health_types::FindingSeverity::Critical => "error",
2428 crate::health_types::FindingSeverity::High => "warning",
2429 crate::health_types::FindingSeverity::Moderate => "note",
2430 };
2431 let source_snippet = snippets.line(&finding.path, finding.line);
2432 sarif_results.push(sarif_result_with_snippet(
2433 rule_id,
2434 level,
2435 &message,
2436 &uri,
2437 Some((finding.line, finding.col + 1)),
2438 source_snippet.as_deref(),
2439 ));
2440 }
2441}
2442
2443fn health_complexity_sarif_message(
2444 finding: &crate::health_types::ComplexityViolation,
2445 report: &crate::health_types::HealthReport,
2446) -> (&'static str, String) {
2447 match finding.exceeded {
2448 crate::health_types::ExceededThreshold::Cyclomatic => (
2449 "fallow/high-cyclomatic-complexity",
2450 format!(
2451 "'{}' has cyclomatic complexity {} (threshold: {})",
2452 finding.name, finding.cyclomatic, report.summary.max_cyclomatic_threshold,
2453 ),
2454 ),
2455 crate::health_types::ExceededThreshold::Cognitive => (
2456 "fallow/high-cognitive-complexity",
2457 format!(
2458 "'{}' has cognitive complexity {} (threshold: {})",
2459 finding.name, finding.cognitive, report.summary.max_cognitive_threshold,
2460 ),
2461 ),
2462 crate::health_types::ExceededThreshold::Both => (
2463 "fallow/high-complexity",
2464 format!(
2465 "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
2466 finding.name,
2467 finding.cyclomatic,
2468 report.summary.max_cyclomatic_threshold,
2469 finding.cognitive,
2470 report.summary.max_cognitive_threshold,
2471 ),
2472 ),
2473 crate::health_types::ExceededThreshold::Crap
2474 | crate::health_types::ExceededThreshold::CyclomaticCrap
2475 | crate::health_types::ExceededThreshold::CognitiveCrap
2476 | crate::health_types::ExceededThreshold::All => {
2477 let crap = finding.crap.unwrap_or(0.0);
2478 let coverage = finding
2479 .coverage_pct
2480 .map(|pct| format!(", coverage {pct:.0}%"))
2481 .unwrap_or_default();
2482 (
2483 "fallow/high-crap-score",
2484 format!(
2485 "'{}' has CRAP score {:.1} (threshold: {:.1}, cyclomatic {}{})",
2486 finding.name,
2487 crap,
2488 report.summary.max_crap_threshold,
2489 finding.cyclomatic,
2490 coverage,
2491 ),
2492 )
2493 }
2494 }
2495}
2496
2497fn append_refactoring_target_sarif_results(
2498 sarif_results: &mut Vec<serde_json::Value>,
2499 report: &crate::health_types::HealthReport,
2500 root: &Path,
2501) {
2502 for target in &report.targets {
2503 let uri = relative_uri(&target.path, root);
2504 let message = format!(
2505 "[{}] {} (priority: {:.1}, efficiency: {:.1}, effort: {}, confidence: {})",
2506 target.category.label(),
2507 target.recommendation,
2508 target.priority,
2509 target.efficiency,
2510 target.effort.label(),
2511 target.confidence.label(),
2512 );
2513 sarif_results.push(sarif_result(
2514 "fallow/refactoring-target",
2515 "warning",
2516 &message,
2517 &uri,
2518 None,
2519 ));
2520 }
2521}
2522
2523fn append_coverage_gap_sarif_results(
2524 sarif_results: &mut Vec<serde_json::Value>,
2525 report: &crate::health_types::HealthReport,
2526 root: &Path,
2527 snippets: &mut SourceSnippetCache,
2528) {
2529 let Some(ref gaps) = report.coverage_gaps else {
2530 return;
2531 };
2532 for item in &gaps.files {
2533 let uri = relative_uri(&item.file.path, root);
2534 let message = format!(
2535 "File is runtime-reachable but has no test dependency path ({} value export{})",
2536 item.file.value_export_count,
2537 if item.file.value_export_count == 1 {
2538 ""
2539 } else {
2540 "s"
2541 },
2542 );
2543 sarif_results.push(sarif_result(
2544 "fallow/untested-file",
2545 "warning",
2546 &message,
2547 &uri,
2548 None,
2549 ));
2550 }
2551
2552 for item in &gaps.exports {
2553 let uri = relative_uri(&item.export.path, root);
2554 let message = format!(
2555 "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
2556 item.export.export_name
2557 );
2558 let source_snippet = snippets.line(&item.export.path, item.export.line);
2559 sarif_results.push(sarif_result_with_snippet(
2560 "fallow/untested-export",
2561 "warning",
2562 &message,
2563 &uri,
2564 Some((item.export.line, item.export.col + 1)),
2565 source_snippet.as_deref(),
2566 ));
2567 }
2568}
2569
2570fn append_runtime_coverage_sarif_results(
2571 sarif_results: &mut Vec<serde_json::Value>,
2572 production: &crate::health_types::RuntimeCoverageReport,
2573 root: &Path,
2574 snippets: &mut SourceSnippetCache,
2575) {
2576 for finding in &production.findings {
2577 let uri = relative_uri(&finding.path, root);
2578 let rule_id = match finding.verdict {
2579 crate::health_types::RuntimeCoverageVerdict::SafeToDelete => {
2580 "fallow/runtime-safe-to-delete"
2581 }
2582 crate::health_types::RuntimeCoverageVerdict::ReviewRequired => {
2583 "fallow/runtime-review-required"
2584 }
2585 crate::health_types::RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
2586 crate::health_types::RuntimeCoverageVerdict::CoverageUnavailable => {
2587 "fallow/runtime-coverage-unavailable"
2588 }
2589 crate::health_types::RuntimeCoverageVerdict::Active
2590 | crate::health_types::RuntimeCoverageVerdict::Unknown => "fallow/runtime-coverage",
2591 };
2592 let level = match finding.verdict {
2593 crate::health_types::RuntimeCoverageVerdict::SafeToDelete
2594 | crate::health_types::RuntimeCoverageVerdict::ReviewRequired => "warning",
2595 _ => "note",
2596 };
2597 let invocations_hint = finding.invocations.map_or_else(
2598 || "untracked".to_owned(),
2599 |hits| format!("{hits} invocations"),
2600 );
2601 let message = format!(
2602 "'{}' runtime coverage verdict: {} ({})",
2603 finding.function,
2604 finding.verdict.human_label(),
2605 invocations_hint,
2606 );
2607 let source_snippet = snippets.line(&finding.path, finding.line);
2608 sarif_results.push(sarif_result_with_snippet(
2609 rule_id,
2610 level,
2611 &message,
2612 &uri,
2613 Some((finding.line, 1)),
2614 source_snippet.as_deref(),
2615 ));
2616 }
2617}
2618
2619fn append_coverage_intelligence_sarif_results(
2620 sarif_results: &mut Vec<serde_json::Value>,
2621 intelligence: &crate::health_types::CoverageIntelligenceReport,
2622 root: &Path,
2623 snippets: &mut SourceSnippetCache,
2624) {
2625 for finding in &intelligence.findings {
2626 let rule_id = coverage_intelligence_rule_id(finding.recommendation);
2627 let level = match finding.verdict {
2628 crate::health_types::CoverageIntelligenceVerdict::Clean
2629 | crate::health_types::CoverageIntelligenceVerdict::Unknown => continue,
2630 _ => "warning",
2631 };
2632 let uri = relative_uri(&finding.path, root);
2633 let identity = finding.identity.as_deref().unwrap_or("code");
2634 let signals = finding
2635 .signals
2636 .iter()
2637 .map(ToString::to_string)
2638 .collect::<Vec<_>>()
2639 .join(", ");
2640 let message = format!(
2641 "'{}' coverage intelligence verdict: {} ({}, signals: {})",
2642 identity, finding.verdict, finding.recommendation, signals,
2643 );
2644 let source_snippet = snippets.line(&finding.path, finding.line);
2645 let mut result = sarif_result_with_snippet(
2646 rule_id,
2647 level,
2648 &message,
2649 &uri,
2650 Some((finding.line, 1)),
2651 source_snippet.as_deref(),
2652 );
2653 result["properties"] = serde_json::json!({
2654 "coverage_intelligence_id": &finding.id,
2655 "verdict": finding.verdict,
2656 "recommendation": finding.recommendation,
2657 "confidence": finding.confidence,
2658 "signals": &finding.signals,
2659 "related_ids": &finding.related_ids,
2660 });
2661 sarif_results.push(result);
2662 }
2663}
2664
2665fn coverage_intelligence_rule_id(
2666 recommendation: crate::health_types::CoverageIntelligenceRecommendation,
2667) -> &'static str {
2668 match recommendation {
2669 crate::health_types::CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge => {
2670 "fallow/coverage-intelligence-risky-change"
2671 }
2672 crate::health_types::CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner => {
2673 "fallow/coverage-intelligence-delete"
2674 }
2675 crate::health_types::CoverageIntelligenceRecommendation::ReviewBeforeChanging => {
2676 "fallow/coverage-intelligence-review"
2677 }
2678 crate::health_types::CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior => {
2679 "fallow/coverage-intelligence-refactor"
2680 }
2681 }
2682}
2683
2684pub(super) fn print_health_sarif(
2685 report: &crate::health_types::HealthReport,
2686 root: &Path,
2687) -> ExitCode {
2688 let sarif = build_health_sarif(report, root);
2689 emit_json(&sarif, "SARIF")
2690}
2691
2692#[expect(
2703 clippy::expect_used,
2704 reason = "grouped health SARIF entries are JSON objects created by build_health_sarif"
2705)]
2706pub(super) fn print_grouped_health_sarif(
2707 report: &crate::health_types::HealthReport,
2708 root: &Path,
2709 resolver: &OwnershipResolver,
2710) -> ExitCode {
2711 let mut sarif = build_health_sarif(report, root);
2712
2713 if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
2714 for run in runs {
2715 if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
2716 for result in results {
2717 let uri = result
2718 .pointer("/locations/0/physicalLocation/artifactLocation/uri")
2719 .and_then(|v| v.as_str())
2720 .unwrap_or("");
2721 let decoded = uri.replace("%5B", "[").replace("%5D", "]");
2722 let group =
2723 grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
2724 let props = result
2725 .as_object_mut()
2726 .expect("SARIF result should be an object")
2727 .entry("properties")
2728 .or_insert_with(|| serde_json::json!({}));
2729 props
2730 .as_object_mut()
2731 .expect("properties should be an object")
2732 .insert("group".to_string(), serde_json::Value::String(group));
2733 }
2734 }
2735 }
2736 }
2737
2738 emit_json(&sarif, "SARIF")
2739}
2740
2741#[cfg(test)]
2742mod tests {
2743 use super::*;
2744 use crate::report::test_helpers::sample_results;
2745 use fallow_core::results::*;
2746 use std::path::PathBuf;
2747
2748 #[test]
2749 fn sarif_has_required_top_level_fields() {
2750 let root = PathBuf::from("/project");
2751 let results = AnalysisResults::default();
2752 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2753
2754 assert_eq!(
2755 sarif["$schema"],
2756 "https://json.schemastore.org/sarif-2.1.0.json"
2757 );
2758 assert_eq!(sarif["version"], "2.1.0");
2759 assert!(sarif["runs"].is_array());
2760 }
2761
2762 #[test]
2763 fn sarif_missing_suppression_reason_uses_reason_rule_severity() {
2764 let root = PathBuf::from("/project");
2765 let mut results = AnalysisResults::default();
2766 results.stale_suppressions.push(StaleSuppression {
2767 path: root.join("src/file.ts"),
2768 line: 1,
2769 col: 0,
2770 origin: SuppressionOrigin::Comment {
2771 issue_kind: Some("unused-exports".to_string()),
2772 reason: None,
2773 is_file_level: false,
2774 kind_known: true,
2775 },
2776 missing_reason: true,
2777 actions: StaleSuppression::actions_for(true),
2778 });
2779 let rules = RulesConfig {
2780 stale_suppressions: Severity::Off,
2781 require_suppression_reason: Severity::Error,
2782 ..Default::default()
2783 };
2784
2785 let sarif = build_sarif(&results, &root, &rules);
2786
2787 assert_eq!(
2788 sarif["runs"][0]["results"][0]["ruleId"],
2789 "fallow/missing-suppression-reason"
2790 );
2791 assert_eq!(sarif["runs"][0]["results"][0]["level"], "error");
2792 assert!(
2793 sarif["runs"][0]["tool"]["driver"]["rules"]
2794 .as_array()
2795 .unwrap()
2796 .iter()
2797 .any(|rule| rule["id"].as_str().unwrap() == "fallow/missing-suppression-reason")
2798 );
2799 }
2800
2801 #[test]
2802 fn sarif_stale_and_missing_suppression_have_distinct_identities() {
2803 let root = PathBuf::from("/project");
2804 let mut results = AnalysisResults::default();
2805 let origin = SuppressionOrigin::Comment {
2806 issue_kind: Some("unused-exports".to_string()),
2807 reason: None,
2808 is_file_level: false,
2809 kind_known: true,
2810 };
2811 results.stale_suppressions.push(StaleSuppression {
2812 path: root.join("src/file.ts"),
2813 line: 1,
2814 col: 0,
2815 origin: origin.clone(),
2816 missing_reason: false,
2817 actions: StaleSuppression::actions_for(false),
2818 });
2819 results.stale_suppressions.push(StaleSuppression {
2820 path: root.join("src/file.ts"),
2821 line: 1,
2822 col: 0,
2823 origin,
2824 missing_reason: true,
2825 actions: StaleSuppression::actions_for(true),
2826 });
2827 let rules = RulesConfig {
2828 stale_suppressions: Severity::Warn,
2829 require_suppression_reason: Severity::Error,
2830 ..Default::default()
2831 };
2832
2833 let sarif = build_sarif(&results, &root, &rules);
2834 let results = sarif["runs"][0]["results"].as_array().unwrap();
2835
2836 assert_eq!(results[0]["ruleId"], "fallow/stale-suppression");
2837 assert_eq!(results[1]["ruleId"], "fallow/missing-suppression-reason");
2838 assert_ne!(
2839 results[0]["partialFingerprints"][fingerprint::FINGERPRINT_KEY],
2840 results[1]["partialFingerprints"][fingerprint::FINGERPRINT_KEY]
2841 );
2842 }
2843
2844 #[test]
2845 fn sarif_has_tool_driver_info() {
2846 let root = PathBuf::from("/project");
2847 let results = AnalysisResults::default();
2848 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2849
2850 let driver = &sarif["runs"][0]["tool"]["driver"];
2851 assert_eq!(driver["name"], "fallow");
2852 assert!(driver["version"].is_string());
2853 assert_eq!(
2854 driver["informationUri"],
2855 "https://github.com/fallow-rs/fallow"
2856 );
2857 }
2858
2859 #[test]
2860 fn sarif_declares_all_rules() {
2861 let root = PathBuf::from("/project");
2862 let results = AnalysisResults::default();
2863 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2864
2865 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
2866 .as_array()
2867 .expect("rules should be an array");
2868 assert_eq!(rules.len(), 45);
2869
2870 let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
2871 assert!(rule_ids.contains(&"fallow/duplicate-prop-shape"));
2872 assert!(rule_ids.contains(&"fallow/thin-wrapper"));
2873 assert!(rule_ids.contains(&"fallow/unrendered-component"));
2874 assert!(rule_ids.contains(&"fallow/unused-component-prop"));
2875 assert!(rule_ids.contains(&"fallow/unused-component-emit"));
2876 assert!(rule_ids.contains(&"fallow/unused-component-input"));
2877 assert!(rule_ids.contains(&"fallow/unused-component-output"));
2878 assert!(rule_ids.contains(&"fallow/unused-svelte-event"));
2879 assert!(rule_ids.contains(&"fallow/unused-server-action"));
2880 assert!(rule_ids.contains(&"fallow/unused-load-data-key"));
2881 assert!(rule_ids.contains(&"fallow/prop-drilling"));
2882 assert!(rule_ids.contains(&"fallow/route-collision"));
2883 assert!(rule_ids.contains(&"fallow/dynamic-segment-name-conflict"));
2884 assert!(rule_ids.contains(&"fallow/unused-file"));
2885 assert!(rule_ids.contains(&"fallow/unused-export"));
2886 assert!(rule_ids.contains(&"fallow/unused-type"));
2887 assert!(rule_ids.contains(&"fallow/private-type-leak"));
2888 assert!(rule_ids.contains(&"fallow/unused-dependency"));
2889 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
2890 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
2891 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
2892 assert!(rule_ids.contains(&"fallow/test-only-dependency"));
2893 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
2894 assert!(rule_ids.contains(&"fallow/unused-class-member"));
2895 assert!(rule_ids.contains(&"fallow/unused-store-member"));
2896 assert!(rule_ids.contains(&"fallow/unresolved-import"));
2897 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
2898 assert!(rule_ids.contains(&"fallow/duplicate-export"));
2899 assert!(rule_ids.contains(&"fallow/circular-dependency"));
2900 assert!(rule_ids.contains(&"fallow/re-export-cycle"));
2901 assert!(rule_ids.contains(&"fallow/boundary-violation"));
2902 assert!(rule_ids.contains(&"fallow/boundary-coverage"));
2903 assert!(rule_ids.contains(&"fallow/boundary-call-violation"));
2904 assert!(rule_ids.contains(&"fallow/policy-violation"));
2905 assert!(rule_ids.contains(&"fallow/unused-catalog-entry"));
2906 assert!(rule_ids.contains(&"fallow/empty-catalog-group"));
2907 assert!(rule_ids.contains(&"fallow/unresolved-catalog-reference"));
2908 assert!(rule_ids.contains(&"fallow/unused-dependency-override"));
2909 assert!(rule_ids.contains(&"fallow/misconfigured-dependency-override"));
2910 assert!(rule_ids.contains(&"fallow/invalid-client-export"));
2911 assert!(rule_ids.contains(&"fallow/mixed-client-server-barrel"));
2912 assert!(rule_ids.contains(&"fallow/misplaced-directive"));
2913 assert!(rule_ids.contains(&"fallow/unprovided-inject"));
2914 }
2915
2916 #[test]
2917 fn sarif_empty_results_no_results_entries() {
2918 let root = PathBuf::from("/project");
2919 let results = AnalysisResults::default();
2920 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2921
2922 let sarif_results = sarif["runs"][0]["results"]
2923 .as_array()
2924 .expect("results should be an array");
2925 assert!(sarif_results.is_empty());
2926 }
2927
2928 #[test]
2929 fn sarif_unused_file_result() {
2930 let root = PathBuf::from("/project");
2931 let mut results = AnalysisResults::default();
2932 results
2933 .unused_files
2934 .push(UnusedFileFinding::with_actions(UnusedFile {
2935 path: root.join("src/dead.ts"),
2936 }));
2937
2938 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2939 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2940 assert_eq!(entries.len(), 1);
2941
2942 let entry = &entries[0];
2943 assert_eq!(entry["ruleId"], "fallow/unused-file");
2944 assert_eq!(entry["level"], "error");
2945 assert_eq!(
2946 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2947 "src/dead.ts"
2948 );
2949 }
2950
2951 #[test]
2952 fn sarif_unused_export_includes_region() {
2953 let root = PathBuf::from("/project");
2954 let mut results = AnalysisResults::default();
2955 results
2956 .unused_exports
2957 .push(UnusedExportFinding::with_actions(UnusedExport {
2958 path: root.join("src/utils.ts"),
2959 export_name: "helperFn".to_string(),
2960 is_type_only: false,
2961 line: 10,
2962 col: 4,
2963 span_start: 120,
2964 is_re_export: false,
2965 }));
2966
2967 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2968 let entry = &sarif["runs"][0]["results"][0];
2969 assert_eq!(entry["ruleId"], "fallow/unused-export");
2970
2971 let region = &entry["locations"][0]["physicalLocation"]["region"];
2972 assert_eq!(region["startLine"], 10);
2973 assert_eq!(region["startColumn"], 5);
2974 }
2975
2976 #[test]
2977 fn sarif_unresolved_import_is_error_level() {
2978 let root = PathBuf::from("/project");
2979 let mut results = AnalysisResults::default();
2980 results
2981 .unresolved_imports
2982 .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
2983 path: root.join("src/app.ts"),
2984 specifier: "./missing".to_string(),
2985 line: 1,
2986 col: 0,
2987 specifier_col: 0,
2988 }));
2989
2990 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2991 let entry = &sarif["runs"][0]["results"][0];
2992 assert_eq!(entry["ruleId"], "fallow/unresolved-import");
2993 assert_eq!(entry["level"], "error");
2994 }
2995
2996 #[test]
2997 fn sarif_unlisted_dependency_points_to_import_site() {
2998 let root = PathBuf::from("/project");
2999 let mut results = AnalysisResults::default();
3000 results
3001 .unlisted_dependencies
3002 .push(UnlistedDependencyFinding::with_actions(
3003 UnlistedDependency {
3004 package_name: "chalk".to_string(),
3005 imported_from: vec![ImportSite {
3006 path: root.join("src/cli.ts"),
3007 line: 3,
3008 col: 0,
3009 }],
3010 },
3011 ));
3012
3013 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3014 let entry = &sarif["runs"][0]["results"][0];
3015 assert_eq!(entry["ruleId"], "fallow/unlisted-dependency");
3016 assert_eq!(entry["level"], "error");
3017 assert_eq!(
3018 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3019 "src/cli.ts"
3020 );
3021 let region = &entry["locations"][0]["physicalLocation"]["region"];
3022 assert_eq!(region["startLine"], 3);
3023 assert_eq!(region["startColumn"], 1);
3024 }
3025
3026 #[test]
3027 fn sarif_dependency_issues_point_to_package_json() {
3028 let root = PathBuf::from("/project");
3029 let mut results = AnalysisResults::default();
3030 results
3031 .unused_dependencies
3032 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
3033 package_name: "lodash".to_string(),
3034 location: DependencyLocation::Dependencies,
3035 path: root.join("package.json"),
3036 line: 5,
3037 used_in_workspaces: Vec::new(),
3038 }));
3039 results
3040 .unused_dev_dependencies
3041 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
3042 package_name: "jest".to_string(),
3043 location: DependencyLocation::DevDependencies,
3044 path: root.join("package.json"),
3045 line: 5,
3046 used_in_workspaces: Vec::new(),
3047 }));
3048
3049 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3050 let entries = sarif["runs"][0]["results"].as_array().unwrap();
3051 for entry in entries {
3052 assert_eq!(
3053 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3054 "package.json"
3055 );
3056 }
3057 }
3058
3059 #[test]
3060 fn sarif_duplicate_export_emits_one_result_per_location() {
3061 let root = PathBuf::from("/project");
3062 let mut results = AnalysisResults::default();
3063 results
3064 .duplicate_exports
3065 .push(DuplicateExportFinding::with_actions(DuplicateExport {
3066 export_name: "Config".to_string(),
3067 locations: vec![
3068 DuplicateLocation {
3069 path: root.join("src/a.ts"),
3070 line: 15,
3071 col: 0,
3072 },
3073 DuplicateLocation {
3074 path: root.join("src/b.ts"),
3075 line: 30,
3076 col: 0,
3077 },
3078 ],
3079 }));
3080
3081 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3082 let entries = sarif["runs"][0]["results"].as_array().unwrap();
3083 assert_eq!(entries.len(), 2);
3084 assert_eq!(entries[0]["ruleId"], "fallow/duplicate-export");
3085 assert_eq!(entries[1]["ruleId"], "fallow/duplicate-export");
3086 assert_eq!(
3087 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3088 "src/a.ts"
3089 );
3090 assert_eq!(
3091 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3092 "src/b.ts"
3093 );
3094 }
3095
3096 #[test]
3097 fn sarif_all_issue_types_produce_results() {
3098 let root = PathBuf::from("/project");
3099 let results = sample_results(&root);
3100 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3101
3102 let entries = sarif["runs"][0]["results"].as_array().unwrap();
3103 assert_eq!(entries.len(), results.total_issues() + 1);
3104
3105 let rule_ids: Vec<&str> = entries
3106 .iter()
3107 .map(|e| e["ruleId"].as_str().unwrap())
3108 .collect();
3109 assert!(rule_ids.contains(&"fallow/unused-file"));
3110 assert!(rule_ids.contains(&"fallow/unused-export"));
3111 assert!(rule_ids.contains(&"fallow/unused-type"));
3112 assert!(rule_ids.contains(&"fallow/unused-dependency"));
3113 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
3114 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
3115 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
3116 assert!(rule_ids.contains(&"fallow/test-only-dependency"));
3117 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
3118 assert!(rule_ids.contains(&"fallow/unused-class-member"));
3119 assert!(rule_ids.contains(&"fallow/unused-store-member"));
3120 assert!(rule_ids.contains(&"fallow/unresolved-import"));
3121 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
3122 assert!(rule_ids.contains(&"fallow/duplicate-export"));
3123 assert!(rule_ids.contains(&"fallow/unprovided-inject"));
3124 }
3125
3126 #[test]
3127 fn sarif_serializes_to_valid_json() {
3128 let root = PathBuf::from("/project");
3129 let results = sample_results(&root);
3130 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3131
3132 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
3133 let reparsed: serde_json::Value =
3134 serde_json::from_str(&json_str).expect("SARIF output should be valid JSON");
3135 assert_eq!(reparsed, sarif);
3136 }
3137
3138 #[test]
3139 fn sarif_file_write_produces_valid_sarif() {
3140 let root = PathBuf::from("/project");
3141 let results = sample_results(&root);
3142 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3143 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
3144
3145 let dir = std::env::temp_dir().join("fallow-test-sarif-file");
3146 let _ = std::fs::create_dir_all(&dir);
3147 let sarif_path = dir.join("results.sarif");
3148 std::fs::write(&sarif_path, &json_str).expect("should write SARIF file");
3149
3150 let contents = std::fs::read_to_string(&sarif_path).expect("should read SARIF file");
3151 let parsed: serde_json::Value =
3152 serde_json::from_str(&contents).expect("file should contain valid JSON");
3153
3154 assert_eq!(parsed["version"], "2.1.0");
3155 assert_eq!(
3156 parsed["$schema"],
3157 "https://json.schemastore.org/sarif-2.1.0.json"
3158 );
3159 let sarif_results = parsed["runs"][0]["results"]
3160 .as_array()
3161 .expect("results should be an array");
3162 assert!(!sarif_results.is_empty());
3163
3164 let _ = std::fs::remove_file(&sarif_path);
3165 let _ = std::fs::remove_dir(&dir);
3166 }
3167
3168 #[test]
3169 fn health_sarif_empty_no_results() {
3170 let root = PathBuf::from("/project");
3171 let report = crate::health_types::HealthReport {
3172 summary: crate::health_types::HealthSummary {
3173 files_analyzed: 10,
3174 functions_analyzed: 50,
3175 ..Default::default()
3176 },
3177 ..Default::default()
3178 };
3179 let sarif = build_health_sarif(&report, &root);
3180 assert_eq!(sarif["version"], "2.1.0");
3181 let results = sarif["runs"][0]["results"].as_array().unwrap();
3182 assert!(results.is_empty());
3183 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
3184 .as_array()
3185 .unwrap();
3186 assert_eq!(rules.len(), 16);
3187 }
3188
3189 #[test]
3190 fn health_sarif_coverage_intelligence_preserves_structured_properties() {
3191 use crate::health_types::{
3192 CoverageIntelligenceAction, CoverageIntelligenceConfidence,
3193 CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
3194 CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
3195 CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
3196 CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
3197 HealthReport, HealthSummary,
3198 };
3199
3200 let root = PathBuf::from("/project");
3201 let report = HealthReport {
3202 summary: HealthSummary {
3203 files_analyzed: 10,
3204 functions_analyzed: 50,
3205 ..Default::default()
3206 },
3207 coverage_intelligence: Some(CoverageIntelligenceReport {
3208 schema_version: CoverageIntelligenceSchemaVersion::V1,
3209 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
3210 summary: CoverageIntelligenceSummary {
3211 findings: 1,
3212 high_confidence_deletes: 1,
3213 ..Default::default()
3214 },
3215 findings: vec![CoverageIntelligenceFinding {
3216 id: "fallow:coverage-intel:abc123".to_owned(),
3217 path: root.join("src/dead.ts"),
3218 identity: Some("deadPath".to_owned()),
3219 line: 9,
3220 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
3221 signals: vec![CoverageIntelligenceSignal::RuntimeCold],
3222 recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
3223 confidence: CoverageIntelligenceConfidence::High,
3224 related_ids: vec!["fallow:prod:deadbeef".to_owned()],
3225 evidence: CoverageIntelligenceEvidence {
3226 match_confidence: CoverageIntelligenceMatchConfidence::Direct,
3227 ..Default::default()
3228 },
3229 actions: vec![CoverageIntelligenceAction {
3230 kind: "delete-after-confirming-owner".to_owned(),
3231 description: "Confirm ownership".to_owned(),
3232 auto_fixable: false,
3233 }],
3234 }],
3235 }),
3236 ..Default::default()
3237 };
3238
3239 let sarif = build_health_sarif(&report, &root);
3240 let result = &sarif["runs"][0]["results"][0];
3241 assert_eq!(result["ruleId"], "fallow/coverage-intelligence-delete");
3242 assert_eq!(
3243 result["properties"]["coverage_intelligence_id"],
3244 "fallow:coverage-intel:abc123"
3245 );
3246 assert_eq!(
3247 result["properties"]["recommendation"],
3248 "delete-after-confirming-owner"
3249 );
3250 assert_eq!(result["properties"]["confidence"], "high");
3251 assert_eq!(result["properties"]["signals"][0], "runtime_cold");
3252 assert_eq!(
3253 result["properties"]["related_ids"][0],
3254 "fallow:prod:deadbeef"
3255 );
3256 }
3257
3258 #[test]
3259 fn health_sarif_cyclomatic_only() {
3260 let root = PathBuf::from("/project");
3261 let report = crate::health_types::HealthReport {
3262 findings: vec![
3263 crate::health_types::ComplexityViolation {
3264 path: root.join("src/utils.ts"),
3265 name: "parseExpression".to_string(),
3266 line: 42,
3267 col: 0,
3268 cyclomatic: 25,
3269 cognitive: 10,
3270 line_count: 80,
3271 param_count: 0,
3272 react_hook_count: 0,
3273 react_jsx_max_depth: 0,
3274 react_prop_count: 0,
3275 react_hook_profile: None,
3276 exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
3277 severity: crate::health_types::FindingSeverity::High,
3278 crap: None,
3279 coverage_pct: None,
3280 coverage_tier: None,
3281 coverage_source: None,
3282 inherited_from: None,
3283 component_rollup: None,
3284 contributions: Vec::new(),
3285 effective_thresholds: None,
3286 threshold_source: None,
3287 }
3288 .into(),
3289 ],
3290 summary: crate::health_types::HealthSummary {
3291 files_analyzed: 5,
3292 functions_analyzed: 20,
3293 functions_above_threshold: 1,
3294 ..Default::default()
3295 },
3296 ..Default::default()
3297 };
3298 let sarif = build_health_sarif(&report, &root);
3299 let entry = &sarif["runs"][0]["results"][0];
3300 assert_eq!(entry["ruleId"], "fallow/high-cyclomatic-complexity");
3301 assert_eq!(entry["level"], "warning");
3302 assert!(
3303 entry["message"]["text"]
3304 .as_str()
3305 .unwrap()
3306 .contains("cyclomatic complexity 25")
3307 );
3308 assert_eq!(
3309 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3310 "src/utils.ts"
3311 );
3312 let region = &entry["locations"][0]["physicalLocation"]["region"];
3313 assert_eq!(region["startLine"], 42);
3314 assert_eq!(region["startColumn"], 1);
3315 }
3316
3317 #[test]
3318 fn health_sarif_cognitive_only() {
3319 let root = PathBuf::from("/project");
3320 let report = crate::health_types::HealthReport {
3321 findings: vec![
3322 crate::health_types::ComplexityViolation {
3323 path: root.join("src/api.ts"),
3324 name: "handleRequest".to_string(),
3325 line: 10,
3326 col: 4,
3327 cyclomatic: 8,
3328 cognitive: 20,
3329 line_count: 40,
3330 param_count: 0,
3331 react_hook_count: 0,
3332 react_jsx_max_depth: 0,
3333 react_prop_count: 0,
3334 react_hook_profile: None,
3335 exceeded: crate::health_types::ExceededThreshold::Cognitive,
3336 severity: crate::health_types::FindingSeverity::High,
3337 crap: None,
3338 coverage_pct: None,
3339 coverage_tier: None,
3340 coverage_source: None,
3341 inherited_from: None,
3342 component_rollup: None,
3343 contributions: Vec::new(),
3344 effective_thresholds: None,
3345 threshold_source: None,
3346 }
3347 .into(),
3348 ],
3349 summary: crate::health_types::HealthSummary {
3350 files_analyzed: 3,
3351 functions_analyzed: 10,
3352 functions_above_threshold: 1,
3353 ..Default::default()
3354 },
3355 ..Default::default()
3356 };
3357 let sarif = build_health_sarif(&report, &root);
3358 let entry = &sarif["runs"][0]["results"][0];
3359 assert_eq!(entry["ruleId"], "fallow/high-cognitive-complexity");
3360 assert!(
3361 entry["message"]["text"]
3362 .as_str()
3363 .unwrap()
3364 .contains("cognitive complexity 20")
3365 );
3366 let region = &entry["locations"][0]["physicalLocation"]["region"];
3367 assert_eq!(region["startColumn"], 5); }
3369
3370 #[test]
3371 fn health_sarif_both_thresholds() {
3372 let root = PathBuf::from("/project");
3373 let report = crate::health_types::HealthReport {
3374 findings: vec![
3375 crate::health_types::ComplexityViolation {
3376 path: root.join("src/complex.ts"),
3377 name: "doEverything".to_string(),
3378 line: 1,
3379 col: 0,
3380 cyclomatic: 30,
3381 cognitive: 45,
3382 line_count: 100,
3383 param_count: 0,
3384 react_hook_count: 0,
3385 react_jsx_max_depth: 0,
3386 react_prop_count: 0,
3387 react_hook_profile: None,
3388 exceeded: crate::health_types::ExceededThreshold::Both,
3389 severity: crate::health_types::FindingSeverity::High,
3390 crap: None,
3391 coverage_pct: None,
3392 coverage_tier: None,
3393 coverage_source: None,
3394 inherited_from: None,
3395 component_rollup: None,
3396 contributions: Vec::new(),
3397 effective_thresholds: None,
3398 threshold_source: None,
3399 }
3400 .into(),
3401 ],
3402 summary: crate::health_types::HealthSummary {
3403 files_analyzed: 1,
3404 functions_analyzed: 1,
3405 functions_above_threshold: 1,
3406 ..Default::default()
3407 },
3408 ..Default::default()
3409 };
3410 let sarif = build_health_sarif(&report, &root);
3411 let entry = &sarif["runs"][0]["results"][0];
3412 assert_eq!(entry["ruleId"], "fallow/high-complexity");
3413 let msg = entry["message"]["text"].as_str().unwrap();
3414 assert!(msg.contains("cyclomatic complexity 30"));
3415 assert!(msg.contains("cognitive complexity 45"));
3416 }
3417
3418 #[test]
3419 fn health_sarif_crap_only_emits_crap_rule() {
3420 let root = PathBuf::from("/project");
3421 let report = crate::health_types::HealthReport {
3422 findings: vec![
3423 crate::health_types::ComplexityViolation {
3424 path: root.join("src/untested.ts"),
3425 name: "risky".to_string(),
3426 line: 8,
3427 col: 0,
3428 cyclomatic: 10,
3429 cognitive: 10,
3430 line_count: 20,
3431 param_count: 1,
3432 react_hook_count: 0,
3433 react_jsx_max_depth: 0,
3434 react_prop_count: 0,
3435 react_hook_profile: None,
3436 exceeded: crate::health_types::ExceededThreshold::Crap,
3437 severity: crate::health_types::FindingSeverity::High,
3438 crap: Some(82.2),
3439 coverage_pct: Some(12.0),
3440 coverage_tier: None,
3441 coverage_source: None,
3442 inherited_from: None,
3443 component_rollup: None,
3444 contributions: Vec::new(),
3445 effective_thresholds: None,
3446 threshold_source: None,
3447 }
3448 .into(),
3449 ],
3450 summary: crate::health_types::HealthSummary {
3451 files_analyzed: 1,
3452 functions_analyzed: 1,
3453 functions_above_threshold: 1,
3454 ..Default::default()
3455 },
3456 ..Default::default()
3457 };
3458 let sarif = build_health_sarif(&report, &root);
3459 let entry = &sarif["runs"][0]["results"][0];
3460 assert_eq!(entry["ruleId"], "fallow/high-crap-score");
3461 let msg = entry["message"]["text"].as_str().unwrap();
3462 assert!(msg.contains("CRAP score 82.2"), "msg: {msg}");
3463 assert!(msg.contains("coverage 12%"), "msg: {msg}");
3464 }
3465
3466 #[test]
3467 fn health_sarif_cyclomatic_crap_uses_crap_rule() {
3468 let root = PathBuf::from("/project");
3469 let report = crate::health_types::HealthReport {
3470 findings: vec![
3471 crate::health_types::ComplexityViolation {
3472 path: root.join("src/hot.ts"),
3473 name: "branchy".to_string(),
3474 line: 1,
3475 col: 0,
3476 cyclomatic: 67,
3477 cognitive: 12,
3478 line_count: 80,
3479 param_count: 1,
3480 react_hook_count: 0,
3481 react_jsx_max_depth: 0,
3482 react_prop_count: 0,
3483 react_hook_profile: None,
3484 exceeded: crate::health_types::ExceededThreshold::CyclomaticCrap,
3485 severity: crate::health_types::FindingSeverity::Critical,
3486 crap: Some(182.0),
3487 coverage_pct: None,
3488 coverage_tier: None,
3489 coverage_source: None,
3490 inherited_from: None,
3491 component_rollup: None,
3492 contributions: Vec::new(),
3493 effective_thresholds: None,
3494 threshold_source: None,
3495 }
3496 .into(),
3497 ],
3498 summary: crate::health_types::HealthSummary {
3499 files_analyzed: 1,
3500 functions_analyzed: 1,
3501 functions_above_threshold: 1,
3502 ..Default::default()
3503 },
3504 ..Default::default()
3505 };
3506 let sarif = build_health_sarif(&report, &root);
3507 let results = sarif["runs"][0]["results"].as_array().unwrap();
3508 assert_eq!(
3509 results.len(),
3510 1,
3511 "CyclomaticCrap should emit a single SARIF result under the CRAP rule"
3512 );
3513 assert_eq!(results[0]["ruleId"], "fallow/high-crap-score");
3514 let msg = results[0]["message"]["text"].as_str().unwrap();
3515 assert!(msg.contains("CRAP score 182"), "msg: {msg}");
3516 assert!(!msg.contains("coverage"), "msg: {msg}");
3517 }
3518
3519 #[test]
3520 fn severity_to_sarif_level_error() {
3521 assert_eq!(severity_to_sarif_level(Severity::Error), "error");
3522 }
3523
3524 #[test]
3525 fn severity_to_sarif_level_warn() {
3526 assert_eq!(severity_to_sarif_level(Severity::Warn), "warning");
3527 }
3528
3529 #[test]
3530 #[should_panic(expected = "internal error: entered unreachable code")]
3531 fn severity_to_sarif_level_off() {
3532 let _ = severity_to_sarif_level(Severity::Off);
3533 }
3534
3535 #[test]
3536 fn sarif_re_export_has_properties() {
3537 let root = PathBuf::from("/project");
3538 let mut results = AnalysisResults::default();
3539 results
3540 .unused_exports
3541 .push(UnusedExportFinding::with_actions(UnusedExport {
3542 path: root.join("src/index.ts"),
3543 export_name: "reExported".to_string(),
3544 is_type_only: false,
3545 line: 1,
3546 col: 0,
3547 span_start: 0,
3548 is_re_export: true,
3549 }));
3550
3551 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3552 let entry = &sarif["runs"][0]["results"][0];
3553 assert_eq!(entry["properties"]["is_re_export"], true);
3554 let msg = entry["message"]["text"].as_str().unwrap();
3555 assert!(msg.starts_with("Re-export"));
3556 }
3557
3558 #[test]
3559 fn sarif_non_re_export_has_no_properties() {
3560 let root = PathBuf::from("/project");
3561 let mut results = AnalysisResults::default();
3562 results
3563 .unused_exports
3564 .push(UnusedExportFinding::with_actions(UnusedExport {
3565 path: root.join("src/utils.ts"),
3566 export_name: "foo".to_string(),
3567 is_type_only: false,
3568 line: 5,
3569 col: 0,
3570 span_start: 0,
3571 is_re_export: false,
3572 }));
3573
3574 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3575 let entry = &sarif["runs"][0]["results"][0];
3576 assert!(entry.get("properties").is_none());
3577 let msg = entry["message"]["text"].as_str().unwrap();
3578 assert!(msg.starts_with("Export"));
3579 }
3580
3581 #[test]
3582 fn sarif_type_re_export_message() {
3583 let root = PathBuf::from("/project");
3584 let mut results = AnalysisResults::default();
3585 results
3586 .unused_types
3587 .push(UnusedTypeFinding::with_actions(UnusedExport {
3588 path: root.join("src/index.ts"),
3589 export_name: "MyType".to_string(),
3590 is_type_only: true,
3591 line: 1,
3592 col: 0,
3593 span_start: 0,
3594 is_re_export: true,
3595 }));
3596
3597 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3598 let entry = &sarif["runs"][0]["results"][0];
3599 assert_eq!(entry["ruleId"], "fallow/unused-type");
3600 let msg = entry["message"]["text"].as_str().unwrap();
3601 assert!(msg.starts_with("Type re-export"));
3602 assert_eq!(entry["properties"]["is_re_export"], true);
3603 }
3604
3605 #[test]
3606 fn sarif_dependency_line_zero_skips_region() {
3607 let root = PathBuf::from("/project");
3608 let mut results = AnalysisResults::default();
3609 results
3610 .unused_dependencies
3611 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
3612 package_name: "lodash".to_string(),
3613 location: DependencyLocation::Dependencies,
3614 path: root.join("package.json"),
3615 line: 0,
3616 used_in_workspaces: Vec::new(),
3617 }));
3618
3619 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3620 let entry = &sarif["runs"][0]["results"][0];
3621 let phys = &entry["locations"][0]["physicalLocation"];
3622 assert!(phys.get("region").is_none());
3623 }
3624
3625 #[test]
3626 fn sarif_dependency_line_nonzero_has_region() {
3627 let root = PathBuf::from("/project");
3628 let mut results = AnalysisResults::default();
3629 results
3630 .unused_dependencies
3631 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
3632 package_name: "lodash".to_string(),
3633 location: DependencyLocation::Dependencies,
3634 path: root.join("package.json"),
3635 line: 7,
3636 used_in_workspaces: Vec::new(),
3637 }));
3638
3639 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3640 let entry = &sarif["runs"][0]["results"][0];
3641 let region = &entry["locations"][0]["physicalLocation"]["region"];
3642 assert_eq!(region["startLine"], 7);
3643 assert_eq!(region["startColumn"], 1);
3644 }
3645
3646 #[test]
3647 fn sarif_type_only_dep_line_zero_skips_region() {
3648 let root = PathBuf::from("/project");
3649 let mut results = AnalysisResults::default();
3650 results
3651 .type_only_dependencies
3652 .push(TypeOnlyDependencyFinding::with_actions(
3653 TypeOnlyDependency {
3654 package_name: "zod".to_string(),
3655 path: root.join("package.json"),
3656 line: 0,
3657 },
3658 ));
3659
3660 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3661 let entry = &sarif["runs"][0]["results"][0];
3662 let phys = &entry["locations"][0]["physicalLocation"];
3663 assert!(phys.get("region").is_none());
3664 }
3665
3666 #[test]
3667 fn sarif_circular_dep_line_zero_skips_region() {
3668 let root = PathBuf::from("/project");
3669 let mut results = AnalysisResults::default();
3670 results
3671 .circular_dependencies
3672 .push(CircularDependencyFinding::with_actions(
3673 CircularDependency {
3674 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
3675 length: 2,
3676 line: 0,
3677 col: 0,
3678 edges: Vec::new(),
3679 is_cross_package: false,
3680 },
3681 ));
3682
3683 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3684 let entry = &sarif["runs"][0]["results"][0];
3685 let phys = &entry["locations"][0]["physicalLocation"];
3686 assert!(phys.get("region").is_none());
3687 }
3688
3689 #[test]
3690 fn sarif_circular_dep_line_nonzero_has_region() {
3691 let root = PathBuf::from("/project");
3692 let mut results = AnalysisResults::default();
3693 results
3694 .circular_dependencies
3695 .push(CircularDependencyFinding::with_actions(
3696 CircularDependency {
3697 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
3698 length: 2,
3699 line: 5,
3700 col: 2,
3701 edges: Vec::new(),
3702 is_cross_package: false,
3703 },
3704 ));
3705
3706 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3707 let entry = &sarif["runs"][0]["results"][0];
3708 let region = &entry["locations"][0]["physicalLocation"]["region"];
3709 assert_eq!(region["startLine"], 5);
3710 assert_eq!(region["startColumn"], 3);
3711 }
3712
3713 #[test]
3714 fn sarif_unused_optional_dependency_result() {
3715 let root = PathBuf::from("/project");
3716 let mut results = AnalysisResults::default();
3717 results
3718 .unused_optional_dependencies
3719 .push(UnusedOptionalDependencyFinding::with_actions(
3720 UnusedDependency {
3721 package_name: "fsevents".to_string(),
3722 location: DependencyLocation::OptionalDependencies,
3723 path: root.join("package.json"),
3724 line: 12,
3725 used_in_workspaces: Vec::new(),
3726 },
3727 ));
3728
3729 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3730 let entry = &sarif["runs"][0]["results"][0];
3731 assert_eq!(entry["ruleId"], "fallow/unused-optional-dependency");
3732 let msg = entry["message"]["text"].as_str().unwrap();
3733 assert!(msg.contains("optionalDependencies"));
3734 }
3735
3736 #[test]
3737 fn sarif_enum_member_message_format() {
3738 let root = PathBuf::from("/project");
3739 let mut results = AnalysisResults::default();
3740 results.unused_enum_members.push(
3741 fallow_core::results::UnusedEnumMemberFinding::with_actions(UnusedMember {
3742 path: root.join("src/enums.ts"),
3743 parent_name: "Color".to_string(),
3744 member_name: "Purple".to_string(),
3745 kind: fallow_core::extract::MemberKind::EnumMember,
3746 line: 5,
3747 col: 2,
3748 }),
3749 );
3750
3751 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3752 let entry = &sarif["runs"][0]["results"][0];
3753 assert_eq!(entry["ruleId"], "fallow/unused-enum-member");
3754 let msg = entry["message"]["text"].as_str().unwrap();
3755 assert!(msg.contains("Enum member 'Color.Purple'"));
3756 let region = &entry["locations"][0]["physicalLocation"]["region"];
3757 assert_eq!(region["startColumn"], 3); }
3759
3760 #[test]
3761 fn sarif_class_member_message_format() {
3762 let root = PathBuf::from("/project");
3763 let mut results = AnalysisResults::default();
3764 results.unused_class_members.push(
3765 fallow_core::results::UnusedClassMemberFinding::with_actions(UnusedMember {
3766 path: root.join("src/service.ts"),
3767 parent_name: "API".to_string(),
3768 member_name: "fetch".to_string(),
3769 kind: fallow_core::extract::MemberKind::ClassMethod,
3770 line: 10,
3771 col: 4,
3772 }),
3773 );
3774
3775 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3776 let entry = &sarif["runs"][0]["results"][0];
3777 assert_eq!(entry["ruleId"], "fallow/unused-class-member");
3778 let msg = entry["message"]["text"].as_str().unwrap();
3779 assert!(msg.contains("Class member 'API.fetch'"));
3780 }
3781
3782 #[test]
3783 #[expect(
3784 clippy::cast_possible_truncation,
3785 reason = "test line/col values are trivially small"
3786 )]
3787 fn duplication_sarif_structure() {
3788 use fallow_core::duplicates::*;
3789
3790 let root = PathBuf::from("/project");
3791 let report = DuplicationReport {
3792 clone_groups: vec![CloneGroup {
3793 instances: vec![
3794 CloneInstance {
3795 file: root.join("src/a.ts"),
3796 start_line: 1,
3797 end_line: 10,
3798 start_col: 0,
3799 end_col: 0,
3800 fragment: String::new(),
3801 },
3802 CloneInstance {
3803 file: root.join("src/b.ts"),
3804 start_line: 5,
3805 end_line: 14,
3806 start_col: 2,
3807 end_col: 0,
3808 fragment: String::new(),
3809 },
3810 ],
3811 token_count: 50,
3812 line_count: 10,
3813 }],
3814 clone_families: vec![],
3815 mirrored_directories: vec![],
3816 stats: DuplicationStats::default(),
3817 };
3818
3819 let sarif = serde_json::json!({
3820 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
3821 "version": "2.1.0",
3822 "runs": [{
3823 "tool": {
3824 "driver": {
3825 "name": "fallow",
3826 "version": env!("CARGO_PKG_VERSION"),
3827 "informationUri": "https://github.com/fallow-rs/fallow",
3828 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
3829 }
3830 },
3831 "results": []
3832 }]
3833 });
3834 let _ = sarif;
3835
3836 let mut sarif_results = Vec::new();
3837 for (i, group) in report.clone_groups.iter().enumerate() {
3838 for instance in &group.instances {
3839 sarif_results.push(sarif_result(
3840 "fallow/code-duplication",
3841 "warning",
3842 &format!(
3843 "Code clone group {} ({} lines, {} instances)",
3844 i + 1,
3845 group.line_count,
3846 group.instances.len()
3847 ),
3848 &super::super::relative_uri(&instance.file, &root),
3849 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
3850 ));
3851 }
3852 }
3853 assert_eq!(sarif_results.len(), 2);
3854 assert_eq!(sarif_results[0]["ruleId"], "fallow/code-duplication");
3855 assert!(
3856 sarif_results[0]["message"]["text"]
3857 .as_str()
3858 .unwrap()
3859 .contains("10 lines")
3860 );
3861 let region0 = &sarif_results[0]["locations"][0]["physicalLocation"]["region"];
3862 assert_eq!(region0["startLine"], 1);
3863 assert_eq!(region0["startColumn"], 1); let region1 = &sarif_results[1]["locations"][0]["physicalLocation"]["region"];
3865 assert_eq!(region1["startLine"], 5);
3866 assert_eq!(region1["startColumn"], 3); }
3868
3869 #[test]
3870 fn sarif_rule_known_id_has_full_description() {
3871 let rule = sarif_rule("fallow/unused-file", "fallback text", "error");
3872 assert!(rule.get("fullDescription").is_some());
3873 assert!(rule.get("helpUri").is_some());
3874 }
3875
3876 #[test]
3877 fn sarif_rule_unknown_id_uses_fallback() {
3878 let rule = sarif_rule("fallow/nonexistent", "fallback text", "warning");
3879 assert_eq!(rule["shortDescription"]["text"], "fallback text");
3880 assert!(rule.get("fullDescription").is_none());
3881 assert!(rule.get("helpUri").is_none());
3882 assert_eq!(rule["defaultConfiguration"]["level"], "warning");
3883 }
3884
3885 #[test]
3886 fn sarif_result_no_region_omits_region_key() {
3887 let result = sarif_result("rule/test", "error", "test msg", "src/file.ts", None);
3888 let phys = &result["locations"][0]["physicalLocation"];
3889 assert!(phys.get("region").is_none());
3890 assert_eq!(phys["artifactLocation"]["uri"], "src/file.ts");
3891 }
3892
3893 #[test]
3894 fn sarif_result_with_region_includes_region() {
3895 let result = sarif_result(
3896 "rule/test",
3897 "error",
3898 "test msg",
3899 "src/file.ts",
3900 Some((10, 5)),
3901 );
3902 let region = &result["locations"][0]["physicalLocation"]["region"];
3903 assert_eq!(region["startLine"], 10);
3904 assert_eq!(region["startColumn"], 5);
3905 }
3906
3907 #[test]
3908 fn sarif_partial_fingerprint_ignores_rendered_message() {
3909 let a = sarif_result(
3910 "rule/test",
3911 "error",
3912 "first message",
3913 "src/file.ts",
3914 Some((10, 5)),
3915 );
3916 let b = sarif_result(
3917 "rule/test",
3918 "error",
3919 "rewritten message",
3920 "src/file.ts",
3921 Some((10, 5)),
3922 );
3923 assert_eq!(
3924 a["partialFingerprints"][fingerprint::FINGERPRINT_KEY],
3925 b["partialFingerprints"][fingerprint::FINGERPRINT_KEY]
3926 );
3927 }
3928
3929 #[test]
3930 fn health_sarif_includes_refactoring_targets() {
3931 use crate::health_types::*;
3932
3933 let root = PathBuf::from("/project");
3934 let report = HealthReport {
3935 summary: HealthSummary {
3936 files_analyzed: 10,
3937 functions_analyzed: 50,
3938 ..Default::default()
3939 },
3940 targets: vec![
3941 RefactoringTarget {
3942 path: root.join("src/complex.ts"),
3943 priority: 85.0,
3944 efficiency: 42.5,
3945 recommendation: "Split high-impact file".into(),
3946 category: RecommendationCategory::SplitHighImpact,
3947 effort: EffortEstimate::Medium,
3948 confidence: Confidence::High,
3949 factors: vec![],
3950 evidence: None,
3951 }
3952 .into(),
3953 ],
3954 ..Default::default()
3955 };
3956
3957 let sarif = build_health_sarif(&report, &root);
3958 let entries = sarif["runs"][0]["results"].as_array().unwrap();
3959 assert_eq!(entries.len(), 1);
3960 assert_eq!(entries[0]["ruleId"], "fallow/refactoring-target");
3961 assert_eq!(entries[0]["level"], "warning");
3962 let msg = entries[0]["message"]["text"].as_str().unwrap();
3963 assert!(msg.contains("high impact"));
3964 assert!(msg.contains("Split high-impact file"));
3965 assert!(msg.contains("42.5"));
3966 }
3967
3968 #[test]
3969 fn health_sarif_includes_coverage_gaps() {
3970 use crate::health_types::*;
3971
3972 let root = PathBuf::from("/project");
3973 let report = HealthReport {
3974 summary: HealthSummary {
3975 files_analyzed: 10,
3976 functions_analyzed: 50,
3977 ..Default::default()
3978 },
3979 coverage_gaps: Some(CoverageGaps {
3980 summary: CoverageGapSummary {
3981 runtime_files: 2,
3982 covered_files: 0,
3983 file_coverage_pct: 0.0,
3984 untested_files: 1,
3985 untested_exports: 1,
3986 },
3987 files: vec![UntestedFileFinding::with_actions(
3988 UntestedFile {
3989 path: root.join("src/app.ts"),
3990 value_export_count: 2,
3991 },
3992 &root,
3993 )],
3994 exports: vec![UntestedExportFinding::with_actions(
3995 UntestedExport {
3996 path: root.join("src/app.ts"),
3997 export_name: "loader".into(),
3998 line: 12,
3999 col: 4,
4000 },
4001 &root,
4002 )],
4003 }),
4004 ..Default::default()
4005 };
4006
4007 let sarif = build_health_sarif(&report, &root);
4008 let entries = sarif["runs"][0]["results"].as_array().unwrap();
4009 assert_eq!(entries.len(), 2);
4010 assert_eq!(entries[0]["ruleId"], "fallow/untested-file");
4011 assert_eq!(
4012 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
4013 "src/app.ts"
4014 );
4015 assert!(
4016 entries[0]["message"]["text"]
4017 .as_str()
4018 .unwrap()
4019 .contains("2 value exports")
4020 );
4021 assert_eq!(entries[1]["ruleId"], "fallow/untested-export");
4022 assert_eq!(
4023 entries[1]["locations"][0]["physicalLocation"]["region"]["startLine"],
4024 12
4025 );
4026 assert_eq!(
4027 entries[1]["locations"][0]["physicalLocation"]["region"]["startColumn"],
4028 5
4029 );
4030 }
4031
4032 #[test]
4033 fn health_sarif_rules_have_full_descriptions() {
4034 let root = PathBuf::from("/project");
4035 let report = crate::health_types::HealthReport::default();
4036 let sarif = build_health_sarif(&report, &root);
4037 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
4038 .as_array()
4039 .unwrap();
4040 for rule in rules {
4041 let id = rule["id"].as_str().unwrap();
4042 assert!(
4043 rule.get("fullDescription").is_some(),
4044 "health rule {id} should have fullDescription"
4045 );
4046 assert!(
4047 rule.get("helpUri").is_some(),
4048 "health rule {id} should have helpUri"
4049 );
4050 }
4051 }
4052
4053 #[test]
4054 fn sarif_warn_severity_produces_warning_level() {
4055 let root = PathBuf::from("/project");
4056 let mut results = AnalysisResults::default();
4057 results
4058 .unused_files
4059 .push(UnusedFileFinding::with_actions(UnusedFile {
4060 path: root.join("src/dead.ts"),
4061 }));
4062
4063 let rules = RulesConfig {
4064 unused_files: Severity::Warn,
4065 ..RulesConfig::default()
4066 };
4067
4068 let sarif = build_sarif(&results, &root, &rules);
4069 let entry = &sarif["runs"][0]["results"][0];
4070 assert_eq!(entry["level"], "warning");
4071 }
4072
4073 #[test]
4074 fn sarif_unused_file_has_no_region() {
4075 let root = PathBuf::from("/project");
4076 let mut results = AnalysisResults::default();
4077 results
4078 .unused_files
4079 .push(UnusedFileFinding::with_actions(UnusedFile {
4080 path: root.join("src/dead.ts"),
4081 }));
4082
4083 let sarif = build_sarif(&results, &root, &RulesConfig::default());
4084 let entry = &sarif["runs"][0]["results"][0];
4085 let phys = &entry["locations"][0]["physicalLocation"];
4086 assert!(phys.get("region").is_none());
4087 }
4088
4089 #[test]
4090 fn sarif_unlisted_dep_multiple_import_sites() {
4091 let root = PathBuf::from("/project");
4092 let mut results = AnalysisResults::default();
4093 results
4094 .unlisted_dependencies
4095 .push(UnlistedDependencyFinding::with_actions(
4096 UnlistedDependency {
4097 package_name: "dotenv".to_string(),
4098 imported_from: vec![
4099 ImportSite {
4100 path: root.join("src/a.ts"),
4101 line: 1,
4102 col: 0,
4103 },
4104 ImportSite {
4105 path: root.join("src/b.ts"),
4106 line: 5,
4107 col: 0,
4108 },
4109 ],
4110 },
4111 ));
4112
4113 let sarif = build_sarif(&results, &root, &RulesConfig::default());
4114 let entries = sarif["runs"][0]["results"].as_array().unwrap();
4115 assert_eq!(entries.len(), 2);
4116 assert_eq!(
4117 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
4118 "src/a.ts"
4119 );
4120 assert_eq!(
4121 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
4122 "src/b.ts"
4123 );
4124 }
4125
4126 #[test]
4127 fn sarif_unlisted_dep_no_import_sites() {
4128 let root = PathBuf::from("/project");
4129 let mut results = AnalysisResults::default();
4130 results
4131 .unlisted_dependencies
4132 .push(UnlistedDependencyFinding::with_actions(
4133 UnlistedDependency {
4134 package_name: "phantom".to_string(),
4135 imported_from: vec![],
4136 },
4137 ));
4138
4139 let sarif = build_sarif(&results, &root, &RulesConfig::default());
4140 let entries = sarif["runs"][0]["results"].as_array().unwrap();
4141 assert!(entries.is_empty());
4142 }
4143}