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: "fallow/stale-suppression",
847 level,
848 message: suppression.display_message(),
849 uri: relative_uri(&suppression.path, root),
850 region: Some((suppression.line, suppression.col + 1)),
851 source_path: Some(suppression.path.clone()),
852 properties: None,
853 }
854}
855
856fn sarif_unused_catalog_entry_fields(
857 entry: &UnusedCatalogEntryFinding,
858 root: &Path,
859 level: &'static str,
860) -> SarifFields {
861 let entry = &entry.entry;
862 let message = if entry.catalog_name == "default" {
863 format!(
864 "Catalog entry '{}' is not referenced by any workspace package",
865 entry.entry_name
866 )
867 } else {
868 format!(
869 "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package",
870 entry.entry_name, entry.catalog_name
871 )
872 };
873 SarifFields {
874 rule_id: "fallow/unused-catalog-entry",
875 level,
876 message,
877 uri: relative_uri(&entry.path, root),
878 region: Some((entry.line, 1)),
879 source_path: Some(entry.path.clone()),
880 properties: None,
881 }
882}
883
884fn sarif_unused_dependency_override_fields(
885 finding: &UnusedDependencyOverrideFinding,
886 root: &Path,
887 level: &'static str,
888) -> SarifFields {
889 let finding = &finding.entry;
890 let mut message = format!(
891 "Override `{}` forces version `{}` but `{}` is not declared by any workspace package or resolved in pnpm-lock.yaml",
892 finding.raw_key, finding.version_range, finding.target_package,
893 );
894 if let Some(hint) = &finding.hint {
895 use std::fmt::Write as _;
896 let _ = write!(message, " ({hint})");
897 }
898 SarifFields {
899 rule_id: "fallow/unused-dependency-override",
900 level,
901 message,
902 uri: relative_uri(&finding.path, root),
903 region: Some((finding.line, 1)),
904 source_path: Some(finding.path.clone()),
905 properties: None,
906 }
907}
908
909fn sarif_misconfigured_dependency_override_fields(
910 finding: &MisconfiguredDependencyOverrideFinding,
911 root: &Path,
912 level: &'static str,
913) -> SarifFields {
914 let finding = &finding.entry;
915 let message = format!(
916 "Override `{}` -> `{}` is malformed: {}",
917 finding.raw_key,
918 finding.raw_value,
919 finding.reason.describe(),
920 );
921 SarifFields {
922 rule_id: "fallow/misconfigured-dependency-override",
923 level,
924 message,
925 uri: relative_uri(&finding.path, root),
926 region: Some((finding.line, 1)),
927 source_path: Some(finding.path.clone()),
928 properties: None,
929 }
930}
931
932fn sarif_unresolved_catalog_reference_fields(
933 finding: &UnresolvedCatalogReferenceFinding,
934 root: &Path,
935 level: &'static str,
936) -> SarifFields {
937 let finding = &finding.reference;
938 let catalog_phrase = if finding.catalog_name == "default" {
939 "the default catalog".to_string()
940 } else {
941 format!("catalog '{}'", finding.catalog_name)
942 };
943 let mut message = format!(
944 "Package '{}' is referenced via `catalog:{}` but {} does not declare it",
945 finding.entry_name,
946 if finding.catalog_name == "default" {
947 ""
948 } else {
949 finding.catalog_name.as_str()
950 },
951 catalog_phrase,
952 );
953 if !finding.available_in_catalogs.is_empty() {
954 use std::fmt::Write as _;
955 let _ = write!(
956 message,
957 " (available in: {})",
958 finding.available_in_catalogs.join(", ")
959 );
960 }
961 SarifFields {
962 rule_id: "fallow/unresolved-catalog-reference",
963 level,
964 message,
965 uri: relative_uri(&finding.path, root),
966 region: Some((finding.line, 1)),
967 source_path: Some(finding.path.clone()),
968 properties: None,
969 }
970}
971
972fn sarif_empty_catalog_group_fields(
973 group: &EmptyCatalogGroupFinding,
974 root: &Path,
975 level: &'static str,
976) -> SarifFields {
977 let group = &group.group;
978 SarifFields {
979 rule_id: "fallow/empty-catalog-group",
980 level,
981 message: format!("Catalog group '{}' has no entries", group.catalog_name),
982 uri: relative_uri(&group.path, root),
983 region: Some((group.line, 1)),
984 source_path: Some(group.path.clone()),
985 properties: None,
986 }
987}
988
989fn push_sarif_unlisted_deps(
992 sarif_results: &mut Vec<serde_json::Value>,
993 deps: &[UnlistedDependencyFinding],
994 root: &Path,
995 level: &'static str,
996 snippets: &mut SourceSnippetCache,
997) {
998 for entry in deps {
999 let dep = &entry.dep;
1000 for site in &dep.imported_from {
1001 let uri = relative_uri(&site.path, root);
1002 let source_snippet = snippets.line(&site.path, site.line);
1003 sarif_results.push(sarif_result_with_snippet(
1004 "fallow/unlisted-dependency",
1005 level,
1006 &format!(
1007 "Package '{}' is imported but not listed in package.json",
1008 dep.package_name
1009 ),
1010 &uri,
1011 Some((site.line, site.col + 1)),
1012 source_snippet.as_deref(),
1013 ));
1014 }
1015 }
1016}
1017
1018fn push_sarif_duplicate_exports(
1021 sarif_results: &mut Vec<serde_json::Value>,
1022 dups: &[DuplicateExportFinding],
1023 root: &Path,
1024 level: &'static str,
1025 snippets: &mut SourceSnippetCache,
1026) {
1027 for dup in dups {
1028 let dup = &dup.export;
1029 for loc in &dup.locations {
1030 let uri = relative_uri(&loc.path, root);
1031 let source_snippet = snippets.line(&loc.path, loc.line);
1032 sarif_results.push(sarif_result_with_snippet(
1033 "fallow/duplicate-export",
1034 level,
1035 &format!("Export '{}' appears in multiple modules", dup.export_name),
1036 &uri,
1037 Some((loc.line, loc.col + 1)),
1038 source_snippet.as_deref(),
1039 ));
1040 }
1041 }
1042}
1043
1044fn build_sarif_rules(rules: &RulesConfig) -> Vec<serde_json::Value> {
1046 let mut specs = Vec::new();
1047 specs.extend(sarif_core_rule_specs(rules));
1048 specs.extend(sarif_dependency_rule_specs(rules));
1049 specs.extend(sarif_member_import_rule_specs(rules));
1050 specs.extend(sarif_graph_rule_specs(rules));
1051 specs.extend(sarif_workspace_rule_specs(rules));
1052 specs
1053 .into_iter()
1054 .map(|(id, description, rule_severity)| {
1055 sarif_rule(id, description, configured_sarif_level(rule_severity))
1056 })
1057 .collect()
1058}
1059
1060type SarifRuleSpec = (&'static str, &'static str, Severity);
1061
1062fn sarif_core_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1063 [
1064 (
1065 "fallow/unused-file",
1066 "File is not reachable from any entry point",
1067 rules.unused_files,
1068 ),
1069 (
1070 "fallow/unused-export",
1071 "Export is never imported",
1072 rules.unused_exports,
1073 ),
1074 (
1075 "fallow/unused-type",
1076 "Type export is never imported",
1077 rules.unused_types,
1078 ),
1079 (
1080 "fallow/private-type-leak",
1081 "Exported signature references a same-file private type",
1082 rules.private_type_leaks,
1083 ),
1084 ]
1085 .into()
1086}
1087
1088fn sarif_dependency_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1089 [
1090 (
1091 "fallow/unused-dependency",
1092 "Dependency listed but never imported",
1093 rules.unused_dependencies,
1094 ),
1095 (
1096 "fallow/unused-dev-dependency",
1097 "Dev dependency listed but never imported",
1098 rules.unused_dev_dependencies,
1099 ),
1100 (
1101 "fallow/unused-optional-dependency",
1102 "Optional dependency listed but never imported",
1103 rules.unused_optional_dependencies,
1104 ),
1105 (
1106 "fallow/type-only-dependency",
1107 "Production dependency only used via type-only imports",
1108 rules.type_only_dependencies,
1109 ),
1110 (
1111 "fallow/test-only-dependency",
1112 "Production dependency only imported by test files",
1113 rules.test_only_dependencies,
1114 ),
1115 ]
1116 .into()
1117}
1118
1119fn sarif_member_import_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1120 [
1121 (
1122 "fallow/unused-enum-member",
1123 "Enum member is never referenced",
1124 rules.unused_enum_members,
1125 ),
1126 (
1127 "fallow/unused-class-member",
1128 "Class member is never referenced",
1129 rules.unused_class_members,
1130 ),
1131 (
1132 "fallow/unused-store-member",
1133 "Store member is never referenced",
1134 rules.unused_store_members,
1135 ),
1136 (
1137 "fallow/unresolved-import",
1138 "Import could not be resolved",
1139 rules.unresolved_imports,
1140 ),
1141 (
1142 "fallow/unlisted-dependency",
1143 "Dependency used but not in package.json",
1144 rules.unlisted_dependencies,
1145 ),
1146 (
1147 "fallow/duplicate-export",
1148 "Export name appears in multiple modules",
1149 rules.duplicate_exports,
1150 ),
1151 ]
1152 .into()
1153}
1154
1155fn sarif_graph_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1156 let mut specs = sarif_cycle_rule_specs(rules);
1157 specs.extend(sarif_boundary_rule_specs(rules));
1158 specs.extend(sarif_framework_rule_specs(rules));
1159 specs.extend(sarif_component_rule_specs(rules));
1160 specs.push((
1161 "fallow/stale-suppression",
1162 "Suppression comment or tag no longer matches any issue",
1163 rules.stale_suppressions,
1164 ));
1165 specs
1166}
1167
1168fn sarif_cycle_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1169 vec![
1170 (
1171 "fallow/circular-dependency",
1172 "Circular dependency chain detected",
1173 rules.circular_dependencies,
1174 ),
1175 (
1176 "fallow/re-export-cycle",
1177 "Two or more barrel files re-export from each other in a loop",
1178 rules.re_export_cycle,
1179 ),
1180 ]
1181}
1182
1183fn sarif_boundary_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1184 vec![
1185 (
1186 "fallow/boundary-violation",
1187 "Import crosses an architecture boundary",
1188 rules.boundary_violation,
1189 ),
1190 (
1191 "fallow/boundary-coverage",
1192 "Source file matches no architecture boundary zone",
1193 rules.boundary_violation,
1194 ),
1195 (
1196 "fallow/boundary-call-violation",
1197 "Zoned file calls a callee its zone forbids",
1198 rules.boundary_violation,
1199 ),
1200 (
1201 "fallow/policy-violation",
1202 "Banned call or import matched a rule-pack rule",
1203 rules.policy_violation,
1204 ),
1205 ]
1206}
1207
1208fn sarif_framework_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1209 vec![
1210 (
1211 "fallow/invalid-client-export",
1212 "\"use client\" file exports a server-only / route-config name",
1213 rules.invalid_client_export,
1214 ),
1215 (
1216 "fallow/mixed-client-server-barrel",
1217 "Barrel re-exports both a \"use client\" module and a server-only module",
1218 rules.mixed_client_server_barrel,
1219 ),
1220 (
1221 "fallow/misplaced-directive",
1222 "\"use client\" / \"use server\" directive is not in the leading position and is ignored",
1223 rules.misplaced_directive,
1224 ),
1225 ]
1226}
1227
1228fn sarif_component_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1229 vec![
1230 (
1231 "fallow/unprovided-inject",
1232 "A Vue inject / Svelte getContext whose key is provided nowhere in the project",
1233 rules.unprovided_injects,
1234 ),
1235 (
1236 "fallow/unrendered-component",
1237 "A Vue / Svelte component reachable through a barrel but rendered nowhere in the project",
1238 rules.unrendered_components,
1239 ),
1240 (
1241 "fallow/unused-component-prop",
1242 "A Vue <script setup> defineProps prop referenced nowhere inside its own component",
1243 rules.unused_component_props,
1244 ),
1245 (
1246 "fallow/unused-component-emit",
1247 "A Vue <script setup> defineEmits event emitted nowhere inside its own component",
1248 rules.unused_component_emits,
1249 ),
1250 (
1251 "fallow/unused-component-input",
1252 "An Angular @Input() / signal input() / model() input read nowhere inside its own component",
1253 rules.unused_component_inputs,
1254 ),
1255 (
1256 "fallow/unused-component-output",
1257 "An Angular @Output() / signal output() output emitted nowhere inside its own component",
1258 rules.unused_component_outputs,
1259 ),
1260 (
1261 "fallow/unused-svelte-event",
1262 "A Svelte component dispatching a createEventDispatcher event whose name is listened to nowhere in the project",
1263 rules.unused_svelte_events,
1264 ),
1265 (
1266 "fallow/unused-server-action",
1267 "A Next.js Server Action exported from a \"use server\" file that no code in the project references",
1268 rules.unused_server_actions,
1269 ),
1270 (
1271 "fallow/unused-load-data-key",
1272 "A SvelteKit load() return-object key that no consumer reads (sibling +page.svelte data.<key> or project-wide page.data.<key>)",
1273 rules.unused_load_data_keys,
1274 ),
1275 (
1276 "fallow/prop-drilling",
1277 "A React/Preact prop forwarded unchanged through 3+ pass-through components to a distant consumer",
1278 rules.prop_drilling,
1279 ),
1280 (
1281 "fallow/thin-wrapper",
1282 "A React/Preact component whose whole body is a single spread-forwarded child render (a candidate for inlining)",
1283 rules.thin_wrapper,
1284 ),
1285 (
1286 "fallow/duplicate-prop-shape",
1287 "Three or more React/Preact components across two or more files declare an identical prop-name set (a missing shared Props type)",
1288 rules.duplicate_prop_shape,
1289 ),
1290 (
1291 "fallow/route-collision",
1292 "Two or more Next.js App Router route files resolve to the same URL",
1293 rules.route_collision,
1294 ),
1295 (
1296 "fallow/dynamic-segment-name-conflict",
1297 "Sibling Next.js dynamic route segments use different slug names at the same position",
1298 rules.dynamic_segment_name_conflict,
1299 ),
1300 ]
1301}
1302
1303fn sarif_workspace_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1304 [
1305 (
1306 "fallow/unused-catalog-entry",
1307 "pnpm catalog entry not referenced by any workspace package",
1308 rules.unused_catalog_entries,
1309 ),
1310 (
1311 "fallow/empty-catalog-group",
1312 "pnpm named catalog group has no entries",
1313 rules.empty_catalog_groups,
1314 ),
1315 (
1316 "fallow/unresolved-catalog-reference",
1317 "package.json catalog reference points at a catalog that does not declare the package",
1318 rules.unresolved_catalog_references,
1319 ),
1320 (
1321 "fallow/unused-dependency-override",
1322 "pnpm dependency override target is not declared or lockfile-resolved",
1323 rules.unused_dependency_overrides,
1324 ),
1325 (
1326 "fallow/misconfigured-dependency-override",
1327 "pnpm dependency override key or value is malformed",
1328 rules.misconfigured_dependency_overrides,
1329 ),
1330 ]
1331 .into()
1332}
1333
1334#[must_use]
1335pub fn build_sarif(
1336 results: &AnalysisResults,
1337 root: &Path,
1338 rules: &RulesConfig,
1339) -> serde_json::Value {
1340 let mut sarif_results = Vec::new();
1341 let mut snippets = SourceSnippetCache::default();
1342
1343 push_primary_dead_code_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
1344 push_dependency_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
1345 push_member_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
1346 push_sarif_results(
1347 &mut sarif_results,
1348 &results.unresolved_imports,
1349 &mut snippets,
1350 |i| {
1351 sarif_unresolved_import_fields(
1352 &i.import,
1353 root,
1354 severity_to_sarif_level(rules.unresolved_imports),
1355 )
1356 },
1357 );
1358 push_misc_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
1359 push_graph_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
1360 push_catalog_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
1361
1362 let sarif_rules = build_sarif_rules(rules);
1363 sarif_document(&sarif_results, &sarif_rules)
1364}
1365
1366fn push_primary_dead_code_sarif_results(
1367 sarif_results: &mut Vec<serde_json::Value>,
1368 results: &AnalysisResults,
1369 root: &Path,
1370 rules: &RulesConfig,
1371 snippets: &mut SourceSnippetCache,
1372) {
1373 push_sarif_results(sarif_results, &results.unused_files, snippets, |finding| {
1374 sarif_unused_file_fields(
1375 &finding.file,
1376 root,
1377 severity_to_sarif_level(rules.unused_files),
1378 )
1379 });
1380 push_sarif_results(
1381 sarif_results,
1382 &results.unused_exports,
1383 snippets,
1384 |finding| {
1385 sarif_export_fields(
1386 &finding.export,
1387 root,
1388 "fallow/unused-export",
1389 severity_to_sarif_level(rules.unused_exports),
1390 "Export",
1391 "Re-export",
1392 )
1393 },
1394 );
1395 push_sarif_results(sarif_results, &results.unused_types, snippets, |finding| {
1396 sarif_export_fields(
1397 &finding.export,
1398 root,
1399 "fallow/unused-type",
1400 severity_to_sarif_level(rules.unused_types),
1401 "Type export",
1402 "Type re-export",
1403 )
1404 });
1405 push_sarif_results(
1406 sarif_results,
1407 &results.private_type_leaks,
1408 snippets,
1409 |finding| {
1410 sarif_private_type_leak_fields(
1411 &finding.leak,
1412 root,
1413 severity_to_sarif_level(rules.private_type_leaks),
1414 )
1415 },
1416 );
1417}
1418
1419fn sarif_document(
1420 sarif_results: &[serde_json::Value],
1421 sarif_rules: &[serde_json::Value],
1422) -> serde_json::Value {
1423 serde_json::json!({
1424 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1425 "version": "2.1.0",
1426 "runs": [{
1427 "tool": {
1428 "driver": {
1429 "name": "fallow",
1430 "version": env!("CARGO_PKG_VERSION"),
1431 "informationUri": "https://github.com/fallow-rs/fallow",
1432 "rules": sarif_rules
1433 }
1434 },
1435 "results": sarif_results
1436 }]
1437 })
1438}
1439
1440fn push_dependency_sarif_results(
1441 sarif_results: &mut Vec<serde_json::Value>,
1442 results: &AnalysisResults,
1443 root: &Path,
1444 rules: &RulesConfig,
1445 snippets: &mut SourceSnippetCache,
1446) {
1447 push_sarif_results(sarif_results, &results.unused_dependencies, snippets, |d| {
1448 sarif_dep_fields(
1449 &d.dep,
1450 root,
1451 "fallow/unused-dependency",
1452 severity_to_sarif_level(rules.unused_dependencies),
1453 "dependencies",
1454 )
1455 });
1456 push_sarif_results(
1457 sarif_results,
1458 &results.unused_dev_dependencies,
1459 snippets,
1460 |d| {
1461 sarif_dep_fields(
1462 &d.dep,
1463 root,
1464 "fallow/unused-dev-dependency",
1465 severity_to_sarif_level(rules.unused_dev_dependencies),
1466 "devDependencies",
1467 )
1468 },
1469 );
1470 push_sarif_results(
1471 sarif_results,
1472 &results.unused_optional_dependencies,
1473 snippets,
1474 |d| {
1475 sarif_dep_fields(
1476 &d.dep,
1477 root,
1478 "fallow/unused-optional-dependency",
1479 severity_to_sarif_level(rules.unused_optional_dependencies),
1480 "optionalDependencies",
1481 )
1482 },
1483 );
1484 push_sarif_results(
1485 sarif_results,
1486 &results.type_only_dependencies,
1487 snippets,
1488 |d| {
1489 sarif_type_only_dep_fields(
1490 &d.dep,
1491 root,
1492 severity_to_sarif_level(rules.type_only_dependencies),
1493 )
1494 },
1495 );
1496 push_sarif_results(
1497 sarif_results,
1498 &results.test_only_dependencies,
1499 snippets,
1500 |d| {
1501 sarif_test_only_dep_fields(
1502 &d.dep,
1503 root,
1504 severity_to_sarif_level(rules.test_only_dependencies),
1505 )
1506 },
1507 );
1508}
1509
1510fn push_member_sarif_results(
1511 sarif_results: &mut Vec<serde_json::Value>,
1512 results: &AnalysisResults,
1513 root: &Path,
1514 rules: &RulesConfig,
1515 snippets: &mut SourceSnippetCache,
1516) {
1517 push_sarif_results(sarif_results, &results.unused_enum_members, snippets, |m| {
1518 sarif_member_fields(
1519 &m.member,
1520 root,
1521 "fallow/unused-enum-member",
1522 severity_to_sarif_level(rules.unused_enum_members),
1523 "Enum",
1524 )
1525 });
1526 push_sarif_results(
1527 sarif_results,
1528 &results.unused_class_members,
1529 snippets,
1530 |m| {
1531 sarif_member_fields(
1532 &m.member,
1533 root,
1534 "fallow/unused-class-member",
1535 severity_to_sarif_level(rules.unused_class_members),
1536 "Class",
1537 )
1538 },
1539 );
1540 push_sarif_results(
1541 sarif_results,
1542 &results.unused_store_members,
1543 snippets,
1544 |m| {
1545 sarif_member_fields(
1546 &m.member,
1547 root,
1548 "fallow/unused-store-member",
1549 severity_to_sarif_level(rules.unused_store_members),
1550 "Store",
1551 )
1552 },
1553 );
1554}
1555
1556fn push_misc_sarif_results(
1557 sarif_results: &mut Vec<serde_json::Value>,
1558 results: &AnalysisResults,
1559 root: &Path,
1560 rules: &RulesConfig,
1561 snippets: &mut SourceSnippetCache,
1562) {
1563 if !results.unlisted_dependencies.is_empty() {
1564 push_sarif_unlisted_deps(
1565 sarif_results,
1566 &results.unlisted_dependencies,
1567 root,
1568 severity_to_sarif_level(rules.unlisted_dependencies),
1569 snippets,
1570 );
1571 }
1572 if !results.duplicate_exports.is_empty() {
1573 push_sarif_duplicate_exports(
1574 sarif_results,
1575 &results.duplicate_exports,
1576 root,
1577 severity_to_sarif_level(rules.duplicate_exports),
1578 snippets,
1579 );
1580 }
1581}
1582
1583fn push_component_contract_sarif_results(
1587 sarif_results: &mut Vec<serde_json::Value>,
1588 results: &AnalysisResults,
1589 root: &Path,
1590 rules: &RulesConfig,
1591 snippets: &mut SourceSnippetCache,
1592) {
1593 push_sarif_results(
1594 sarif_results,
1595 &results.unused_component_props,
1596 snippets,
1597 |p| {
1598 sarif_unused_component_prop_fields(
1599 &p.prop,
1600 root,
1601 severity_to_sarif_level(rules.unused_component_props),
1602 )
1603 },
1604 );
1605 push_sarif_results(
1606 sarif_results,
1607 &results.unused_component_emits,
1608 snippets,
1609 |e| {
1610 sarif_unused_component_emit_fields(
1611 &e.emit,
1612 root,
1613 severity_to_sarif_level(rules.unused_component_emits),
1614 )
1615 },
1616 );
1617 push_sarif_results(
1618 sarif_results,
1619 &results.unused_component_inputs,
1620 snippets,
1621 |i| {
1622 sarif_unused_component_input_fields(
1623 &i.input,
1624 root,
1625 severity_to_sarif_level(rules.unused_component_inputs),
1626 )
1627 },
1628 );
1629 push_sarif_results(
1630 sarif_results,
1631 &results.unused_component_outputs,
1632 snippets,
1633 |o| {
1634 sarif_unused_component_output_fields(
1635 &o.output,
1636 root,
1637 severity_to_sarif_level(rules.unused_component_outputs),
1638 )
1639 },
1640 );
1641 push_sarif_results(
1642 sarif_results,
1643 &results.unused_svelte_events,
1644 snippets,
1645 |e| {
1646 sarif_unused_svelte_event_fields(
1647 &e.event,
1648 root,
1649 severity_to_sarif_level(rules.unused_svelte_events),
1650 )
1651 },
1652 );
1653 push_sarif_results(
1654 sarif_results,
1655 &results.unused_server_actions,
1656 snippets,
1657 |a| {
1658 sarif_unused_server_action_fields(
1659 &a.action,
1660 root,
1661 severity_to_sarif_level(rules.unused_server_actions),
1662 )
1663 },
1664 );
1665 push_sarif_results(
1666 sarif_results,
1667 &results.unused_load_data_keys,
1668 snippets,
1669 |k| {
1670 sarif_unused_load_data_key_fields(
1671 &k.key,
1672 root,
1673 severity_to_sarif_level(rules.unused_load_data_keys),
1674 )
1675 },
1676 );
1677 push_sarif_results(
1678 sarif_results,
1679 &results.prop_drilling_chains,
1680 snippets,
1681 |c| {
1682 sarif_prop_drilling_fields(&c.chain, root, severity_to_sarif_level(rules.prop_drilling))
1683 },
1684 );
1685 push_sarif_results(sarif_results, &results.thin_wrappers, snippets, |w| {
1686 sarif_thin_wrapper_fields(
1687 &w.wrapper,
1688 root,
1689 severity_to_sarif_level(rules.thin_wrapper),
1690 )
1691 });
1692 push_sarif_results(
1693 sarif_results,
1694 &results.duplicate_prop_shapes,
1695 snippets,
1696 |d| {
1697 sarif_duplicate_prop_shape_fields(
1698 &d.shape,
1699 root,
1700 severity_to_sarif_level(rules.duplicate_prop_shape),
1701 )
1702 },
1703 );
1704}
1705
1706fn push_graph_sarif_results(
1707 sarif_results: &mut Vec<serde_json::Value>,
1708 results: &AnalysisResults,
1709 root: &Path,
1710 rules: &RulesConfig,
1711 snippets: &mut SourceSnippetCache,
1712) {
1713 push_structure_sarif_results(sarif_results, results, root, rules, snippets);
1714 push_framework_sarif_results(sarif_results, results, root, rules, snippets);
1715 push_route_sarif_results(sarif_results, results, root, rules, snippets);
1716 push_suppression_sarif_results(sarif_results, results, root, rules, snippets);
1717}
1718
1719fn push_structure_sarif_results(
1720 sarif_results: &mut Vec<serde_json::Value>,
1721 results: &AnalysisResults,
1722 root: &Path,
1723 rules: &RulesConfig,
1724 snippets: &mut SourceSnippetCache,
1725) {
1726 push_sarif_results(
1727 sarif_results,
1728 &results.circular_dependencies,
1729 snippets,
1730 |c| {
1731 sarif_circular_dep_fields(
1732 &c.cycle,
1733 root,
1734 severity_to_sarif_level(rules.circular_dependencies),
1735 )
1736 },
1737 );
1738 push_sarif_results(sarif_results, &results.re_export_cycles, snippets, |c| {
1739 sarif_re_export_cycle_fields(
1740 &c.cycle,
1741 root,
1742 severity_to_sarif_level(rules.re_export_cycle),
1743 )
1744 });
1745 push_sarif_results(sarif_results, &results.boundary_violations, snippets, |v| {
1746 sarif_boundary_violation_fields(
1747 &v.violation,
1748 root,
1749 severity_to_sarif_level(rules.boundary_violation),
1750 )
1751 });
1752 push_sarif_results(
1753 sarif_results,
1754 &results.boundary_coverage_violations,
1755 snippets,
1756 |v| {
1757 sarif_boundary_coverage_fields(
1758 &v.violation,
1759 root,
1760 severity_to_sarif_level(rules.boundary_violation),
1761 )
1762 },
1763 );
1764 push_sarif_results(
1765 sarif_results,
1766 &results.boundary_call_violations,
1767 snippets,
1768 |v| {
1769 sarif_boundary_call_fields(
1770 &v.violation,
1771 root,
1772 severity_to_sarif_level(rules.boundary_violation),
1773 )
1774 },
1775 );
1776 push_sarif_results(sarif_results, &results.policy_violations, snippets, |v| {
1777 sarif_policy_violation_fields(&v.violation, root)
1778 });
1779}
1780
1781fn push_framework_sarif_results(
1782 sarif_results: &mut Vec<serde_json::Value>,
1783 results: &AnalysisResults,
1784 root: &Path,
1785 rules: &RulesConfig,
1786 snippets: &mut SourceSnippetCache,
1787) {
1788 push_sarif_results(
1789 sarif_results,
1790 &results.invalid_client_exports,
1791 snippets,
1792 |e| {
1793 sarif_invalid_client_export_fields(
1794 &e.export,
1795 root,
1796 severity_to_sarif_level(rules.invalid_client_export),
1797 )
1798 },
1799 );
1800 push_sarif_results(
1801 sarif_results,
1802 &results.mixed_client_server_barrels,
1803 snippets,
1804 |b| {
1805 sarif_mixed_client_server_barrel_fields(
1806 &b.barrel,
1807 root,
1808 severity_to_sarif_level(rules.mixed_client_server_barrel),
1809 )
1810 },
1811 );
1812 push_sarif_results(
1813 sarif_results,
1814 &results.misplaced_directives,
1815 snippets,
1816 |d| {
1817 sarif_misplaced_directive_fields(
1818 &d.directive_site,
1819 root,
1820 severity_to_sarif_level(rules.misplaced_directive),
1821 )
1822 },
1823 );
1824 push_sarif_results(sarif_results, &results.unprovided_injects, snippets, |i| {
1825 sarif_unprovided_inject_fields(
1826 &i.inject,
1827 root,
1828 severity_to_sarif_level(rules.unprovided_injects),
1829 )
1830 });
1831 push_sarif_results(
1832 sarif_results,
1833 &results.unrendered_components,
1834 snippets,
1835 |c| {
1836 sarif_unrendered_component_fields(
1837 &c.component,
1838 root,
1839 severity_to_sarif_level(rules.unrendered_components),
1840 )
1841 },
1842 );
1843 push_component_contract_sarif_results(sarif_results, results, root, rules, snippets);
1844}
1845
1846fn push_route_sarif_results(
1847 sarif_results: &mut Vec<serde_json::Value>,
1848 results: &AnalysisResults,
1849 root: &Path,
1850 rules: &RulesConfig,
1851 snippets: &mut SourceSnippetCache,
1852) {
1853 push_sarif_results(sarif_results, &results.route_collisions, snippets, |c| {
1854 sarif_route_collision_fields(
1855 &c.collision,
1856 root,
1857 severity_to_sarif_level(rules.route_collision),
1858 )
1859 });
1860 push_sarif_results(
1861 sarif_results,
1862 &results.dynamic_segment_name_conflicts,
1863 snippets,
1864 |c| {
1865 sarif_dynamic_segment_name_conflict_fields(
1866 &c.conflict,
1867 root,
1868 severity_to_sarif_level(rules.dynamic_segment_name_conflict),
1869 )
1870 },
1871 );
1872}
1873
1874fn push_suppression_sarif_results(
1875 sarif_results: &mut Vec<serde_json::Value>,
1876 results: &AnalysisResults,
1877 root: &Path,
1878 rules: &RulesConfig,
1879 snippets: &mut SourceSnippetCache,
1880) {
1881 push_sarif_results(sarif_results, &results.stale_suppressions, snippets, |s| {
1882 sarif_stale_suppression_fields(s, root, severity_to_sarif_level(rules.stale_suppressions))
1883 });
1884}
1885
1886fn push_catalog_sarif_results(
1887 sarif_results: &mut Vec<serde_json::Value>,
1888 results: &AnalysisResults,
1889 root: &Path,
1890 rules: &RulesConfig,
1891 snippets: &mut SourceSnippetCache,
1892) {
1893 push_sarif_results(
1894 sarif_results,
1895 &results.unused_catalog_entries,
1896 snippets,
1897 |e| {
1898 sarif_unused_catalog_entry_fields(
1899 e,
1900 root,
1901 severity_to_sarif_level(rules.unused_catalog_entries),
1902 )
1903 },
1904 );
1905 push_sarif_results(
1906 sarif_results,
1907 &results.empty_catalog_groups,
1908 snippets,
1909 |g| {
1910 sarif_empty_catalog_group_fields(
1911 g,
1912 root,
1913 severity_to_sarif_level(rules.empty_catalog_groups),
1914 )
1915 },
1916 );
1917 push_sarif_results(
1918 sarif_results,
1919 &results.unresolved_catalog_references,
1920 snippets,
1921 |f| {
1922 sarif_unresolved_catalog_reference_fields(
1923 f,
1924 root,
1925 severity_to_sarif_level(rules.unresolved_catalog_references),
1926 )
1927 },
1928 );
1929 push_sarif_results(
1930 sarif_results,
1931 &results.unused_dependency_overrides,
1932 snippets,
1933 |f| {
1934 sarif_unused_dependency_override_fields(
1935 f,
1936 root,
1937 severity_to_sarif_level(rules.unused_dependency_overrides),
1938 )
1939 },
1940 );
1941 push_sarif_results(
1942 sarif_results,
1943 &results.misconfigured_dependency_overrides,
1944 snippets,
1945 |f| {
1946 sarif_misconfigured_dependency_override_fields(
1947 f,
1948 root,
1949 severity_to_sarif_level(rules.misconfigured_dependency_overrides),
1950 )
1951 },
1952 );
1953}
1954
1955pub(super) fn print_sarif(results: &AnalysisResults, root: &Path, rules: &RulesConfig) -> ExitCode {
1956 let sarif = build_sarif(results, root, rules);
1957 emit_json(&sarif, "SARIF")
1958}
1959
1960#[expect(
1966 clippy::expect_used,
1967 reason = "grouped SARIF entries are JSON objects created by build_sarif"
1968)]
1969pub(super) fn print_grouped_sarif(
1970 results: &AnalysisResults,
1971 root: &Path,
1972 rules: &RulesConfig,
1973 resolver: &OwnershipResolver,
1974) -> ExitCode {
1975 let mut sarif = build_sarif(results, root, rules);
1976
1977 if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
1978 for run in runs {
1979 if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
1980 for result in results {
1981 let uri = result
1982 .pointer("/locations/0/physicalLocation/artifactLocation/uri")
1983 .and_then(|v| v.as_str())
1984 .unwrap_or("");
1985 let decoded = uri.replace("%5B", "[").replace("%5D", "]");
1986 let owner =
1987 grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
1988 let props = result
1989 .as_object_mut()
1990 .expect("SARIF result should be an object")
1991 .entry("properties")
1992 .or_insert_with(|| serde_json::json!({}));
1993 props
1994 .as_object_mut()
1995 .expect("properties should be an object")
1996 .insert("owner".to_string(), serde_json::Value::String(owner));
1997 }
1998 }
1999 }
2000 }
2001
2002 emit_json(&sarif, "SARIF")
2003}
2004
2005#[expect(
2006 clippy::cast_possible_truncation,
2007 reason = "line/col numbers are bounded by source size"
2008)]
2009pub(super) fn print_duplication_sarif(report: &DuplicationReport, root: &Path) -> ExitCode {
2010 let mut sarif_results = Vec::new();
2011 let mut snippets = SourceSnippetCache::default();
2012
2013 for (i, group) in report.clone_groups.iter().enumerate() {
2014 for instance in &group.instances {
2015 let uri = relative_uri(&instance.file, root);
2016 let source_snippet = snippets.line(&instance.file, instance.start_line as u32);
2017 sarif_results.push(sarif_result_with_snippet(
2018 "fallow/code-duplication",
2019 "warning",
2020 &format!(
2021 "Code clone group {} ({} lines, {} instances)",
2022 i + 1,
2023 group.line_count,
2024 group.instances.len()
2025 ),
2026 &uri,
2027 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
2028 source_snippet.as_deref(),
2029 ));
2030 }
2031 }
2032
2033 let sarif = serde_json::json!({
2034 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
2035 "version": "2.1.0",
2036 "runs": [{
2037 "tool": {
2038 "driver": {
2039 "name": "fallow",
2040 "version": env!("CARGO_PKG_VERSION"),
2041 "informationUri": "https://github.com/fallow-rs/fallow",
2042 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
2043 }
2044 },
2045 "results": sarif_results
2046 }]
2047 });
2048
2049 emit_json(&sarif, "SARIF")
2050}
2051
2052#[expect(
2063 clippy::cast_possible_truncation,
2064 reason = "line/col numbers are bounded by source size"
2065)]
2066#[expect(
2067 clippy::expect_used,
2068 reason = "duplication SARIF entries are JSON objects created by sarif_result_with_snippet"
2069)]
2070pub(super) fn print_grouped_duplication_sarif(
2071 report: &DuplicationReport,
2072 root: &Path,
2073 resolver: &OwnershipResolver,
2074) -> ExitCode {
2075 let mut sarif_results = Vec::new();
2076 let mut snippets = SourceSnippetCache::default();
2077
2078 for (i, group) in report.clone_groups.iter().enumerate() {
2079 let primary_owner = super::dupes_grouping::largest_owner(group, root, resolver);
2080 for instance in &group.instances {
2081 let uri = relative_uri(&instance.file, root);
2082 let source_snippet = snippets.line(&instance.file, instance.start_line as u32);
2083 let mut result = sarif_result_with_snippet(
2084 "fallow/code-duplication",
2085 "warning",
2086 &format!(
2087 "Code clone group {} ({} lines, {} instances)",
2088 i + 1,
2089 group.line_count,
2090 group.instances.len()
2091 ),
2092 &uri,
2093 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
2094 source_snippet.as_deref(),
2095 );
2096 let props = result
2097 .as_object_mut()
2098 .expect("SARIF result should be an object")
2099 .entry("properties")
2100 .or_insert_with(|| serde_json::json!({}));
2101 props
2102 .as_object_mut()
2103 .expect("properties should be an object")
2104 .insert(
2105 "group".to_string(),
2106 serde_json::Value::String(primary_owner.clone()),
2107 );
2108 sarif_results.push(result);
2109 }
2110 }
2111
2112 let sarif = serde_json::json!({
2113 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
2114 "version": "2.1.0",
2115 "runs": [{
2116 "tool": {
2117 "driver": {
2118 "name": "fallow",
2119 "version": env!("CARGO_PKG_VERSION"),
2120 "informationUri": "https://github.com/fallow-rs/fallow",
2121 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
2122 }
2123 },
2124 "results": sarif_results
2125 }]
2126 });
2127
2128 emit_json(&sarif, "SARIF")
2129}
2130
2131#[must_use]
2132pub fn build_health_sarif(
2133 report: &crate::health_types::HealthReport,
2134 root: &Path,
2135) -> serde_json::Value {
2136 let mut sarif_results = Vec::new();
2137 let mut snippets = SourceSnippetCache::default();
2138
2139 append_health_sarif_results(report, root, &mut sarif_results, &mut snippets);
2140 let health_rules = health_sarif_rules();
2141 health_sarif_document(&sarif_results, &health_rules)
2142}
2143
2144fn append_health_sarif_results(
2145 report: &crate::health_types::HealthReport,
2146 root: &Path,
2147 sarif_results: &mut Vec<serde_json::Value>,
2148 snippets: &mut SourceSnippetCache,
2149) {
2150 append_complexity_sarif_results(sarif_results, report, root, snippets);
2151
2152 if let Some(ref production) = report.runtime_coverage {
2153 append_runtime_coverage_sarif_results(sarif_results, production, root, snippets);
2154 }
2155 if let Some(ref intelligence) = report.coverage_intelligence {
2156 append_coverage_intelligence_sarif_results(sarif_results, intelligence, root, snippets);
2157 }
2158
2159 append_refactoring_target_sarif_results(sarif_results, report, root);
2160 append_coverage_gap_sarif_results(sarif_results, report, root, snippets);
2161}
2162
2163fn health_sarif_rules() -> Vec<serde_json::Value> {
2164 let mut rules = health_complexity_sarif_rules();
2165 rules.extend(health_runtime_sarif_rules());
2166 rules.extend(health_coverage_intelligence_sarif_rules());
2167 rules
2168}
2169
2170fn health_complexity_sarif_rules() -> Vec<serde_json::Value> {
2171 vec![
2172 sarif_rule(
2173 "fallow/high-cyclomatic-complexity",
2174 "Function has high cyclomatic complexity",
2175 "note",
2176 ),
2177 sarif_rule(
2178 "fallow/high-cognitive-complexity",
2179 "Function has high cognitive complexity",
2180 "note",
2181 ),
2182 sarif_rule(
2183 "fallow/high-complexity",
2184 "Function exceeds both complexity thresholds",
2185 "note",
2186 ),
2187 sarif_rule(
2188 "fallow/high-crap-score",
2189 "Function has a high CRAP score (high complexity combined with low coverage)",
2190 "warning",
2191 ),
2192 sarif_rule(
2193 "fallow/refactoring-target",
2194 "File identified as a high-priority refactoring candidate",
2195 "warning",
2196 ),
2197 ]
2198}
2199
2200fn health_runtime_sarif_rules() -> Vec<serde_json::Value> {
2201 vec![
2202 sarif_rule(
2203 "fallow/untested-file",
2204 "Runtime-reachable file has no test dependency path",
2205 "warning",
2206 ),
2207 sarif_rule(
2208 "fallow/untested-export",
2209 "Runtime-reachable export has no test dependency path",
2210 "warning",
2211 ),
2212 sarif_rule(
2213 "fallow/runtime-safe-to-delete",
2214 "Function is statically unused and was never invoked in production",
2215 "warning",
2216 ),
2217 sarif_rule(
2218 "fallow/runtime-review-required",
2219 "Function is statically used but was never invoked in production",
2220 "warning",
2221 ),
2222 sarif_rule(
2223 "fallow/runtime-low-traffic",
2224 "Function was invoked below the low-traffic threshold relative to total trace count",
2225 "note",
2226 ),
2227 sarif_rule(
2228 "fallow/runtime-coverage-unavailable",
2229 "Runtime coverage could not be resolved for this function",
2230 "note",
2231 ),
2232 sarif_rule(
2233 "fallow/runtime-coverage",
2234 "Runtime coverage finding",
2235 "note",
2236 ),
2237 ]
2238}
2239
2240fn health_coverage_intelligence_sarif_rules() -> Vec<serde_json::Value> {
2241 vec![
2242 sarif_rule(
2243 "fallow/coverage-intelligence-risky-change",
2244 "Changed hot path combines high CRAP and low test coverage",
2245 "warning",
2246 ),
2247 sarif_rule(
2248 "fallow/coverage-intelligence-delete",
2249 "Static and runtime evidence indicate code can be deleted",
2250 "warning",
2251 ),
2252 sarif_rule(
2253 "fallow/coverage-intelligence-review",
2254 "Cold reachable uncovered code needs owner review",
2255 "warning",
2256 ),
2257 sarif_rule(
2258 "fallow/coverage-intelligence-refactor",
2259 "Hot covered code has high CRAP and should be refactored carefully",
2260 "warning",
2261 ),
2262 ]
2263}
2264
2265fn health_sarif_document(
2266 sarif_results: &[serde_json::Value],
2267 health_rules: &[serde_json::Value],
2268) -> serde_json::Value {
2269 serde_json::json!({
2270 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
2271 "version": "2.1.0",
2272 "runs": [{
2273 "tool": {
2274 "driver": {
2275 "name": "fallow",
2276 "version": env!("CARGO_PKG_VERSION"),
2277 "informationUri": "https://github.com/fallow-rs/fallow",
2278 "rules": health_rules
2279 }
2280 },
2281 "results": sarif_results
2282 }]
2283 })
2284}
2285
2286fn append_complexity_sarif_results(
2287 sarif_results: &mut Vec<serde_json::Value>,
2288 report: &crate::health_types::HealthReport,
2289 root: &Path,
2290 snippets: &mut SourceSnippetCache,
2291) {
2292 for finding in &report.findings {
2293 let uri = relative_uri(&finding.path, root);
2294 let (rule_id, message) = health_complexity_sarif_message(finding, report);
2295 let level = match finding.severity {
2296 crate::health_types::FindingSeverity::Critical => "error",
2297 crate::health_types::FindingSeverity::High => "warning",
2298 crate::health_types::FindingSeverity::Moderate => "note",
2299 };
2300 let source_snippet = snippets.line(&finding.path, finding.line);
2301 sarif_results.push(sarif_result_with_snippet(
2302 rule_id,
2303 level,
2304 &message,
2305 &uri,
2306 Some((finding.line, finding.col + 1)),
2307 source_snippet.as_deref(),
2308 ));
2309 }
2310}
2311
2312fn health_complexity_sarif_message(
2313 finding: &crate::health_types::ComplexityViolation,
2314 report: &crate::health_types::HealthReport,
2315) -> (&'static str, String) {
2316 match finding.exceeded {
2317 crate::health_types::ExceededThreshold::Cyclomatic => (
2318 "fallow/high-cyclomatic-complexity",
2319 format!(
2320 "'{}' has cyclomatic complexity {} (threshold: {})",
2321 finding.name, finding.cyclomatic, report.summary.max_cyclomatic_threshold,
2322 ),
2323 ),
2324 crate::health_types::ExceededThreshold::Cognitive => (
2325 "fallow/high-cognitive-complexity",
2326 format!(
2327 "'{}' has cognitive complexity {} (threshold: {})",
2328 finding.name, finding.cognitive, report.summary.max_cognitive_threshold,
2329 ),
2330 ),
2331 crate::health_types::ExceededThreshold::Both => (
2332 "fallow/high-complexity",
2333 format!(
2334 "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
2335 finding.name,
2336 finding.cyclomatic,
2337 report.summary.max_cyclomatic_threshold,
2338 finding.cognitive,
2339 report.summary.max_cognitive_threshold,
2340 ),
2341 ),
2342 crate::health_types::ExceededThreshold::Crap
2343 | crate::health_types::ExceededThreshold::CyclomaticCrap
2344 | crate::health_types::ExceededThreshold::CognitiveCrap
2345 | crate::health_types::ExceededThreshold::All => {
2346 let crap = finding.crap.unwrap_or(0.0);
2347 let coverage = finding
2348 .coverage_pct
2349 .map(|pct| format!(", coverage {pct:.0}%"))
2350 .unwrap_or_default();
2351 (
2352 "fallow/high-crap-score",
2353 format!(
2354 "'{}' has CRAP score {:.1} (threshold: {:.1}, cyclomatic {}{})",
2355 finding.name,
2356 crap,
2357 report.summary.max_crap_threshold,
2358 finding.cyclomatic,
2359 coverage,
2360 ),
2361 )
2362 }
2363 }
2364}
2365
2366fn append_refactoring_target_sarif_results(
2367 sarif_results: &mut Vec<serde_json::Value>,
2368 report: &crate::health_types::HealthReport,
2369 root: &Path,
2370) {
2371 for target in &report.targets {
2372 let uri = relative_uri(&target.path, root);
2373 let message = format!(
2374 "[{}] {} (priority: {:.1}, efficiency: {:.1}, effort: {}, confidence: {})",
2375 target.category.label(),
2376 target.recommendation,
2377 target.priority,
2378 target.efficiency,
2379 target.effort.label(),
2380 target.confidence.label(),
2381 );
2382 sarif_results.push(sarif_result(
2383 "fallow/refactoring-target",
2384 "warning",
2385 &message,
2386 &uri,
2387 None,
2388 ));
2389 }
2390}
2391
2392fn append_coverage_gap_sarif_results(
2393 sarif_results: &mut Vec<serde_json::Value>,
2394 report: &crate::health_types::HealthReport,
2395 root: &Path,
2396 snippets: &mut SourceSnippetCache,
2397) {
2398 let Some(ref gaps) = report.coverage_gaps else {
2399 return;
2400 };
2401 for item in &gaps.files {
2402 let uri = relative_uri(&item.file.path, root);
2403 let message = format!(
2404 "File is runtime-reachable but has no test dependency path ({} value export{})",
2405 item.file.value_export_count,
2406 if item.file.value_export_count == 1 {
2407 ""
2408 } else {
2409 "s"
2410 },
2411 );
2412 sarif_results.push(sarif_result(
2413 "fallow/untested-file",
2414 "warning",
2415 &message,
2416 &uri,
2417 None,
2418 ));
2419 }
2420
2421 for item in &gaps.exports {
2422 let uri = relative_uri(&item.export.path, root);
2423 let message = format!(
2424 "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
2425 item.export.export_name
2426 );
2427 let source_snippet = snippets.line(&item.export.path, item.export.line);
2428 sarif_results.push(sarif_result_with_snippet(
2429 "fallow/untested-export",
2430 "warning",
2431 &message,
2432 &uri,
2433 Some((item.export.line, item.export.col + 1)),
2434 source_snippet.as_deref(),
2435 ));
2436 }
2437}
2438
2439fn append_runtime_coverage_sarif_results(
2440 sarif_results: &mut Vec<serde_json::Value>,
2441 production: &crate::health_types::RuntimeCoverageReport,
2442 root: &Path,
2443 snippets: &mut SourceSnippetCache,
2444) {
2445 for finding in &production.findings {
2446 let uri = relative_uri(&finding.path, root);
2447 let rule_id = match finding.verdict {
2448 crate::health_types::RuntimeCoverageVerdict::SafeToDelete => {
2449 "fallow/runtime-safe-to-delete"
2450 }
2451 crate::health_types::RuntimeCoverageVerdict::ReviewRequired => {
2452 "fallow/runtime-review-required"
2453 }
2454 crate::health_types::RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
2455 crate::health_types::RuntimeCoverageVerdict::CoverageUnavailable => {
2456 "fallow/runtime-coverage-unavailable"
2457 }
2458 crate::health_types::RuntimeCoverageVerdict::Active
2459 | crate::health_types::RuntimeCoverageVerdict::Unknown => "fallow/runtime-coverage",
2460 };
2461 let level = match finding.verdict {
2462 crate::health_types::RuntimeCoverageVerdict::SafeToDelete
2463 | crate::health_types::RuntimeCoverageVerdict::ReviewRequired => "warning",
2464 _ => "note",
2465 };
2466 let invocations_hint = finding.invocations.map_or_else(
2467 || "untracked".to_owned(),
2468 |hits| format!("{hits} invocations"),
2469 );
2470 let message = format!(
2471 "'{}' runtime coverage verdict: {} ({})",
2472 finding.function,
2473 finding.verdict.human_label(),
2474 invocations_hint,
2475 );
2476 let source_snippet = snippets.line(&finding.path, finding.line);
2477 sarif_results.push(sarif_result_with_snippet(
2478 rule_id,
2479 level,
2480 &message,
2481 &uri,
2482 Some((finding.line, 1)),
2483 source_snippet.as_deref(),
2484 ));
2485 }
2486}
2487
2488fn append_coverage_intelligence_sarif_results(
2489 sarif_results: &mut Vec<serde_json::Value>,
2490 intelligence: &crate::health_types::CoverageIntelligenceReport,
2491 root: &Path,
2492 snippets: &mut SourceSnippetCache,
2493) {
2494 for finding in &intelligence.findings {
2495 let rule_id = coverage_intelligence_rule_id(finding.recommendation);
2496 let level = match finding.verdict {
2497 crate::health_types::CoverageIntelligenceVerdict::Clean
2498 | crate::health_types::CoverageIntelligenceVerdict::Unknown => continue,
2499 _ => "warning",
2500 };
2501 let uri = relative_uri(&finding.path, root);
2502 let identity = finding.identity.as_deref().unwrap_or("code");
2503 let signals = finding
2504 .signals
2505 .iter()
2506 .map(ToString::to_string)
2507 .collect::<Vec<_>>()
2508 .join(", ");
2509 let message = format!(
2510 "'{}' coverage intelligence verdict: {} ({}, signals: {})",
2511 identity, finding.verdict, finding.recommendation, signals,
2512 );
2513 let source_snippet = snippets.line(&finding.path, finding.line);
2514 let mut result = sarif_result_with_snippet(
2515 rule_id,
2516 level,
2517 &message,
2518 &uri,
2519 Some((finding.line, 1)),
2520 source_snippet.as_deref(),
2521 );
2522 result["properties"] = serde_json::json!({
2523 "coverage_intelligence_id": &finding.id,
2524 "verdict": finding.verdict,
2525 "recommendation": finding.recommendation,
2526 "confidence": finding.confidence,
2527 "signals": &finding.signals,
2528 "related_ids": &finding.related_ids,
2529 });
2530 sarif_results.push(result);
2531 }
2532}
2533
2534fn coverage_intelligence_rule_id(
2535 recommendation: crate::health_types::CoverageIntelligenceRecommendation,
2536) -> &'static str {
2537 match recommendation {
2538 crate::health_types::CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge => {
2539 "fallow/coverage-intelligence-risky-change"
2540 }
2541 crate::health_types::CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner => {
2542 "fallow/coverage-intelligence-delete"
2543 }
2544 crate::health_types::CoverageIntelligenceRecommendation::ReviewBeforeChanging => {
2545 "fallow/coverage-intelligence-review"
2546 }
2547 crate::health_types::CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior => {
2548 "fallow/coverage-intelligence-refactor"
2549 }
2550 }
2551}
2552
2553pub(super) fn print_health_sarif(
2554 report: &crate::health_types::HealthReport,
2555 root: &Path,
2556) -> ExitCode {
2557 let sarif = build_health_sarif(report, root);
2558 emit_json(&sarif, "SARIF")
2559}
2560
2561#[expect(
2572 clippy::expect_used,
2573 reason = "grouped health SARIF entries are JSON objects created by build_health_sarif"
2574)]
2575pub(super) fn print_grouped_health_sarif(
2576 report: &crate::health_types::HealthReport,
2577 root: &Path,
2578 resolver: &OwnershipResolver,
2579) -> ExitCode {
2580 let mut sarif = build_health_sarif(report, root);
2581
2582 if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
2583 for run in runs {
2584 if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
2585 for result in results {
2586 let uri = result
2587 .pointer("/locations/0/physicalLocation/artifactLocation/uri")
2588 .and_then(|v| v.as_str())
2589 .unwrap_or("");
2590 let decoded = uri.replace("%5B", "[").replace("%5D", "]");
2591 let group =
2592 grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
2593 let props = result
2594 .as_object_mut()
2595 .expect("SARIF result should be an object")
2596 .entry("properties")
2597 .or_insert_with(|| serde_json::json!({}));
2598 props
2599 .as_object_mut()
2600 .expect("properties should be an object")
2601 .insert("group".to_string(), serde_json::Value::String(group));
2602 }
2603 }
2604 }
2605 }
2606
2607 emit_json(&sarif, "SARIF")
2608}
2609
2610#[cfg(test)]
2611mod tests {
2612 use super::*;
2613 use crate::report::test_helpers::sample_results;
2614 use fallow_core::results::*;
2615 use std::path::PathBuf;
2616
2617 #[test]
2618 fn sarif_has_required_top_level_fields() {
2619 let root = PathBuf::from("/project");
2620 let results = AnalysisResults::default();
2621 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2622
2623 assert_eq!(
2624 sarif["$schema"],
2625 "https://json.schemastore.org/sarif-2.1.0.json"
2626 );
2627 assert_eq!(sarif["version"], "2.1.0");
2628 assert!(sarif["runs"].is_array());
2629 }
2630
2631 #[test]
2632 fn sarif_has_tool_driver_info() {
2633 let root = PathBuf::from("/project");
2634 let results = AnalysisResults::default();
2635 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2636
2637 let driver = &sarif["runs"][0]["tool"]["driver"];
2638 assert_eq!(driver["name"], "fallow");
2639 assert!(driver["version"].is_string());
2640 assert_eq!(
2641 driver["informationUri"],
2642 "https://github.com/fallow-rs/fallow"
2643 );
2644 }
2645
2646 #[test]
2647 fn sarif_declares_all_rules() {
2648 let root = PathBuf::from("/project");
2649 let results = AnalysisResults::default();
2650 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2651
2652 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
2653 .as_array()
2654 .expect("rules should be an array");
2655 assert_eq!(rules.len(), 44);
2656
2657 let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
2658 assert!(rule_ids.contains(&"fallow/duplicate-prop-shape"));
2659 assert!(rule_ids.contains(&"fallow/thin-wrapper"));
2660 assert!(rule_ids.contains(&"fallow/unrendered-component"));
2661 assert!(rule_ids.contains(&"fallow/unused-component-prop"));
2662 assert!(rule_ids.contains(&"fallow/unused-component-emit"));
2663 assert!(rule_ids.contains(&"fallow/unused-component-input"));
2664 assert!(rule_ids.contains(&"fallow/unused-component-output"));
2665 assert!(rule_ids.contains(&"fallow/unused-svelte-event"));
2666 assert!(rule_ids.contains(&"fallow/unused-server-action"));
2667 assert!(rule_ids.contains(&"fallow/unused-load-data-key"));
2668 assert!(rule_ids.contains(&"fallow/prop-drilling"));
2669 assert!(rule_ids.contains(&"fallow/route-collision"));
2670 assert!(rule_ids.contains(&"fallow/dynamic-segment-name-conflict"));
2671 assert!(rule_ids.contains(&"fallow/unused-file"));
2672 assert!(rule_ids.contains(&"fallow/unused-export"));
2673 assert!(rule_ids.contains(&"fallow/unused-type"));
2674 assert!(rule_ids.contains(&"fallow/private-type-leak"));
2675 assert!(rule_ids.contains(&"fallow/unused-dependency"));
2676 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
2677 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
2678 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
2679 assert!(rule_ids.contains(&"fallow/test-only-dependency"));
2680 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
2681 assert!(rule_ids.contains(&"fallow/unused-class-member"));
2682 assert!(rule_ids.contains(&"fallow/unused-store-member"));
2683 assert!(rule_ids.contains(&"fallow/unresolved-import"));
2684 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
2685 assert!(rule_ids.contains(&"fallow/duplicate-export"));
2686 assert!(rule_ids.contains(&"fallow/circular-dependency"));
2687 assert!(rule_ids.contains(&"fallow/re-export-cycle"));
2688 assert!(rule_ids.contains(&"fallow/boundary-violation"));
2689 assert!(rule_ids.contains(&"fallow/boundary-coverage"));
2690 assert!(rule_ids.contains(&"fallow/boundary-call-violation"));
2691 assert!(rule_ids.contains(&"fallow/policy-violation"));
2692 assert!(rule_ids.contains(&"fallow/unused-catalog-entry"));
2693 assert!(rule_ids.contains(&"fallow/empty-catalog-group"));
2694 assert!(rule_ids.contains(&"fallow/unresolved-catalog-reference"));
2695 assert!(rule_ids.contains(&"fallow/unused-dependency-override"));
2696 assert!(rule_ids.contains(&"fallow/misconfigured-dependency-override"));
2697 assert!(rule_ids.contains(&"fallow/invalid-client-export"));
2698 assert!(rule_ids.contains(&"fallow/mixed-client-server-barrel"));
2699 assert!(rule_ids.contains(&"fallow/misplaced-directive"));
2700 assert!(rule_ids.contains(&"fallow/unprovided-inject"));
2701 }
2702
2703 #[test]
2704 fn sarif_empty_results_no_results_entries() {
2705 let root = PathBuf::from("/project");
2706 let results = AnalysisResults::default();
2707 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2708
2709 let sarif_results = sarif["runs"][0]["results"]
2710 .as_array()
2711 .expect("results should be an array");
2712 assert!(sarif_results.is_empty());
2713 }
2714
2715 #[test]
2716 fn sarif_unused_file_result() {
2717 let root = PathBuf::from("/project");
2718 let mut results = AnalysisResults::default();
2719 results
2720 .unused_files
2721 .push(UnusedFileFinding::with_actions(UnusedFile {
2722 path: root.join("src/dead.ts"),
2723 }));
2724
2725 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2726 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2727 assert_eq!(entries.len(), 1);
2728
2729 let entry = &entries[0];
2730 assert_eq!(entry["ruleId"], "fallow/unused-file");
2731 assert_eq!(entry["level"], "error");
2732 assert_eq!(
2733 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2734 "src/dead.ts"
2735 );
2736 }
2737
2738 #[test]
2739 fn sarif_unused_export_includes_region() {
2740 let root = PathBuf::from("/project");
2741 let mut results = AnalysisResults::default();
2742 results
2743 .unused_exports
2744 .push(UnusedExportFinding::with_actions(UnusedExport {
2745 path: root.join("src/utils.ts"),
2746 export_name: "helperFn".to_string(),
2747 is_type_only: false,
2748 line: 10,
2749 col: 4,
2750 span_start: 120,
2751 is_re_export: false,
2752 }));
2753
2754 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2755 let entry = &sarif["runs"][0]["results"][0];
2756 assert_eq!(entry["ruleId"], "fallow/unused-export");
2757
2758 let region = &entry["locations"][0]["physicalLocation"]["region"];
2759 assert_eq!(region["startLine"], 10);
2760 assert_eq!(region["startColumn"], 5);
2761 }
2762
2763 #[test]
2764 fn sarif_unresolved_import_is_error_level() {
2765 let root = PathBuf::from("/project");
2766 let mut results = AnalysisResults::default();
2767 results
2768 .unresolved_imports
2769 .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
2770 path: root.join("src/app.ts"),
2771 specifier: "./missing".to_string(),
2772 line: 1,
2773 col: 0,
2774 specifier_col: 0,
2775 }));
2776
2777 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2778 let entry = &sarif["runs"][0]["results"][0];
2779 assert_eq!(entry["ruleId"], "fallow/unresolved-import");
2780 assert_eq!(entry["level"], "error");
2781 }
2782
2783 #[test]
2784 fn sarif_unlisted_dependency_points_to_import_site() {
2785 let root = PathBuf::from("/project");
2786 let mut results = AnalysisResults::default();
2787 results
2788 .unlisted_dependencies
2789 .push(UnlistedDependencyFinding::with_actions(
2790 UnlistedDependency {
2791 package_name: "chalk".to_string(),
2792 imported_from: vec![ImportSite {
2793 path: root.join("src/cli.ts"),
2794 line: 3,
2795 col: 0,
2796 }],
2797 },
2798 ));
2799
2800 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2801 let entry = &sarif["runs"][0]["results"][0];
2802 assert_eq!(entry["ruleId"], "fallow/unlisted-dependency");
2803 assert_eq!(entry["level"], "error");
2804 assert_eq!(
2805 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2806 "src/cli.ts"
2807 );
2808 let region = &entry["locations"][0]["physicalLocation"]["region"];
2809 assert_eq!(region["startLine"], 3);
2810 assert_eq!(region["startColumn"], 1);
2811 }
2812
2813 #[test]
2814 fn sarif_dependency_issues_point_to_package_json() {
2815 let root = PathBuf::from("/project");
2816 let mut results = AnalysisResults::default();
2817 results
2818 .unused_dependencies
2819 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2820 package_name: "lodash".to_string(),
2821 location: DependencyLocation::Dependencies,
2822 path: root.join("package.json"),
2823 line: 5,
2824 used_in_workspaces: Vec::new(),
2825 }));
2826 results
2827 .unused_dev_dependencies
2828 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
2829 package_name: "jest".to_string(),
2830 location: DependencyLocation::DevDependencies,
2831 path: root.join("package.json"),
2832 line: 5,
2833 used_in_workspaces: Vec::new(),
2834 }));
2835
2836 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2837 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2838 for entry in entries {
2839 assert_eq!(
2840 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2841 "package.json"
2842 );
2843 }
2844 }
2845
2846 #[test]
2847 fn sarif_duplicate_export_emits_one_result_per_location() {
2848 let root = PathBuf::from("/project");
2849 let mut results = AnalysisResults::default();
2850 results
2851 .duplicate_exports
2852 .push(DuplicateExportFinding::with_actions(DuplicateExport {
2853 export_name: "Config".to_string(),
2854 locations: vec![
2855 DuplicateLocation {
2856 path: root.join("src/a.ts"),
2857 line: 15,
2858 col: 0,
2859 },
2860 DuplicateLocation {
2861 path: root.join("src/b.ts"),
2862 line: 30,
2863 col: 0,
2864 },
2865 ],
2866 }));
2867
2868 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2869 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2870 assert_eq!(entries.len(), 2);
2871 assert_eq!(entries[0]["ruleId"], "fallow/duplicate-export");
2872 assert_eq!(entries[1]["ruleId"], "fallow/duplicate-export");
2873 assert_eq!(
2874 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2875 "src/a.ts"
2876 );
2877 assert_eq!(
2878 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2879 "src/b.ts"
2880 );
2881 }
2882
2883 #[test]
2884 fn sarif_all_issue_types_produce_results() {
2885 let root = PathBuf::from("/project");
2886 let results = sample_results(&root);
2887 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2888
2889 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2890 assert_eq!(entries.len(), results.total_issues() + 1);
2891
2892 let rule_ids: Vec<&str> = entries
2893 .iter()
2894 .map(|e| e["ruleId"].as_str().unwrap())
2895 .collect();
2896 assert!(rule_ids.contains(&"fallow/unused-file"));
2897 assert!(rule_ids.contains(&"fallow/unused-export"));
2898 assert!(rule_ids.contains(&"fallow/unused-type"));
2899 assert!(rule_ids.contains(&"fallow/unused-dependency"));
2900 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
2901 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
2902 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
2903 assert!(rule_ids.contains(&"fallow/test-only-dependency"));
2904 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
2905 assert!(rule_ids.contains(&"fallow/unused-class-member"));
2906 assert!(rule_ids.contains(&"fallow/unused-store-member"));
2907 assert!(rule_ids.contains(&"fallow/unresolved-import"));
2908 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
2909 assert!(rule_ids.contains(&"fallow/duplicate-export"));
2910 assert!(rule_ids.contains(&"fallow/unprovided-inject"));
2911 }
2912
2913 #[test]
2914 fn sarif_serializes_to_valid_json() {
2915 let root = PathBuf::from("/project");
2916 let results = sample_results(&root);
2917 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2918
2919 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
2920 let reparsed: serde_json::Value =
2921 serde_json::from_str(&json_str).expect("SARIF output should be valid JSON");
2922 assert_eq!(reparsed, sarif);
2923 }
2924
2925 #[test]
2926 fn sarif_file_write_produces_valid_sarif() {
2927 let root = PathBuf::from("/project");
2928 let results = sample_results(&root);
2929 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2930 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
2931
2932 let dir = std::env::temp_dir().join("fallow-test-sarif-file");
2933 let _ = std::fs::create_dir_all(&dir);
2934 let sarif_path = dir.join("results.sarif");
2935 std::fs::write(&sarif_path, &json_str).expect("should write SARIF file");
2936
2937 let contents = std::fs::read_to_string(&sarif_path).expect("should read SARIF file");
2938 let parsed: serde_json::Value =
2939 serde_json::from_str(&contents).expect("file should contain valid JSON");
2940
2941 assert_eq!(parsed["version"], "2.1.0");
2942 assert_eq!(
2943 parsed["$schema"],
2944 "https://json.schemastore.org/sarif-2.1.0.json"
2945 );
2946 let sarif_results = parsed["runs"][0]["results"]
2947 .as_array()
2948 .expect("results should be an array");
2949 assert!(!sarif_results.is_empty());
2950
2951 let _ = std::fs::remove_file(&sarif_path);
2952 let _ = std::fs::remove_dir(&dir);
2953 }
2954
2955 #[test]
2956 fn health_sarif_empty_no_results() {
2957 let root = PathBuf::from("/project");
2958 let report = crate::health_types::HealthReport {
2959 summary: crate::health_types::HealthSummary {
2960 files_analyzed: 10,
2961 functions_analyzed: 50,
2962 ..Default::default()
2963 },
2964 ..Default::default()
2965 };
2966 let sarif = build_health_sarif(&report, &root);
2967 assert_eq!(sarif["version"], "2.1.0");
2968 let results = sarif["runs"][0]["results"].as_array().unwrap();
2969 assert!(results.is_empty());
2970 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
2971 .as_array()
2972 .unwrap();
2973 assert_eq!(rules.len(), 16);
2974 }
2975
2976 #[test]
2977 fn health_sarif_coverage_intelligence_preserves_structured_properties() {
2978 use crate::health_types::{
2979 CoverageIntelligenceAction, CoverageIntelligenceConfidence,
2980 CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
2981 CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
2982 CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2983 CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
2984 HealthReport, HealthSummary,
2985 };
2986
2987 let root = PathBuf::from("/project");
2988 let report = HealthReport {
2989 summary: HealthSummary {
2990 files_analyzed: 10,
2991 functions_analyzed: 50,
2992 ..Default::default()
2993 },
2994 coverage_intelligence: Some(CoverageIntelligenceReport {
2995 schema_version: CoverageIntelligenceSchemaVersion::V1,
2996 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2997 summary: CoverageIntelligenceSummary {
2998 findings: 1,
2999 high_confidence_deletes: 1,
3000 ..Default::default()
3001 },
3002 findings: vec![CoverageIntelligenceFinding {
3003 id: "fallow:coverage-intel:abc123".to_owned(),
3004 path: root.join("src/dead.ts"),
3005 identity: Some("deadPath".to_owned()),
3006 line: 9,
3007 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
3008 signals: vec![CoverageIntelligenceSignal::RuntimeCold],
3009 recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
3010 confidence: CoverageIntelligenceConfidence::High,
3011 related_ids: vec!["fallow:prod:deadbeef".to_owned()],
3012 evidence: CoverageIntelligenceEvidence {
3013 match_confidence: CoverageIntelligenceMatchConfidence::Direct,
3014 ..Default::default()
3015 },
3016 actions: vec![CoverageIntelligenceAction {
3017 kind: "delete-after-confirming-owner".to_owned(),
3018 description: "Confirm ownership".to_owned(),
3019 auto_fixable: false,
3020 }],
3021 }],
3022 }),
3023 ..Default::default()
3024 };
3025
3026 let sarif = build_health_sarif(&report, &root);
3027 let result = &sarif["runs"][0]["results"][0];
3028 assert_eq!(result["ruleId"], "fallow/coverage-intelligence-delete");
3029 assert_eq!(
3030 result["properties"]["coverage_intelligence_id"],
3031 "fallow:coverage-intel:abc123"
3032 );
3033 assert_eq!(
3034 result["properties"]["recommendation"],
3035 "delete-after-confirming-owner"
3036 );
3037 assert_eq!(result["properties"]["confidence"], "high");
3038 assert_eq!(result["properties"]["signals"][0], "runtime_cold");
3039 assert_eq!(
3040 result["properties"]["related_ids"][0],
3041 "fallow:prod:deadbeef"
3042 );
3043 }
3044
3045 #[test]
3046 fn health_sarif_cyclomatic_only() {
3047 let root = PathBuf::from("/project");
3048 let report = crate::health_types::HealthReport {
3049 findings: vec![
3050 crate::health_types::ComplexityViolation {
3051 path: root.join("src/utils.ts"),
3052 name: "parseExpression".to_string(),
3053 line: 42,
3054 col: 0,
3055 cyclomatic: 25,
3056 cognitive: 10,
3057 line_count: 80,
3058 param_count: 0,
3059 react_hook_count: 0,
3060 react_jsx_max_depth: 0,
3061 react_prop_count: 0,
3062 react_hook_profile: None,
3063 exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
3064 severity: crate::health_types::FindingSeverity::High,
3065 crap: None,
3066 coverage_pct: None,
3067 coverage_tier: None,
3068 coverage_source: None,
3069 inherited_from: None,
3070 component_rollup: None,
3071 contributions: Vec::new(),
3072 effective_thresholds: None,
3073 threshold_source: None,
3074 }
3075 .into(),
3076 ],
3077 summary: crate::health_types::HealthSummary {
3078 files_analyzed: 5,
3079 functions_analyzed: 20,
3080 functions_above_threshold: 1,
3081 ..Default::default()
3082 },
3083 ..Default::default()
3084 };
3085 let sarif = build_health_sarif(&report, &root);
3086 let entry = &sarif["runs"][0]["results"][0];
3087 assert_eq!(entry["ruleId"], "fallow/high-cyclomatic-complexity");
3088 assert_eq!(entry["level"], "warning");
3089 assert!(
3090 entry["message"]["text"]
3091 .as_str()
3092 .unwrap()
3093 .contains("cyclomatic complexity 25")
3094 );
3095 assert_eq!(
3096 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3097 "src/utils.ts"
3098 );
3099 let region = &entry["locations"][0]["physicalLocation"]["region"];
3100 assert_eq!(region["startLine"], 42);
3101 assert_eq!(region["startColumn"], 1);
3102 }
3103
3104 #[test]
3105 fn health_sarif_cognitive_only() {
3106 let root = PathBuf::from("/project");
3107 let report = crate::health_types::HealthReport {
3108 findings: vec![
3109 crate::health_types::ComplexityViolation {
3110 path: root.join("src/api.ts"),
3111 name: "handleRequest".to_string(),
3112 line: 10,
3113 col: 4,
3114 cyclomatic: 8,
3115 cognitive: 20,
3116 line_count: 40,
3117 param_count: 0,
3118 react_hook_count: 0,
3119 react_jsx_max_depth: 0,
3120 react_prop_count: 0,
3121 react_hook_profile: None,
3122 exceeded: crate::health_types::ExceededThreshold::Cognitive,
3123 severity: crate::health_types::FindingSeverity::High,
3124 crap: None,
3125 coverage_pct: None,
3126 coverage_tier: None,
3127 coverage_source: None,
3128 inherited_from: None,
3129 component_rollup: None,
3130 contributions: Vec::new(),
3131 effective_thresholds: None,
3132 threshold_source: None,
3133 }
3134 .into(),
3135 ],
3136 summary: crate::health_types::HealthSummary {
3137 files_analyzed: 3,
3138 functions_analyzed: 10,
3139 functions_above_threshold: 1,
3140 ..Default::default()
3141 },
3142 ..Default::default()
3143 };
3144 let sarif = build_health_sarif(&report, &root);
3145 let entry = &sarif["runs"][0]["results"][0];
3146 assert_eq!(entry["ruleId"], "fallow/high-cognitive-complexity");
3147 assert!(
3148 entry["message"]["text"]
3149 .as_str()
3150 .unwrap()
3151 .contains("cognitive complexity 20")
3152 );
3153 let region = &entry["locations"][0]["physicalLocation"]["region"];
3154 assert_eq!(region["startColumn"], 5); }
3156
3157 #[test]
3158 fn health_sarif_both_thresholds() {
3159 let root = PathBuf::from("/project");
3160 let report = crate::health_types::HealthReport {
3161 findings: vec![
3162 crate::health_types::ComplexityViolation {
3163 path: root.join("src/complex.ts"),
3164 name: "doEverything".to_string(),
3165 line: 1,
3166 col: 0,
3167 cyclomatic: 30,
3168 cognitive: 45,
3169 line_count: 100,
3170 param_count: 0,
3171 react_hook_count: 0,
3172 react_jsx_max_depth: 0,
3173 react_prop_count: 0,
3174 react_hook_profile: None,
3175 exceeded: crate::health_types::ExceededThreshold::Both,
3176 severity: crate::health_types::FindingSeverity::High,
3177 crap: None,
3178 coverage_pct: None,
3179 coverage_tier: None,
3180 coverage_source: None,
3181 inherited_from: None,
3182 component_rollup: None,
3183 contributions: Vec::new(),
3184 effective_thresholds: None,
3185 threshold_source: None,
3186 }
3187 .into(),
3188 ],
3189 summary: crate::health_types::HealthSummary {
3190 files_analyzed: 1,
3191 functions_analyzed: 1,
3192 functions_above_threshold: 1,
3193 ..Default::default()
3194 },
3195 ..Default::default()
3196 };
3197 let sarif = build_health_sarif(&report, &root);
3198 let entry = &sarif["runs"][0]["results"][0];
3199 assert_eq!(entry["ruleId"], "fallow/high-complexity");
3200 let msg = entry["message"]["text"].as_str().unwrap();
3201 assert!(msg.contains("cyclomatic complexity 30"));
3202 assert!(msg.contains("cognitive complexity 45"));
3203 }
3204
3205 #[test]
3206 fn health_sarif_crap_only_emits_crap_rule() {
3207 let root = PathBuf::from("/project");
3208 let report = crate::health_types::HealthReport {
3209 findings: vec![
3210 crate::health_types::ComplexityViolation {
3211 path: root.join("src/untested.ts"),
3212 name: "risky".to_string(),
3213 line: 8,
3214 col: 0,
3215 cyclomatic: 10,
3216 cognitive: 10,
3217 line_count: 20,
3218 param_count: 1,
3219 react_hook_count: 0,
3220 react_jsx_max_depth: 0,
3221 react_prop_count: 0,
3222 react_hook_profile: None,
3223 exceeded: crate::health_types::ExceededThreshold::Crap,
3224 severity: crate::health_types::FindingSeverity::High,
3225 crap: Some(82.2),
3226 coverage_pct: Some(12.0),
3227 coverage_tier: None,
3228 coverage_source: None,
3229 inherited_from: None,
3230 component_rollup: None,
3231 contributions: Vec::new(),
3232 effective_thresholds: None,
3233 threshold_source: None,
3234 }
3235 .into(),
3236 ],
3237 summary: crate::health_types::HealthSummary {
3238 files_analyzed: 1,
3239 functions_analyzed: 1,
3240 functions_above_threshold: 1,
3241 ..Default::default()
3242 },
3243 ..Default::default()
3244 };
3245 let sarif = build_health_sarif(&report, &root);
3246 let entry = &sarif["runs"][0]["results"][0];
3247 assert_eq!(entry["ruleId"], "fallow/high-crap-score");
3248 let msg = entry["message"]["text"].as_str().unwrap();
3249 assert!(msg.contains("CRAP score 82.2"), "msg: {msg}");
3250 assert!(msg.contains("coverage 12%"), "msg: {msg}");
3251 }
3252
3253 #[test]
3254 fn health_sarif_cyclomatic_crap_uses_crap_rule() {
3255 let root = PathBuf::from("/project");
3256 let report = crate::health_types::HealthReport {
3257 findings: vec![
3258 crate::health_types::ComplexityViolation {
3259 path: root.join("src/hot.ts"),
3260 name: "branchy".to_string(),
3261 line: 1,
3262 col: 0,
3263 cyclomatic: 67,
3264 cognitive: 12,
3265 line_count: 80,
3266 param_count: 1,
3267 react_hook_count: 0,
3268 react_jsx_max_depth: 0,
3269 react_prop_count: 0,
3270 react_hook_profile: None,
3271 exceeded: crate::health_types::ExceededThreshold::CyclomaticCrap,
3272 severity: crate::health_types::FindingSeverity::Critical,
3273 crap: Some(182.0),
3274 coverage_pct: None,
3275 coverage_tier: None,
3276 coverage_source: None,
3277 inherited_from: None,
3278 component_rollup: None,
3279 contributions: Vec::new(),
3280 effective_thresholds: None,
3281 threshold_source: None,
3282 }
3283 .into(),
3284 ],
3285 summary: crate::health_types::HealthSummary {
3286 files_analyzed: 1,
3287 functions_analyzed: 1,
3288 functions_above_threshold: 1,
3289 ..Default::default()
3290 },
3291 ..Default::default()
3292 };
3293 let sarif = build_health_sarif(&report, &root);
3294 let results = sarif["runs"][0]["results"].as_array().unwrap();
3295 assert_eq!(
3296 results.len(),
3297 1,
3298 "CyclomaticCrap should emit a single SARIF result under the CRAP rule"
3299 );
3300 assert_eq!(results[0]["ruleId"], "fallow/high-crap-score");
3301 let msg = results[0]["message"]["text"].as_str().unwrap();
3302 assert!(msg.contains("CRAP score 182"), "msg: {msg}");
3303 assert!(!msg.contains("coverage"), "msg: {msg}");
3304 }
3305
3306 #[test]
3307 fn severity_to_sarif_level_error() {
3308 assert_eq!(severity_to_sarif_level(Severity::Error), "error");
3309 }
3310
3311 #[test]
3312 fn severity_to_sarif_level_warn() {
3313 assert_eq!(severity_to_sarif_level(Severity::Warn), "warning");
3314 }
3315
3316 #[test]
3317 #[should_panic(expected = "internal error: entered unreachable code")]
3318 fn severity_to_sarif_level_off() {
3319 let _ = severity_to_sarif_level(Severity::Off);
3320 }
3321
3322 #[test]
3323 fn sarif_re_export_has_properties() {
3324 let root = PathBuf::from("/project");
3325 let mut results = AnalysisResults::default();
3326 results
3327 .unused_exports
3328 .push(UnusedExportFinding::with_actions(UnusedExport {
3329 path: root.join("src/index.ts"),
3330 export_name: "reExported".to_string(),
3331 is_type_only: false,
3332 line: 1,
3333 col: 0,
3334 span_start: 0,
3335 is_re_export: true,
3336 }));
3337
3338 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3339 let entry = &sarif["runs"][0]["results"][0];
3340 assert_eq!(entry["properties"]["is_re_export"], true);
3341 let msg = entry["message"]["text"].as_str().unwrap();
3342 assert!(msg.starts_with("Re-export"));
3343 }
3344
3345 #[test]
3346 fn sarif_non_re_export_has_no_properties() {
3347 let root = PathBuf::from("/project");
3348 let mut results = AnalysisResults::default();
3349 results
3350 .unused_exports
3351 .push(UnusedExportFinding::with_actions(UnusedExport {
3352 path: root.join("src/utils.ts"),
3353 export_name: "foo".to_string(),
3354 is_type_only: false,
3355 line: 5,
3356 col: 0,
3357 span_start: 0,
3358 is_re_export: false,
3359 }));
3360
3361 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3362 let entry = &sarif["runs"][0]["results"][0];
3363 assert!(entry.get("properties").is_none());
3364 let msg = entry["message"]["text"].as_str().unwrap();
3365 assert!(msg.starts_with("Export"));
3366 }
3367
3368 #[test]
3369 fn sarif_type_re_export_message() {
3370 let root = PathBuf::from("/project");
3371 let mut results = AnalysisResults::default();
3372 results
3373 .unused_types
3374 .push(UnusedTypeFinding::with_actions(UnusedExport {
3375 path: root.join("src/index.ts"),
3376 export_name: "MyType".to_string(),
3377 is_type_only: true,
3378 line: 1,
3379 col: 0,
3380 span_start: 0,
3381 is_re_export: true,
3382 }));
3383
3384 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3385 let entry = &sarif["runs"][0]["results"][0];
3386 assert_eq!(entry["ruleId"], "fallow/unused-type");
3387 let msg = entry["message"]["text"].as_str().unwrap();
3388 assert!(msg.starts_with("Type re-export"));
3389 assert_eq!(entry["properties"]["is_re_export"], true);
3390 }
3391
3392 #[test]
3393 fn sarif_dependency_line_zero_skips_region() {
3394 let root = PathBuf::from("/project");
3395 let mut results = AnalysisResults::default();
3396 results
3397 .unused_dependencies
3398 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
3399 package_name: "lodash".to_string(),
3400 location: DependencyLocation::Dependencies,
3401 path: root.join("package.json"),
3402 line: 0,
3403 used_in_workspaces: Vec::new(),
3404 }));
3405
3406 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3407 let entry = &sarif["runs"][0]["results"][0];
3408 let phys = &entry["locations"][0]["physicalLocation"];
3409 assert!(phys.get("region").is_none());
3410 }
3411
3412 #[test]
3413 fn sarif_dependency_line_nonzero_has_region() {
3414 let root = PathBuf::from("/project");
3415 let mut results = AnalysisResults::default();
3416 results
3417 .unused_dependencies
3418 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
3419 package_name: "lodash".to_string(),
3420 location: DependencyLocation::Dependencies,
3421 path: root.join("package.json"),
3422 line: 7,
3423 used_in_workspaces: Vec::new(),
3424 }));
3425
3426 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3427 let entry = &sarif["runs"][0]["results"][0];
3428 let region = &entry["locations"][0]["physicalLocation"]["region"];
3429 assert_eq!(region["startLine"], 7);
3430 assert_eq!(region["startColumn"], 1);
3431 }
3432
3433 #[test]
3434 fn sarif_type_only_dep_line_zero_skips_region() {
3435 let root = PathBuf::from("/project");
3436 let mut results = AnalysisResults::default();
3437 results
3438 .type_only_dependencies
3439 .push(TypeOnlyDependencyFinding::with_actions(
3440 TypeOnlyDependency {
3441 package_name: "zod".to_string(),
3442 path: root.join("package.json"),
3443 line: 0,
3444 },
3445 ));
3446
3447 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3448 let entry = &sarif["runs"][0]["results"][0];
3449 let phys = &entry["locations"][0]["physicalLocation"];
3450 assert!(phys.get("region").is_none());
3451 }
3452
3453 #[test]
3454 fn sarif_circular_dep_line_zero_skips_region() {
3455 let root = PathBuf::from("/project");
3456 let mut results = AnalysisResults::default();
3457 results
3458 .circular_dependencies
3459 .push(CircularDependencyFinding::with_actions(
3460 CircularDependency {
3461 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
3462 length: 2,
3463 line: 0,
3464 col: 0,
3465 edges: Vec::new(),
3466 is_cross_package: false,
3467 },
3468 ));
3469
3470 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3471 let entry = &sarif["runs"][0]["results"][0];
3472 let phys = &entry["locations"][0]["physicalLocation"];
3473 assert!(phys.get("region").is_none());
3474 }
3475
3476 #[test]
3477 fn sarif_circular_dep_line_nonzero_has_region() {
3478 let root = PathBuf::from("/project");
3479 let mut results = AnalysisResults::default();
3480 results
3481 .circular_dependencies
3482 .push(CircularDependencyFinding::with_actions(
3483 CircularDependency {
3484 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
3485 length: 2,
3486 line: 5,
3487 col: 2,
3488 edges: Vec::new(),
3489 is_cross_package: false,
3490 },
3491 ));
3492
3493 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3494 let entry = &sarif["runs"][0]["results"][0];
3495 let region = &entry["locations"][0]["physicalLocation"]["region"];
3496 assert_eq!(region["startLine"], 5);
3497 assert_eq!(region["startColumn"], 3);
3498 }
3499
3500 #[test]
3501 fn sarif_unused_optional_dependency_result() {
3502 let root = PathBuf::from("/project");
3503 let mut results = AnalysisResults::default();
3504 results
3505 .unused_optional_dependencies
3506 .push(UnusedOptionalDependencyFinding::with_actions(
3507 UnusedDependency {
3508 package_name: "fsevents".to_string(),
3509 location: DependencyLocation::OptionalDependencies,
3510 path: root.join("package.json"),
3511 line: 12,
3512 used_in_workspaces: Vec::new(),
3513 },
3514 ));
3515
3516 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3517 let entry = &sarif["runs"][0]["results"][0];
3518 assert_eq!(entry["ruleId"], "fallow/unused-optional-dependency");
3519 let msg = entry["message"]["text"].as_str().unwrap();
3520 assert!(msg.contains("optionalDependencies"));
3521 }
3522
3523 #[test]
3524 fn sarif_enum_member_message_format() {
3525 let root = PathBuf::from("/project");
3526 let mut results = AnalysisResults::default();
3527 results.unused_enum_members.push(
3528 fallow_core::results::UnusedEnumMemberFinding::with_actions(UnusedMember {
3529 path: root.join("src/enums.ts"),
3530 parent_name: "Color".to_string(),
3531 member_name: "Purple".to_string(),
3532 kind: fallow_core::extract::MemberKind::EnumMember,
3533 line: 5,
3534 col: 2,
3535 }),
3536 );
3537
3538 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3539 let entry = &sarif["runs"][0]["results"][0];
3540 assert_eq!(entry["ruleId"], "fallow/unused-enum-member");
3541 let msg = entry["message"]["text"].as_str().unwrap();
3542 assert!(msg.contains("Enum member 'Color.Purple'"));
3543 let region = &entry["locations"][0]["physicalLocation"]["region"];
3544 assert_eq!(region["startColumn"], 3); }
3546
3547 #[test]
3548 fn sarif_class_member_message_format() {
3549 let root = PathBuf::from("/project");
3550 let mut results = AnalysisResults::default();
3551 results.unused_class_members.push(
3552 fallow_core::results::UnusedClassMemberFinding::with_actions(UnusedMember {
3553 path: root.join("src/service.ts"),
3554 parent_name: "API".to_string(),
3555 member_name: "fetch".to_string(),
3556 kind: fallow_core::extract::MemberKind::ClassMethod,
3557 line: 10,
3558 col: 4,
3559 }),
3560 );
3561
3562 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3563 let entry = &sarif["runs"][0]["results"][0];
3564 assert_eq!(entry["ruleId"], "fallow/unused-class-member");
3565 let msg = entry["message"]["text"].as_str().unwrap();
3566 assert!(msg.contains("Class member 'API.fetch'"));
3567 }
3568
3569 #[test]
3570 #[expect(
3571 clippy::cast_possible_truncation,
3572 reason = "test line/col values are trivially small"
3573 )]
3574 fn duplication_sarif_structure() {
3575 use fallow_core::duplicates::*;
3576
3577 let root = PathBuf::from("/project");
3578 let report = DuplicationReport {
3579 clone_groups: vec![CloneGroup {
3580 instances: vec![
3581 CloneInstance {
3582 file: root.join("src/a.ts"),
3583 start_line: 1,
3584 end_line: 10,
3585 start_col: 0,
3586 end_col: 0,
3587 fragment: String::new(),
3588 },
3589 CloneInstance {
3590 file: root.join("src/b.ts"),
3591 start_line: 5,
3592 end_line: 14,
3593 start_col: 2,
3594 end_col: 0,
3595 fragment: String::new(),
3596 },
3597 ],
3598 token_count: 50,
3599 line_count: 10,
3600 }],
3601 clone_families: vec![],
3602 mirrored_directories: vec![],
3603 stats: DuplicationStats::default(),
3604 };
3605
3606 let sarif = serde_json::json!({
3607 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
3608 "version": "2.1.0",
3609 "runs": [{
3610 "tool": {
3611 "driver": {
3612 "name": "fallow",
3613 "version": env!("CARGO_PKG_VERSION"),
3614 "informationUri": "https://github.com/fallow-rs/fallow",
3615 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
3616 }
3617 },
3618 "results": []
3619 }]
3620 });
3621 let _ = sarif;
3622
3623 let mut sarif_results = Vec::new();
3624 for (i, group) in report.clone_groups.iter().enumerate() {
3625 for instance in &group.instances {
3626 sarif_results.push(sarif_result(
3627 "fallow/code-duplication",
3628 "warning",
3629 &format!(
3630 "Code clone group {} ({} lines, {} instances)",
3631 i + 1,
3632 group.line_count,
3633 group.instances.len()
3634 ),
3635 &super::super::relative_uri(&instance.file, &root),
3636 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
3637 ));
3638 }
3639 }
3640 assert_eq!(sarif_results.len(), 2);
3641 assert_eq!(sarif_results[0]["ruleId"], "fallow/code-duplication");
3642 assert!(
3643 sarif_results[0]["message"]["text"]
3644 .as_str()
3645 .unwrap()
3646 .contains("10 lines")
3647 );
3648 let region0 = &sarif_results[0]["locations"][0]["physicalLocation"]["region"];
3649 assert_eq!(region0["startLine"], 1);
3650 assert_eq!(region0["startColumn"], 1); let region1 = &sarif_results[1]["locations"][0]["physicalLocation"]["region"];
3652 assert_eq!(region1["startLine"], 5);
3653 assert_eq!(region1["startColumn"], 3); }
3655
3656 #[test]
3657 fn sarif_rule_known_id_has_full_description() {
3658 let rule = sarif_rule("fallow/unused-file", "fallback text", "error");
3659 assert!(rule.get("fullDescription").is_some());
3660 assert!(rule.get("helpUri").is_some());
3661 }
3662
3663 #[test]
3664 fn sarif_rule_unknown_id_uses_fallback() {
3665 let rule = sarif_rule("fallow/nonexistent", "fallback text", "warning");
3666 assert_eq!(rule["shortDescription"]["text"], "fallback text");
3667 assert!(rule.get("fullDescription").is_none());
3668 assert!(rule.get("helpUri").is_none());
3669 assert_eq!(rule["defaultConfiguration"]["level"], "warning");
3670 }
3671
3672 #[test]
3673 fn sarif_result_no_region_omits_region_key() {
3674 let result = sarif_result("rule/test", "error", "test msg", "src/file.ts", None);
3675 let phys = &result["locations"][0]["physicalLocation"];
3676 assert!(phys.get("region").is_none());
3677 assert_eq!(phys["artifactLocation"]["uri"], "src/file.ts");
3678 }
3679
3680 #[test]
3681 fn sarif_result_with_region_includes_region() {
3682 let result = sarif_result(
3683 "rule/test",
3684 "error",
3685 "test msg",
3686 "src/file.ts",
3687 Some((10, 5)),
3688 );
3689 let region = &result["locations"][0]["physicalLocation"]["region"];
3690 assert_eq!(region["startLine"], 10);
3691 assert_eq!(region["startColumn"], 5);
3692 }
3693
3694 #[test]
3695 fn sarif_partial_fingerprint_ignores_rendered_message() {
3696 let a = sarif_result(
3697 "rule/test",
3698 "error",
3699 "first message",
3700 "src/file.ts",
3701 Some((10, 5)),
3702 );
3703 let b = sarif_result(
3704 "rule/test",
3705 "error",
3706 "rewritten message",
3707 "src/file.ts",
3708 Some((10, 5)),
3709 );
3710 assert_eq!(
3711 a["partialFingerprints"][fingerprint::FINGERPRINT_KEY],
3712 b["partialFingerprints"][fingerprint::FINGERPRINT_KEY]
3713 );
3714 }
3715
3716 #[test]
3717 fn health_sarif_includes_refactoring_targets() {
3718 use crate::health_types::*;
3719
3720 let root = PathBuf::from("/project");
3721 let report = HealthReport {
3722 summary: HealthSummary {
3723 files_analyzed: 10,
3724 functions_analyzed: 50,
3725 ..Default::default()
3726 },
3727 targets: vec![
3728 RefactoringTarget {
3729 path: root.join("src/complex.ts"),
3730 priority: 85.0,
3731 efficiency: 42.5,
3732 recommendation: "Split high-impact file".into(),
3733 category: RecommendationCategory::SplitHighImpact,
3734 effort: EffortEstimate::Medium,
3735 confidence: Confidence::High,
3736 factors: vec![],
3737 evidence: None,
3738 }
3739 .into(),
3740 ],
3741 ..Default::default()
3742 };
3743
3744 let sarif = build_health_sarif(&report, &root);
3745 let entries = sarif["runs"][0]["results"].as_array().unwrap();
3746 assert_eq!(entries.len(), 1);
3747 assert_eq!(entries[0]["ruleId"], "fallow/refactoring-target");
3748 assert_eq!(entries[0]["level"], "warning");
3749 let msg = entries[0]["message"]["text"].as_str().unwrap();
3750 assert!(msg.contains("high impact"));
3751 assert!(msg.contains("Split high-impact file"));
3752 assert!(msg.contains("42.5"));
3753 }
3754
3755 #[test]
3756 fn health_sarif_includes_coverage_gaps() {
3757 use crate::health_types::*;
3758
3759 let root = PathBuf::from("/project");
3760 let report = HealthReport {
3761 summary: HealthSummary {
3762 files_analyzed: 10,
3763 functions_analyzed: 50,
3764 ..Default::default()
3765 },
3766 coverage_gaps: Some(CoverageGaps {
3767 summary: CoverageGapSummary {
3768 runtime_files: 2,
3769 covered_files: 0,
3770 file_coverage_pct: 0.0,
3771 untested_files: 1,
3772 untested_exports: 1,
3773 },
3774 files: vec![UntestedFileFinding::with_actions(
3775 UntestedFile {
3776 path: root.join("src/app.ts"),
3777 value_export_count: 2,
3778 },
3779 &root,
3780 )],
3781 exports: vec![UntestedExportFinding::with_actions(
3782 UntestedExport {
3783 path: root.join("src/app.ts"),
3784 export_name: "loader".into(),
3785 line: 12,
3786 col: 4,
3787 },
3788 &root,
3789 )],
3790 }),
3791 ..Default::default()
3792 };
3793
3794 let sarif = build_health_sarif(&report, &root);
3795 let entries = sarif["runs"][0]["results"].as_array().unwrap();
3796 assert_eq!(entries.len(), 2);
3797 assert_eq!(entries[0]["ruleId"], "fallow/untested-file");
3798 assert_eq!(
3799 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3800 "src/app.ts"
3801 );
3802 assert!(
3803 entries[0]["message"]["text"]
3804 .as_str()
3805 .unwrap()
3806 .contains("2 value exports")
3807 );
3808 assert_eq!(entries[1]["ruleId"], "fallow/untested-export");
3809 assert_eq!(
3810 entries[1]["locations"][0]["physicalLocation"]["region"]["startLine"],
3811 12
3812 );
3813 assert_eq!(
3814 entries[1]["locations"][0]["physicalLocation"]["region"]["startColumn"],
3815 5
3816 );
3817 }
3818
3819 #[test]
3820 fn health_sarif_rules_have_full_descriptions() {
3821 let root = PathBuf::from("/project");
3822 let report = crate::health_types::HealthReport::default();
3823 let sarif = build_health_sarif(&report, &root);
3824 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
3825 .as_array()
3826 .unwrap();
3827 for rule in rules {
3828 let id = rule["id"].as_str().unwrap();
3829 assert!(
3830 rule.get("fullDescription").is_some(),
3831 "health rule {id} should have fullDescription"
3832 );
3833 assert!(
3834 rule.get("helpUri").is_some(),
3835 "health rule {id} should have helpUri"
3836 );
3837 }
3838 }
3839
3840 #[test]
3841 fn sarif_warn_severity_produces_warning_level() {
3842 let root = PathBuf::from("/project");
3843 let mut results = AnalysisResults::default();
3844 results
3845 .unused_files
3846 .push(UnusedFileFinding::with_actions(UnusedFile {
3847 path: root.join("src/dead.ts"),
3848 }));
3849
3850 let rules = RulesConfig {
3851 unused_files: Severity::Warn,
3852 ..RulesConfig::default()
3853 };
3854
3855 let sarif = build_sarif(&results, &root, &rules);
3856 let entry = &sarif["runs"][0]["results"][0];
3857 assert_eq!(entry["level"], "warning");
3858 }
3859
3860 #[test]
3861 fn sarif_unused_file_has_no_region() {
3862 let root = PathBuf::from("/project");
3863 let mut results = AnalysisResults::default();
3864 results
3865 .unused_files
3866 .push(UnusedFileFinding::with_actions(UnusedFile {
3867 path: root.join("src/dead.ts"),
3868 }));
3869
3870 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3871 let entry = &sarif["runs"][0]["results"][0];
3872 let phys = &entry["locations"][0]["physicalLocation"];
3873 assert!(phys.get("region").is_none());
3874 }
3875
3876 #[test]
3877 fn sarif_unlisted_dep_multiple_import_sites() {
3878 let root = PathBuf::from("/project");
3879 let mut results = AnalysisResults::default();
3880 results
3881 .unlisted_dependencies
3882 .push(UnlistedDependencyFinding::with_actions(
3883 UnlistedDependency {
3884 package_name: "dotenv".to_string(),
3885 imported_from: vec![
3886 ImportSite {
3887 path: root.join("src/a.ts"),
3888 line: 1,
3889 col: 0,
3890 },
3891 ImportSite {
3892 path: root.join("src/b.ts"),
3893 line: 5,
3894 col: 0,
3895 },
3896 ],
3897 },
3898 ));
3899
3900 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3901 let entries = sarif["runs"][0]["results"].as_array().unwrap();
3902 assert_eq!(entries.len(), 2);
3903 assert_eq!(
3904 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3905 "src/a.ts"
3906 );
3907 assert_eq!(
3908 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3909 "src/b.ts"
3910 );
3911 }
3912
3913 #[test]
3914 fn sarif_unlisted_dep_no_import_sites() {
3915 let root = PathBuf::from("/project");
3916 let mut results = AnalysisResults::default();
3917 results
3918 .unlisted_dependencies
3919 .push(UnlistedDependencyFinding::with_actions(
3920 UnlistedDependency {
3921 package_name: "phantom".to_string(),
3922 imported_from: vec![],
3923 },
3924 ));
3925
3926 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3927 let entries = sarif["runs"][0]["results"].as_array().unwrap();
3928 assert!(entries.is_empty());
3929 }
3930}