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