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