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