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 call or import 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_sarif_results(sarif_results, &results.unused_dependencies, snippets, |d| {
1465 sarif_dep_fields(
1466 &d.dep,
1467 root,
1468 "fallow/unused-dependency",
1469 severity_to_sarif_level(rules.unused_dependencies),
1470 "dependencies",
1471 )
1472 });
1473 push_sarif_results(
1474 sarif_results,
1475 &results.unused_dev_dependencies,
1476 snippets,
1477 |d| {
1478 sarif_dep_fields(
1479 &d.dep,
1480 root,
1481 "fallow/unused-dev-dependency",
1482 severity_to_sarif_level(rules.unused_dev_dependencies),
1483 "devDependencies",
1484 )
1485 },
1486 );
1487 push_sarif_results(
1488 sarif_results,
1489 &results.unused_optional_dependencies,
1490 snippets,
1491 |d| {
1492 sarif_dep_fields(
1493 &d.dep,
1494 root,
1495 "fallow/unused-optional-dependency",
1496 severity_to_sarif_level(rules.unused_optional_dependencies),
1497 "optionalDependencies",
1498 )
1499 },
1500 );
1501 push_sarif_results(
1502 sarif_results,
1503 &results.type_only_dependencies,
1504 snippets,
1505 |d| {
1506 sarif_type_only_dep_fields(
1507 &d.dep,
1508 root,
1509 severity_to_sarif_level(rules.type_only_dependencies),
1510 )
1511 },
1512 );
1513 push_sarif_results(
1514 sarif_results,
1515 &results.test_only_dependencies,
1516 snippets,
1517 |d| {
1518 sarif_test_only_dep_fields(
1519 &d.dep,
1520 root,
1521 severity_to_sarif_level(rules.test_only_dependencies),
1522 )
1523 },
1524 );
1525}
1526
1527fn push_member_sarif_results(
1528 sarif_results: &mut Vec<serde_json::Value>,
1529 results: &AnalysisResults,
1530 root: &Path,
1531 rules: &RulesConfig,
1532 snippets: &mut SourceSnippetCache,
1533) {
1534 push_sarif_results(sarif_results, &results.unused_enum_members, snippets, |m| {
1535 sarif_member_fields(
1536 &m.member,
1537 root,
1538 "fallow/unused-enum-member",
1539 severity_to_sarif_level(rules.unused_enum_members),
1540 "Enum",
1541 )
1542 });
1543 push_sarif_results(
1544 sarif_results,
1545 &results.unused_class_members,
1546 snippets,
1547 |m| {
1548 sarif_member_fields(
1549 &m.member,
1550 root,
1551 "fallow/unused-class-member",
1552 severity_to_sarif_level(rules.unused_class_members),
1553 "Class",
1554 )
1555 },
1556 );
1557 push_sarif_results(
1558 sarif_results,
1559 &results.unused_store_members,
1560 snippets,
1561 |m| {
1562 sarif_member_fields(
1563 &m.member,
1564 root,
1565 "fallow/unused-store-member",
1566 severity_to_sarif_level(rules.unused_store_members),
1567 "Store",
1568 )
1569 },
1570 );
1571}
1572
1573fn push_misc_sarif_results(
1574 sarif_results: &mut Vec<serde_json::Value>,
1575 results: &AnalysisResults,
1576 root: &Path,
1577 rules: &RulesConfig,
1578 snippets: &mut SourceSnippetCache,
1579) {
1580 if !results.unlisted_dependencies.is_empty() {
1581 push_sarif_unlisted_deps(
1582 sarif_results,
1583 &results.unlisted_dependencies,
1584 root,
1585 severity_to_sarif_level(rules.unlisted_dependencies),
1586 snippets,
1587 );
1588 }
1589 if !results.duplicate_exports.is_empty() {
1590 push_sarif_duplicate_exports(
1591 sarif_results,
1592 &results.duplicate_exports,
1593 root,
1594 severity_to_sarif_level(rules.duplicate_exports),
1595 snippets,
1596 );
1597 }
1598}
1599
1600fn push_component_contract_sarif_results(
1604 sarif_results: &mut Vec<serde_json::Value>,
1605 results: &AnalysisResults,
1606 root: &Path,
1607 rules: &RulesConfig,
1608 snippets: &mut SourceSnippetCache,
1609) {
1610 push_sarif_results(
1611 sarif_results,
1612 &results.unused_component_props,
1613 snippets,
1614 |p| {
1615 sarif_unused_component_prop_fields(
1616 &p.prop,
1617 root,
1618 severity_to_sarif_level(rules.unused_component_props),
1619 )
1620 },
1621 );
1622 push_sarif_results(
1623 sarif_results,
1624 &results.unused_component_emits,
1625 snippets,
1626 |e| {
1627 sarif_unused_component_emit_fields(
1628 &e.emit,
1629 root,
1630 severity_to_sarif_level(rules.unused_component_emits),
1631 )
1632 },
1633 );
1634 push_sarif_results(
1635 sarif_results,
1636 &results.unused_component_inputs,
1637 snippets,
1638 |i| {
1639 sarif_unused_component_input_fields(
1640 &i.input,
1641 root,
1642 severity_to_sarif_level(rules.unused_component_inputs),
1643 )
1644 },
1645 );
1646 push_sarif_results(
1647 sarif_results,
1648 &results.unused_component_outputs,
1649 snippets,
1650 |o| {
1651 sarif_unused_component_output_fields(
1652 &o.output,
1653 root,
1654 severity_to_sarif_level(rules.unused_component_outputs),
1655 )
1656 },
1657 );
1658 push_sarif_results(
1659 sarif_results,
1660 &results.unused_svelte_events,
1661 snippets,
1662 |e| {
1663 sarif_unused_svelte_event_fields(
1664 &e.event,
1665 root,
1666 severity_to_sarif_level(rules.unused_svelte_events),
1667 )
1668 },
1669 );
1670 push_sarif_results(
1671 sarif_results,
1672 &results.unused_server_actions,
1673 snippets,
1674 |a| {
1675 sarif_unused_server_action_fields(
1676 &a.action,
1677 root,
1678 severity_to_sarif_level(rules.unused_server_actions),
1679 )
1680 },
1681 );
1682 push_sarif_results(
1683 sarif_results,
1684 &results.unused_load_data_keys,
1685 snippets,
1686 |k| {
1687 sarif_unused_load_data_key_fields(
1688 &k.key,
1689 root,
1690 severity_to_sarif_level(rules.unused_load_data_keys),
1691 )
1692 },
1693 );
1694 push_sarif_results(
1695 sarif_results,
1696 &results.prop_drilling_chains,
1697 snippets,
1698 |c| {
1699 sarif_prop_drilling_fields(&c.chain, root, severity_to_sarif_level(rules.prop_drilling))
1700 },
1701 );
1702 push_sarif_results(sarif_results, &results.thin_wrappers, snippets, |w| {
1703 sarif_thin_wrapper_fields(
1704 &w.wrapper,
1705 root,
1706 severity_to_sarif_level(rules.thin_wrapper),
1707 )
1708 });
1709 push_sarif_results(
1710 sarif_results,
1711 &results.duplicate_prop_shapes,
1712 snippets,
1713 |d| {
1714 sarif_duplicate_prop_shape_fields(
1715 &d.shape,
1716 root,
1717 severity_to_sarif_level(rules.duplicate_prop_shape),
1718 )
1719 },
1720 );
1721}
1722
1723fn push_graph_sarif_results(
1724 sarif_results: &mut Vec<serde_json::Value>,
1725 results: &AnalysisResults,
1726 root: &Path,
1727 rules: &RulesConfig,
1728 snippets: &mut SourceSnippetCache,
1729) {
1730 push_structure_sarif_results(sarif_results, results, root, rules, snippets);
1731 push_framework_sarif_results(sarif_results, results, root, rules, snippets);
1732 push_route_sarif_results(sarif_results, results, root, rules, snippets);
1733 push_suppression_sarif_results(sarif_results, results, root, rules, snippets);
1734}
1735
1736fn push_structure_sarif_results(
1737 sarif_results: &mut Vec<serde_json::Value>,
1738 results: &AnalysisResults,
1739 root: &Path,
1740 rules: &RulesConfig,
1741 snippets: &mut SourceSnippetCache,
1742) {
1743 push_sarif_results(
1744 sarif_results,
1745 &results.circular_dependencies,
1746 snippets,
1747 |c| {
1748 sarif_circular_dep_fields(
1749 &c.cycle,
1750 root,
1751 severity_to_sarif_level(rules.circular_dependencies),
1752 )
1753 },
1754 );
1755 push_sarif_results(sarif_results, &results.re_export_cycles, snippets, |c| {
1756 sarif_re_export_cycle_fields(
1757 &c.cycle,
1758 root,
1759 severity_to_sarif_level(rules.re_export_cycle),
1760 )
1761 });
1762 push_sarif_results(sarif_results, &results.boundary_violations, snippets, |v| {
1763 sarif_boundary_violation_fields(
1764 &v.violation,
1765 root,
1766 severity_to_sarif_level(rules.boundary_violation),
1767 )
1768 });
1769 push_sarif_results(
1770 sarif_results,
1771 &results.boundary_coverage_violations,
1772 snippets,
1773 |v| {
1774 sarif_boundary_coverage_fields(
1775 &v.violation,
1776 root,
1777 severity_to_sarif_level(rules.boundary_violation),
1778 )
1779 },
1780 );
1781 push_sarif_results(
1782 sarif_results,
1783 &results.boundary_call_violations,
1784 snippets,
1785 |v| {
1786 sarif_boundary_call_fields(
1787 &v.violation,
1788 root,
1789 severity_to_sarif_level(rules.boundary_violation),
1790 )
1791 },
1792 );
1793 push_sarif_results(sarif_results, &results.policy_violations, snippets, |v| {
1794 sarif_policy_violation_fields(&v.violation, root)
1795 });
1796}
1797
1798fn push_framework_sarif_results(
1799 sarif_results: &mut Vec<serde_json::Value>,
1800 results: &AnalysisResults,
1801 root: &Path,
1802 rules: &RulesConfig,
1803 snippets: &mut SourceSnippetCache,
1804) {
1805 push_sarif_results(
1806 sarif_results,
1807 &results.invalid_client_exports,
1808 snippets,
1809 |e| {
1810 sarif_invalid_client_export_fields(
1811 &e.export,
1812 root,
1813 severity_to_sarif_level(rules.invalid_client_export),
1814 )
1815 },
1816 );
1817 push_sarif_results(
1818 sarif_results,
1819 &results.mixed_client_server_barrels,
1820 snippets,
1821 |b| {
1822 sarif_mixed_client_server_barrel_fields(
1823 &b.barrel,
1824 root,
1825 severity_to_sarif_level(rules.mixed_client_server_barrel),
1826 )
1827 },
1828 );
1829 push_sarif_results(
1830 sarif_results,
1831 &results.misplaced_directives,
1832 snippets,
1833 |d| {
1834 sarif_misplaced_directive_fields(
1835 &d.directive_site,
1836 root,
1837 severity_to_sarif_level(rules.misplaced_directive),
1838 )
1839 },
1840 );
1841 push_sarif_results(sarif_results, &results.unprovided_injects, snippets, |i| {
1842 sarif_unprovided_inject_fields(
1843 &i.inject,
1844 root,
1845 severity_to_sarif_level(rules.unprovided_injects),
1846 )
1847 });
1848 push_sarif_results(
1849 sarif_results,
1850 &results.unrendered_components,
1851 snippets,
1852 |c| {
1853 sarif_unrendered_component_fields(
1854 &c.component,
1855 root,
1856 severity_to_sarif_level(rules.unrendered_components),
1857 )
1858 },
1859 );
1860 push_component_contract_sarif_results(sarif_results, results, root, rules, snippets);
1861}
1862
1863fn push_route_sarif_results(
1864 sarif_results: &mut Vec<serde_json::Value>,
1865 results: &AnalysisResults,
1866 root: &Path,
1867 rules: &RulesConfig,
1868 snippets: &mut SourceSnippetCache,
1869) {
1870 push_sarif_results(sarif_results, &results.route_collisions, snippets, |c| {
1871 sarif_route_collision_fields(
1872 &c.collision,
1873 root,
1874 severity_to_sarif_level(rules.route_collision),
1875 )
1876 });
1877 push_sarif_results(
1878 sarif_results,
1879 &results.dynamic_segment_name_conflicts,
1880 snippets,
1881 |c| {
1882 sarif_dynamic_segment_name_conflict_fields(
1883 &c.conflict,
1884 root,
1885 severity_to_sarif_level(rules.dynamic_segment_name_conflict),
1886 )
1887 },
1888 );
1889}
1890
1891fn push_suppression_sarif_results(
1892 sarif_results: &mut Vec<serde_json::Value>,
1893 results: &AnalysisResults,
1894 root: &Path,
1895 rules: &RulesConfig,
1896 snippets: &mut SourceSnippetCache,
1897) {
1898 push_sarif_results(sarif_results, &results.stale_suppressions, snippets, |s| {
1899 sarif_stale_suppression_fields(
1900 s,
1901 root,
1902 severity_to_sarif_level(stale_suppression_severity(s, rules)),
1903 )
1904 });
1905}
1906
1907fn push_catalog_sarif_results(
1908 sarif_results: &mut Vec<serde_json::Value>,
1909 results: &AnalysisResults,
1910 root: &Path,
1911 rules: &RulesConfig,
1912 snippets: &mut SourceSnippetCache,
1913) {
1914 push_sarif_results(
1915 sarif_results,
1916 &results.unused_catalog_entries,
1917 snippets,
1918 |e| {
1919 sarif_unused_catalog_entry_fields(
1920 e,
1921 root,
1922 severity_to_sarif_level(rules.unused_catalog_entries),
1923 )
1924 },
1925 );
1926 push_sarif_results(
1927 sarif_results,
1928 &results.empty_catalog_groups,
1929 snippets,
1930 |g| {
1931 sarif_empty_catalog_group_fields(
1932 g,
1933 root,
1934 severity_to_sarif_level(rules.empty_catalog_groups),
1935 )
1936 },
1937 );
1938 push_sarif_results(
1939 sarif_results,
1940 &results.unresolved_catalog_references,
1941 snippets,
1942 |f| {
1943 sarif_unresolved_catalog_reference_fields(
1944 f,
1945 root,
1946 severity_to_sarif_level(rules.unresolved_catalog_references),
1947 )
1948 },
1949 );
1950 push_sarif_results(
1951 sarif_results,
1952 &results.unused_dependency_overrides,
1953 snippets,
1954 |f| {
1955 sarif_unused_dependency_override_fields(
1956 f,
1957 root,
1958 severity_to_sarif_level(rules.unused_dependency_overrides),
1959 )
1960 },
1961 );
1962 push_sarif_results(
1963 sarif_results,
1964 &results.misconfigured_dependency_overrides,
1965 snippets,
1966 |f| {
1967 sarif_misconfigured_dependency_override_fields(
1968 f,
1969 root,
1970 severity_to_sarif_level(rules.misconfigured_dependency_overrides),
1971 )
1972 },
1973 );
1974}
1975
1976pub(super) fn print_sarif(results: &AnalysisResults, root: &Path, rules: &RulesConfig) -> ExitCode {
1977 let sarif = build_sarif(results, root, rules);
1978 emit_json(&sarif, "SARIF")
1979}
1980
1981#[expect(
1987 clippy::expect_used,
1988 reason = "grouped SARIF entries are JSON objects created by build_sarif"
1989)]
1990pub(super) fn print_grouped_sarif(
1991 results: &AnalysisResults,
1992 root: &Path,
1993 rules: &RulesConfig,
1994 resolver: &OwnershipResolver,
1995) -> ExitCode {
1996 let mut sarif = build_sarif(results, root, rules);
1997
1998 if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
1999 for run in runs {
2000 if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
2001 for result in results {
2002 let uri = result
2003 .pointer("/locations/0/physicalLocation/artifactLocation/uri")
2004 .and_then(|v| v.as_str())
2005 .unwrap_or("");
2006 let decoded = uri.replace("%5B", "[").replace("%5D", "]");
2007 let owner =
2008 grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
2009 let props = result
2010 .as_object_mut()
2011 .expect("SARIF result should be an object")
2012 .entry("properties")
2013 .or_insert_with(|| serde_json::json!({}));
2014 props
2015 .as_object_mut()
2016 .expect("properties should be an object")
2017 .insert("owner".to_string(), serde_json::Value::String(owner));
2018 }
2019 }
2020 }
2021 }
2022
2023 emit_json(&sarif, "SARIF")
2024}
2025
2026#[expect(
2027 clippy::cast_possible_truncation,
2028 reason = "line/col numbers are bounded by source size"
2029)]
2030pub(super) fn print_duplication_sarif(report: &DuplicationReport, root: &Path) -> ExitCode {
2031 let mut sarif_results = Vec::new();
2032 let mut snippets = SourceSnippetCache::default();
2033
2034 for (i, group) in report.clone_groups.iter().enumerate() {
2035 for instance in &group.instances {
2036 let uri = relative_uri(&instance.file, root);
2037 let source_snippet = snippets.line(&instance.file, instance.start_line as u32);
2038 sarif_results.push(sarif_result_with_snippet(
2039 "fallow/code-duplication",
2040 "warning",
2041 &format!(
2042 "Code clone group {} ({} lines, {} instances)",
2043 i + 1,
2044 group.line_count,
2045 group.instances.len()
2046 ),
2047 &uri,
2048 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
2049 source_snippet.as_deref(),
2050 ));
2051 }
2052 }
2053
2054 let sarif = serde_json::json!({
2055 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
2056 "version": "2.1.0",
2057 "runs": [{
2058 "tool": {
2059 "driver": {
2060 "name": "fallow",
2061 "version": env!("CARGO_PKG_VERSION"),
2062 "informationUri": "https://github.com/fallow-rs/fallow",
2063 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
2064 }
2065 },
2066 "results": sarif_results
2067 }]
2068 });
2069
2070 emit_json(&sarif, "SARIF")
2071}
2072
2073#[expect(
2084 clippy::cast_possible_truncation,
2085 reason = "line/col numbers are bounded by source size"
2086)]
2087#[expect(
2088 clippy::expect_used,
2089 reason = "duplication SARIF entries are JSON objects created by sarif_result_with_snippet"
2090)]
2091pub(super) fn print_grouped_duplication_sarif(
2092 report: &DuplicationReport,
2093 root: &Path,
2094 resolver: &OwnershipResolver,
2095) -> ExitCode {
2096 let mut sarif_results = Vec::new();
2097 let mut snippets = SourceSnippetCache::default();
2098
2099 for (i, group) in report.clone_groups.iter().enumerate() {
2100 let primary_owner = super::dupes_grouping::largest_owner(group, root, resolver);
2101 for instance in &group.instances {
2102 let uri = relative_uri(&instance.file, root);
2103 let source_snippet = snippets.line(&instance.file, instance.start_line as u32);
2104 let mut result = sarif_result_with_snippet(
2105 "fallow/code-duplication",
2106 "warning",
2107 &format!(
2108 "Code clone group {} ({} lines, {} instances)",
2109 i + 1,
2110 group.line_count,
2111 group.instances.len()
2112 ),
2113 &uri,
2114 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
2115 source_snippet.as_deref(),
2116 );
2117 let props = result
2118 .as_object_mut()
2119 .expect("SARIF result should be an object")
2120 .entry("properties")
2121 .or_insert_with(|| serde_json::json!({}));
2122 props
2123 .as_object_mut()
2124 .expect("properties should be an object")
2125 .insert(
2126 "group".to_string(),
2127 serde_json::Value::String(primary_owner.clone()),
2128 );
2129 sarif_results.push(result);
2130 }
2131 }
2132
2133 let sarif = serde_json::json!({
2134 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
2135 "version": "2.1.0",
2136 "runs": [{
2137 "tool": {
2138 "driver": {
2139 "name": "fallow",
2140 "version": env!("CARGO_PKG_VERSION"),
2141 "informationUri": "https://github.com/fallow-rs/fallow",
2142 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
2143 }
2144 },
2145 "results": sarif_results
2146 }]
2147 });
2148
2149 emit_json(&sarif, "SARIF")
2150}
2151
2152#[must_use]
2153pub fn build_health_sarif(
2154 report: &crate::health_types::HealthReport,
2155 root: &Path,
2156) -> serde_json::Value {
2157 let mut sarif_results = Vec::new();
2158 let mut snippets = SourceSnippetCache::default();
2159
2160 append_health_sarif_results(report, root, &mut sarif_results, &mut snippets);
2161 let health_rules = health_sarif_rules();
2162 health_sarif_document(&sarif_results, &health_rules)
2163}
2164
2165fn append_health_sarif_results(
2166 report: &crate::health_types::HealthReport,
2167 root: &Path,
2168 sarif_results: &mut Vec<serde_json::Value>,
2169 snippets: &mut SourceSnippetCache,
2170) {
2171 append_complexity_sarif_results(sarif_results, report, root, snippets);
2172
2173 if let Some(ref production) = report.runtime_coverage {
2174 append_runtime_coverage_sarif_results(sarif_results, production, root, snippets);
2175 }
2176 if let Some(ref intelligence) = report.coverage_intelligence {
2177 append_coverage_intelligence_sarif_results(sarif_results, intelligence, root, snippets);
2178 }
2179
2180 append_refactoring_target_sarif_results(sarif_results, report, root);
2181 append_coverage_gap_sarif_results(sarif_results, report, root, snippets);
2182}
2183
2184fn health_sarif_rules() -> Vec<serde_json::Value> {
2185 let mut rules = health_complexity_sarif_rules();
2186 rules.extend(health_runtime_sarif_rules());
2187 rules.extend(health_coverage_intelligence_sarif_rules());
2188 rules
2189}
2190
2191fn health_complexity_sarif_rules() -> Vec<serde_json::Value> {
2192 vec![
2193 sarif_rule(
2194 "fallow/high-cyclomatic-complexity",
2195 "Function has high cyclomatic complexity",
2196 "note",
2197 ),
2198 sarif_rule(
2199 "fallow/high-cognitive-complexity",
2200 "Function has high cognitive complexity",
2201 "note",
2202 ),
2203 sarif_rule(
2204 "fallow/high-complexity",
2205 "Function exceeds both complexity thresholds",
2206 "note",
2207 ),
2208 sarif_rule(
2209 "fallow/high-crap-score",
2210 "Function has a high CRAP score (high complexity combined with low coverage)",
2211 "warning",
2212 ),
2213 sarif_rule(
2214 "fallow/refactoring-target",
2215 "File identified as a high-priority refactoring candidate",
2216 "warning",
2217 ),
2218 ]
2219}
2220
2221fn health_runtime_sarif_rules() -> Vec<serde_json::Value> {
2222 vec![
2223 sarif_rule(
2224 "fallow/untested-file",
2225 "Runtime-reachable file has no test dependency path",
2226 "warning",
2227 ),
2228 sarif_rule(
2229 "fallow/untested-export",
2230 "Runtime-reachable export has no test dependency path",
2231 "warning",
2232 ),
2233 sarif_rule(
2234 "fallow/runtime-safe-to-delete",
2235 "Function is statically unused and was never invoked in production",
2236 "warning",
2237 ),
2238 sarif_rule(
2239 "fallow/runtime-review-required",
2240 "Function is statically used but was never invoked in production",
2241 "warning",
2242 ),
2243 sarif_rule(
2244 "fallow/runtime-low-traffic",
2245 "Function was invoked below the low-traffic threshold relative to total trace count",
2246 "note",
2247 ),
2248 sarif_rule(
2249 "fallow/runtime-coverage-unavailable",
2250 "Runtime coverage could not be resolved for this function",
2251 "note",
2252 ),
2253 sarif_rule(
2254 "fallow/runtime-coverage",
2255 "Runtime coverage finding",
2256 "note",
2257 ),
2258 ]
2259}
2260
2261fn health_coverage_intelligence_sarif_rules() -> Vec<serde_json::Value> {
2262 vec![
2263 sarif_rule(
2264 "fallow/coverage-intelligence-risky-change",
2265 "Changed hot path combines high CRAP and low test coverage",
2266 "warning",
2267 ),
2268 sarif_rule(
2269 "fallow/coverage-intelligence-delete",
2270 "Static and runtime evidence indicate code can be deleted",
2271 "warning",
2272 ),
2273 sarif_rule(
2274 "fallow/coverage-intelligence-review",
2275 "Cold reachable uncovered code needs owner review",
2276 "warning",
2277 ),
2278 sarif_rule(
2279 "fallow/coverage-intelligence-refactor",
2280 "Hot covered code has high CRAP and should be refactored carefully",
2281 "warning",
2282 ),
2283 ]
2284}
2285
2286fn health_sarif_document(
2287 sarif_results: &[serde_json::Value],
2288 health_rules: &[serde_json::Value],
2289) -> serde_json::Value {
2290 serde_json::json!({
2291 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
2292 "version": "2.1.0",
2293 "runs": [{
2294 "tool": {
2295 "driver": {
2296 "name": "fallow",
2297 "version": env!("CARGO_PKG_VERSION"),
2298 "informationUri": "https://github.com/fallow-rs/fallow",
2299 "rules": health_rules
2300 }
2301 },
2302 "results": sarif_results
2303 }]
2304 })
2305}
2306
2307fn append_complexity_sarif_results(
2308 sarif_results: &mut Vec<serde_json::Value>,
2309 report: &crate::health_types::HealthReport,
2310 root: &Path,
2311 snippets: &mut SourceSnippetCache,
2312) {
2313 for finding in &report.findings {
2314 let uri = relative_uri(&finding.path, root);
2315 let (rule_id, message) = health_complexity_sarif_message(finding, report);
2316 let level = match finding.severity {
2317 crate::health_types::FindingSeverity::Critical => "error",
2318 crate::health_types::FindingSeverity::High => "warning",
2319 crate::health_types::FindingSeverity::Moderate => "note",
2320 };
2321 let source_snippet = snippets.line(&finding.path, finding.line);
2322 sarif_results.push(sarif_result_with_snippet(
2323 rule_id,
2324 level,
2325 &message,
2326 &uri,
2327 Some((finding.line, finding.col + 1)),
2328 source_snippet.as_deref(),
2329 ));
2330 }
2331}
2332
2333fn health_complexity_sarif_message(
2334 finding: &crate::health_types::ComplexityViolation,
2335 report: &crate::health_types::HealthReport,
2336) -> (&'static str, String) {
2337 match finding.exceeded {
2338 crate::health_types::ExceededThreshold::Cyclomatic => (
2339 "fallow/high-cyclomatic-complexity",
2340 format!(
2341 "'{}' has cyclomatic complexity {} (threshold: {})",
2342 finding.name, finding.cyclomatic, report.summary.max_cyclomatic_threshold,
2343 ),
2344 ),
2345 crate::health_types::ExceededThreshold::Cognitive => (
2346 "fallow/high-cognitive-complexity",
2347 format!(
2348 "'{}' has cognitive complexity {} (threshold: {})",
2349 finding.name, finding.cognitive, report.summary.max_cognitive_threshold,
2350 ),
2351 ),
2352 crate::health_types::ExceededThreshold::Both => (
2353 "fallow/high-complexity",
2354 format!(
2355 "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
2356 finding.name,
2357 finding.cyclomatic,
2358 report.summary.max_cyclomatic_threshold,
2359 finding.cognitive,
2360 report.summary.max_cognitive_threshold,
2361 ),
2362 ),
2363 crate::health_types::ExceededThreshold::Crap
2364 | crate::health_types::ExceededThreshold::CyclomaticCrap
2365 | crate::health_types::ExceededThreshold::CognitiveCrap
2366 | crate::health_types::ExceededThreshold::All => {
2367 let crap = finding.crap.unwrap_or(0.0);
2368 let coverage = finding
2369 .coverage_pct
2370 .map(|pct| format!(", coverage {pct:.0}%"))
2371 .unwrap_or_default();
2372 (
2373 "fallow/high-crap-score",
2374 format!(
2375 "'{}' has CRAP score {:.1} (threshold: {:.1}, cyclomatic {}{})",
2376 finding.name,
2377 crap,
2378 report.summary.max_crap_threshold,
2379 finding.cyclomatic,
2380 coverage,
2381 ),
2382 )
2383 }
2384 }
2385}
2386
2387fn append_refactoring_target_sarif_results(
2388 sarif_results: &mut Vec<serde_json::Value>,
2389 report: &crate::health_types::HealthReport,
2390 root: &Path,
2391) {
2392 for target in &report.targets {
2393 let uri = relative_uri(&target.path, root);
2394 let message = format!(
2395 "[{}] {} (priority: {:.1}, efficiency: {:.1}, effort: {}, confidence: {})",
2396 target.category.label(),
2397 target.recommendation,
2398 target.priority,
2399 target.efficiency,
2400 target.effort.label(),
2401 target.confidence.label(),
2402 );
2403 sarif_results.push(sarif_result(
2404 "fallow/refactoring-target",
2405 "warning",
2406 &message,
2407 &uri,
2408 None,
2409 ));
2410 }
2411}
2412
2413fn append_coverage_gap_sarif_results(
2414 sarif_results: &mut Vec<serde_json::Value>,
2415 report: &crate::health_types::HealthReport,
2416 root: &Path,
2417 snippets: &mut SourceSnippetCache,
2418) {
2419 let Some(ref gaps) = report.coverage_gaps else {
2420 return;
2421 };
2422 for item in &gaps.files {
2423 let uri = relative_uri(&item.file.path, root);
2424 let message = format!(
2425 "File is runtime-reachable but has no test dependency path ({} value export{})",
2426 item.file.value_export_count,
2427 if item.file.value_export_count == 1 {
2428 ""
2429 } else {
2430 "s"
2431 },
2432 );
2433 sarif_results.push(sarif_result(
2434 "fallow/untested-file",
2435 "warning",
2436 &message,
2437 &uri,
2438 None,
2439 ));
2440 }
2441
2442 for item in &gaps.exports {
2443 let uri = relative_uri(&item.export.path, root);
2444 let message = format!(
2445 "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
2446 item.export.export_name
2447 );
2448 let source_snippet = snippets.line(&item.export.path, item.export.line);
2449 sarif_results.push(sarif_result_with_snippet(
2450 "fallow/untested-export",
2451 "warning",
2452 &message,
2453 &uri,
2454 Some((item.export.line, item.export.col + 1)),
2455 source_snippet.as_deref(),
2456 ));
2457 }
2458}
2459
2460fn append_runtime_coverage_sarif_results(
2461 sarif_results: &mut Vec<serde_json::Value>,
2462 production: &crate::health_types::RuntimeCoverageReport,
2463 root: &Path,
2464 snippets: &mut SourceSnippetCache,
2465) {
2466 for finding in &production.findings {
2467 let uri = relative_uri(&finding.path, root);
2468 let rule_id = match finding.verdict {
2469 crate::health_types::RuntimeCoverageVerdict::SafeToDelete => {
2470 "fallow/runtime-safe-to-delete"
2471 }
2472 crate::health_types::RuntimeCoverageVerdict::ReviewRequired => {
2473 "fallow/runtime-review-required"
2474 }
2475 crate::health_types::RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
2476 crate::health_types::RuntimeCoverageVerdict::CoverageUnavailable => {
2477 "fallow/runtime-coverage-unavailable"
2478 }
2479 crate::health_types::RuntimeCoverageVerdict::Active
2480 | crate::health_types::RuntimeCoverageVerdict::Unknown => "fallow/runtime-coverage",
2481 };
2482 let level = match finding.verdict {
2483 crate::health_types::RuntimeCoverageVerdict::SafeToDelete
2484 | crate::health_types::RuntimeCoverageVerdict::ReviewRequired => "warning",
2485 _ => "note",
2486 };
2487 let invocations_hint = finding.invocations.map_or_else(
2488 || "untracked".to_owned(),
2489 |hits| format!("{hits} invocations"),
2490 );
2491 let message = format!(
2492 "'{}' runtime coverage verdict: {} ({})",
2493 finding.function,
2494 finding.verdict.human_label(),
2495 invocations_hint,
2496 );
2497 let source_snippet = snippets.line(&finding.path, finding.line);
2498 sarif_results.push(sarif_result_with_snippet(
2499 rule_id,
2500 level,
2501 &message,
2502 &uri,
2503 Some((finding.line, 1)),
2504 source_snippet.as_deref(),
2505 ));
2506 }
2507}
2508
2509fn append_coverage_intelligence_sarif_results(
2510 sarif_results: &mut Vec<serde_json::Value>,
2511 intelligence: &crate::health_types::CoverageIntelligenceReport,
2512 root: &Path,
2513 snippets: &mut SourceSnippetCache,
2514) {
2515 for finding in &intelligence.findings {
2516 let rule_id = coverage_intelligence_rule_id(finding.recommendation);
2517 let level = match finding.verdict {
2518 crate::health_types::CoverageIntelligenceVerdict::Clean
2519 | crate::health_types::CoverageIntelligenceVerdict::Unknown => continue,
2520 _ => "warning",
2521 };
2522 let uri = relative_uri(&finding.path, root);
2523 let identity = finding.identity.as_deref().unwrap_or("code");
2524 let signals = finding
2525 .signals
2526 .iter()
2527 .map(ToString::to_string)
2528 .collect::<Vec<_>>()
2529 .join(", ");
2530 let message = format!(
2531 "'{}' coverage intelligence verdict: {} ({}, signals: {})",
2532 identity, finding.verdict, finding.recommendation, signals,
2533 );
2534 let source_snippet = snippets.line(&finding.path, finding.line);
2535 let mut result = sarif_result_with_snippet(
2536 rule_id,
2537 level,
2538 &message,
2539 &uri,
2540 Some((finding.line, 1)),
2541 source_snippet.as_deref(),
2542 );
2543 result["properties"] = serde_json::json!({
2544 "coverage_intelligence_id": &finding.id,
2545 "verdict": finding.verdict,
2546 "recommendation": finding.recommendation,
2547 "confidence": finding.confidence,
2548 "signals": &finding.signals,
2549 "related_ids": &finding.related_ids,
2550 });
2551 sarif_results.push(result);
2552 }
2553}
2554
2555fn coverage_intelligence_rule_id(
2556 recommendation: crate::health_types::CoverageIntelligenceRecommendation,
2557) -> &'static str {
2558 match recommendation {
2559 crate::health_types::CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge => {
2560 "fallow/coverage-intelligence-risky-change"
2561 }
2562 crate::health_types::CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner => {
2563 "fallow/coverage-intelligence-delete"
2564 }
2565 crate::health_types::CoverageIntelligenceRecommendation::ReviewBeforeChanging => {
2566 "fallow/coverage-intelligence-review"
2567 }
2568 crate::health_types::CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior => {
2569 "fallow/coverage-intelligence-refactor"
2570 }
2571 }
2572}
2573
2574pub(super) fn print_health_sarif(
2575 report: &crate::health_types::HealthReport,
2576 root: &Path,
2577) -> ExitCode {
2578 let sarif = build_health_sarif(report, root);
2579 emit_json(&sarif, "SARIF")
2580}
2581
2582#[expect(
2593 clippy::expect_used,
2594 reason = "grouped health SARIF entries are JSON objects created by build_health_sarif"
2595)]
2596pub(super) fn print_grouped_health_sarif(
2597 report: &crate::health_types::HealthReport,
2598 root: &Path,
2599 resolver: &OwnershipResolver,
2600) -> ExitCode {
2601 let mut sarif = build_health_sarif(report, root);
2602
2603 if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
2604 for run in runs {
2605 if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
2606 for result in results {
2607 let uri = result
2608 .pointer("/locations/0/physicalLocation/artifactLocation/uri")
2609 .and_then(|v| v.as_str())
2610 .unwrap_or("");
2611 let decoded = uri.replace("%5B", "[").replace("%5D", "]");
2612 let group =
2613 grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
2614 let props = result
2615 .as_object_mut()
2616 .expect("SARIF result should be an object")
2617 .entry("properties")
2618 .or_insert_with(|| serde_json::json!({}));
2619 props
2620 .as_object_mut()
2621 .expect("properties should be an object")
2622 .insert("group".to_string(), serde_json::Value::String(group));
2623 }
2624 }
2625 }
2626 }
2627
2628 emit_json(&sarif, "SARIF")
2629}
2630
2631#[cfg(test)]
2632mod tests {
2633 use super::*;
2634 use crate::report::test_helpers::sample_results;
2635 use fallow_core::results::*;
2636 use std::path::PathBuf;
2637
2638 #[test]
2639 fn sarif_has_required_top_level_fields() {
2640 let root = PathBuf::from("/project");
2641 let results = AnalysisResults::default();
2642 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2643
2644 assert_eq!(
2645 sarif["$schema"],
2646 "https://json.schemastore.org/sarif-2.1.0.json"
2647 );
2648 assert_eq!(sarif["version"], "2.1.0");
2649 assert!(sarif["runs"].is_array());
2650 }
2651
2652 #[test]
2653 fn sarif_missing_suppression_reason_uses_reason_rule_severity() {
2654 let root = PathBuf::from("/project");
2655 let mut results = AnalysisResults::default();
2656 results.stale_suppressions.push(StaleSuppression {
2657 path: root.join("src/file.ts"),
2658 line: 1,
2659 col: 0,
2660 origin: SuppressionOrigin::Comment {
2661 issue_kind: Some("unused-exports".to_string()),
2662 reason: None,
2663 is_file_level: false,
2664 kind_known: true,
2665 },
2666 missing_reason: true,
2667 actions: StaleSuppression::actions_for(true),
2668 });
2669 let rules = RulesConfig {
2670 stale_suppressions: Severity::Off,
2671 require_suppression_reason: Severity::Error,
2672 ..Default::default()
2673 };
2674
2675 let sarif = build_sarif(&results, &root, &rules);
2676
2677 assert_eq!(
2678 sarif["runs"][0]["results"][0]["ruleId"],
2679 "fallow/missing-suppression-reason"
2680 );
2681 assert_eq!(sarif["runs"][0]["results"][0]["level"], "error");
2682 assert!(
2683 sarif["runs"][0]["tool"]["driver"]["rules"]
2684 .as_array()
2685 .unwrap()
2686 .iter()
2687 .any(|rule| rule["id"].as_str().unwrap() == "fallow/missing-suppression-reason")
2688 );
2689 }
2690
2691 #[test]
2692 fn sarif_stale_and_missing_suppression_have_distinct_identities() {
2693 let root = PathBuf::from("/project");
2694 let mut results = AnalysisResults::default();
2695 let origin = SuppressionOrigin::Comment {
2696 issue_kind: Some("unused-exports".to_string()),
2697 reason: None,
2698 is_file_level: false,
2699 kind_known: true,
2700 };
2701 results.stale_suppressions.push(StaleSuppression {
2702 path: root.join("src/file.ts"),
2703 line: 1,
2704 col: 0,
2705 origin: origin.clone(),
2706 missing_reason: false,
2707 actions: StaleSuppression::actions_for(false),
2708 });
2709 results.stale_suppressions.push(StaleSuppression {
2710 path: root.join("src/file.ts"),
2711 line: 1,
2712 col: 0,
2713 origin,
2714 missing_reason: true,
2715 actions: StaleSuppression::actions_for(true),
2716 });
2717 let rules = RulesConfig {
2718 stale_suppressions: Severity::Warn,
2719 require_suppression_reason: Severity::Error,
2720 ..Default::default()
2721 };
2722
2723 let sarif = build_sarif(&results, &root, &rules);
2724 let results = sarif["runs"][0]["results"].as_array().unwrap();
2725
2726 assert_eq!(results[0]["ruleId"], "fallow/stale-suppression");
2727 assert_eq!(results[1]["ruleId"], "fallow/missing-suppression-reason");
2728 assert_ne!(
2729 results[0]["partialFingerprints"][fingerprint::FINGERPRINT_KEY],
2730 results[1]["partialFingerprints"][fingerprint::FINGERPRINT_KEY]
2731 );
2732 }
2733
2734 #[test]
2735 fn sarif_has_tool_driver_info() {
2736 let root = PathBuf::from("/project");
2737 let results = AnalysisResults::default();
2738 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2739
2740 let driver = &sarif["runs"][0]["tool"]["driver"];
2741 assert_eq!(driver["name"], "fallow");
2742 assert!(driver["version"].is_string());
2743 assert_eq!(
2744 driver["informationUri"],
2745 "https://github.com/fallow-rs/fallow"
2746 );
2747 }
2748
2749 #[test]
2750 fn sarif_declares_all_rules() {
2751 let root = PathBuf::from("/project");
2752 let results = AnalysisResults::default();
2753 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2754
2755 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
2756 .as_array()
2757 .expect("rules should be an array");
2758 assert_eq!(rules.len(), 45);
2759
2760 let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
2761 assert!(rule_ids.contains(&"fallow/duplicate-prop-shape"));
2762 assert!(rule_ids.contains(&"fallow/thin-wrapper"));
2763 assert!(rule_ids.contains(&"fallow/unrendered-component"));
2764 assert!(rule_ids.contains(&"fallow/unused-component-prop"));
2765 assert!(rule_ids.contains(&"fallow/unused-component-emit"));
2766 assert!(rule_ids.contains(&"fallow/unused-component-input"));
2767 assert!(rule_ids.contains(&"fallow/unused-component-output"));
2768 assert!(rule_ids.contains(&"fallow/unused-svelte-event"));
2769 assert!(rule_ids.contains(&"fallow/unused-server-action"));
2770 assert!(rule_ids.contains(&"fallow/unused-load-data-key"));
2771 assert!(rule_ids.contains(&"fallow/prop-drilling"));
2772 assert!(rule_ids.contains(&"fallow/route-collision"));
2773 assert!(rule_ids.contains(&"fallow/dynamic-segment-name-conflict"));
2774 assert!(rule_ids.contains(&"fallow/unused-file"));
2775 assert!(rule_ids.contains(&"fallow/unused-export"));
2776 assert!(rule_ids.contains(&"fallow/unused-type"));
2777 assert!(rule_ids.contains(&"fallow/private-type-leak"));
2778 assert!(rule_ids.contains(&"fallow/unused-dependency"));
2779 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
2780 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
2781 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
2782 assert!(rule_ids.contains(&"fallow/test-only-dependency"));
2783 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
2784 assert!(rule_ids.contains(&"fallow/unused-class-member"));
2785 assert!(rule_ids.contains(&"fallow/unused-store-member"));
2786 assert!(rule_ids.contains(&"fallow/unresolved-import"));
2787 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
2788 assert!(rule_ids.contains(&"fallow/duplicate-export"));
2789 assert!(rule_ids.contains(&"fallow/circular-dependency"));
2790 assert!(rule_ids.contains(&"fallow/re-export-cycle"));
2791 assert!(rule_ids.contains(&"fallow/boundary-violation"));
2792 assert!(rule_ids.contains(&"fallow/boundary-coverage"));
2793 assert!(rule_ids.contains(&"fallow/boundary-call-violation"));
2794 assert!(rule_ids.contains(&"fallow/policy-violation"));
2795 assert!(rule_ids.contains(&"fallow/unused-catalog-entry"));
2796 assert!(rule_ids.contains(&"fallow/empty-catalog-group"));
2797 assert!(rule_ids.contains(&"fallow/unresolved-catalog-reference"));
2798 assert!(rule_ids.contains(&"fallow/unused-dependency-override"));
2799 assert!(rule_ids.contains(&"fallow/misconfigured-dependency-override"));
2800 assert!(rule_ids.contains(&"fallow/invalid-client-export"));
2801 assert!(rule_ids.contains(&"fallow/mixed-client-server-barrel"));
2802 assert!(rule_ids.contains(&"fallow/misplaced-directive"));
2803 assert!(rule_ids.contains(&"fallow/unprovided-inject"));
2804 }
2805
2806 #[test]
2807 fn sarif_empty_results_no_results_entries() {
2808 let root = PathBuf::from("/project");
2809 let results = AnalysisResults::default();
2810 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2811
2812 let sarif_results = sarif["runs"][0]["results"]
2813 .as_array()
2814 .expect("results should be an array");
2815 assert!(sarif_results.is_empty());
2816 }
2817
2818 #[test]
2819 fn sarif_unused_file_result() {
2820 let root = PathBuf::from("/project");
2821 let mut results = AnalysisResults::default();
2822 results
2823 .unused_files
2824 .push(UnusedFileFinding::with_actions(UnusedFile {
2825 path: root.join("src/dead.ts"),
2826 }));
2827
2828 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2829 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2830 assert_eq!(entries.len(), 1);
2831
2832 let entry = &entries[0];
2833 assert_eq!(entry["ruleId"], "fallow/unused-file");
2834 assert_eq!(entry["level"], "error");
2835 assert_eq!(
2836 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2837 "src/dead.ts"
2838 );
2839 }
2840
2841 #[test]
2842 fn sarif_unused_export_includes_region() {
2843 let root = PathBuf::from("/project");
2844 let mut results = AnalysisResults::default();
2845 results
2846 .unused_exports
2847 .push(UnusedExportFinding::with_actions(UnusedExport {
2848 path: root.join("src/utils.ts"),
2849 export_name: "helperFn".to_string(),
2850 is_type_only: false,
2851 line: 10,
2852 col: 4,
2853 span_start: 120,
2854 is_re_export: false,
2855 }));
2856
2857 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2858 let entry = &sarif["runs"][0]["results"][0];
2859 assert_eq!(entry["ruleId"], "fallow/unused-export");
2860
2861 let region = &entry["locations"][0]["physicalLocation"]["region"];
2862 assert_eq!(region["startLine"], 10);
2863 assert_eq!(region["startColumn"], 5);
2864 }
2865
2866 #[test]
2867 fn sarif_unresolved_import_is_error_level() {
2868 let root = PathBuf::from("/project");
2869 let mut results = AnalysisResults::default();
2870 results
2871 .unresolved_imports
2872 .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
2873 path: root.join("src/app.ts"),
2874 specifier: "./missing".to_string(),
2875 line: 1,
2876 col: 0,
2877 specifier_col: 0,
2878 }));
2879
2880 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2881 let entry = &sarif["runs"][0]["results"][0];
2882 assert_eq!(entry["ruleId"], "fallow/unresolved-import");
2883 assert_eq!(entry["level"], "error");
2884 }
2885
2886 #[test]
2887 fn sarif_unlisted_dependency_points_to_import_site() {
2888 let root = PathBuf::from("/project");
2889 let mut results = AnalysisResults::default();
2890 results
2891 .unlisted_dependencies
2892 .push(UnlistedDependencyFinding::with_actions(
2893 UnlistedDependency {
2894 package_name: "chalk".to_string(),
2895 imported_from: vec![ImportSite {
2896 path: root.join("src/cli.ts"),
2897 line: 3,
2898 col: 0,
2899 }],
2900 },
2901 ));
2902
2903 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2904 let entry = &sarif["runs"][0]["results"][0];
2905 assert_eq!(entry["ruleId"], "fallow/unlisted-dependency");
2906 assert_eq!(entry["level"], "error");
2907 assert_eq!(
2908 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2909 "src/cli.ts"
2910 );
2911 let region = &entry["locations"][0]["physicalLocation"]["region"];
2912 assert_eq!(region["startLine"], 3);
2913 assert_eq!(region["startColumn"], 1);
2914 }
2915
2916 #[test]
2917 fn sarif_dependency_issues_point_to_package_json() {
2918 let root = PathBuf::from("/project");
2919 let mut results = AnalysisResults::default();
2920 results
2921 .unused_dependencies
2922 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2923 package_name: "lodash".to_string(),
2924 location: DependencyLocation::Dependencies,
2925 path: root.join("package.json"),
2926 line: 5,
2927 used_in_workspaces: Vec::new(),
2928 }));
2929 results
2930 .unused_dev_dependencies
2931 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
2932 package_name: "jest".to_string(),
2933 location: DependencyLocation::DevDependencies,
2934 path: root.join("package.json"),
2935 line: 5,
2936 used_in_workspaces: Vec::new(),
2937 }));
2938
2939 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2940 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2941 for entry in entries {
2942 assert_eq!(
2943 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2944 "package.json"
2945 );
2946 }
2947 }
2948
2949 #[test]
2950 fn sarif_duplicate_export_emits_one_result_per_location() {
2951 let root = PathBuf::from("/project");
2952 let mut results = AnalysisResults::default();
2953 results
2954 .duplicate_exports
2955 .push(DuplicateExportFinding::with_actions(DuplicateExport {
2956 export_name: "Config".to_string(),
2957 locations: vec![
2958 DuplicateLocation {
2959 path: root.join("src/a.ts"),
2960 line: 15,
2961 col: 0,
2962 },
2963 DuplicateLocation {
2964 path: root.join("src/b.ts"),
2965 line: 30,
2966 col: 0,
2967 },
2968 ],
2969 }));
2970
2971 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2972 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2973 assert_eq!(entries.len(), 2);
2974 assert_eq!(entries[0]["ruleId"], "fallow/duplicate-export");
2975 assert_eq!(entries[1]["ruleId"], "fallow/duplicate-export");
2976 assert_eq!(
2977 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2978 "src/a.ts"
2979 );
2980 assert_eq!(
2981 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2982 "src/b.ts"
2983 );
2984 }
2985
2986 #[test]
2987 fn sarif_all_issue_types_produce_results() {
2988 let root = PathBuf::from("/project");
2989 let results = sample_results(&root);
2990 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2991
2992 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2993 assert_eq!(entries.len(), results.total_issues() + 1);
2994
2995 let rule_ids: Vec<&str> = entries
2996 .iter()
2997 .map(|e| e["ruleId"].as_str().unwrap())
2998 .collect();
2999 assert!(rule_ids.contains(&"fallow/unused-file"));
3000 assert!(rule_ids.contains(&"fallow/unused-export"));
3001 assert!(rule_ids.contains(&"fallow/unused-type"));
3002 assert!(rule_ids.contains(&"fallow/unused-dependency"));
3003 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
3004 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
3005 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
3006 assert!(rule_ids.contains(&"fallow/test-only-dependency"));
3007 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
3008 assert!(rule_ids.contains(&"fallow/unused-class-member"));
3009 assert!(rule_ids.contains(&"fallow/unused-store-member"));
3010 assert!(rule_ids.contains(&"fallow/unresolved-import"));
3011 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
3012 assert!(rule_ids.contains(&"fallow/duplicate-export"));
3013 assert!(rule_ids.contains(&"fallow/unprovided-inject"));
3014 }
3015
3016 #[test]
3017 fn sarif_serializes_to_valid_json() {
3018 let root = PathBuf::from("/project");
3019 let results = sample_results(&root);
3020 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3021
3022 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
3023 let reparsed: serde_json::Value =
3024 serde_json::from_str(&json_str).expect("SARIF output should be valid JSON");
3025 assert_eq!(reparsed, sarif);
3026 }
3027
3028 #[test]
3029 fn sarif_file_write_produces_valid_sarif() {
3030 let root = PathBuf::from("/project");
3031 let results = sample_results(&root);
3032 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3033 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
3034
3035 let dir = std::env::temp_dir().join("fallow-test-sarif-file");
3036 let _ = std::fs::create_dir_all(&dir);
3037 let sarif_path = dir.join("results.sarif");
3038 std::fs::write(&sarif_path, &json_str).expect("should write SARIF file");
3039
3040 let contents = std::fs::read_to_string(&sarif_path).expect("should read SARIF file");
3041 let parsed: serde_json::Value =
3042 serde_json::from_str(&contents).expect("file should contain valid JSON");
3043
3044 assert_eq!(parsed["version"], "2.1.0");
3045 assert_eq!(
3046 parsed["$schema"],
3047 "https://json.schemastore.org/sarif-2.1.0.json"
3048 );
3049 let sarif_results = parsed["runs"][0]["results"]
3050 .as_array()
3051 .expect("results should be an array");
3052 assert!(!sarif_results.is_empty());
3053
3054 let _ = std::fs::remove_file(&sarif_path);
3055 let _ = std::fs::remove_dir(&dir);
3056 }
3057
3058 #[test]
3059 fn health_sarif_empty_no_results() {
3060 let root = PathBuf::from("/project");
3061 let report = crate::health_types::HealthReport {
3062 summary: crate::health_types::HealthSummary {
3063 files_analyzed: 10,
3064 functions_analyzed: 50,
3065 ..Default::default()
3066 },
3067 ..Default::default()
3068 };
3069 let sarif = build_health_sarif(&report, &root);
3070 assert_eq!(sarif["version"], "2.1.0");
3071 let results = sarif["runs"][0]["results"].as_array().unwrap();
3072 assert!(results.is_empty());
3073 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
3074 .as_array()
3075 .unwrap();
3076 assert_eq!(rules.len(), 16);
3077 }
3078
3079 #[test]
3080 fn health_sarif_coverage_intelligence_preserves_structured_properties() {
3081 use crate::health_types::{
3082 CoverageIntelligenceAction, CoverageIntelligenceConfidence,
3083 CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
3084 CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
3085 CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
3086 CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
3087 HealthReport, HealthSummary,
3088 };
3089
3090 let root = PathBuf::from("/project");
3091 let report = HealthReport {
3092 summary: HealthSummary {
3093 files_analyzed: 10,
3094 functions_analyzed: 50,
3095 ..Default::default()
3096 },
3097 coverage_intelligence: Some(CoverageIntelligenceReport {
3098 schema_version: CoverageIntelligenceSchemaVersion::V1,
3099 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
3100 summary: CoverageIntelligenceSummary {
3101 findings: 1,
3102 high_confidence_deletes: 1,
3103 ..Default::default()
3104 },
3105 findings: vec![CoverageIntelligenceFinding {
3106 id: "fallow:coverage-intel:abc123".to_owned(),
3107 path: root.join("src/dead.ts"),
3108 identity: Some("deadPath".to_owned()),
3109 line: 9,
3110 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
3111 signals: vec![CoverageIntelligenceSignal::RuntimeCold],
3112 recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
3113 confidence: CoverageIntelligenceConfidence::High,
3114 related_ids: vec!["fallow:prod:deadbeef".to_owned()],
3115 evidence: CoverageIntelligenceEvidence {
3116 match_confidence: CoverageIntelligenceMatchConfidence::Direct,
3117 ..Default::default()
3118 },
3119 actions: vec![CoverageIntelligenceAction {
3120 kind: "delete-after-confirming-owner".to_owned(),
3121 description: "Confirm ownership".to_owned(),
3122 auto_fixable: false,
3123 }],
3124 }],
3125 }),
3126 ..Default::default()
3127 };
3128
3129 let sarif = build_health_sarif(&report, &root);
3130 let result = &sarif["runs"][0]["results"][0];
3131 assert_eq!(result["ruleId"], "fallow/coverage-intelligence-delete");
3132 assert_eq!(
3133 result["properties"]["coverage_intelligence_id"],
3134 "fallow:coverage-intel:abc123"
3135 );
3136 assert_eq!(
3137 result["properties"]["recommendation"],
3138 "delete-after-confirming-owner"
3139 );
3140 assert_eq!(result["properties"]["confidence"], "high");
3141 assert_eq!(result["properties"]["signals"][0], "runtime_cold");
3142 assert_eq!(
3143 result["properties"]["related_ids"][0],
3144 "fallow:prod:deadbeef"
3145 );
3146 }
3147
3148 #[test]
3149 fn health_sarif_cyclomatic_only() {
3150 let root = PathBuf::from("/project");
3151 let report = crate::health_types::HealthReport {
3152 findings: vec![
3153 crate::health_types::ComplexityViolation {
3154 path: root.join("src/utils.ts"),
3155 name: "parseExpression".to_string(),
3156 line: 42,
3157 col: 0,
3158 cyclomatic: 25,
3159 cognitive: 10,
3160 line_count: 80,
3161 param_count: 0,
3162 react_hook_count: 0,
3163 react_jsx_max_depth: 0,
3164 react_prop_count: 0,
3165 react_hook_profile: None,
3166 exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
3167 severity: crate::health_types::FindingSeverity::High,
3168 crap: None,
3169 coverage_pct: None,
3170 coverage_tier: None,
3171 coverage_source: None,
3172 inherited_from: None,
3173 component_rollup: None,
3174 contributions: Vec::new(),
3175 effective_thresholds: None,
3176 threshold_source: None,
3177 }
3178 .into(),
3179 ],
3180 summary: crate::health_types::HealthSummary {
3181 files_analyzed: 5,
3182 functions_analyzed: 20,
3183 functions_above_threshold: 1,
3184 ..Default::default()
3185 },
3186 ..Default::default()
3187 };
3188 let sarif = build_health_sarif(&report, &root);
3189 let entry = &sarif["runs"][0]["results"][0];
3190 assert_eq!(entry["ruleId"], "fallow/high-cyclomatic-complexity");
3191 assert_eq!(entry["level"], "warning");
3192 assert!(
3193 entry["message"]["text"]
3194 .as_str()
3195 .unwrap()
3196 .contains("cyclomatic complexity 25")
3197 );
3198 assert_eq!(
3199 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3200 "src/utils.ts"
3201 );
3202 let region = &entry["locations"][0]["physicalLocation"]["region"];
3203 assert_eq!(region["startLine"], 42);
3204 assert_eq!(region["startColumn"], 1);
3205 }
3206
3207 #[test]
3208 fn health_sarif_cognitive_only() {
3209 let root = PathBuf::from("/project");
3210 let report = crate::health_types::HealthReport {
3211 findings: vec![
3212 crate::health_types::ComplexityViolation {
3213 path: root.join("src/api.ts"),
3214 name: "handleRequest".to_string(),
3215 line: 10,
3216 col: 4,
3217 cyclomatic: 8,
3218 cognitive: 20,
3219 line_count: 40,
3220 param_count: 0,
3221 react_hook_count: 0,
3222 react_jsx_max_depth: 0,
3223 react_prop_count: 0,
3224 react_hook_profile: None,
3225 exceeded: crate::health_types::ExceededThreshold::Cognitive,
3226 severity: crate::health_types::FindingSeverity::High,
3227 crap: None,
3228 coverage_pct: None,
3229 coverage_tier: None,
3230 coverage_source: None,
3231 inherited_from: None,
3232 component_rollup: None,
3233 contributions: Vec::new(),
3234 effective_thresholds: None,
3235 threshold_source: None,
3236 }
3237 .into(),
3238 ],
3239 summary: crate::health_types::HealthSummary {
3240 files_analyzed: 3,
3241 functions_analyzed: 10,
3242 functions_above_threshold: 1,
3243 ..Default::default()
3244 },
3245 ..Default::default()
3246 };
3247 let sarif = build_health_sarif(&report, &root);
3248 let entry = &sarif["runs"][0]["results"][0];
3249 assert_eq!(entry["ruleId"], "fallow/high-cognitive-complexity");
3250 assert!(
3251 entry["message"]["text"]
3252 .as_str()
3253 .unwrap()
3254 .contains("cognitive complexity 20")
3255 );
3256 let region = &entry["locations"][0]["physicalLocation"]["region"];
3257 assert_eq!(region["startColumn"], 5); }
3259
3260 #[test]
3261 fn health_sarif_both_thresholds() {
3262 let root = PathBuf::from("/project");
3263 let report = crate::health_types::HealthReport {
3264 findings: vec![
3265 crate::health_types::ComplexityViolation {
3266 path: root.join("src/complex.ts"),
3267 name: "doEverything".to_string(),
3268 line: 1,
3269 col: 0,
3270 cyclomatic: 30,
3271 cognitive: 45,
3272 line_count: 100,
3273 param_count: 0,
3274 react_hook_count: 0,
3275 react_jsx_max_depth: 0,
3276 react_prop_count: 0,
3277 react_hook_profile: None,
3278 exceeded: crate::health_types::ExceededThreshold::Both,
3279 severity: crate::health_types::FindingSeverity::High,
3280 crap: None,
3281 coverage_pct: None,
3282 coverage_tier: None,
3283 coverage_source: None,
3284 inherited_from: None,
3285 component_rollup: None,
3286 contributions: Vec::new(),
3287 effective_thresholds: None,
3288 threshold_source: None,
3289 }
3290 .into(),
3291 ],
3292 summary: crate::health_types::HealthSummary {
3293 files_analyzed: 1,
3294 functions_analyzed: 1,
3295 functions_above_threshold: 1,
3296 ..Default::default()
3297 },
3298 ..Default::default()
3299 };
3300 let sarif = build_health_sarif(&report, &root);
3301 let entry = &sarif["runs"][0]["results"][0];
3302 assert_eq!(entry["ruleId"], "fallow/high-complexity");
3303 let msg = entry["message"]["text"].as_str().unwrap();
3304 assert!(msg.contains("cyclomatic complexity 30"));
3305 assert!(msg.contains("cognitive complexity 45"));
3306 }
3307
3308 #[test]
3309 fn health_sarif_crap_only_emits_crap_rule() {
3310 let root = PathBuf::from("/project");
3311 let report = crate::health_types::HealthReport {
3312 findings: vec![
3313 crate::health_types::ComplexityViolation {
3314 path: root.join("src/untested.ts"),
3315 name: "risky".to_string(),
3316 line: 8,
3317 col: 0,
3318 cyclomatic: 10,
3319 cognitive: 10,
3320 line_count: 20,
3321 param_count: 1,
3322 react_hook_count: 0,
3323 react_jsx_max_depth: 0,
3324 react_prop_count: 0,
3325 react_hook_profile: None,
3326 exceeded: crate::health_types::ExceededThreshold::Crap,
3327 severity: crate::health_types::FindingSeverity::High,
3328 crap: Some(82.2),
3329 coverage_pct: Some(12.0),
3330 coverage_tier: None,
3331 coverage_source: None,
3332 inherited_from: None,
3333 component_rollup: None,
3334 contributions: Vec::new(),
3335 effective_thresholds: None,
3336 threshold_source: None,
3337 }
3338 .into(),
3339 ],
3340 summary: crate::health_types::HealthSummary {
3341 files_analyzed: 1,
3342 functions_analyzed: 1,
3343 functions_above_threshold: 1,
3344 ..Default::default()
3345 },
3346 ..Default::default()
3347 };
3348 let sarif = build_health_sarif(&report, &root);
3349 let entry = &sarif["runs"][0]["results"][0];
3350 assert_eq!(entry["ruleId"], "fallow/high-crap-score");
3351 let msg = entry["message"]["text"].as_str().unwrap();
3352 assert!(msg.contains("CRAP score 82.2"), "msg: {msg}");
3353 assert!(msg.contains("coverage 12%"), "msg: {msg}");
3354 }
3355
3356 #[test]
3357 fn health_sarif_cyclomatic_crap_uses_crap_rule() {
3358 let root = PathBuf::from("/project");
3359 let report = crate::health_types::HealthReport {
3360 findings: vec![
3361 crate::health_types::ComplexityViolation {
3362 path: root.join("src/hot.ts"),
3363 name: "branchy".to_string(),
3364 line: 1,
3365 col: 0,
3366 cyclomatic: 67,
3367 cognitive: 12,
3368 line_count: 80,
3369 param_count: 1,
3370 react_hook_count: 0,
3371 react_jsx_max_depth: 0,
3372 react_prop_count: 0,
3373 react_hook_profile: None,
3374 exceeded: crate::health_types::ExceededThreshold::CyclomaticCrap,
3375 severity: crate::health_types::FindingSeverity::Critical,
3376 crap: Some(182.0),
3377 coverage_pct: None,
3378 coverage_tier: None,
3379 coverage_source: None,
3380 inherited_from: None,
3381 component_rollup: None,
3382 contributions: Vec::new(),
3383 effective_thresholds: None,
3384 threshold_source: None,
3385 }
3386 .into(),
3387 ],
3388 summary: crate::health_types::HealthSummary {
3389 files_analyzed: 1,
3390 functions_analyzed: 1,
3391 functions_above_threshold: 1,
3392 ..Default::default()
3393 },
3394 ..Default::default()
3395 };
3396 let sarif = build_health_sarif(&report, &root);
3397 let results = sarif["runs"][0]["results"].as_array().unwrap();
3398 assert_eq!(
3399 results.len(),
3400 1,
3401 "CyclomaticCrap should emit a single SARIF result under the CRAP rule"
3402 );
3403 assert_eq!(results[0]["ruleId"], "fallow/high-crap-score");
3404 let msg = results[0]["message"]["text"].as_str().unwrap();
3405 assert!(msg.contains("CRAP score 182"), "msg: {msg}");
3406 assert!(!msg.contains("coverage"), "msg: {msg}");
3407 }
3408
3409 #[test]
3410 fn severity_to_sarif_level_error() {
3411 assert_eq!(severity_to_sarif_level(Severity::Error), "error");
3412 }
3413
3414 #[test]
3415 fn severity_to_sarif_level_warn() {
3416 assert_eq!(severity_to_sarif_level(Severity::Warn), "warning");
3417 }
3418
3419 #[test]
3420 #[should_panic(expected = "internal error: entered unreachable code")]
3421 fn severity_to_sarif_level_off() {
3422 let _ = severity_to_sarif_level(Severity::Off);
3423 }
3424
3425 #[test]
3426 fn sarif_re_export_has_properties() {
3427 let root = PathBuf::from("/project");
3428 let mut results = AnalysisResults::default();
3429 results
3430 .unused_exports
3431 .push(UnusedExportFinding::with_actions(UnusedExport {
3432 path: root.join("src/index.ts"),
3433 export_name: "reExported".to_string(),
3434 is_type_only: false,
3435 line: 1,
3436 col: 0,
3437 span_start: 0,
3438 is_re_export: true,
3439 }));
3440
3441 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3442 let entry = &sarif["runs"][0]["results"][0];
3443 assert_eq!(entry["properties"]["is_re_export"], true);
3444 let msg = entry["message"]["text"].as_str().unwrap();
3445 assert!(msg.starts_with("Re-export"));
3446 }
3447
3448 #[test]
3449 fn sarif_non_re_export_has_no_properties() {
3450 let root = PathBuf::from("/project");
3451 let mut results = AnalysisResults::default();
3452 results
3453 .unused_exports
3454 .push(UnusedExportFinding::with_actions(UnusedExport {
3455 path: root.join("src/utils.ts"),
3456 export_name: "foo".to_string(),
3457 is_type_only: false,
3458 line: 5,
3459 col: 0,
3460 span_start: 0,
3461 is_re_export: false,
3462 }));
3463
3464 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3465 let entry = &sarif["runs"][0]["results"][0];
3466 assert!(entry.get("properties").is_none());
3467 let msg = entry["message"]["text"].as_str().unwrap();
3468 assert!(msg.starts_with("Export"));
3469 }
3470
3471 #[test]
3472 fn sarif_type_re_export_message() {
3473 let root = PathBuf::from("/project");
3474 let mut results = AnalysisResults::default();
3475 results
3476 .unused_types
3477 .push(UnusedTypeFinding::with_actions(UnusedExport {
3478 path: root.join("src/index.ts"),
3479 export_name: "MyType".to_string(),
3480 is_type_only: true,
3481 line: 1,
3482 col: 0,
3483 span_start: 0,
3484 is_re_export: true,
3485 }));
3486
3487 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3488 let entry = &sarif["runs"][0]["results"][0];
3489 assert_eq!(entry["ruleId"], "fallow/unused-type");
3490 let msg = entry["message"]["text"].as_str().unwrap();
3491 assert!(msg.starts_with("Type re-export"));
3492 assert_eq!(entry["properties"]["is_re_export"], true);
3493 }
3494
3495 #[test]
3496 fn sarif_dependency_line_zero_skips_region() {
3497 let root = PathBuf::from("/project");
3498 let mut results = AnalysisResults::default();
3499 results
3500 .unused_dependencies
3501 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
3502 package_name: "lodash".to_string(),
3503 location: DependencyLocation::Dependencies,
3504 path: root.join("package.json"),
3505 line: 0,
3506 used_in_workspaces: Vec::new(),
3507 }));
3508
3509 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3510 let entry = &sarif["runs"][0]["results"][0];
3511 let phys = &entry["locations"][0]["physicalLocation"];
3512 assert!(phys.get("region").is_none());
3513 }
3514
3515 #[test]
3516 fn sarif_dependency_line_nonzero_has_region() {
3517 let root = PathBuf::from("/project");
3518 let mut results = AnalysisResults::default();
3519 results
3520 .unused_dependencies
3521 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
3522 package_name: "lodash".to_string(),
3523 location: DependencyLocation::Dependencies,
3524 path: root.join("package.json"),
3525 line: 7,
3526 used_in_workspaces: Vec::new(),
3527 }));
3528
3529 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3530 let entry = &sarif["runs"][0]["results"][0];
3531 let region = &entry["locations"][0]["physicalLocation"]["region"];
3532 assert_eq!(region["startLine"], 7);
3533 assert_eq!(region["startColumn"], 1);
3534 }
3535
3536 #[test]
3537 fn sarif_type_only_dep_line_zero_skips_region() {
3538 let root = PathBuf::from("/project");
3539 let mut results = AnalysisResults::default();
3540 results
3541 .type_only_dependencies
3542 .push(TypeOnlyDependencyFinding::with_actions(
3543 TypeOnlyDependency {
3544 package_name: "zod".to_string(),
3545 path: root.join("package.json"),
3546 line: 0,
3547 },
3548 ));
3549
3550 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3551 let entry = &sarif["runs"][0]["results"][0];
3552 let phys = &entry["locations"][0]["physicalLocation"];
3553 assert!(phys.get("region").is_none());
3554 }
3555
3556 #[test]
3557 fn sarif_circular_dep_line_zero_skips_region() {
3558 let root = PathBuf::from("/project");
3559 let mut results = AnalysisResults::default();
3560 results
3561 .circular_dependencies
3562 .push(CircularDependencyFinding::with_actions(
3563 CircularDependency {
3564 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
3565 length: 2,
3566 line: 0,
3567 col: 0,
3568 edges: Vec::new(),
3569 is_cross_package: false,
3570 },
3571 ));
3572
3573 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3574 let entry = &sarif["runs"][0]["results"][0];
3575 let phys = &entry["locations"][0]["physicalLocation"];
3576 assert!(phys.get("region").is_none());
3577 }
3578
3579 #[test]
3580 fn sarif_circular_dep_line_nonzero_has_region() {
3581 let root = PathBuf::from("/project");
3582 let mut results = AnalysisResults::default();
3583 results
3584 .circular_dependencies
3585 .push(CircularDependencyFinding::with_actions(
3586 CircularDependency {
3587 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
3588 length: 2,
3589 line: 5,
3590 col: 2,
3591 edges: Vec::new(),
3592 is_cross_package: false,
3593 },
3594 ));
3595
3596 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3597 let entry = &sarif["runs"][0]["results"][0];
3598 let region = &entry["locations"][0]["physicalLocation"]["region"];
3599 assert_eq!(region["startLine"], 5);
3600 assert_eq!(region["startColumn"], 3);
3601 }
3602
3603 #[test]
3604 fn sarif_unused_optional_dependency_result() {
3605 let root = PathBuf::from("/project");
3606 let mut results = AnalysisResults::default();
3607 results
3608 .unused_optional_dependencies
3609 .push(UnusedOptionalDependencyFinding::with_actions(
3610 UnusedDependency {
3611 package_name: "fsevents".to_string(),
3612 location: DependencyLocation::OptionalDependencies,
3613 path: root.join("package.json"),
3614 line: 12,
3615 used_in_workspaces: Vec::new(),
3616 },
3617 ));
3618
3619 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3620 let entry = &sarif["runs"][0]["results"][0];
3621 assert_eq!(entry["ruleId"], "fallow/unused-optional-dependency");
3622 let msg = entry["message"]["text"].as_str().unwrap();
3623 assert!(msg.contains("optionalDependencies"));
3624 }
3625
3626 #[test]
3627 fn sarif_enum_member_message_format() {
3628 let root = PathBuf::from("/project");
3629 let mut results = AnalysisResults::default();
3630 results.unused_enum_members.push(
3631 fallow_core::results::UnusedEnumMemberFinding::with_actions(UnusedMember {
3632 path: root.join("src/enums.ts"),
3633 parent_name: "Color".to_string(),
3634 member_name: "Purple".to_string(),
3635 kind: fallow_core::extract::MemberKind::EnumMember,
3636 line: 5,
3637 col: 2,
3638 }),
3639 );
3640
3641 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3642 let entry = &sarif["runs"][0]["results"][0];
3643 assert_eq!(entry["ruleId"], "fallow/unused-enum-member");
3644 let msg = entry["message"]["text"].as_str().unwrap();
3645 assert!(msg.contains("Enum member 'Color.Purple'"));
3646 let region = &entry["locations"][0]["physicalLocation"]["region"];
3647 assert_eq!(region["startColumn"], 3); }
3649
3650 #[test]
3651 fn sarif_class_member_message_format() {
3652 let root = PathBuf::from("/project");
3653 let mut results = AnalysisResults::default();
3654 results.unused_class_members.push(
3655 fallow_core::results::UnusedClassMemberFinding::with_actions(UnusedMember {
3656 path: root.join("src/service.ts"),
3657 parent_name: "API".to_string(),
3658 member_name: "fetch".to_string(),
3659 kind: fallow_core::extract::MemberKind::ClassMethod,
3660 line: 10,
3661 col: 4,
3662 }),
3663 );
3664
3665 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3666 let entry = &sarif["runs"][0]["results"][0];
3667 assert_eq!(entry["ruleId"], "fallow/unused-class-member");
3668 let msg = entry["message"]["text"].as_str().unwrap();
3669 assert!(msg.contains("Class member 'API.fetch'"));
3670 }
3671
3672 #[test]
3673 #[expect(
3674 clippy::cast_possible_truncation,
3675 reason = "test line/col values are trivially small"
3676 )]
3677 fn duplication_sarif_structure() {
3678 use fallow_core::duplicates::*;
3679
3680 let root = PathBuf::from("/project");
3681 let report = DuplicationReport {
3682 clone_groups: vec![CloneGroup {
3683 instances: vec![
3684 CloneInstance {
3685 file: root.join("src/a.ts"),
3686 start_line: 1,
3687 end_line: 10,
3688 start_col: 0,
3689 end_col: 0,
3690 fragment: String::new(),
3691 },
3692 CloneInstance {
3693 file: root.join("src/b.ts"),
3694 start_line: 5,
3695 end_line: 14,
3696 start_col: 2,
3697 end_col: 0,
3698 fragment: String::new(),
3699 },
3700 ],
3701 token_count: 50,
3702 line_count: 10,
3703 }],
3704 clone_families: vec![],
3705 mirrored_directories: vec![],
3706 stats: DuplicationStats::default(),
3707 };
3708
3709 let sarif = serde_json::json!({
3710 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
3711 "version": "2.1.0",
3712 "runs": [{
3713 "tool": {
3714 "driver": {
3715 "name": "fallow",
3716 "version": env!("CARGO_PKG_VERSION"),
3717 "informationUri": "https://github.com/fallow-rs/fallow",
3718 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
3719 }
3720 },
3721 "results": []
3722 }]
3723 });
3724 let _ = sarif;
3725
3726 let mut sarif_results = Vec::new();
3727 for (i, group) in report.clone_groups.iter().enumerate() {
3728 for instance in &group.instances {
3729 sarif_results.push(sarif_result(
3730 "fallow/code-duplication",
3731 "warning",
3732 &format!(
3733 "Code clone group {} ({} lines, {} instances)",
3734 i + 1,
3735 group.line_count,
3736 group.instances.len()
3737 ),
3738 &super::super::relative_uri(&instance.file, &root),
3739 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
3740 ));
3741 }
3742 }
3743 assert_eq!(sarif_results.len(), 2);
3744 assert_eq!(sarif_results[0]["ruleId"], "fallow/code-duplication");
3745 assert!(
3746 sarif_results[0]["message"]["text"]
3747 .as_str()
3748 .unwrap()
3749 .contains("10 lines")
3750 );
3751 let region0 = &sarif_results[0]["locations"][0]["physicalLocation"]["region"];
3752 assert_eq!(region0["startLine"], 1);
3753 assert_eq!(region0["startColumn"], 1); let region1 = &sarif_results[1]["locations"][0]["physicalLocation"]["region"];
3755 assert_eq!(region1["startLine"], 5);
3756 assert_eq!(region1["startColumn"], 3); }
3758
3759 #[test]
3760 fn sarif_rule_known_id_has_full_description() {
3761 let rule = sarif_rule("fallow/unused-file", "fallback text", "error");
3762 assert!(rule.get("fullDescription").is_some());
3763 assert!(rule.get("helpUri").is_some());
3764 }
3765
3766 #[test]
3767 fn sarif_rule_unknown_id_uses_fallback() {
3768 let rule = sarif_rule("fallow/nonexistent", "fallback text", "warning");
3769 assert_eq!(rule["shortDescription"]["text"], "fallback text");
3770 assert!(rule.get("fullDescription").is_none());
3771 assert!(rule.get("helpUri").is_none());
3772 assert_eq!(rule["defaultConfiguration"]["level"], "warning");
3773 }
3774
3775 #[test]
3776 fn sarif_result_no_region_omits_region_key() {
3777 let result = sarif_result("rule/test", "error", "test msg", "src/file.ts", None);
3778 let phys = &result["locations"][0]["physicalLocation"];
3779 assert!(phys.get("region").is_none());
3780 assert_eq!(phys["artifactLocation"]["uri"], "src/file.ts");
3781 }
3782
3783 #[test]
3784 fn sarif_result_with_region_includes_region() {
3785 let result = sarif_result(
3786 "rule/test",
3787 "error",
3788 "test msg",
3789 "src/file.ts",
3790 Some((10, 5)),
3791 );
3792 let region = &result["locations"][0]["physicalLocation"]["region"];
3793 assert_eq!(region["startLine"], 10);
3794 assert_eq!(region["startColumn"], 5);
3795 }
3796
3797 #[test]
3798 fn sarif_partial_fingerprint_ignores_rendered_message() {
3799 let a = sarif_result(
3800 "rule/test",
3801 "error",
3802 "first message",
3803 "src/file.ts",
3804 Some((10, 5)),
3805 );
3806 let b = sarif_result(
3807 "rule/test",
3808 "error",
3809 "rewritten message",
3810 "src/file.ts",
3811 Some((10, 5)),
3812 );
3813 assert_eq!(
3814 a["partialFingerprints"][fingerprint::FINGERPRINT_KEY],
3815 b["partialFingerprints"][fingerprint::FINGERPRINT_KEY]
3816 );
3817 }
3818
3819 #[test]
3820 fn health_sarif_includes_refactoring_targets() {
3821 use crate::health_types::*;
3822
3823 let root = PathBuf::from("/project");
3824 let report = HealthReport {
3825 summary: HealthSummary {
3826 files_analyzed: 10,
3827 functions_analyzed: 50,
3828 ..Default::default()
3829 },
3830 targets: vec![
3831 RefactoringTarget {
3832 path: root.join("src/complex.ts"),
3833 priority: 85.0,
3834 efficiency: 42.5,
3835 recommendation: "Split high-impact file".into(),
3836 category: RecommendationCategory::SplitHighImpact,
3837 effort: EffortEstimate::Medium,
3838 confidence: Confidence::High,
3839 factors: vec![],
3840 evidence: None,
3841 }
3842 .into(),
3843 ],
3844 ..Default::default()
3845 };
3846
3847 let sarif = build_health_sarif(&report, &root);
3848 let entries = sarif["runs"][0]["results"].as_array().unwrap();
3849 assert_eq!(entries.len(), 1);
3850 assert_eq!(entries[0]["ruleId"], "fallow/refactoring-target");
3851 assert_eq!(entries[0]["level"], "warning");
3852 let msg = entries[0]["message"]["text"].as_str().unwrap();
3853 assert!(msg.contains("high impact"));
3854 assert!(msg.contains("Split high-impact file"));
3855 assert!(msg.contains("42.5"));
3856 }
3857
3858 #[test]
3859 fn health_sarif_includes_coverage_gaps() {
3860 use crate::health_types::*;
3861
3862 let root = PathBuf::from("/project");
3863 let report = HealthReport {
3864 summary: HealthSummary {
3865 files_analyzed: 10,
3866 functions_analyzed: 50,
3867 ..Default::default()
3868 },
3869 coverage_gaps: Some(CoverageGaps {
3870 summary: CoverageGapSummary {
3871 runtime_files: 2,
3872 covered_files: 0,
3873 file_coverage_pct: 0.0,
3874 untested_files: 1,
3875 untested_exports: 1,
3876 },
3877 files: vec![UntestedFileFinding::with_actions(
3878 UntestedFile {
3879 path: root.join("src/app.ts"),
3880 value_export_count: 2,
3881 },
3882 &root,
3883 )],
3884 exports: vec![UntestedExportFinding::with_actions(
3885 UntestedExport {
3886 path: root.join("src/app.ts"),
3887 export_name: "loader".into(),
3888 line: 12,
3889 col: 4,
3890 },
3891 &root,
3892 )],
3893 }),
3894 ..Default::default()
3895 };
3896
3897 let sarif = build_health_sarif(&report, &root);
3898 let entries = sarif["runs"][0]["results"].as_array().unwrap();
3899 assert_eq!(entries.len(), 2);
3900 assert_eq!(entries[0]["ruleId"], "fallow/untested-file");
3901 assert_eq!(
3902 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3903 "src/app.ts"
3904 );
3905 assert!(
3906 entries[0]["message"]["text"]
3907 .as_str()
3908 .unwrap()
3909 .contains("2 value exports")
3910 );
3911 assert_eq!(entries[1]["ruleId"], "fallow/untested-export");
3912 assert_eq!(
3913 entries[1]["locations"][0]["physicalLocation"]["region"]["startLine"],
3914 12
3915 );
3916 assert_eq!(
3917 entries[1]["locations"][0]["physicalLocation"]["region"]["startColumn"],
3918 5
3919 );
3920 }
3921
3922 #[test]
3923 fn health_sarif_rules_have_full_descriptions() {
3924 let root = PathBuf::from("/project");
3925 let report = crate::health_types::HealthReport::default();
3926 let sarif = build_health_sarif(&report, &root);
3927 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
3928 .as_array()
3929 .unwrap();
3930 for rule in rules {
3931 let id = rule["id"].as_str().unwrap();
3932 assert!(
3933 rule.get("fullDescription").is_some(),
3934 "health rule {id} should have fullDescription"
3935 );
3936 assert!(
3937 rule.get("helpUri").is_some(),
3938 "health rule {id} should have helpUri"
3939 );
3940 }
3941 }
3942
3943 #[test]
3944 fn sarif_warn_severity_produces_warning_level() {
3945 let root = PathBuf::from("/project");
3946 let mut results = AnalysisResults::default();
3947 results
3948 .unused_files
3949 .push(UnusedFileFinding::with_actions(UnusedFile {
3950 path: root.join("src/dead.ts"),
3951 }));
3952
3953 let rules = RulesConfig {
3954 unused_files: Severity::Warn,
3955 ..RulesConfig::default()
3956 };
3957
3958 let sarif = build_sarif(&results, &root, &rules);
3959 let entry = &sarif["runs"][0]["results"][0];
3960 assert_eq!(entry["level"], "warning");
3961 }
3962
3963 #[test]
3964 fn sarif_unused_file_has_no_region() {
3965 let root = PathBuf::from("/project");
3966 let mut results = AnalysisResults::default();
3967 results
3968 .unused_files
3969 .push(UnusedFileFinding::with_actions(UnusedFile {
3970 path: root.join("src/dead.ts"),
3971 }));
3972
3973 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3974 let entry = &sarif["runs"][0]["results"][0];
3975 let phys = &entry["locations"][0]["physicalLocation"];
3976 assert!(phys.get("region").is_none());
3977 }
3978
3979 #[test]
3980 fn sarif_unlisted_dep_multiple_import_sites() {
3981 let root = PathBuf::from("/project");
3982 let mut results = AnalysisResults::default();
3983 results
3984 .unlisted_dependencies
3985 .push(UnlistedDependencyFinding::with_actions(
3986 UnlistedDependency {
3987 package_name: "dotenv".to_string(),
3988 imported_from: vec![
3989 ImportSite {
3990 path: root.join("src/a.ts"),
3991 line: 1,
3992 col: 0,
3993 },
3994 ImportSite {
3995 path: root.join("src/b.ts"),
3996 line: 5,
3997 col: 0,
3998 },
3999 ],
4000 },
4001 ));
4002
4003 let sarif = build_sarif(&results, &root, &RulesConfig::default());
4004 let entries = sarif["runs"][0]["results"].as_array().unwrap();
4005 assert_eq!(entries.len(), 2);
4006 assert_eq!(
4007 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
4008 "src/a.ts"
4009 );
4010 assert_eq!(
4011 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
4012 "src/b.ts"
4013 );
4014 }
4015
4016 #[test]
4017 fn sarif_unlisted_dep_no_import_sites() {
4018 let root = PathBuf::from("/project");
4019 let mut results = AnalysisResults::default();
4020 results
4021 .unlisted_dependencies
4022 .push(UnlistedDependencyFinding::with_actions(
4023 UnlistedDependency {
4024 package_name: "phantom".to_string(),
4025 imported_from: vec![],
4026 },
4027 ));
4028
4029 let sarif = build_sarif(&results, &root, &RulesConfig::default());
4030 let entries = sarif["runs"][0]["results"].as_array().unwrap();
4031 assert!(entries.is_empty());
4032 }
4033}