Skip to main content

fallow_cli/report/
github_annotations.rs

1//! `--format github-annotations`: GitHub Actions workflow-command
2//! annotations (`::error` / `::warning` / `::notice` lines on stdout).
3//!
4//! The per-kind titles and message templates were first ported from the
5//! bundled action's jq renderers (`action/jq/annotations-{check,dupes,
6//! health}.jq`); the security emitter is net-new (the jq layer has no security
7//! annotations). This renderer is now the source of truth for fallow 3.4.2 and
8//! later. The action keeps the jq files as frozen legacy renderers for older
9//! binaries, so new issue kinds go here and not into the jq files. Messages are built with real newlines and escaped at the
10//! render boundary per the strict contract in [`super::github`].
11//!
12//! The renderer is value-driven: it consumes the same JSON envelope that
13//! `--format json` serializes, which is what makes `fallow report --from
14//! <results.json>` byte-identical to the direct format run.
15
16use std::path::Path;
17use std::process::ExitCode;
18
19use fallow_types::output_dead_code::caveat_labels_for_tokens;
20use serde_json::Value;
21
22use super::github::{
23    Annotation, AnnotationLevel, PackageManager, RenderOptions, arr, b, budget_notice, fmt_num,
24    num, one_based_col, render_annotation, resolve_render_options, s, sort_annotations, u,
25};
26use crate::report::sink::outln;
27
28/// Which JSON envelope family is being rendered. Mirrors the `kind` field on
29/// the `--format json` root plus the two aggregate shapes.
30#[derive(Clone, Copy, PartialEq, Eq, Debug)]
31pub enum EnvelopeKind {
32    DeadCode,
33    Dupes,
34    Health,
35    Audit,
36    Combined,
37    Security,
38    /// The `fallow fix --format json` envelope. It carries no top-level `kind`
39    /// field (see `crates/output/src/fix.rs`), so `fallow report --from`
40    /// detects it by its stable top-level keys rather than a `kind` string.
41    Fix,
42}
43
44/// Render and print the annotation stream for one envelope, resolving the
45/// ambient path-rebase and package-manager options at this boundary.
46pub(crate) fn print_annotations(kind: EnvelopeKind, envelope: &Value, root: &Path) -> ExitCode {
47    let options = resolve_render_options(root);
48    let rendered = render_annotations(kind, envelope, &options);
49    // Before the findings, because the consumer caps this stream: the action
50    // pipes it through `head -n "$MAX"` (`action/scripts/annotate.sh`), so a
51    // verdict appended after the findings is the first thing a noisy run drops.
52    if let Some(line) = crate::report::gate_outcome_text::annotation_line(envelope) {
53        outln!("{line}");
54    }
55    if let Some(line) = crate::report::request_outcome_text::annotation_line(envelope) {
56        outln!("{line}");
57    }
58    if !rendered.is_empty() {
59        outln!("{rendered}");
60    }
61    ExitCode::SUCCESS
62}
63
64/// Pure renderer: collect per-kind annotations, sort most-severe-first
65/// (severity, then path, then line), rebase paths onto the repo root, and
66/// append the trailing budget notice.
67#[must_use]
68pub fn render_annotations(kind: EnvelopeKind, envelope: &Value, options: &RenderOptions) -> String {
69    let mut annotations = collect_annotations(kind, envelope, options.pm);
70    sort_annotations(&mut annotations);
71    let mut lines: Vec<String> = Vec::with_capacity(annotations.len() + 1);
72    for annotation in &mut annotations {
73        annotation.path = options.rebase.apply(&annotation.path);
74        lines.push(render_annotation(annotation));
75    }
76    if let Some(notice) = budget_notice(annotations.len()) {
77        lines.push(notice);
78    }
79    lines.join("\n")
80}
81
82pub(crate) fn collect_annotations(
83    kind: EnvelopeKind,
84    envelope: &Value,
85    pm: PackageManager,
86) -> Vec<Annotation> {
87    let mut out = Vec::new();
88    match kind {
89        EnvelopeKind::DeadCode => collect_check(envelope, pm, &mut out),
90        EnvelopeKind::Dupes => collect_dupes(envelope, &mut out),
91        EnvelopeKind::Health => collect_health(envelope, &mut out),
92        EnvelopeKind::Security => collect_security(envelope, &mut out),
93        EnvelopeKind::Audit => {
94            collect_section(envelope, "dead_code", pm, &mut out, collect_check);
95            collect_value_section(envelope, "complexity", &mut out, collect_health);
96            collect_value_section(envelope, "duplication", &mut out, collect_dupes);
97        }
98        EnvelopeKind::Combined => {
99            collect_section(envelope, "check", pm, &mut out, collect_check);
100            collect_value_section(envelope, "health", &mut out, collect_health);
101            collect_value_section(envelope, "dupes", &mut out, collect_dupes);
102        }
103        // The bundled action no-ops annotations for the fix command
104        // (`action/scripts/annotate.sh` skips fix), so the native renderer
105        // matches by emitting nothing.
106        EnvelopeKind::Fix => {}
107    }
108    out
109}
110
111fn collect_section(
112    envelope: &Value,
113    key: &str,
114    pm: PackageManager,
115    out: &mut Vec<Annotation>,
116    collect: fn(&Value, PackageManager, &mut Vec<Annotation>),
117) {
118    if let Some(section) = envelope.get(key).filter(|section| !section.is_null()) {
119        collect(section, pm, out);
120    }
121}
122
123fn collect_value_section(
124    envelope: &Value,
125    key: &str,
126    out: &mut Vec<Annotation>,
127    collect: fn(&Value, &mut Vec<Annotation>),
128) {
129    if let Some(section) = envelope.get(key).filter(|section| !section.is_null()) {
130        collect(section, out);
131    }
132}
133
134/// Line/column anchor for one annotation.
135#[derive(Clone, Copy, Default)]
136struct Anchor {
137    line: Option<u64>,
138    col: Option<u64>,
139}
140
141impl Anchor {
142    /// Unconditional `line=` + 1-based `col=` (the common jq shape).
143    fn line_col(item: &Value) -> Self {
144        Self {
145            line: Some(u(item, "line")),
146            col: Some(one_based_col(u(item, "col"))),
147        }
148    }
149
150    /// `line=` only, no column (jq templates without `col`).
151    fn line_only(item: &Value) -> Self {
152        Self {
153            line: Some(u(item, "line")),
154            col: None,
155        }
156    }
157
158    /// jq's `if .line > 0 then ",line=..,col=.." else ""` gate.
159    fn gated_line_col(item: &Value) -> Self {
160        if u(item, "line") > 0 {
161            Self::line_col(item)
162        } else {
163            Self::default()
164        }
165    }
166
167    /// jq's `if .line > 0 then ",line=.." else ""` gate (dependency kinds).
168    fn gated_line(item: &Value) -> Self {
169        let line = u(item, "line");
170        Self {
171            line: (line > 0).then_some(line),
172            col: None,
173        }
174    }
175}
176
177fn push(
178    out: &mut Vec<Annotation>,
179    level: AnnotationLevel,
180    path: &str,
181    anchor: Anchor,
182    title: String,
183    message: String,
184) {
185    out.push(Annotation {
186        level,
187        path: path.to_owned(),
188        line: anchor.line,
189        end_line: None,
190        col: anchor.col,
191        title,
192        message,
193    });
194}
195
196/// The annotation level for a dead-code finding.
197///
198/// The level follows the `effective_severity` field of the finding: `error`
199/// gives `::error` and `warn` gives `::warning`. A finding without the field
200/// (a saved report from an older version) or with an unknown value keeps
201/// `fallback`, which is the level those versions used.
202fn gate_level(item: &Value, fallback: AnnotationLevel) -> AnnotationLevel {
203    match item.get("effective_severity").and_then(Value::as_str) {
204        Some("error") => AnnotationLevel::Error,
205        Some("warn") => AnnotationLevel::Warning,
206        _ => fallback,
207    }
208}
209
210/// Emit one annotation per item of `env[key]`, with the level from
211/// [`gate_level`], the anchor style chosen by `anchor` and the message built
212/// by `message`.
213fn push_each(
214    out: &mut Vec<Annotation>,
215    env: &Value,
216    key: &str,
217    title: &str,
218    anchor: fn(&Value) -> Anchor,
219    message: impl Fn(&Value) -> String,
220) {
221    for item in arr(env, key) {
222        push(
223            out,
224            gate_level(item, AnnotationLevel::Warning),
225            s(item, "path"),
226            anchor(item),
227            title.to_owned(),
228            message(item),
229        );
230    }
231}
232
233fn no_anchor(_item: &Value) -> Anchor {
234    Anchor::default()
235}
236
237fn joined_strs(item: &Value, key: &str, separator: &str) -> String {
238    arr(item, key)
239        .filter_map(Value::as_str)
240        .collect::<Vec<_>>()
241        .join(separator)
242}
243
244/// The qualifier appended to a dead-code annotation whose reachability
245/// verdict rests on a file this run never fully read. Empty when the finding
246/// carries no `reachability_caveats[]`, so a clean run's annotation stream is
247/// byte-identical to what it was before this hedge existed.
248///
249/// An annotation is the surface that suggests the mutation ("Run: npm
250/// uninstall x", "remove the export keyword"), so the qualifier has to travel
251/// with it: a suggestion that reads as confident while the evidence behind it
252/// is incomplete is the exact failure this mechanism exists to prevent.
253fn caveat_note(item: &Value) -> String {
254    caveat_labels_for_tokens(arr(item, "reachability_caveats").filter_map(Value::as_str)).map_or_else(
255        String::new,
256        |labels| {
257            format!(
258                "\n\nCaveat: {labels}. A file this run did not fully read can hide the reference that would credit this, so verify before removing."
259            )
260        },
261    )
262}
263
264fn workspace_context(item: &Value) -> String {
265    let workspaces = joined_strs(item, "used_in_workspaces", ", ");
266    if workspaces.is_empty() {
267        String::new()
268    } else {
269        format!("\n\nImported in other workspaces: {workspaces}")
270    }
271}
272
273fn dependency_action(item: &Value, pm: PackageManager) -> String {
274    if arr(item, "used_in_workspaces").next().is_some() {
275        "Move this dependency to the consuming workspace package.json.".to_owned()
276    } else {
277        format!("Run: {}", pm.remove_command(s(item, "package_name")))
278    }
279}
280
281fn unused_dependency_message(item: &Value, section: &str, pm: PackageManager) -> String {
282    format!(
283        "Package '{}' is listed in {section} but never imported by this package.{}\n\n{}{}",
284        s(item, "package_name"),
285        workspace_context(item),
286        dependency_action(item, pm),
287        caveat_note(item),
288    )
289}
290
291fn collect_check(env: &Value, pm: PackageManager, out: &mut Vec<Annotation>) {
292    collect_check_files_and_exports(env, out);
293    collect_check_dependencies(env, pm, out);
294    collect_check_members(env, out);
295    collect_check_graph(env, out);
296    collect_check_boundaries(env, out);
297    collect_check_frameworks(env, out);
298    collect_check_components(env, out);
299    collect_check_suppressions(env, out);
300    collect_check_catalog(env, out);
301}
302
303fn collect_check_files_and_exports(env: &Value, out: &mut Vec<Annotation>) {
304    push_each(out, env, "unused_files", "Unused file", no_anchor, |it| {
305        format!(
306            "This file is not imported by any other module and unreachable from entry points.\nConsider removing it or importing it where needed.{}",
307            caveat_note(it),
308        )
309    });
310    push_each(
311        out,
312        env,
313        "unused_exports",
314        "Unused export",
315        Anchor::line_col,
316        |it| {
317            format!(
318                "{} {} '{}' is never imported by other modules.\n\nIf this export is part of a public API, consider adding it to the entry configuration.\nOtherwise, remove the export keyword or delete the declaration.{}",
319                if b(it, "is_re_export") {
320                    "Re-exported"
321                } else {
322                    "Exported"
323                },
324                if b(it, "is_type_only") {
325                    "type"
326                } else {
327                    "value"
328                },
329                s(it, "export_name"),
330                caveat_note(it),
331            )
332        },
333    );
334    push_each(
335        out,
336        env,
337        "unused_types",
338        "Unused type",
339        Anchor::line_col,
340        |it| {
341            format!(
342                "{} type '{}' is never imported by other modules.\n\nIf only used internally, remove the export keyword.{}",
343                if b(it, "is_re_export") {
344                    "Re-exported"
345                } else {
346                    "Exported"
347                },
348                s(it, "export_name"),
349                caveat_note(it),
350            )
351        },
352    );
353    push_each(
354        out,
355        env,
356        "private_type_leaks",
357        "Private type leak",
358        Anchor::line_col,
359        |it| {
360            format!(
361                "Export '{}' references private type '{}'.\n\nExport the referenced type or remove it from the public signature.",
362                s(it, "export_name"),
363                s(it, "type_name"),
364            )
365        },
366    );
367    push_each(
368        out,
369        env,
370        "deprecated_exports_in_use",
371        "Deprecated export in use",
372        Anchor::line_col,
373        deprecated_export_message,
374    );
375}
376
377fn deprecated_export_message(item: &Value) -> String {
378    let count = u(item, "consumer_count");
379    let reason = item
380        .get("deprecated_reason")
381        .and_then(Value::as_str)
382        .map_or_else(String::new, |reason| {
383            format!("\n\nDeprecation message: {reason}")
384        });
385    format!(
386        "Deprecated export '{}' is still used by {count} consumer{}.{reason}\n\nMove the consumers to the replacement, then remove the export.",
387        s(item, "export_name"),
388        if count == 1 { "" } else { "s" },
389    )
390}
391
392fn collect_check_dependencies(env: &Value, pm: PackageManager, out: &mut Vec<Annotation>) {
393    push_each(
394        out,
395        env,
396        "unused_dependencies",
397        "Unused dependency",
398        Anchor::gated_line,
399        |it| unused_dependency_message(it, "dependencies", pm),
400    );
401    push_each(
402        out,
403        env,
404        "unused_dev_dependencies",
405        "Unused devDependency",
406        Anchor::gated_line,
407        |it| unused_dependency_message(it, "devDependencies", pm),
408    );
409    push_each(
410        out,
411        env,
412        "unused_optional_dependencies",
413        "Unused optionalDependency",
414        Anchor::gated_line,
415        |it| unused_dependency_message(it, "optionalDependencies", pm),
416    );
417    for dependency in arr(env, "unlisted_dependencies") {
418        let package = s(dependency, "package_name");
419        for site in arr(dependency, "imported_from") {
420            push(
421                out,
422                gate_level(dependency, AnnotationLevel::Warning),
423                s(site, "path"),
424                Anchor::line_col(site),
425                "Unlisted dependency".to_owned(),
426                format!(
427                    "Package '{package}' is imported here but not listed in package.json.\n\nRun: {}",
428                    pm.add_command(package),
429                ),
430            );
431        }
432    }
433    push_each(
434        out,
435        env,
436        "type_only_dependencies",
437        "Type-only dependency",
438        Anchor::gated_line,
439        |it| {
440            format!(
441                "Package '{}' is only used via type imports.\n\nMove it from dependencies to devDependencies to reduce production bundle size.",
442                s(it, "package_name"),
443            )
444        },
445    );
446    push_each(
447        out,
448        env,
449        "test_only_dependencies",
450        "Test-only dependency",
451        Anchor::gated_line,
452        |it| {
453            format!(
454                "Package '{}' is only imported from test or config files.\n\nMove it from dependencies to devDependencies to reduce production bundle size.",
455                s(it, "package_name"),
456            )
457        },
458    );
459    push_each(
460        out,
461        env,
462        "dev_dependencies_in_production",
463        "Dev dependency in production",
464        Anchor::gated_line,
465        |it| {
466            format!(
467                "Package '{}' is a devDependency imported by production code at runtime.\n\nMove it from devDependencies to dependencies so a production-only install does not break at runtime.",
468                s(it, "package_name"),
469            )
470        },
471    );
472}
473
474fn collect_check_members(env: &Value, out: &mut Vec<Annotation>) {
475    push_each(
476        out,
477        env,
478        "unused_enum_members",
479        "Unused enum member",
480        Anchor::line_col,
481        |it| {
482            format!(
483                "Enum member '{}.{}' is never referenced in the codebase.\n\nConsider removing it to keep the enum minimal.{}",
484                s(it, "parent_name"),
485                s(it, "member_name"),
486                caveat_note(it),
487            )
488        },
489    );
490    push_each(
491        out,
492        env,
493        "unused_class_members",
494        "Unused class member",
495        Anchor::line_col,
496        |it| {
497            format!(
498                "Class member '{}.{}' is never referenced.\n\nConsider removing it or marking it as private.{}",
499                s(it, "parent_name"),
500                s(it, "member_name"),
501                caveat_note(it),
502            )
503        },
504    );
505    push_each(
506        out,
507        env,
508        "unused_store_members",
509        "Unused store member",
510        Anchor::line_col,
511        |it| {
512            format!(
513                "Store member '{}.{}' is never accessed by any consumer.\n\nConsider removing the unused store state, getter, or action.{}",
514                s(it, "parent_name"),
515                s(it, "member_name"),
516                caveat_note(it),
517            )
518        },
519    );
520}
521
522fn collect_check_graph(env: &Value, out: &mut Vec<Annotation>) {
523    push_each(
524        out,
525        env,
526        "unresolved_imports",
527        "Unresolved import",
528        Anchor::line_col,
529        |it| {
530            format!(
531                "Import '{}' could not be resolved to a file or package.\n\nCheck for typos, missing dependencies, or incorrect path aliases.",
532                s(it, "specifier"),
533            )
534        },
535    );
536    for duplicate in arr(env, "duplicate_exports") {
537        let name = s(duplicate, "export_name");
538        let locations: Vec<&Value> = arr(duplicate, "locations").collect();
539        let listing = locations
540            .iter()
541            .map(|location| {
542                format!(
543                    "  \u{2022} {}:{}",
544                    s(location, "path"),
545                    num(location, "line")
546                )
547            })
548            .collect::<Vec<_>>()
549            .join("\n");
550        for location in &locations {
551            push(
552                out,
553                gate_level(duplicate, AnnotationLevel::Warning),
554                s(location, "path"),
555                Anchor::line_col(location),
556                "Duplicate export".to_owned(),
557                format!(
558                    "Export '{name}' is defined in {} modules:\n{listing}\n\nThis causes ambiguity for consumers. Keep one canonical location.",
559                    locations.len(),
560                ),
561            );
562        }
563    }
564    for cycle in arr(env, "circular_dependencies") {
565        let files: Vec<&str> = arr(cycle, "files").filter_map(Value::as_str).collect();
566        let first = files.first().copied().unwrap_or_default();
567        push(
568            out,
569            gate_level(cycle, AnnotationLevel::Warning),
570            first,
571            Anchor::gated_line_col(cycle),
572            "Circular dependency".to_owned(),
573            format!(
574                "Circular import chain detected:\n{} \u{2192} {first}\n\nCircular dependencies can cause initialization bugs and make code harder to reason about.\nConsider extracting shared logic into a separate module.",
575                files.join(" \u{2192} "),
576            ),
577        );
578    }
579    for cycle in arr(env, "re_export_cycles") {
580        let files: Vec<&str> = arr(cycle, "files").filter_map(Value::as_str).collect();
581        let kind = s(cycle, "kind");
582        let headline = if kind == "self-loop" {
583            "Self-loop: this file re-exports from itself.".to_owned()
584        } else {
585            format!(
586                "Re-export cycle ({} files): {}.",
587                files.len(),
588                files.join(" <-> "),
589            )
590        };
591        let remedy = if kind == "self-loop" {
592            "Remove the `export * from './'` (or equivalent) inside this file."
593        } else {
594            "Remove one `export * from` statement on any one member file to break the cycle."
595        };
596        push(
597            out,
598            gate_level(cycle, AnnotationLevel::Warning),
599            files.first().copied().unwrap_or_default(),
600            Anchor::default(),
601            "Re-export cycle".to_owned(),
602            format!(
603                "{headline}\n\nChain propagation through the loop is a no-op, so imports through any member may silently come up empty.\n{remedy}",
604            ),
605        );
606    }
607    collect_check_package_cycles(env, out);
608}
609
610fn collect_check_package_cycles(env: &Value, out: &mut Vec<Annotation>) {
611    for cycle in arr(env, "package_cycles") {
612        let packages: Vec<&str> = arr(cycle, "packages").filter_map(Value::as_str).collect();
613        let Some(anchor) = arr(cycle, "edges").next() else {
614            continue;
615        };
616        let first = packages.first().copied().unwrap_or_default();
617        let hops = arr(cycle, "edges")
618            .map(|edge| {
619                let type_tag = if edge.get("type_only").and_then(Value::as_bool) == Some(true) {
620                    " (type-only)"
621                } else {
622                    ""
623                };
624                format!(
625                    "  \u{2022} {} \u{2192} {}: {}:{}{type_tag}",
626                    s(edge, "from_package"),
627                    s(edge, "to_package"),
628                    s(edge, "path"),
629                    num(edge, "line"),
630                )
631            })
632            .collect::<Vec<_>>()
633            .join("\n");
634        let note = if cycle.get("group_truncated").and_then(Value::as_bool) == Some(true) {
635            format!(
636                "\nNote: {}. Break this cycle and run again to see the rest.",
637                fallow_types::results::PackageCycle::GROUP_TRUNCATED_NOTE
638            )
639        } else {
640            String::new()
641        };
642        push(
643            out,
644            gate_level(cycle, AnnotationLevel::Warning),
645            s(anchor, "path"),
646            Anchor::line_col(anchor),
647            "Package cycle".to_owned(),
648            format!(
649                "Workspace packages import each other in a loop:\n{} \u{2192} {first}\n{hops}\n\nPackages in a cycle cannot be built in dependency order.\nRemove the imports on one hop to break the cycle.{note}",
650                packages.join(" \u{2192} "),
651            ),
652        );
653    }
654}
655
656fn collect_check_boundaries(env: &Value, out: &mut Vec<Annotation>) {
657    for violation in arr(env, "boundary_violations") {
658        push(
659            out,
660            gate_level(violation, AnnotationLevel::Warning),
661            s(violation, "from_path"),
662            Anchor::gated_line_col(violation),
663            "Boundary violation".to_owned(),
664            format!(
665                "Import from zone '{}' to zone '{}' is not allowed.\n{} -> {}\n\nRoute the import through an allowed zone or restructure the dependency.",
666                s(violation, "from_zone"),
667                s(violation, "to_zone"),
668                s(violation, "from_path"),
669                s(violation, "to_path"),
670            ),
671        );
672    }
673    push_each(
674        out,
675        env,
676        "boundary_coverage_violations",
677        "Boundary coverage",
678        Anchor::gated_line_col,
679        |_| {
680            "File does not match any configured architecture boundary zone.\n\nAdd the file to a zone pattern or allow-list it with boundaries.coverage.allowUnmatched.".to_owned()
681        },
682    );
683    push_each(
684        out,
685        env,
686        "boundary_call_violations",
687        "Boundary call violation",
688        Anchor::gated_line_col,
689        |it| {
690            format!(
691                "Call to '{}' matches forbidden pattern '{}' in zone '{}'.\n\nMove the call behind an allowed abstraction or adjust boundaries.calls.forbidden.",
692                s(it, "callee"),
693                s(it, "pattern"),
694                s(it, "zone"),
695            )
696        },
697    );
698    for violation in arr(env, "policy_violations") {
699        let level = if s(violation, "severity") == "error" {
700            AnnotationLevel::Error
701        } else {
702            AnnotationLevel::Warning
703        };
704        let message_suffix = violation
705            .get("message")
706            .and_then(Value::as_str)
707            .map(|message| format!("\n\n{message}"))
708            .unwrap_or_default();
709        push(
710            out,
711            level,
712            s(violation, "path"),
713            Anchor::gated_line_col(violation),
714            "Policy violation".to_owned(),
715            format!(
716                "'{}' is banned by rule '{}/{}'.{message_suffix}",
717                s(violation, "matched"),
718                s(violation, "pack"),
719                s(violation, "rule_id"),
720            ),
721        );
722    }
723}
724
725fn collect_check_frameworks(env: &Value, out: &mut Vec<Annotation>) {
726    push_each(
727        out,
728        env,
729        "invalid_client_exports",
730        "Invalid client export",
731        Anchor::line_col,
732        |it| {
733            format!(
734                "Export '{}' is not allowed in a \"{directive}\" file (Next.js server-only / route-config name).\n\nMove the server-only export to a non-client module, or remove the \"{directive}\" directive.",
735                s(it, "export_name"),
736                directive = s(it, "directive"),
737            )
738        },
739    );
740    push_each(
741        out,
742        env,
743        "mixed_client_server_barrels",
744        "Mixed client/server barrel",
745        Anchor::line_col,
746        |it| {
747            format!(
748                "This barrel re-exports both a \"use client\" module ('{}') and a server-only module ('{}'); one import drags the other's directive across the boundary.\n\nSplit the barrel so client and server-only modules are re-exported from separate entry points.",
749                s(it, "client_origin"),
750                s(it, "server_origin"),
751            )
752        },
753    );
754    push_each(
755        out,
756        env,
757        "misplaced_directives",
758        "Misplaced directive",
759        Anchor::line_col,
760        |it| {
761            format!(
762                "Directive \"{}\" is not in the leading position, so the RSC bundler ignores it.\n\nMove the directive to the very top of the file, above every import.",
763                s(it, "directive"),
764            )
765        },
766    );
767    push_each(
768        out,
769        env,
770        "unused_server_actions",
771        "Unused server action",
772        Anchor::line_col,
773        |it| {
774            format!(
775                "Server Action '{}' in this \"use server\" file is referenced by no project code.\n\nThe action stays POST-able, but nothing calls it. Remove it to shrink the action surface, or wire it up to a consumer.",
776                s(it, "action_name"),
777            )
778        },
779    );
780    push_each(
781        out,
782        env,
783        "route_collisions",
784        "Route collision",
785        no_anchor,
786        |it| {
787            format!(
788                "This route file resolves to '{}', also owned by {} other file(s). Next.js fails the build because a URL can have only one owner.\n\nMove or merge one of the colliding files; route groups and parallel slots do not change the URL.",
789                s(it, "url"),
790                arr(it, "conflicting_paths").count(),
791            )
792        },
793    );
794    push_each(
795        out,
796        env,
797        "dynamic_segment_name_conflicts",
798        "Dynamic segment conflict",
799        no_anchor,
800        |it| {
801            format!(
802                "Dynamic segments at '{}' use different slug names ({}). Next.js requires one consistent name per dynamic path.\n\nRename the dynamic segments at this position to a single slug name.",
803                s(it, "position"),
804                joined_strs(it, "conflicting_segments", ", "),
805            )
806        },
807    );
808}
809
810fn collect_check_components(env: &Value, out: &mut Vec<Annotation>) {
811    push_each(
812        out,
813        env,
814        "unrendered_components",
815        "Unrendered component",
816        Anchor::line_col,
817        |it| {
818            format!(
819                "{} component '{}' is reachable but rendered nowhere: no tag, no dynamic binding, no registration.\n\nRender it where it is needed, or remove the component and the re-export keeping it reachable.",
820                s(it, "framework"),
821                s(it, "component_name"),
822            )
823        },
824    );
825    push_each(
826        out,
827        env,
828        "unused_component_props",
829        "Unused component prop",
830        Anchor::line_col,
831        |it| {
832            format!(
833                "Prop '{}' on component '{}' is referenced nowhere in its own component (neither script nor template).\n\nRemove the prop, or use it. If it is part of a deliberately-stable public API, suppress this finding.",
834                s(it, "prop_name"),
835                s(it, "component_name"),
836            )
837        },
838    );
839    push_each(
840        out,
841        env,
842        "unused_component_emits",
843        "Unused component emit",
844        Anchor::line_col,
845        |it| {
846            format!(
847                "Emit '{}' on component '{}' is emitted nowhere in its own component.\n\nRemove the emit, or emit it. If it is part of a deliberately-stable public API, suppress this finding.",
848                s(it, "emit_name"),
849                s(it, "component_name"),
850            )
851        },
852    );
853    push_each(
854        out,
855        env,
856        "unused_component_inputs",
857        "Unused component input",
858        Anchor::line_col,
859        |it| {
860            format!(
861                "Input '{}' on component '{}' is read nowhere in its own component (neither class body nor template).\n\nRemove the input, or use it. If it is part of a deliberately-stable public API, suppress this finding.",
862                s(it, "input_name"),
863                s(it, "component_name"),
864            )
865        },
866    );
867    push_each(
868        out,
869        env,
870        "unused_component_outputs",
871        "Unused component output",
872        Anchor::line_col,
873        |it| {
874            format!(
875                "Output '{}' on component '{}' is emitted nowhere in its own component.\n\nRemove the output, or emit it. If it is part of a deliberately-stable public API, suppress this finding.",
876                s(it, "output_name"),
877                s(it, "component_name"),
878            )
879        },
880    );
881    collect_check_component_wiring(env, out);
882}
883
884fn collect_check_component_wiring(env: &Value, out: &mut Vec<Annotation>) {
885    push_each(
886        out,
887        env,
888        "unused_svelte_events",
889        "Unused Svelte event",
890        Anchor::line_col,
891        |it| {
892            format!(
893                "Event '{}' dispatched by component '{}' is listened to nowhere in the project.\n\nRemove the dispatched event, or listen for it. If it is part of a deliberately-stable public API, suppress this finding.",
894                s(it, "event_name"),
895                s(it, "component_name"),
896            )
897        },
898    );
899    push_each(
900        out,
901        env,
902        "unprovided_injects",
903        "Unprovided inject",
904        Anchor::line_col,
905        |it| {
906            format!(
907                "{} inject for key '{}' has no matching provider in the project.\n\nAdd a provide/setContext for this key, or remove the dead inject.",
908                s(it, "framework"),
909                s(it, "key_name"),
910            )
911        },
912    );
913    push_each(
914        out,
915        env,
916        "unused_load_data_keys",
917        "Unused load data key",
918        Anchor::line_only,
919        |it| {
920            format!(
921                "SvelteKit load() return key '{}' is read by no consumer (neither the sibling +page.svelte nor $page.data).\n\nThe key runs a real server fetch / DB cost per request for data nothing renders. Remove the key, or use it.",
922                s(it, "key_name"),
923            )
924        },
925    );
926}
927
928fn stale_suppression_message(item: &Value) -> (String, String) {
929    let origin = item.get("origin").cloned().unwrap_or(Value::Null);
930    let comment_form = if b(&origin, "is_file_level") {
931        "fallow-ignore-file"
932    } else {
933        "fallow-ignore-next-line"
934    };
935    if s(&origin, "type") == "jsdoc_tag" {
936        return (
937            "Stale @expected-unused".to_owned(),
938            format!(
939                "The @expected-unused tag on '{}' is stale because the export is now used.\n\nRemove the @expected-unused tag.",
940                s(&origin, "export_name"),
941            ),
942        );
943    }
944    if origin.get("kind_known").and_then(Value::as_bool) == Some(false) {
945        return (
946            "Unknown suppression kind".to_owned(),
947            format!(
948                "'{}' is not a recognized fallow issue kind. Other tokens on this '{comment_form}' line still apply.\n\nFix the typo or remove the unknown token.",
949                s(&origin, "issue_kind"),
950            ),
951        );
952    }
953    let kind_clause = origin
954        .get("issue_kind")
955        .and_then(Value::as_str)
956        .map(|kind| format!(" for '{kind}'"))
957        .unwrap_or_default();
958    (
959        "Stale suppression".to_owned(),
960        format!(
961            "This '{comment_form}' comment{kind_clause} no longer matches any active issue.\n\nRemove the suppression comment to keep the codebase clean.",
962        ),
963    )
964}
965
966fn collect_check_suppressions(env: &Value, out: &mut Vec<Annotation>) {
967    for item in arr(env, "stale_suppressions") {
968        let (title, message) = stale_suppression_message(item);
969        push(
970            out,
971            gate_level(item, AnnotationLevel::Warning),
972            s(item, "path"),
973            Anchor::line_col(item),
974            title,
975            message,
976        );
977    }
978}
979
980fn unresolved_catalog_reference_message(item: &Value) -> String {
981    let catalog = s(item, "catalog_name");
982    let (reference, described) = if catalog == "default" {
983        (String::new(), "the default catalog".to_owned())
984    } else {
985        (catalog.to_owned(), format!("catalog '{catalog}'"))
986    };
987    let available = joined_strs(item, "available_in_catalogs", ", ");
988    let remedy = if available.is_empty() {
989        "Add this package to the named catalog in pnpm-workspace.yaml, or remove the reference and pin a hardcoded version.".to_owned()
990    } else {
991        format!(
992            "Available in: {available}.\nSwitch the reference to a catalog that declares this package, or add it to the named catalog.",
993        )
994    };
995    format!(
996        "Package '{}' is referenced via `catalog:{reference}` but {described} does not declare it. `pnpm install` will fail.\n\n{remedy}",
997        s(item, "entry_name"),
998    )
999}
1000
1001fn collect_check_catalog(env: &Value, out: &mut Vec<Annotation>) {
1002    push_each(
1003        out,
1004        env,
1005        "unused_catalog_entries",
1006        "Unused catalog entry",
1007        Anchor::line_only,
1008        |it| {
1009            let consumers = joined_strs(it, "hardcoded_consumers", ", ");
1010            let remedy = if consumers.is_empty() {
1011                "Remove the entry from pnpm-workspace.yaml.".to_owned()
1012            } else {
1013                format!(
1014                    "Hardcoded consumers: {consumers}.\nSwitch them to catalog: before removing."
1015                )
1016            };
1017            format!(
1018                "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package via the catalog: protocol.\n\n{remedy}",
1019                s(it, "entry_name"),
1020                s(it, "catalog_name"),
1021            )
1022        },
1023    );
1024    push_each(
1025        out,
1026        env,
1027        "empty_catalog_groups",
1028        "Empty catalog group",
1029        Anchor::line_only,
1030        |it| {
1031            format!(
1032                "Catalog group '{}' has no entries.\n\nRemove the empty group header from pnpm-workspace.yaml.",
1033                s(it, "catalog_name"),
1034            )
1035        },
1036    );
1037    for item in arr(env, "unresolved_catalog_references") {
1038        push(
1039            out,
1040            gate_level(item, AnnotationLevel::Error),
1041            s(item, "path"),
1042            Anchor::line_only(item),
1043            "Unresolved catalog reference".to_owned(),
1044            unresolved_catalog_reference_message(item),
1045        );
1046    }
1047    push_each(
1048        out,
1049        env,
1050        "unused_dependency_overrides",
1051        "Unused dependency override",
1052        Anchor::line_only,
1053        |it| {
1054            let target = s(it, "target_package");
1055            let hint = it
1056                .get("hint")
1057                .and_then(Value::as_str)
1058                .map(|hint| format!("{hint}.\n"))
1059                .unwrap_or_default();
1060            format!(
1061                "Override `{}` forces `{target}` to `{}` but no workspace package depends on `{target}`.\n\n{hint}Delete the entry, or scope it under a real parent (`pkg>{target}`) if it pins a transitive.",
1062                s(it, "raw_key"),
1063                s(it, "version_range"),
1064            )
1065        },
1066    );
1067    for item in arr(env, "misconfigured_dependency_overrides") {
1068        let reason = item
1069            .get("reason")
1070            .and_then(Value::as_str)
1071            .unwrap_or("unparsable");
1072        push(
1073            out,
1074            gate_level(item, AnnotationLevel::Error),
1075            s(item, "path"),
1076            Anchor::line_only(item),
1077            "Misconfigured dependency override".to_owned(),
1078            format!(
1079                "Override `{}` -> `{}` is malformed ({reason}). The active package manager will reject or ignore this entry.\n\nFix the key or value to match its override grammar, or remove the entry.",
1080                s(item, "raw_key"),
1081                s(item, "raw_value"),
1082            ),
1083        );
1084    }
1085}
1086
1087fn short_path(path: &str) -> String {
1088    let segments: Vec<&str> = path.split('/').collect();
1089    if segments.len() > 3 {
1090        segments[segments.len() - 3..].join("/")
1091    } else {
1092        segments.join("/")
1093    }
1094}
1095
1096fn collect_dupes(env: &Value, out: &mut Vec<Annotation>) {
1097    for group in arr(env, "clone_groups") {
1098        let instances: Vec<&Value> = arr(group, "instances").collect();
1099        for instance in &instances {
1100            // jq removes every instance deep-equal to the current one, so
1101            // identical duplicates drop out of their own "Also in" list.
1102            let others = instances
1103                .iter()
1104                .filter(|other| ***other != **instance)
1105                .fold(String::new(), |mut acc, other| {
1106                    use std::fmt::Write as _;
1107                    let _ = write!(
1108                        acc,
1109                        "\n  \u{2192} {}:{}-{}",
1110                        short_path(s(other, "file")),
1111                        num(other, "start_line"),
1112                        num(other, "end_line"),
1113                    );
1114                    acc
1115                });
1116            out.push(Annotation {
1117                level: AnnotationLevel::Warning,
1118                path: s(instance, "file").to_owned(),
1119                line: Some(u(instance, "start_line")),
1120                end_line: Some(u(instance, "end_line")),
1121                col: Some(one_based_col(u(instance, "start_col"))),
1122                title: "Code duplication".to_owned(),
1123                message: format!(
1124                    "{} duplicated lines ({} tokens)\n\n{} instances found. Also in:{others}\n\nExtract a shared function to eliminate this duplication.",
1125                    num(group, "line_count"),
1126                    num(group, "token_count"),
1127                    instances.len(),
1128                ),
1129            });
1130        }
1131    }
1132}
1133
1134fn threshold(env: &Value, key: &str, default: &str) -> String {
1135    env.get("summary")
1136        .and_then(|summary| summary.get(key))
1137        .filter(|value| !value.is_null())
1138        .map_or_else(|| default.to_owned(), fmt_num)
1139}
1140
1141/// Workflow-command level of a complexity finding.
1142///
1143/// The gate severity from the `complexity-*` rules sets the level: `error`
1144/// gives `::error` and `warn` gives `::warning`, whatever the band. The band
1145/// stays in the title and the message. A saved report without
1146/// `effective_severity` (from an older version) keeps the old band mapping:
1147/// `critical` and `high` give `::error`, `moderate` gives `::warning`.
1148fn complexity_level(finding: &Value, band: &str) -> AnnotationLevel {
1149    let band_level = if matches!(band, "critical" | "high") {
1150        AnnotationLevel::Error
1151    } else {
1152        AnnotationLevel::Warning
1153    };
1154    gate_level(finding, band_level)
1155}
1156
1157struct ComplexityThresholds {
1158    cyclomatic: String,
1159    cognitive: String,
1160    crap: String,
1161}
1162
1163/// Ceilings a single finding was evaluated against: its `effective_thresholds`
1164/// when a `thresholdOverrides` entry moved them, otherwise the run's global
1165/// summary ceilings. The `Value`-side mirror of
1166/// `ComplexityViolation::resolved_thresholds`, which this renderer cannot call
1167/// because it works over the serialized envelope (issue #2163).
1168fn finding_thresholds(finding: &Value, run: &ComplexityThresholds) -> ComplexityThresholds {
1169    let effective = finding.get("effective_thresholds");
1170    let pick = |key: &str, fallback: &str| {
1171        effective
1172            .and_then(|thresholds| thresholds.get(key))
1173            .filter(|value| !value.is_null())
1174            .map_or_else(|| fallback.to_owned(), fmt_num)
1175    };
1176    ComplexityThresholds {
1177        cyclomatic: pick("max_cyclomatic", &run.cyclomatic),
1178        cognitive: pick("max_cognitive", &run.cognitive),
1179        crap: pick("max_crap", &run.crap),
1180    }
1181}
1182
1183fn complexity_annotation(finding: &Value, ctx: &ComplexityThresholds) -> (String, String) {
1184    let severity = finding
1185        .get("severity")
1186        .and_then(Value::as_str)
1187        .unwrap_or("moderate");
1188    let name = s(finding, "name");
1189    let cyclomatic = num(finding, "cyclomatic");
1190    let cognitive = num(finding, "cognitive");
1191    let lines = num(finding, "line_count");
1192    let crap_line = finding
1193        .get("crap")
1194        .filter(|crap| !crap.is_null())
1195        .map(|crap| {
1196            format!(
1197                "  \u{2022} CRAP: {} (threshold: {})\n",
1198                fmt_num(crap),
1199                ctx.crap
1200            )
1201        })
1202        .unwrap_or_default();
1203    match s(finding, "exceeded") {
1204        "crap" | "cyclomatic_crap" | "cognitive_crap" | "all" => (
1205            format!("High CRAP score ({severity})"),
1206            format!(
1207                "Function '{name}' has a CRAP score of {} (threshold: {}).\n\n  \u{2022} Severity: {severity}\n  \u{2022} Cyclomatic: {cyclomatic}\n  \u{2022} Cognitive: {cognitive}\n{crap_line}  \u{2022} Lines: {lines}\n\nCRAP combines complexity with coverage: high CRAP means changes here carry high risk.\nConsider adding tests, simplifying the function, or both.",
1208                num(finding, "crap"),
1209                ctx.crap,
1210            ),
1211        ),
1212        "both" => (
1213            format!("High complexity ({severity})"),
1214            format!(
1215                "Function '{name}' exceeds both complexity thresholds:\n\n  \u{2022} Severity: {severity}\n  \u{2022} Cyclomatic: {cyclomatic} (threshold: {})\n  \u{2022} Cognitive: {cognitive} (threshold: {})\n{crap_line}  \u{2022} Lines: {lines}\n\nConsider splitting this function into smaller, focused functions.",
1216                ctx.cyclomatic, ctx.cognitive,
1217            ),
1218        ),
1219        "cyclomatic" => (
1220            format!("High cyclomatic complexity ({severity})"),
1221            format!(
1222                "Function '{name}' has {cyclomatic} code paths (threshold: {}).\n\n  \u{2022} Severity: {severity}\n  \u{2022} Cyclomatic: {cyclomatic}\n  \u{2022} Cognitive: {cognitive}\n{crap_line}  \u{2022} Lines: {lines}\n\nHigh cyclomatic complexity means many branches to test.\nConsider extracting conditionals or using early returns.",
1223                ctx.cyclomatic,
1224            ),
1225        ),
1226        _ => (
1227            format!("High cognitive complexity ({severity})"),
1228            format!(
1229                "Function '{name}' is hard to understand (cognitive: {cognitive}, threshold: {}).\n\n  \u{2022} Severity: {severity}\n  \u{2022} Cyclomatic: {cyclomatic}\n  \u{2022} Cognitive: {cognitive}\n{crap_line}  \u{2022} Lines: {lines}\n\nHigh cognitive complexity means deeply nested or interleaved logic.\nConsider flattening control flow or extracting helper functions.",
1230                ctx.cognitive,
1231            ),
1232        ),
1233    }
1234}
1235
1236fn collect_health(env: &Value, out: &mut Vec<Annotation>) {
1237    let ctx = ComplexityThresholds {
1238        cyclomatic: threshold(env, "max_cyclomatic_threshold", "20"),
1239        cognitive: threshold(env, "max_cognitive_threshold", "15"),
1240        crap: threshold(env, "max_crap_threshold", "30"),
1241    };
1242    for finding in arr(env, "findings") {
1243        let severity = finding
1244            .get("severity")
1245            .and_then(Value::as_str)
1246            .unwrap_or("moderate");
1247        let (title, message) = complexity_annotation(finding, &finding_thresholds(finding, &ctx));
1248        push(
1249            out,
1250            complexity_level(finding, severity),
1251            s(finding, "path"),
1252            Anchor::line_col(finding),
1253            title,
1254            message,
1255        );
1256    }
1257    collect_runtime_coverage(env, out);
1258    collect_coverage_intelligence(env, out);
1259    collect_targets(env, out);
1260}
1261
1262fn collect_runtime_coverage(env: &Value, out: &mut Vec<Annotation>) {
1263    let Some(runtime) = env.get("runtime_coverage") else {
1264        return;
1265    };
1266    for finding in arr(runtime, "findings") {
1267        let verdict = s(finding, "verdict");
1268        let level = if verdict == "coverage_unavailable" {
1269            AnnotationLevel::Notice
1270        } else {
1271            AnnotationLevel::Warning
1272        };
1273        let invocations = finding
1274            .get("invocations")
1275            .filter(|value| !value.is_null())
1276            .map_or_else(|| "-".to_owned(), fmt_num);
1277        let evidence = finding.get("evidence").cloned().unwrap_or(Value::Null);
1278        let tracking = evidence
1279            .get("untracked_reason")
1280            .and_then(Value::as_str)
1281            .map_or_else(
1282                || s(&evidence, "v8_tracking").to_owned(),
1283                |reason| format!("{} ({reason})", s(&evidence, "v8_tracking")),
1284            );
1285        // A statically-unused function whose only remaining references live in
1286        // test files is not dead code, so the annotation says so next to the
1287        // static verdict rather than leaving a bare "unused".
1288        let static_status = if evidence
1289            .get("test_only_reference")
1290            .and_then(Value::as_bool)
1291            .unwrap_or(false)
1292        {
1293            format!(
1294                "{} (referenced only from tests)",
1295                s(&evidence, "static_status")
1296            )
1297        } else {
1298            s(&evidence, "static_status").to_owned()
1299        };
1300        let advice = arr(finding, "actions")
1301            .next()
1302            .and_then(|action| action.get("description"))
1303            .and_then(Value::as_str)
1304            .unwrap_or("Review the runtime evidence before changing this path.");
1305        push(
1306            out,
1307            level,
1308            s(finding, "path"),
1309            Anchor::line_only(finding),
1310            format!("Runtime coverage ({verdict})"),
1311            format!(
1312                "Function '{}' is flagged by runtime coverage.\n\n  \u{2022} Verdict: {verdict}\n  \u{2022} Invocations: {invocations}\n  \u{2022} Confidence: {}\n  \u{2022} Static: {static_status}\n  \u{2022} Tests: {}\n  \u{2022} V8: {tracking}\n\n{advice}",
1313                s(finding, "function"),
1314                s(finding, "confidence"),
1315                s(&evidence, "test_coverage"),
1316            ),
1317        );
1318    }
1319}
1320
1321fn collect_coverage_intelligence(env: &Value, out: &mut Vec<Annotation>) {
1322    let Some(intelligence) = env.get("coverage_intelligence") else {
1323        return;
1324    };
1325    for finding in arr(intelligence, "findings") {
1326        let verdict = s(finding, "verdict");
1327        if matches!(verdict, "clean" | "unknown") {
1328            continue;
1329        }
1330        let recommendation = s(finding, "recommendation");
1331        let level = if matches!(verdict, "risky-change-detected" | "high-confidence-delete") {
1332            AnnotationLevel::Error
1333        } else {
1334            AnnotationLevel::Warning
1335        };
1336        let identity = finding
1337            .get("identity")
1338            .and_then(Value::as_str)
1339            .unwrap_or("code");
1340        push(
1341            out,
1342            level,
1343            s(finding, "path"),
1344            Anchor::line_only(finding),
1345            format!("Coverage intelligence ({recommendation})"),
1346            format!("'{identity}' coverage intelligence verdict: {verdict} ({recommendation})"),
1347        );
1348    }
1349}
1350
1351fn collect_targets(env: &Value, out: &mut Vec<Annotation>) {
1352    let targets = env
1353        .get("targets")
1354        .filter(|value| !value.is_null())
1355        .or_else(|| env.get("refactoring_targets"))
1356        .and_then(Value::as_array)
1357        .map(Vec::as_slice)
1358        .unwrap_or_default();
1359    // The jq template annotates only the top 5 targets (a content decision in
1360    // `annotations-health.jq`, distinct from the removed MAX_ANNOTATIONS cap).
1361    for target in targets.iter().take(5) {
1362        let factors = target
1363            .get("factors")
1364            .and_then(Value::as_array)
1365            .map(|factors| {
1366                factors
1367                    .iter()
1368                    .map(|factor| {
1369                        let detail = factor
1370                            .get("detail")
1371                            .and_then(Value::as_str)
1372                            .map_or_else(|| num(factor, "value"), str::to_owned);
1373                        format!("  \u{2022} {}: {detail}", s(factor, "metric"))
1374                    })
1375                    .collect::<Vec<_>>()
1376                    .join("\n")
1377            })
1378            .unwrap_or_default();
1379        push(
1380            out,
1381            AnnotationLevel::Notice,
1382            s(target, "path"),
1383            Anchor::default(),
1384            format!("Refactoring target ({} effort)", s(target, "effort")),
1385            format!(
1386                "Priority: {} | Confidence: {}\n\n{}\n\n{factors}",
1387                s(target, "priority"),
1388                s(target, "confidence"),
1389                s(target, "recommendation"),
1390            ),
1391        );
1392    }
1393}
1394
1395/// Net-new security annotations (the jq layer has no
1396/// `annotations-security.jq`): every candidate renders at `::notice`, because
1397/// fallow surfaces unverified candidates, not confirmed vulnerabilities.
1398fn collect_security(env: &Value, out: &mut Vec<Annotation>) {
1399    for finding in arr(env, "security_findings") {
1400        let kind = s(finding, "kind");
1401        let severity = finding
1402            .get("severity")
1403            .and_then(Value::as_str)
1404            .unwrap_or("unknown");
1405        let callee = finding
1406            .get("candidate")
1407            .and_then(|candidate| candidate.get("sink"))
1408            .and_then(|sink| sink.get("callee"))
1409            .and_then(Value::as_str)
1410            .filter(|callee| !callee.is_empty())
1411            .unwrap_or("-");
1412        push(
1413            out,
1414            AnnotationLevel::Notice,
1415            s(finding, "path"),
1416            Anchor::line_col(finding),
1417            format!("Security candidate ({kind})"),
1418            format!(
1419                "Local security candidate (severity: {severity}).\n\n  \u{2022} Sink: {callee}\n  \u{2022} Evidence: {}\n\nTreat this as a candidate for verification, not a confirmed vulnerability.",
1420                s(finding, "evidence"),
1421            ),
1422        );
1423    }
1424}