Skip to main content

fallow_cli/report/
sarif.rs

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
25/// Intermediate fields extracted from an issue for SARIF result construction.
26struct 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
71/// Build a single SARIF result object.
72///
73/// When `region` is `Some((line, col))`, a `region` block with 1-based
74/// `startLine` and `startColumn` is included in the physical location.
75fn 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
124/// Append SARIF results for a slice of items using a closure to extract fields.
125fn 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
153/// Build a SARIF rule definition with optional `fullDescription` and `helpUri`
154/// sourced from the centralized explain module.
155fn 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
176/// Extract SARIF fields for an unused export or type export.
177fn 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
223/// Extract SARIF fields for an unused dependency.
224fn 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
260/// Extract SARIF fields for an unused enum or class member.
261fn 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        // The SARIF rule id is the static `fallow/policy-violation`; the
497        // per-rule policy identity rides in properties so code-scanning
498        // consumers can group or filter per pack rule without parsing the
499        // message. Dynamic per-rule SARIF rule synthesis is a tracked
500        // follow-up shared with boundary zone rules.
501        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    // Anchor at the source hop (the prop owner). Path / line come from the first
741    // hop; the message names the depth and the consumer at the chain tail.
742    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
989/// Unlisted deps fan out to one SARIF result per import site, so they do not
990/// fit `push_sarif_results`. Keep the nested-loop shape in its own helper.
991fn 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
1018/// Duplicate exports fan out to one SARIF result per location
1019/// (SARIF 2.1.0 section 3.27.12), so they do not fit `push_sarif_results`.
1020fn 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
1044/// Build the SARIF rules list from the current rules configuration.
1045fn 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
1583/// Push the component-contract SARIF results (`unused-component-prop` and
1584/// `unused-component-emit`). Extracted from `push_graph_sarif_results` to keep
1585/// that function under the unit-size lint.
1586fn 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/// Print SARIF output with owner properties added to each result.
1961///
1962/// Calls `build_sarif` to produce the standard SARIF JSON, then post-processes
1963/// each result to add `"properties": { "owner": "@team" }` by resolving the
1964/// artifact location URI through the `OwnershipResolver`.
1965#[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/// Print SARIF duplication output with a `properties.group` tag on every
2053/// result.
2054///
2055/// Each clone group is attributed to its largest owner (most instances; ties
2056/// broken alphabetically) via [`super::dupes_grouping::largest_owner`], and
2057/// every result emitted for that group's instances carries the same
2058/// `properties.group` value. This mirrors the health SARIF convention
2059/// (`print_grouped_health_sarif`) so consumers (GitHub Code Scanning, GitLab
2060/// Code Quality) can partition findings per team / package / directory
2061/// without re-resolving ownership.
2062#[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/// Print health SARIF with a per-result `properties.group` tag.
2562///
2563/// Mirrors the dead-code grouped SARIF pattern (`print_grouped_sarif`):
2564/// build the standard SARIF first, then post-process each result to inject
2565/// the resolver-derived group key on `properties.group`. Consumers that read
2566/// SARIF (GitHub Code Scanning, GitLab Code Quality) can then partition
2567/// findings per team / package / directory without dropping out of the
2568/// SARIF pipeline. Each finding's URI is decoded (`%5B` -> `[`, `%5D` -> `]`)
2569/// before resolution, matching the dead-code behaviour for paths containing
2570/// brackets like Next.js dynamic routes.
2571#[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); // col 4 + 1
3155    }
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); // col 2 + 1
3545    }
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); // start_col 0 + 1
3651        let region1 = &sarif_results[1]["locations"][0]["physicalLocation"]["region"];
3652        assert_eq!(region1["startLine"], 5);
3653        assert_eq!(region1["startColumn"], 3); // start_col 2 + 1
3654    }
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}