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