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