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