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        return format!("\n\nImported in other workspaces: {workspaces}");
268    }
269    let declaring = joined_strs(item, "declared_and_imported_in", ", ");
270    if declaring.is_empty() {
271        String::new()
272    } else {
273        format!("\n\nDeclared and imported in other workspaces: {declaring}")
274    }
275}
276
277fn dependency_action(item: &Value, pm: PackageManager) -> String {
278    if arr(item, "used_in_workspaces").next().is_some() {
279        "Move this dependency to the consuming workspace package.json.".to_owned()
280    } else {
281        format!("Run: {}", pm.remove_command(s(item, "package_name")))
282    }
283}
284
285fn unused_dependency_message(item: &Value, section: &str, pm: PackageManager) -> String {
286    format!(
287        "Package '{}' is listed in {section} but never imported by this package.{}\n\n{}{}",
288        s(item, "package_name"),
289        workspace_context(item),
290        dependency_action(item, pm),
291        caveat_note(item),
292    )
293}
294
295fn collect_check(env: &Value, pm: PackageManager, out: &mut Vec<Annotation>) {
296    collect_check_files_and_exports(env, out);
297    collect_check_dependencies(env, pm, out);
298    collect_check_members(env, out);
299    collect_check_graph(env, out);
300    collect_check_boundaries(env, out);
301    collect_check_frameworks(env, out);
302    collect_check_components(env, out);
303    collect_check_suppressions(env, out);
304    collect_check_catalog(env, out);
305}
306
307fn collect_check_files_and_exports(env: &Value, out: &mut Vec<Annotation>) {
308    push_each(out, env, "unused_files", "Unused file", no_anchor, |it| {
309        format!(
310            "This file is not imported by any other module and unreachable from entry points.\nConsider removing it or importing it where needed.{}",
311            caveat_note(it),
312        )
313    });
314    push_each(
315        out,
316        env,
317        "unused_exports",
318        "Unused export",
319        Anchor::line_col,
320        |it| {
321            format!(
322                "{} {} '{}' 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.{}",
323                if b(it, "is_re_export") {
324                    "Re-exported"
325                } else {
326                    "Exported"
327                },
328                if b(it, "is_type_only") {
329                    "type"
330                } else {
331                    "value"
332                },
333                s(it, "export_name"),
334                caveat_note(it),
335            )
336        },
337    );
338    push_each(
339        out,
340        env,
341        "unused_types",
342        "Unused type",
343        Anchor::line_col,
344        |it| {
345            format!(
346                "{} type '{}' is never imported by other modules.\n\nIf only used internally, remove the export keyword.{}",
347                if b(it, "is_re_export") {
348                    "Re-exported"
349                } else {
350                    "Exported"
351                },
352                s(it, "export_name"),
353                caveat_note(it),
354            )
355        },
356    );
357    push_each(
358        out,
359        env,
360        "private_type_leaks",
361        "Private type leak",
362        Anchor::line_col,
363        |it| {
364            format!(
365                "Export '{}' references private type '{}'.\n\nExport the referenced type or remove it from the public signature.",
366                s(it, "export_name"),
367                s(it, "type_name"),
368            )
369        },
370    );
371    push_each(
372        out,
373        env,
374        "deprecated_exports_in_use",
375        "Deprecated export in use",
376        Anchor::line_col,
377        deprecated_export_message,
378    );
379}
380
381fn deprecated_export_message(item: &Value) -> String {
382    let count = u(item, "consumer_count");
383    let reason = item
384        .get("deprecated_reason")
385        .and_then(Value::as_str)
386        .map_or_else(String::new, |reason| {
387            format!("\n\nDeprecation message: {reason}")
388        });
389    format!(
390        "Deprecated export '{}' is still used by {count} consumer{}.{reason}\n\nMove the consumers to the replacement, then remove the export.",
391        s(item, "export_name"),
392        if count == 1 { "" } else { "s" },
393    )
394}
395
396fn collect_check_dependencies(env: &Value, pm: PackageManager, out: &mut Vec<Annotation>) {
397    push_each(
398        out,
399        env,
400        "unused_dependencies",
401        "Unused dependency",
402        Anchor::gated_line,
403        |it| unused_dependency_message(it, "dependencies", pm),
404    );
405    push_each(
406        out,
407        env,
408        "unused_dev_dependencies",
409        "Unused devDependency",
410        Anchor::gated_line,
411        |it| unused_dependency_message(it, "devDependencies", pm),
412    );
413    push_each(
414        out,
415        env,
416        "unused_optional_dependencies",
417        "Unused optionalDependency",
418        Anchor::gated_line,
419        |it| unused_dependency_message(it, "optionalDependencies", pm),
420    );
421    for dependency in arr(env, "unlisted_dependencies") {
422        let package = s(dependency, "package_name");
423        for site in arr(dependency, "imported_from") {
424            push(
425                out,
426                gate_level(dependency, AnnotationLevel::Warning),
427                s(site, "path"),
428                Anchor::line_col(site),
429                "Unlisted dependency".to_owned(),
430                format!(
431                    "Package '{package}' is imported here but not listed in package.json.\n\nRun: {}",
432                    pm.add_command(package),
433                ),
434            );
435        }
436    }
437    push_each(
438        out,
439        env,
440        "type_only_dependencies",
441        "Type-only dependency",
442        Anchor::gated_line,
443        |it| {
444            format!(
445                "Package '{}' is only used via type imports.\n\nMove it from dependencies to devDependencies to reduce production bundle size.",
446                s(it, "package_name"),
447            )
448        },
449    );
450    push_each(
451        out,
452        env,
453        "test_only_dependencies",
454        "Test-only dependency",
455        Anchor::gated_line,
456        |it| {
457            format!(
458                "Package '{}' is only imported from test or config files.\n\nMove it from dependencies to devDependencies to reduce production bundle size.",
459                s(it, "package_name"),
460            )
461        },
462    );
463    push_each(
464        out,
465        env,
466        "dev_dependencies_in_production",
467        "Dev dependency in production",
468        Anchor::gated_line,
469        |it| {
470            format!(
471                "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.",
472                s(it, "package_name"),
473            )
474        },
475    );
476}
477
478fn collect_check_members(env: &Value, out: &mut Vec<Annotation>) {
479    push_each(
480        out,
481        env,
482        "unused_enum_members",
483        "Unused enum member",
484        Anchor::line_col,
485        |it| {
486            format!(
487                "Enum member '{}.{}' is never referenced in the codebase.\n\nConsider removing it to keep the enum minimal.{}",
488                s(it, "parent_name"),
489                s(it, "member_name"),
490                caveat_note(it),
491            )
492        },
493    );
494    push_each(
495        out,
496        env,
497        "unused_class_members",
498        "Unused class member",
499        Anchor::line_col,
500        |it| {
501            format!(
502                "Class member '{}.{}' is never referenced.\n\nConsider removing it or marking it as private.{}",
503                s(it, "parent_name"),
504                s(it, "member_name"),
505                caveat_note(it),
506            )
507        },
508    );
509    push_each(
510        out,
511        env,
512        "unused_store_members",
513        "Unused store member",
514        Anchor::line_col,
515        |it| {
516            format!(
517                "Store member '{}.{}' is never accessed by any consumer.\n\nConsider removing the unused store state, getter, or action.{}",
518                s(it, "parent_name"),
519                s(it, "member_name"),
520                caveat_note(it),
521            )
522        },
523    );
524}
525
526fn collect_check_graph(env: &Value, out: &mut Vec<Annotation>) {
527    push_each(
528        out,
529        env,
530        "unresolved_imports",
531        "Unresolved import",
532        Anchor::line_col,
533        |it| {
534            format!(
535                "Import '{}' could not be resolved to a file or package.\n\nCheck for typos, missing dependencies, or incorrect path aliases.",
536                s(it, "specifier"),
537            )
538        },
539    );
540    for duplicate in arr(env, "duplicate_exports") {
541        let name = s(duplicate, "export_name");
542        let locations: Vec<&Value> = arr(duplicate, "locations").collect();
543        let listing = locations
544            .iter()
545            .map(|location| {
546                format!(
547                    "  \u{2022} {}:{}",
548                    s(location, "path"),
549                    num(location, "line")
550                )
551            })
552            .collect::<Vec<_>>()
553            .join("\n");
554        for location in &locations {
555            push(
556                out,
557                gate_level(duplicate, AnnotationLevel::Warning),
558                s(location, "path"),
559                Anchor::line_col(location),
560                "Duplicate export".to_owned(),
561                format!(
562                    "Export '{name}' is defined in {} modules:\n{listing}\n\nThis causes ambiguity for consumers. Keep one canonical location.",
563                    locations.len(),
564                ),
565            );
566        }
567    }
568    for cycle in arr(env, "circular_dependencies") {
569        let files: Vec<&str> = arr(cycle, "files").filter_map(Value::as_str).collect();
570        let first = files.first().copied().unwrap_or_default();
571        push(
572            out,
573            gate_level(cycle, AnnotationLevel::Warning),
574            first,
575            Anchor::gated_line_col(cycle),
576            "Circular dependency".to_owned(),
577            format!(
578                "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.",
579                files.join(" \u{2192} "),
580            ),
581        );
582    }
583    for cycle in arr(env, "re_export_cycles") {
584        let files: Vec<&str> = arr(cycle, "files").filter_map(Value::as_str).collect();
585        let kind = s(cycle, "kind");
586        let headline = if kind == "self-loop" {
587            "Self-loop: this file re-exports from itself.".to_owned()
588        } else {
589            format!(
590                "Re-export cycle ({} files): {}.",
591                files.len(),
592                files.join(" <-> "),
593            )
594        };
595        let remedy = if kind == "self-loop" {
596            "Remove the `export * from './'` (or equivalent) inside this file."
597        } else {
598            "Remove one `export * from` statement on any one member file to break the cycle."
599        };
600        push(
601            out,
602            gate_level(cycle, AnnotationLevel::Warning),
603            files.first().copied().unwrap_or_default(),
604            Anchor::default(),
605            "Re-export cycle".to_owned(),
606            format!(
607                "{headline}\n\nChain propagation through the loop is a no-op, so imports through any member may silently come up empty.\n{remedy}",
608            ),
609        );
610    }
611    collect_check_package_cycles(env, out);
612}
613
614fn collect_check_package_cycles(env: &Value, out: &mut Vec<Annotation>) {
615    for cycle in arr(env, "package_cycles") {
616        let packages: Vec<&str> = arr(cycle, "packages").filter_map(Value::as_str).collect();
617        let Some(anchor) = arr(cycle, "edges").next() else {
618            continue;
619        };
620        let first = packages.first().copied().unwrap_or_default();
621        let hops = arr(cycle, "edges")
622            .map(|edge| {
623                let type_tag = if edge.get("type_only").and_then(Value::as_bool) == Some(true) {
624                    " (type-only)"
625                } else {
626                    ""
627                };
628                format!(
629                    "  \u{2022} {} \u{2192} {}: {}:{}{type_tag}",
630                    s(edge, "from_package"),
631                    s(edge, "to_package"),
632                    s(edge, "path"),
633                    num(edge, "line"),
634                )
635            })
636            .collect::<Vec<_>>()
637            .join("\n");
638        let note = if cycle.get("group_truncated").and_then(Value::as_bool) == Some(true) {
639            format!(
640                "\nNote: {}. Break this cycle and run again to see the rest.",
641                fallow_types::results::PackageCycle::GROUP_TRUNCATED_NOTE
642            )
643        } else {
644            String::new()
645        };
646        push(
647            out,
648            gate_level(cycle, AnnotationLevel::Warning),
649            s(anchor, "path"),
650            Anchor::line_col(anchor),
651            "Package cycle".to_owned(),
652            format!(
653                "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}",
654                packages.join(" \u{2192} "),
655            ),
656        );
657    }
658}
659
660fn collect_check_boundaries(env: &Value, out: &mut Vec<Annotation>) {
661    for violation in arr(env, "boundary_violations") {
662        push(
663            out,
664            gate_level(violation, AnnotationLevel::Warning),
665            s(violation, "from_path"),
666            Anchor::gated_line_col(violation),
667            "Boundary violation".to_owned(),
668            format!(
669                "Import from zone '{}' to zone '{}' is not allowed.\n{} -> {}\n\nRoute the import through an allowed zone or restructure the dependency.",
670                s(violation, "from_zone"),
671                s(violation, "to_zone"),
672                s(violation, "from_path"),
673                s(violation, "to_path"),
674            ),
675        );
676    }
677    push_each(
678        out,
679        env,
680        "boundary_coverage_violations",
681        "Boundary coverage",
682        Anchor::gated_line_col,
683        |_| {
684            "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()
685        },
686    );
687    push_each(
688        out,
689        env,
690        "boundary_call_violations",
691        "Boundary call violation",
692        Anchor::gated_line_col,
693        |it| {
694            format!(
695                "Call to '{}' matches forbidden pattern '{}' in zone '{}'.\n\nMove the call behind an allowed abstraction or adjust boundaries.calls.forbidden.",
696                s(it, "callee"),
697                s(it, "pattern"),
698                s(it, "zone"),
699            )
700        },
701    );
702    for violation in arr(env, "policy_violations") {
703        let level = if s(violation, "severity") == "error" {
704            AnnotationLevel::Error
705        } else {
706            AnnotationLevel::Warning
707        };
708        let message_suffix = violation
709            .get("message")
710            .and_then(Value::as_str)
711            .map(|message| format!("\n\n{message}"))
712            .unwrap_or_default();
713        push(
714            out,
715            level,
716            s(violation, "path"),
717            Anchor::gated_line_col(violation),
718            "Policy violation".to_owned(),
719            format!(
720                "'{}' is banned by rule '{}/{}'.{message_suffix}",
721                s(violation, "matched"),
722                s(violation, "pack"),
723                s(violation, "rule_id"),
724            ),
725        );
726    }
727}
728
729fn collect_check_frameworks(env: &Value, out: &mut Vec<Annotation>) {
730    push_each(
731        out,
732        env,
733        "invalid_client_exports",
734        "Invalid client export",
735        Anchor::line_col,
736        |it| {
737            format!(
738                "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.",
739                s(it, "export_name"),
740                directive = s(it, "directive"),
741            )
742        },
743    );
744    push_each(
745        out,
746        env,
747        "mixed_client_server_barrels",
748        "Mixed client/server barrel",
749        Anchor::line_col,
750        |it| {
751            format!(
752                "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.",
753                s(it, "client_origin"),
754                s(it, "server_origin"),
755            )
756        },
757    );
758    push_each(
759        out,
760        env,
761        "misplaced_directives",
762        "Misplaced directive",
763        Anchor::line_col,
764        |it| {
765            format!(
766                "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.",
767                s(it, "directive"),
768            )
769        },
770    );
771    push_each(
772        out,
773        env,
774        "unused_server_actions",
775        "Unused server action",
776        Anchor::line_col,
777        |it| {
778            format!(
779                "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.",
780                s(it, "action_name"),
781            )
782        },
783    );
784    push_each(
785        out,
786        env,
787        "route_collisions",
788        "Route collision",
789        no_anchor,
790        |it| {
791            format!(
792                "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.",
793                s(it, "url"),
794                arr(it, "conflicting_paths").count(),
795            )
796        },
797    );
798    push_each(
799        out,
800        env,
801        "dynamic_segment_name_conflicts",
802        "Dynamic segment conflict",
803        no_anchor,
804        |it| {
805            format!(
806                "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.",
807                s(it, "position"),
808                joined_strs(it, "conflicting_segments", ", "),
809            )
810        },
811    );
812}
813
814fn collect_check_components(env: &Value, out: &mut Vec<Annotation>) {
815    push_each(
816        out,
817        env,
818        "unrendered_components",
819        "Unrendered component",
820        Anchor::line_col,
821        |it| {
822            format!(
823                "{} 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.",
824                s(it, "framework"),
825                s(it, "component_name"),
826            )
827        },
828    );
829    push_each(
830        out,
831        env,
832        "absent_component_props",
833        "Absent optional component prop",
834        Anchor::line_col,
835        |it| {
836            format!(
837                "Optional prop '{}.{}' is absent from inspected callers. {}",
838                s(it, "component_name"),
839                s(it, "prop_name"),
840                s(it, "explanation")
841            )
842        },
843    );
844    push_each(
845        out,
846        env,
847        "unused_component_props",
848        "Unused component prop",
849        Anchor::line_col,
850        |it| {
851            format!(
852                "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.",
853                s(it, "prop_name"),
854                s(it, "component_name"),
855            )
856        },
857    );
858    push_each(
859        out,
860        env,
861        "unused_component_emits",
862        "Unused component emit",
863        Anchor::line_col,
864        |it| {
865            format!(
866                "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.",
867                s(it, "emit_name"),
868                s(it, "component_name"),
869            )
870        },
871    );
872    push_each(
873        out,
874        env,
875        "unused_component_inputs",
876        "Unused component input",
877        Anchor::line_col,
878        |it| {
879            format!(
880                "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.",
881                s(it, "input_name"),
882                s(it, "component_name"),
883            )
884        },
885    );
886    push_each(
887        out,
888        env,
889        "unused_component_outputs",
890        "Unused component output",
891        Anchor::line_col,
892        |it| {
893            format!(
894                "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.",
895                s(it, "output_name"),
896                s(it, "component_name"),
897            )
898        },
899    );
900    collect_check_component_wiring(env, out);
901}
902
903fn collect_check_component_wiring(env: &Value, out: &mut Vec<Annotation>) {
904    push_each(
905        out,
906        env,
907        "unused_svelte_events",
908        "Unused Svelte event",
909        Anchor::line_col,
910        |it| {
911            format!(
912                "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.",
913                s(it, "event_name"),
914                s(it, "component_name"),
915            )
916        },
917    );
918    push_each(
919        out,
920        env,
921        "unprovided_injects",
922        "Unprovided inject",
923        Anchor::line_col,
924        |it| {
925            format!(
926                "{} inject for key '{}' has no matching provider in the project.\n\nAdd a provide/setContext for this key, or remove the dead inject.",
927                s(it, "framework"),
928                s(it, "key_name"),
929            )
930        },
931    );
932    push_each(
933        out,
934        env,
935        "unused_load_data_keys",
936        "Unused load data key",
937        Anchor::line_only,
938        |it| {
939            format!(
940                "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.",
941                s(it, "key_name"),
942            )
943        },
944    );
945}
946
947fn stale_suppression_message(item: &Value) -> (String, String) {
948    let origin = item.get("origin").cloned().unwrap_or(Value::Null);
949    let comment_form = if b(&origin, "is_file_level") {
950        "fallow-ignore-file"
951    } else {
952        "fallow-ignore-next-line"
953    };
954    if s(&origin, "type") == "jsdoc_tag" {
955        return (
956            "Stale @expected-unused".to_owned(),
957            format!(
958                "The @expected-unused tag on '{}' is stale because the export is now used.\n\nRemove the @expected-unused tag.",
959                s(&origin, "export_name"),
960            ),
961        );
962    }
963    if origin.get("kind_known").and_then(Value::as_bool) == Some(false) {
964        return (
965            "Unknown suppression kind".to_owned(),
966            format!(
967                "'{}' 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.",
968                s(&origin, "issue_kind"),
969            ),
970        );
971    }
972    let kind_clause = origin
973        .get("issue_kind")
974        .and_then(Value::as_str)
975        .map(|kind| format!(" for '{kind}'"))
976        .unwrap_or_default();
977    (
978        "Stale suppression".to_owned(),
979        format!(
980            "This '{comment_form}' comment{kind_clause} no longer matches any active issue.\n\nRemove the suppression comment to keep the codebase clean.",
981        ),
982    )
983}
984
985fn collect_check_suppressions(env: &Value, out: &mut Vec<Annotation>) {
986    for item in arr(env, "stale_suppressions") {
987        let (title, message) = stale_suppression_message(item);
988        push(
989            out,
990            gate_level(item, AnnotationLevel::Warning),
991            s(item, "path"),
992            Anchor::line_col(item),
993            title,
994            message,
995        );
996    }
997}
998
999fn unresolved_catalog_reference_message(item: &Value) -> String {
1000    let catalog = s(item, "catalog_name");
1001    let (reference, described) = if catalog == "default" {
1002        (String::new(), "the default catalog".to_owned())
1003    } else {
1004        (catalog.to_owned(), format!("catalog '{catalog}'"))
1005    };
1006    let available = joined_strs(item, "available_in_catalogs", ", ");
1007    let remedy = if available.is_empty() {
1008        "Add this package to the named catalog in pnpm-workspace.yaml, or remove the reference and pin a hardcoded version.".to_owned()
1009    } else {
1010        format!(
1011            "Available in: {available}.\nSwitch the reference to a catalog that declares this package, or add it to the named catalog.",
1012        )
1013    };
1014    format!(
1015        "Package '{}' is referenced via `catalog:{reference}` but {described} does not declare it. `pnpm install` will fail.\n\n{remedy}",
1016        s(item, "entry_name"),
1017    )
1018}
1019
1020fn collect_check_catalog(env: &Value, out: &mut Vec<Annotation>) {
1021    push_each(
1022        out,
1023        env,
1024        "unused_catalog_entries",
1025        "Unused catalog entry",
1026        Anchor::line_only,
1027        |it| {
1028            let consumers = joined_strs(it, "hardcoded_consumers", ", ");
1029            let remedy = if consumers.is_empty() {
1030                "Remove the entry from pnpm-workspace.yaml.".to_owned()
1031            } else {
1032                format!(
1033                    "Hardcoded consumers: {consumers}.\nSwitch them to catalog: before removing."
1034                )
1035            };
1036            format!(
1037                "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package via the catalog: protocol.\n\n{remedy}",
1038                s(it, "entry_name"),
1039                s(it, "catalog_name"),
1040            )
1041        },
1042    );
1043    push_each(
1044        out,
1045        env,
1046        "empty_catalog_groups",
1047        "Empty catalog group",
1048        Anchor::line_only,
1049        |it| {
1050            format!(
1051                "Catalog group '{}' has no entries.\n\nRemove the empty group header from pnpm-workspace.yaml.",
1052                s(it, "catalog_name"),
1053            )
1054        },
1055    );
1056    for item in arr(env, "unresolved_catalog_references") {
1057        push(
1058            out,
1059            gate_level(item, AnnotationLevel::Error),
1060            s(item, "path"),
1061            Anchor::line_only(item),
1062            "Unresolved catalog reference".to_owned(),
1063            unresolved_catalog_reference_message(item),
1064        );
1065    }
1066    push_each(
1067        out,
1068        env,
1069        "unused_dependency_overrides",
1070        "Unused dependency override",
1071        Anchor::line_only,
1072        |it| {
1073            let target = s(it, "target_package");
1074            let hint = it
1075                .get("hint")
1076                .and_then(Value::as_str)
1077                .map(|hint| format!("{hint}.\n"))
1078                .unwrap_or_default();
1079            format!(
1080                "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.",
1081                s(it, "raw_key"),
1082                s(it, "version_range"),
1083            )
1084        },
1085    );
1086    for item in arr(env, "misconfigured_dependency_overrides") {
1087        let reason = item
1088            .get("reason")
1089            .and_then(Value::as_str)
1090            .unwrap_or("unparsable");
1091        push(
1092            out,
1093            gate_level(item, AnnotationLevel::Error),
1094            s(item, "path"),
1095            Anchor::line_only(item),
1096            "Misconfigured dependency override".to_owned(),
1097            format!(
1098                "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.",
1099                s(item, "raw_key"),
1100                s(item, "raw_value"),
1101            ),
1102        );
1103    }
1104}
1105
1106fn short_path(path: &str) -> String {
1107    let segments: Vec<&str> = path.split('/').collect();
1108    if segments.len() > 3 {
1109        segments[segments.len() - 3..].join("/")
1110    } else {
1111        segments.join("/")
1112    }
1113}
1114
1115fn collect_dupes(env: &Value, out: &mut Vec<Annotation>) {
1116    for group in arr(env, "clone_groups") {
1117        let instances: Vec<&Value> = arr(group, "instances").collect();
1118        for instance in &instances {
1119            // jq removes every instance deep-equal to the current one, so
1120            // identical duplicates drop out of their own "Also in" list.
1121            let others = instances
1122                .iter()
1123                .filter(|other| ***other != **instance)
1124                .fold(String::new(), |mut acc, other| {
1125                    use std::fmt::Write as _;
1126                    let _ = write!(
1127                        acc,
1128                        "\n  \u{2192} {}:{}-{}",
1129                        short_path(s(other, "file")),
1130                        num(other, "start_line"),
1131                        num(other, "end_line"),
1132                    );
1133                    acc
1134                });
1135            out.push(Annotation {
1136                level: AnnotationLevel::Warning,
1137                path: s(instance, "file").to_owned(),
1138                line: Some(u(instance, "start_line")),
1139                end_line: Some(u(instance, "end_line")),
1140                col: Some(one_based_col(u(instance, "start_col"))),
1141                title: "Code duplication".to_owned(),
1142                message: format!(
1143                    "{} duplicated lines ({} tokens)\n\n{} instances found. Also in:{others}\n\nExtract a shared function to eliminate this duplication.",
1144                    num(group, "line_count"),
1145                    num(group, "token_count"),
1146                    instances.len(),
1147                ),
1148            });
1149        }
1150    }
1151}
1152
1153fn threshold(env: &Value, key: &str, default: &str) -> String {
1154    env.get("summary")
1155        .and_then(|summary| summary.get(key))
1156        .filter(|value| !value.is_null())
1157        .map_or_else(|| default.to_owned(), fmt_num)
1158}
1159
1160/// Workflow-command level of a complexity finding.
1161///
1162/// The gate severity from the `complexity-*` rules sets the level: `error`
1163/// gives `::error` and `warn` gives `::warning`, whatever the band. The band
1164/// stays in the title and the message. A saved report without
1165/// `effective_severity` (from an older version) keeps the old band mapping:
1166/// `critical` and `high` give `::error`, `moderate` gives `::warning`.
1167fn complexity_level(finding: &Value, band: &str) -> AnnotationLevel {
1168    let band_level = if matches!(band, "critical" | "high") {
1169        AnnotationLevel::Error
1170    } else {
1171        AnnotationLevel::Warning
1172    };
1173    gate_level(finding, band_level)
1174}
1175
1176struct ComplexityThresholds {
1177    cyclomatic: String,
1178    cognitive: String,
1179    crap: String,
1180}
1181
1182/// Ceilings a single finding was evaluated against: its `effective_thresholds`
1183/// when a `thresholdOverrides` entry moved them, otherwise the run's global
1184/// summary ceilings. The `Value`-side mirror of
1185/// `ComplexityViolation::resolved_thresholds`, which this renderer cannot call
1186/// because it works over the serialized envelope (issue #2163).
1187fn finding_thresholds(finding: &Value, run: &ComplexityThresholds) -> ComplexityThresholds {
1188    let effective = finding.get("effective_thresholds");
1189    let pick = |key: &str, fallback: &str| {
1190        effective
1191            .and_then(|thresholds| thresholds.get(key))
1192            .filter(|value| !value.is_null())
1193            .map_or_else(|| fallback.to_owned(), fmt_num)
1194    };
1195    ComplexityThresholds {
1196        cyclomatic: pick("max_cyclomatic", &run.cyclomatic),
1197        cognitive: pick("max_cognitive", &run.cognitive),
1198        crap: pick("max_crap", &run.crap),
1199    }
1200}
1201
1202fn complexity_annotation(finding: &Value, ctx: &ComplexityThresholds) -> (String, String) {
1203    let severity = finding
1204        .get("severity")
1205        .and_then(Value::as_str)
1206        .unwrap_or("moderate");
1207    let name = s(finding, "name");
1208    let cyclomatic = num(finding, "cyclomatic");
1209    let cognitive = num(finding, "cognitive");
1210    let lines = num(finding, "line_count");
1211    let crap_line = finding
1212        .get("crap")
1213        .filter(|crap| !crap.is_null())
1214        .map(|crap| {
1215            format!(
1216                "  \u{2022} CRAP: {} (threshold: {})\n",
1217                fmt_num(crap),
1218                ctx.crap
1219            )
1220        })
1221        .unwrap_or_default();
1222    match s(finding, "exceeded") {
1223        "crap" | "cyclomatic_crap" | "cognitive_crap" | "all" => (
1224            format!("High CRAP score ({severity})"),
1225            format!(
1226                "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.",
1227                num(finding, "crap"),
1228                ctx.crap,
1229            ),
1230        ),
1231        "both" => (
1232            format!("High complexity ({severity})"),
1233            format!(
1234                "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.",
1235                ctx.cyclomatic, ctx.cognitive,
1236            ),
1237        ),
1238        "cyclomatic" => (
1239            format!("High cyclomatic complexity ({severity})"),
1240            format!(
1241                "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.",
1242                ctx.cyclomatic,
1243            ),
1244        ),
1245        _ => (
1246            format!("High cognitive complexity ({severity})"),
1247            format!(
1248                "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.",
1249                ctx.cognitive,
1250            ),
1251        ),
1252    }
1253}
1254
1255fn collect_health(env: &Value, out: &mut Vec<Annotation>) {
1256    let ctx = ComplexityThresholds {
1257        cyclomatic: threshold(env, "max_cyclomatic_threshold", "20"),
1258        cognitive: threshold(env, "max_cognitive_threshold", "15"),
1259        crap: threshold(env, "max_crap_threshold", "30"),
1260    };
1261    for finding in arr(env, "findings") {
1262        let severity = finding
1263            .get("severity")
1264            .and_then(Value::as_str)
1265            .unwrap_or("moderate");
1266        let (title, message) = complexity_annotation(finding, &finding_thresholds(finding, &ctx));
1267        push(
1268            out,
1269            complexity_level(finding, severity),
1270            s(finding, "path"),
1271            Anchor::line_col(finding),
1272            title,
1273            message,
1274        );
1275    }
1276    collect_runtime_coverage(env, out);
1277    collect_coverage_intelligence(env, out);
1278    collect_targets(env, out);
1279}
1280
1281fn collect_runtime_coverage(env: &Value, out: &mut Vec<Annotation>) {
1282    let Some(runtime) = env.get("runtime_coverage") else {
1283        return;
1284    };
1285    for finding in arr(runtime, "findings") {
1286        let verdict = s(finding, "verdict");
1287        let level = if verdict == "coverage_unavailable" {
1288            AnnotationLevel::Notice
1289        } else {
1290            AnnotationLevel::Warning
1291        };
1292        let invocations = finding
1293            .get("invocations")
1294            .filter(|value| !value.is_null())
1295            .map_or_else(|| "-".to_owned(), fmt_num);
1296        let evidence = finding.get("evidence").cloned().unwrap_or(Value::Null);
1297        let tracking = evidence
1298            .get("untracked_reason")
1299            .and_then(Value::as_str)
1300            .map_or_else(
1301                || s(&evidence, "v8_tracking").to_owned(),
1302                |reason| format!("{} ({reason})", s(&evidence, "v8_tracking")),
1303            );
1304        // A statically-unused function whose only remaining references live in
1305        // test files is not dead code, so the annotation says so next to the
1306        // static verdict rather than leaving a bare "unused".
1307        let static_status = if evidence
1308            .get("test_only_reference")
1309            .and_then(Value::as_bool)
1310            .unwrap_or(false)
1311        {
1312            format!(
1313                "{} (referenced only from tests)",
1314                s(&evidence, "static_status")
1315            )
1316        } else {
1317            s(&evidence, "static_status").to_owned()
1318        };
1319        let advice = arr(finding, "actions")
1320            .next()
1321            .and_then(|action| action.get("description"))
1322            .and_then(Value::as_str)
1323            .unwrap_or("Review the runtime evidence before changing this path.");
1324        push(
1325            out,
1326            level,
1327            s(finding, "path"),
1328            Anchor::line_only(finding),
1329            format!("Runtime coverage ({verdict})"),
1330            format!(
1331                "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}",
1332                s(finding, "function"),
1333                s(finding, "confidence"),
1334                s(&evidence, "test_coverage"),
1335            ),
1336        );
1337    }
1338}
1339
1340fn collect_coverage_intelligence(env: &Value, out: &mut Vec<Annotation>) {
1341    let Some(intelligence) = env.get("coverage_intelligence") else {
1342        return;
1343    };
1344    for finding in arr(intelligence, "findings") {
1345        let verdict = s(finding, "verdict");
1346        if matches!(verdict, "clean" | "unknown") {
1347            continue;
1348        }
1349        let recommendation = s(finding, "recommendation");
1350        let level = if matches!(verdict, "risky-change-detected" | "high-confidence-delete") {
1351            AnnotationLevel::Error
1352        } else {
1353            AnnotationLevel::Warning
1354        };
1355        let identity = finding
1356            .get("identity")
1357            .and_then(Value::as_str)
1358            .unwrap_or("code");
1359        push(
1360            out,
1361            level,
1362            s(finding, "path"),
1363            Anchor::line_only(finding),
1364            format!("Coverage intelligence ({recommendation})"),
1365            format!("'{identity}' coverage intelligence verdict: {verdict} ({recommendation})"),
1366        );
1367    }
1368}
1369
1370fn collect_targets(env: &Value, out: &mut Vec<Annotation>) {
1371    let targets = env
1372        .get("targets")
1373        .filter(|value| !value.is_null())
1374        .or_else(|| env.get("refactoring_targets"))
1375        .and_then(Value::as_array)
1376        .map(Vec::as_slice)
1377        .unwrap_or_default();
1378    // The jq template annotates only the top 5 targets (a content decision in
1379    // `annotations-health.jq`, distinct from the removed MAX_ANNOTATIONS cap).
1380    for target in targets.iter().take(5) {
1381        let factors = target
1382            .get("factors")
1383            .and_then(Value::as_array)
1384            .map(|factors| {
1385                factors
1386                    .iter()
1387                    .map(|factor| {
1388                        let detail = factor
1389                            .get("detail")
1390                            .and_then(Value::as_str)
1391                            .map_or_else(|| num(factor, "value"), str::to_owned);
1392                        format!("  \u{2022} {}: {detail}", s(factor, "metric"))
1393                    })
1394                    .collect::<Vec<_>>()
1395                    .join("\n")
1396            })
1397            .unwrap_or_default();
1398        push(
1399            out,
1400            AnnotationLevel::Notice,
1401            s(target, "path"),
1402            Anchor::default(),
1403            format!("Refactoring target ({} effort)", s(target, "effort")),
1404            format!(
1405                "Priority: {} | Confidence: {}\n\n{}\n\n{factors}",
1406                s(target, "priority"),
1407                s(target, "confidence"),
1408                s(target, "recommendation"),
1409            ),
1410        );
1411    }
1412}
1413
1414/// Net-new security annotations (the jq layer has no
1415/// `annotations-security.jq`): every candidate renders at `::notice`, because
1416/// fallow surfaces unverified candidates, not confirmed vulnerabilities.
1417fn collect_security(env: &Value, out: &mut Vec<Annotation>) {
1418    for finding in arr(env, "security_findings") {
1419        let kind = s(finding, "kind");
1420        let severity = finding
1421            .get("severity")
1422            .and_then(Value::as_str)
1423            .unwrap_or("unknown");
1424        let callee = finding
1425            .get("candidate")
1426            .and_then(|candidate| candidate.get("sink"))
1427            .and_then(|sink| sink.get("callee"))
1428            .and_then(Value::as_str)
1429            .filter(|callee| !callee.is_empty())
1430            .unwrap_or("-");
1431        push(
1432            out,
1433            AnnotationLevel::Notice,
1434            s(finding, "path"),
1435            Anchor::line_col(finding),
1436            format!("Security candidate ({kind})"),
1437            format!(
1438                "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.",
1439                s(finding, "evidence"),
1440            ),
1441        );
1442    }
1443}