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