Skip to main content

fallow_cli/report/
github_summary.rs

1//! `--format github-summary`: GitHub Actions job-summary markdown, written
2//! by workflows as `fallow ... --format github-summary >> "$GITHUB_STEP_SUMMARY"`.
3//!
4//! Sections, ordering, and truncation caps are ported from the bundled
5//! action's jq renderers (`action/jq/summary-{check,dupes,health,audit,
6//! security,fix,combined}.jq`). Deviations from the jq layer: em dashes in
7//! the jq templates render as plain hyphens (repo style rule), and the
8//! combined view's dupes file links read `GH_REPO` / `GITHUB_REPOSITORY` and
9//! `PR_HEAD_SHA` / `GITHUB_SHA` (the jq layer read only the action-set
10//! `GH_REPO` / `PR_HEAD_SHA`), with the link path prefix coming from the
11//! path-rebase resolution instead of the action-set `PREFIX` env var.
12//!
13//! Like the annotations renderer, this is value-driven over the `--format
14//! json` envelope, which keeps `fallow report --from` output byte-identical
15//! to the direct format run.
16
17use std::fmt::Write as _;
18use std::path::Path;
19use std::process::ExitCode;
20
21use serde_json::Value;
22
23use super::github::{PathRebase, arr, b, fmt_num, num, resolve_render_options, s, u};
24use super::github_annotations::EnvelopeKind;
25use crate::report::sink::outln;
26
27const DEAD_CODE_DOCS: &str = "https://docs.fallow.tools/explanations/dead-code";
28const HEALTH_DOCS: &str = "https://docs.fallow.tools/explanations/health";
29const DUPES_DOCS: &str = "https://docs.fallow.tools/explanations/duplication";
30const SUPPRESSION_DOCS: &str = "https://docs.fallow.tools/configuration/suppression";
31
32/// Environment-derived context for the combined view's dupes file links.
33#[derive(Debug, Default, Clone)]
34pub struct LinkContext {
35    /// Repo-root path prefix for link targets (empty or `dir/` with a
36    /// trailing slash).
37    pub prefix: String,
38    /// `owner/repo`; links render as plain code when empty.
39    pub repo: String,
40    /// Head commit SHA; links render as plain code when empty.
41    pub sha: String,
42}
43
44impl LinkContext {
45    /// Resolve from the workflow environment plus the path-rebase offset.
46    #[must_use]
47    pub fn from_env(rebase: &PathRebase) -> Self {
48        let env = |primary: &str, fallback: &str| {
49            std::env::var(primary)
50                .or_else(|_| std::env::var(fallback))
51                .unwrap_or_default()
52        };
53        let prefix = match rebase {
54            PathRebase::None => String::new(),
55            PathRebase::Prefix(prefix) => format!("{prefix}/"),
56        };
57        Self {
58            prefix,
59            repo: env("GH_REPO", "GITHUB_REPOSITORY"),
60            sha: env("PR_HEAD_SHA", "GITHUB_SHA"),
61        }
62    }
63}
64
65/// Render and print the job-summary markdown for one envelope.
66pub fn print_summary(kind: EnvelopeKind, envelope: &Value, root: &Path) -> ExitCode {
67    let options = resolve_render_options(root);
68    let links = LinkContext::from_env(&options.rebase);
69    outln!("{}", render_summary(kind, envelope, &links));
70    ExitCode::SUCCESS
71}
72
73/// Render the fix envelope's job summary directly from `fallow fix`. The fix
74/// envelope has no `kind` field; `fallow report --from` reaches the same
75/// renderer via [`EnvelopeKind::Fix`] (resolved by field detection), while the
76/// live `fallow fix` command calls this entry point.
77pub fn print_fix_summary(envelope: &Value) -> ExitCode {
78    outln!("{}", render_fix_summary(envelope));
79    ExitCode::SUCCESS
80}
81
82/// Pure renderer, dispatching on the envelope family.
83#[must_use]
84pub fn render_summary(kind: EnvelopeKind, envelope: &Value, links: &LinkContext) -> String {
85    match kind {
86        EnvelopeKind::DeadCode => render_check_summary(envelope),
87        EnvelopeKind::Dupes => render_dupes_summary(envelope),
88        EnvelopeKind::Health => render_health_summary(envelope),
89        EnvelopeKind::Audit => render_audit_summary(envelope),
90        EnvelopeKind::Security => render_security_summary(envelope),
91        EnvelopeKind::Combined => render_combined_summary(envelope, links),
92        EnvelopeKind::Fix => render_fix_summary(envelope),
93    }
94}
95
96// ---------------------------------------------------------------------------
97// Shared numeric / path helpers (jq `pct`, `signed`, `rel_path`).
98// ---------------------------------------------------------------------------
99
100/// jq `pct`: `. * 10 | round / 10`, interpolated without a trailing `.0`.
101fn pct(value: f64) -> String {
102    let rounded = (value * 10.0).round() / 10.0;
103    fmt_num(&serde_json::json!(rounded))
104}
105
106/// jq `signed`: `+x` for positive, plain for negative, `0.0` for zero.
107fn signed(value: f64) -> String {
108    if value > 0.0 {
109        format!("+{}", pct(value))
110    } else if value < 0.0 {
111        pct(value)
112    } else {
113        "0.0".to_owned()
114    }
115}
116
117fn opt_f(value: &Value, key: &str) -> Option<f64> {
118    value.get(key).and_then(Value::as_f64)
119}
120
121fn f_or_zero(value: &Value, key: &str) -> f64 {
122    opt_f(value, key).unwrap_or_default()
123}
124
125/// jq `rel_path` (audit/security flavor): shorten only absolute paths to
126/// their last three segments.
127fn rel_path_absolute_only(path: &str) -> String {
128    if path.starts_with('/') {
129        last_three_segments(path)
130    } else {
131        path.to_owned()
132    }
133}
134
135/// jq `rel_path` (combined flavor): always shorten to the last three
136/// segments.
137fn last_three_segments(path: &str) -> String {
138    let segments: Vec<&str> = path.split('/').collect();
139    if segments.len() > 3 {
140        segments[segments.len() - 3..].join("/")
141    } else {
142        segments.join("/")
143    }
144}
145
146/// jq `plural(n; word)`.
147fn plural_n(n: usize, word: &str) -> String {
148    let suffix = if n == 1 { "" } else { "s" };
149    format!("{n} {word}{suffix}")
150}
151
152fn str_or<'v>(value: &'v Value, key: &str, default: &'v str) -> &'v str {
153    value.get(key).and_then(Value::as_str).unwrap_or(default)
154}
155
156/// `` `path` `` / `` `path:line` `` with the jq truthiness gate: the `:line`
157/// suffix renders whenever `line` is present and non-null (0 included).
158fn path_line(item: &Value) -> String {
159    let path = rel_path_absolute_only(s(item, "path"));
160    match item.get("line").filter(|line| !line.is_null()) {
161        Some(line) => format!("`{path}:{}`", fmt_num(line)),
162        None => format!("`{path}`"),
163    }
164}
165
166fn backtick_join(item: &Value, key: &str) -> String {
167    arr(item, key)
168        .filter_map(Value::as_str)
169        .map(|entry| format!("`{entry}`"))
170        .collect::<Vec<_>>()
171        .join(", ")
172}
173
174// ---------------------------------------------------------------------------
175// Dead-code category table (shared by the check summary and the combined
176// code-issues breakdown; labels and docs anchors from the jq layer).
177// ---------------------------------------------------------------------------
178
179const DEAD_CODE_CATEGORIES: &[(&str, &str, &str)] = &[
180    ("Unused files", "unused_files", "unused-files"),
181    ("Unused exports", "unused_exports", "unused-exports"),
182    ("Unused types", "unused_types", "unused-types"),
183    (
184        "Private type leaks",
185        "private_type_leaks",
186        "private-type-leaks",
187    ),
188    (
189        "Unused dependencies",
190        "unused_dependencies",
191        "unused-dependencies",
192    ),
193    (
194        "Unused devDependencies",
195        "unused_dev_dependencies",
196        "unused-dependencies",
197    ),
198    (
199        "Unused optionalDependencies",
200        "unused_optional_dependencies",
201        "unused-dependencies",
202    ),
203    (
204        "Unused enum members",
205        "unused_enum_members",
206        "unused-enum-members",
207    ),
208    (
209        "Unused class members",
210        "unused_class_members",
211        "unused-class-members",
212    ),
213    (
214        "Unused store members",
215        "unused_store_members",
216        "unused-store-members",
217    ),
218    (
219        "Unresolved imports",
220        "unresolved_imports",
221        "unresolved-imports",
222    ),
223    (
224        "Unlisted dependencies",
225        "unlisted_dependencies",
226        "unlisted-dependencies",
227    ),
228    (
229        "Duplicate exports",
230        "duplicate_exports",
231        "duplicate-exports",
232    ),
233    (
234        "Circular dependencies",
235        "circular_dependencies",
236        "circular-dependencies",
237    ),
238    ("Re-export cycles", "re_export_cycles", "re-export-cycles"),
239    (
240        "Boundary violations",
241        "boundary_violations",
242        "boundary-violations",
243    ),
244    (
245        "Boundary coverage",
246        "boundary_coverage_violations",
247        "boundary-violations",
248    ),
249    (
250        "Boundary calls",
251        "boundary_call_violations",
252        "boundary-violations",
253    ),
254    (
255        "Policy violations",
256        "policy_violations",
257        "policy-violations",
258    ),
259    (
260        "Invalid client exports",
261        "invalid_client_exports",
262        "invalid-client-exports",
263    ),
264    (
265        "Mixed client/server barrels",
266        "mixed_client_server_barrels",
267        "mixed-client-server-barrels",
268    ),
269    (
270        "Misplaced directives",
271        "misplaced_directives",
272        "misplaced-directives",
273    ),
274    (
275        "Unused server actions",
276        "unused_server_actions",
277        "unused-server-action",
278    ),
279    ("Route collisions", "route_collisions", "route-collisions"),
280    (
281        "Dynamic segment conflicts",
282        "dynamic_segment_name_conflicts",
283        "dynamic-segment-name-conflicts",
284    ),
285    (
286        "Unrendered components",
287        "unrendered_components",
288        "unrendered-component",
289    ),
290    (
291        "Unused component props",
292        "unused_component_props",
293        "unused-component-prop",
294    ),
295    (
296        "Unused component emits",
297        "unused_component_emits",
298        "unused-component-emit",
299    ),
300    (
301        "Unused component inputs",
302        "unused_component_inputs",
303        "unused-component-input",
304    ),
305    (
306        "Unused component outputs",
307        "unused_component_outputs",
308        "unused-component-output",
309    ),
310    (
311        "Unused Svelte events",
312        "unused_svelte_events",
313        "unused-svelte-event",
314    ),
315    (
316        "Unprovided injects",
317        "unprovided_injects",
318        "unprovided-inject",
319    ),
320    (
321        "Unused load data keys",
322        "unused_load_data_keys",
323        "unused-load-data-key",
324    ),
325    (
326        "Type-only dependencies",
327        "type_only_dependencies",
328        "type-only-dependencies",
329    ),
330    (
331        "Test-only dependencies",
332        "test_only_dependencies",
333        "test-only-dependencies",
334    ),
335    (
336        "Dev dependencies used in production",
337        "dev_dependencies_in_production",
338        "dev-dependencies-in-production",
339    ),
340    (
341        "Stale suppressions",
342        "stale_suppressions",
343        "stale-suppressions",
344    ),
345    (
346        "Unused catalog entries",
347        "unused_catalog_entries",
348        "unused-catalog-entries",
349    ),
350    (
351        "Empty catalog groups",
352        "empty_catalog_groups",
353        "empty-catalog-groups",
354    ),
355    (
356        "Unresolved catalog references",
357        "unresolved_catalog_references",
358        "unresolved-catalog-references",
359    ),
360    (
361        "Unused dependency overrides",
362        "unused_dependency_overrides",
363        "unused-dependency-overrides",
364    ),
365    (
366        "Misconfigured dependency overrides",
367        "misconfigured_dependency_overrides",
368        "misconfigured-dependency-overrides",
369    ),
370];
371
372fn dead_code_docs(anchor: &str) -> String {
373    format!("{DEAD_CODE_DOCS}#{anchor}")
374}
375
376fn dead_code_category_table(env: &Value) -> String {
377    DEAD_CODE_CATEGORIES
378        .iter()
379        .filter_map(|(name, key, anchor)| {
380            let n = arr(env, key).count();
381            (n > 0).then(|| format!("| [{name}]({}) | {n} |", dead_code_docs(anchor)))
382        })
383        .collect::<Vec<_>>()
384        .join("\n")
385}
386
387// ---------------------------------------------------------------------------
388// summary-check.jq
389// ---------------------------------------------------------------------------
390
391struct SectionSpec {
392    name: &'static str,
393    key: &'static str,
394    header: &'static str,
395    row: fn(&Value) -> String,
396}
397
398fn render_check_section(env: &Value, spec: &SectionSpec) -> String {
399    let items: Vec<&Value> = arr(env, spec.key).collect();
400    let n = items.len();
401    if n == 0 {
402        return String::new();
403    }
404    let rows = items
405        .iter()
406        .take(25)
407        .map(|item| (spec.row)(item))
408        .collect::<Vec<_>>()
409        .join("\n");
410    let tail = if n > 25 {
411        format!(
412            "\n\n> {} more - run `fallow` locally for the full list",
413            n - 25
414        )
415    } else {
416        String::new()
417    };
418    format!(
419        "\n<details><summary><strong>{} ({n})</strong></summary>\n\n{}{rows}{tail}\n\n</details>\n",
420        spec.name, spec.header,
421    )
422}
423
424fn check_workspace_context(item: &Value) -> String {
425    backtick_join(item, "used_in_workspaces")
426}
427
428#[expect(
429    clippy::too_many_lines,
430    reason = "a flat data table of 14 per-kind row templates ported 1:1 from summary-check.jq; splitting would obscure the correspondence"
431)]
432fn check_sections_core() -> Vec<SectionSpec> {
433    vec![
434        SectionSpec {
435            name: "Unused files",
436            key: "unused_files",
437            header: "Files not reachable from any entry point.\n\n| File |\n|------|\n",
438            row: |it| format!("| `{}` |", s(it, "path")),
439        },
440        SectionSpec {
441            name: "Unused exports",
442            key: "unused_exports",
443            header: "Exported symbols with no known consumers.\n\n| File | Line | Export |\n|------|-----:|--------|\n",
444            row: |it| {
445                format!(
446                    "| `{}` | {} | `{}`{} |",
447                    s(it, "path"),
448                    num(it, "line"),
449                    s(it, "export_name"),
450                    if b(it, "is_re_export") {
451                        " *(re-export)*"
452                    } else {
453                        ""
454                    },
455                )
456            },
457        },
458        SectionSpec {
459            name: "Unused types",
460            key: "unused_types",
461            header: "Type exports with no known consumers.\n\n| File | Line | Type |\n|------|-----:|------|\n",
462            row: |it| {
463                format!(
464                    "| `{}` | {} | `{}` |",
465                    s(it, "path"),
466                    num(it, "line"),
467                    s(it, "export_name"),
468                )
469            },
470        },
471        SectionSpec {
472            name: "Private type leaks",
473            key: "private_type_leaks",
474            header: "Exported signatures that reference same-file private types.\n\n| File | Line | Export | Private type |\n|------|-----:|--------|--------------|\n",
475            row: |it| {
476                format!(
477                    "| `{}` | {} | `{}` | `{}` |",
478                    s(it, "path"),
479                    num(it, "line"),
480                    s(it, "export_name"),
481                    s(it, "type_name"),
482                )
483            },
484        },
485        SectionSpec {
486            name: "Unused dependencies",
487            key: "unused_dependencies",
488            header: "Listed in `dependencies` but never imported by the declaring workspace.\n\n| Package | Imported elsewhere |\n|---------|--------------------|\n",
489            row: |it| {
490                format!(
491                    "| `{}` | {} |",
492                    s(it, "package_name"),
493                    check_workspace_context(it),
494                )
495            },
496        },
497        SectionSpec {
498            name: "Unused devDependencies",
499            key: "unused_dev_dependencies",
500            header: "Listed in `devDependencies` but never imported or referenced by the declaring workspace.\n\n| Package | Imported elsewhere |\n|---------|--------------------|\n",
501            row: |it| {
502                format!(
503                    "| `{}` | {} |",
504                    s(it, "package_name"),
505                    check_workspace_context(it),
506                )
507            },
508        },
509        SectionSpec {
510            name: "Unused optionalDependencies",
511            key: "unused_optional_dependencies",
512            header: "Listed in `optionalDependencies` but never imported by the declaring workspace.\n\n| Package | Imported elsewhere |\n|---------|--------------------|\n",
513            row: |it| {
514                format!(
515                    "| `{}` | {} |",
516                    s(it, "package_name"),
517                    check_workspace_context(it),
518                )
519            },
520        },
521        SectionSpec {
522            name: "Unused enum members",
523            key: "unused_enum_members",
524            header: "Enum members never referenced outside their declaration.\n\n| File | Line | Enum | Member |\n|------|-----:|------|--------|\n",
525            row: member_row,
526        },
527        SectionSpec {
528            name: "Unused class members",
529            key: "unused_class_members",
530            header: "Class methods or properties never referenced outside their class.\n\n| File | Line | Class | Member |\n|------|-----:|-------|--------|\n",
531            row: member_row,
532        },
533        SectionSpec {
534            name: "Unused store members",
535            key: "unused_store_members",
536            header: "Pinia store members (state, getter, action) never accessed by any consumer.\n\n| File | Line | Store | Member |\n|------|-----:|-------|--------|\n",
537            row: member_row,
538        },
539        SectionSpec {
540            name: "Unresolved imports",
541            key: "unresolved_imports",
542            header: "Import paths that could not be resolved - check for missing packages or broken paths.\n\n| File | Line | Import |\n|------|-----:|--------|\n",
543            row: |it| {
544                format!(
545                    "| `{}` | {} | `{}` |",
546                    s(it, "path"),
547                    num(it, "line"),
548                    s(it, "specifier"),
549                )
550            },
551        },
552        SectionSpec {
553            name: "Unlisted dependencies",
554            key: "unlisted_dependencies",
555            header: "Packages imported in code but missing from `package.json`.\n\n| Package | Used in |\n|---------|--------|\n",
556            row: |it| {
557                let sites: Vec<&Value> = arr(it, "imported_from").collect();
558                let cell = if sites.is_empty() {
559                    String::new()
560                } else {
561                    let shown = sites
562                        .iter()
563                        .take(3)
564                        .map(|site| format!("`{}:{}`", s(site, "path"), num(site, "line")))
565                        .collect::<Vec<_>>()
566                        .join(", ");
567                    let more = if sites.len() > 3 {
568                        format!(" *+{} more*", sites.len() - 3)
569                    } else {
570                        String::new()
571                    };
572                    format!("{shown}{more}")
573                };
574                format!("| `{}` | {cell} |", s(it, "package_name"))
575            },
576        },
577        SectionSpec {
578            name: "Duplicate exports",
579            key: "duplicate_exports",
580            header: "Same export name defined in multiple files - barrel re-exports may resolve ambiguously.\n\n| Export | Locations |\n|--------|-----------|\n",
581            row: |it| {
582                let locations: Vec<&Value> = arr(it, "locations").collect();
583                let shown = locations
584                    .iter()
585                    .take(3)
586                    .map(|location| format!("`{}:{}`", s(location, "path"), num(location, "line")))
587                    .collect::<Vec<_>>()
588                    .join(", ");
589                let more = if locations.len() > 3 {
590                    format!(" *+{} more*", locations.len() - 3)
591                } else {
592                    String::new()
593                };
594                format!("| `{}` | {shown}{more} |", s(it, "export_name"))
595            },
596        },
597        SectionSpec {
598            name: "Circular dependencies",
599            key: "circular_dependencies",
600            header: "Import cycles that can cause initialization failures and prevent tree-shaking.\n\n| Cycle | Length |\n|-------|-------:|\n",
601            row: |it| {
602                format!(
603                    "| {} | {} |",
604                    plain_join(it, "files", " \u{2192} "),
605                    num(it, "length"),
606                )
607            },
608        },
609    ]
610}
611
612fn member_row(it: &Value) -> String {
613    format!(
614        "| `{}` | {} | `{}` | `{}` |",
615        s(it, "path"),
616        num(it, "line"),
617        s(it, "parent_name"),
618        s(it, "member_name"),
619    )
620}
621
622fn plain_join(item: &Value, key: &str, separator: &str) -> String {
623    arr(item, key)
624        .filter_map(Value::as_str)
625        .collect::<Vec<_>>()
626        .join(separator)
627}
628
629fn path_line_cell(it: &Value) -> String {
630    format!("`{}:{}`", s(it, "path"), num(it, "line"))
631}
632
633#[expect(
634    clippy::too_many_lines,
635    reason = "a flat data table of 14 per-kind row templates ported 1:1 from summary-check.jq; splitting would obscure the correspondence"
636)]
637fn check_sections_architecture() -> Vec<SectionSpec> {
638    vec![
639        SectionSpec {
640            name: "Re-export cycles",
641            key: "re_export_cycles",
642            header: "Barrel files that re-export from each other in a loop. Chain propagation through the loop is a no-op, so imports through any member may silently come up empty.\n\n| Cycle | Kind | Members |\n|-------|------|--------:|\n",
643            row: |it| {
644                let cycle = arr(it, "files")
645                    .filter_map(Value::as_str)
646                    .map(|file| format!("`{file}`"))
647                    .collect::<Vec<_>>()
648                    .join(" <-> ");
649                format!(
650                    "| {cycle} | {} | {} |",
651                    s(it, "kind"),
652                    arr(it, "files").count()
653                )
654            },
655        },
656        SectionSpec {
657            name: "Boundary violations",
658            key: "boundary_violations",
659            header: "Imports that cross defined architecture zone boundaries.\n\n| From | To | Zones |\n|------|-----|-------|\n",
660            row: |it| {
661                format!(
662                    "| `{}:{}` | `{}` | {} \u{2192} {} |",
663                    s(it, "from_path"),
664                    num(it, "line"),
665                    s(it, "to_path"),
666                    s(it, "from_zone"),
667                    s(it, "to_zone"),
668                )
669            },
670        },
671        SectionSpec {
672            name: "Boundary coverage",
673            key: "boundary_coverage_violations",
674            header: "Files that match no configured architecture boundary zone.\n\n| File |\n|------|\n",
675            row: |it| format!("| {} |", path_line_cell(it)),
676        },
677        SectionSpec {
678            name: "Boundary calls",
679            key: "boundary_call_violations",
680            header: "Calls from zoned files to callees forbidden for that zone.\n\n| File | Callee | Zone | Pattern |\n|------|--------|------|---------|\n",
681            row: |it| {
682                format!(
683                    "| {} | `{}` | {} | `{}` |",
684                    path_line_cell(it),
685                    s(it, "callee"),
686                    s(it, "zone"),
687                    s(it, "pattern"),
688                )
689            },
690        },
691        SectionSpec {
692            name: "Policy violations",
693            key: "policy_violations",
694            header: "Banned calls, imports, and catalogue-derived effects matched by configured rule packs.\n\n| File | Matched | Rule | Severity |\n|------|---------|------|----------|\n",
695            row: |it| {
696                format!(
697                    "| {} | `{}` | `{}/{}` | {} |",
698                    path_line_cell(it),
699                    s(it, "matched"),
700                    s(it, "pack"),
701                    s(it, "rule_id"),
702                    s(it, "severity"),
703                )
704            },
705        },
706        SectionSpec {
707            name: "Invalid client exports",
708            key: "invalid_client_exports",
709            header: "`\"use client\"` files exporting a Next.js server-only / route-config name. Next.js rejects this at build time.\n\n| File | Export | Directive |\n|------|--------|-----------|\n",
710            row: |it| {
711                format!(
712                    "| {} | `{}` | `\"{}\"` |",
713                    path_line_cell(it),
714                    s(it, "export_name"),
715                    s(it, "directive"),
716                )
717            },
718        },
719        SectionSpec {
720            name: "Mixed client/server barrels",
721            key: "mixed_client_server_barrels",
722            header: "Barrels re-exporting both a `\"use client\"` module and a server-only module. One import drags the other's directive across the boundary.\n\n| File | Client origin | Server origin |\n|------|---------------|---------------|\n",
723            row: |it| {
724                format!(
725                    "| {} | `{}` | `{}` |",
726                    path_line_cell(it),
727                    s(it, "client_origin"),
728                    s(it, "server_origin"),
729                )
730            },
731        },
732        SectionSpec {
733            name: "Misplaced directives",
734            key: "misplaced_directives",
735            header: "`\"use client\"` / `\"use server\"` directives written after a non-directive statement, so the RSC bundler ignores them. Move the directive to the top of the file.\n\n| File | Directive |\n|------|-----------|\n",
736            row: |it| format!("| {} | `\"{}\"` |", path_line_cell(it), s(it, "directive")),
737        },
738        SectionSpec {
739            name: "Unused server actions",
740            key: "unused_server_actions",
741            header: "Next.js Server Actions (exports of a `\"use server\"` file) that no project code references. The endpoint stays POST-able, but no code calls it (likely dead).\n\n| File | Action |\n|------|--------|\n",
742            row: |it| format!("| {} | `{}` |", path_line_cell(it), s(it, "action_name")),
743        },
744        SectionSpec {
745            name: "Route collisions",
746            key: "route_collisions",
747            header: "Next.js App Router route files that resolve to the same URL within one app-root. Next.js fails the build because a URL can have only one owner.\n\n| File | URL |\n|------|-----|\n",
748            row: |it| format!("| `{}` | `{}` |", s(it, "path"), s(it, "url")),
749        },
750        SectionSpec {
751            name: "Dynamic segment conflicts",
752            key: "dynamic_segment_name_conflicts",
753            header: "Sibling Next.js dynamic route segments at one position using different slug names. Next.js requires one consistent name per dynamic path.\n\n| File | Position | Segments |\n|------|----------|----------|\n",
754            row: |it| {
755                format!(
756                    "| `{}` | `{}` | `{}` |",
757                    s(it, "path"),
758                    s(it, "position"),
759                    plain_join(it, "conflicting_segments", ", "),
760                )
761            },
762        },
763    ]
764}
765
766#[expect(
767    clippy::too_many_lines,
768    reason = "a flat data table of 14 per-kind row templates ported 1:1 from summary-check.jq; splitting would obscure the correspondence"
769)]
770fn check_sections_frameworks_and_hygiene() -> Vec<SectionSpec> {
771    vec![
772        SectionSpec {
773            name: "Unrendered components",
774            key: "unrendered_components",
775            header: "Vue/Svelte components reachable in the module graph but rendered nowhere: no tag, no dynamic binding, no registration. A barrel re-export keeps them alive even though nothing instantiates them.\n\n| File | Component | Framework |\n|------|-----------|-----------|\n",
776            row: |it| {
777                format!(
778                    "| {} | `{}` | {} |",
779                    path_line_cell(it),
780                    s(it, "component_name"),
781                    s(it, "framework"),
782                )
783            },
784        },
785        SectionSpec {
786            name: "Unused component props",
787            key: "unused_component_props",
788            header: "Vue `defineProps` props referenced nowhere inside their own single-file component (neither script nor template).\n\n| File | Component | Prop |\n|------|-----------|------|\n",
789            row: |it| component_detail_row(it, "prop_name"),
790        },
791        SectionSpec {
792            name: "Unused component emits",
793            key: "unused_component_emits",
794            header: "Vue `defineEmits` events emitted nowhere inside their own single-file component (no matching `emit()` call).\n\n| File | Component | Event |\n|------|-----------|-------|\n",
795            row: |it| component_detail_row(it, "emit_name"),
796        },
797        SectionSpec {
798            name: "Unused component inputs",
799            key: "unused_component_inputs",
800            header: "Angular `@Input()` / signal `input()` declarations read nowhere inside their own component (neither class body nor template).\n\n| File | Component | Input |\n|------|-----------|-------|\n",
801            row: |it| component_detail_row(it, "input_name"),
802        },
803        SectionSpec {
804            name: "Unused component outputs",
805            key: "unused_component_outputs",
806            header: "Angular `@Output()` / signal `output()` declarations emitted nowhere inside their own component (no matching `emit()` call).\n\n| File | Component | Output |\n|------|-----------|--------|\n",
807            row: |it| component_detail_row(it, "output_name"),
808        },
809        SectionSpec {
810            name: "Unused Svelte events",
811            key: "unused_svelte_events",
812            header: "Svelte components dispatching a `createEventDispatcher` event listened to nowhere in the project (cross-file dead-output direction).\n\n| File | Component | Event |\n|------|-----------|-------|\n",
813            row: |it| component_detail_row(it, "event_name"),
814        },
815        SectionSpec {
816            name: "Unprovided injects",
817            key: "unprovided_injects",
818            header: "Vue `inject` / Svelte `getContext` calls for a key that no ancestor `provide` / `setContext` supplies.\n\n| File | Key | Framework |\n|------|-----|-----------|\n",
819            row: |it| {
820                format!(
821                    "| {} | `{}` | {} |",
822                    path_line_cell(it),
823                    s(it, "key_name"),
824                    s(it, "framework"),
825                )
826            },
827        },
828        SectionSpec {
829            name: "Unused load data keys",
830            key: "unused_load_data_keys",
831            header: "SvelteKit `load()` return-object keys read by no consumer (neither the sibling `+page.svelte` nor `$page.data`). The key runs a real server fetch / DB cost per request for data nothing renders.\n\n| File | Route | Key |\n|------|-------|-----|\n",
832            row: |it| {
833                format!(
834                    "| {} | `{}` | `{}` |",
835                    path_line_cell(it),
836                    s(it, "route_dir"),
837                    s(it, "key_name"),
838                )
839            },
840        },
841        SectionSpec {
842            name: "Type-only dependencies",
843            key: "type_only_dependencies",
844            header: "Dependencies only used for type imports - consider moving to `devDependencies`.\n\n| Package |\n|---------|\n",
845            row: package_row,
846        },
847        SectionSpec {
848            name: "Test-only dependencies",
849            key: "test_only_dependencies",
850            header: "Production dependencies only imported by test files - consider moving to `devDependencies`.\n\n| Package |\n|---------|\n",
851            row: package_row,
852        },
853        SectionSpec {
854            name: "Dev dependencies used in production",
855            key: "dev_dependencies_in_production",
856            header: "`devDependencies` imported by production code at runtime - consider moving to `dependencies` so a production-only install does not break.\n\n| Package |\n|---------|\n",
857            row: package_row,
858        },
859        SectionSpec {
860            name: "Stale suppressions",
861            key: "stale_suppressions",
862            header: "Suppression comments or JSDoc tags that no longer match any active issue.\n\n| File | Line | Description |\n|------|-----:|-------------|\n",
863            row: |it| {
864                format!(
865                    "| `{}` | {} | {} |",
866                    s(it, "path"),
867                    num(it, "line"),
868                    stale_suppression_description(it),
869                )
870            },
871        },
872    ]
873}
874
875fn component_detail_row(it: &Value, detail_key: &str) -> String {
876    format!(
877        "| {} | `{}` | `{}` |",
878        path_line_cell(it),
879        s(it, "component_name"),
880        s(it, detail_key),
881    )
882}
883
884fn package_row(it: &Value) -> String {
885    format!("| `{}` |", s(it, "package_name"))
886}
887
888fn stale_suppression_description(it: &Value) -> String {
889    let origin = it.get("origin").cloned().unwrap_or(Value::Null);
890    if s(&origin, "type") == "jsdoc_tag" {
891        return format!("`@expected-unused` on `{}`", s(&origin, "export_name"));
892    }
893    if origin.get("kind_known").and_then(Value::as_bool) == Some(false) {
894        return format!("unknown kind `{}`", s(&origin, "issue_kind"));
895    }
896    match origin.get("issue_kind").and_then(Value::as_str) {
897        Some(kind) => format!("`{kind}`"),
898        None => "blanket".to_owned(),
899    }
900}
901
902fn check_sections_catalog() -> Vec<SectionSpec> {
903    vec![
904        SectionSpec {
905            name: "Unused catalog entries",
906            key: "unused_catalog_entries",
907            header: "pnpm catalog entries not referenced by any workspace package.\n\n| Entry | Catalog | Location | Hardcoded consumers |\n|-------|---------|----------|---------------------|\n",
908            row: |it| {
909                format!(
910                    "| `{}` | `{}` | {} | {} |",
911                    s(it, "entry_name"),
912                    s(it, "catalog_name"),
913                    path_line_cell(it),
914                    backtick_join(it, "hardcoded_consumers"),
915                )
916            },
917        },
918        SectionSpec {
919            name: "Empty catalog groups",
920            key: "empty_catalog_groups",
921            header: "Named pnpm catalog groups with no entries.\n\n| Catalog | Location |\n|---------|----------|\n",
922            row: |it| format!("| `{}` | {} |", s(it, "catalog_name"), path_line_cell(it)),
923        },
924        SectionSpec {
925            name: "Unresolved catalog references",
926            key: "unresolved_catalog_references",
927            header: "Workspace `package.json` references to catalogs that do not declare the package. `pnpm install` will fail until each entry is added to its named catalog or the reference is switched.\n\n| Entry | Catalog | Location | Available in |\n|-------|---------|----------|--------------|\n",
928            row: |it| {
929                format!(
930                    "| `{}` | `{}` | {} | {} |",
931                    s(it, "entry_name"),
932                    s(it, "catalog_name"),
933                    path_line_cell(it),
934                    backtick_join(it, "available_in_catalogs"),
935                )
936            },
937        },
938        SectionSpec {
939            name: "Unused dependency overrides",
940            key: "unused_dependency_overrides",
941            header: "`pnpm.overrides` entries forcing a version no workspace package depends on. Some entries may be intentional pins for transitive CVEs; the hint column flags those.\n\n| Override | Forces | Source | Location | Hint |\n|----------|--------|--------|----------|------|\n",
942            row: |it| {
943                format!(
944                    "| `{}` | `{}` -> `{}` | `{}` | {} | {} |",
945                    s(it, "raw_key"),
946                    s(it, "target_package"),
947                    s(it, "version_range"),
948                    s(it, "source"),
949                    path_line_cell(it),
950                    str_or(it, "hint", ""),
951                )
952            },
953        },
954        SectionSpec {
955            name: "Misconfigured dependency overrides",
956            key: "misconfigured_dependency_overrides",
957            header: "`pnpm.overrides` entries with an unparsable key or empty value. `pnpm install` will reject these.\n\n| Override | Value | Source | Location | Reason |\n|----------|-------|--------|----------|--------|\n",
958            row: |it| {
959                format!(
960                    "| `{}` | `{}` | `{}` | {} | {} |",
961                    str_or(it, "raw_key", ""),
962                    str_or(it, "raw_value", ""),
963                    s(it, "source"),
964                    path_line_cell(it),
965                    str_or(it, "reason", "unparsable"),
966                )
967            },
968        },
969    ]
970}
971
972fn check_tips(env: &Value) -> String {
973    let fixable = arr(env, "unused_exports").count()
974        + arr(env, "unused_dependencies").count()
975        + arr(env, "unused_enum_members").count();
976    let mut tips = String::from("\n\n> [!TIP]\n");
977    if fixable > 0 {
978        tips.push_str("> Run `fallow fix --dry-run` to preview safe auto-fixes.\n");
979    }
980    if arr(env, "unused_exports").count() > 0 {
981        let _ = writeln!(
982            tips,
983            "> Intentionally public? Add [`/** @public */`]({SUPPRESSION_DOCS}) above exports to preserve them."
984        );
985    }
986    let _ = write!(
987        tips,
988        "> Add [`// fallow-ignore-next-line`]({SUPPRESSION_DOCS}) above a line to suppress a specific finding."
989    );
990    tips
991}
992
993/// Port of `summary-check.jq`.
994#[must_use]
995pub fn render_check_summary(env: &Value) -> String {
996    let elapsed = num(env, "elapsed_ms");
997    let total_issues = u(env, "total_issues");
998    if total_issues == 0 {
999        return format!(
1000            "# Fallow Analysis\n\n> [!NOTE]\n> **No issues found** \u{b7} {elapsed}ms\n\nAll exports are used, all dependencies are declared, and no issues were detected."
1001        );
1002    }
1003    let mut sections = String::new();
1004    for group in [
1005        check_sections_core(),
1006        check_sections_architecture(),
1007        check_sections_frameworks_and_hygiene(),
1008        check_sections_catalog(),
1009    ] {
1010        for spec in &group {
1011            sections.push_str(&render_check_section(env, spec));
1012        }
1013    }
1014    let issue_noun = if total_issues == 1 { "issue" } else { "issues" };
1015    format!(
1016        "# Fallow Analysis\n\n> [!WARNING]\n> **{total_issues} {issue_noun}** found \u{b7} {elapsed}ms\n\n| Category | Count |\n|----------|------:|\n{}\n\n---\n{sections}{}",
1017        dead_code_category_table(env),
1018        check_tips(env),
1019    )
1020}
1021
1022// ---------------------------------------------------------------------------
1023// summary-dupes.jq
1024// ---------------------------------------------------------------------------
1025
1026fn dupes_family_entry(family: &Value) -> String {
1027    let files: Vec<&str> = arr(family, "files").filter_map(Value::as_str).collect();
1028    let shown = files.iter().take(3).copied().collect::<Vec<_>>().join(", ");
1029    let more = if files.len() > 3 {
1030        format!(" (+{} more)", files.len() - 3)
1031    } else {
1032        String::new()
1033    };
1034    let mut entry = format!(
1035        "- **{shown}{more}** - {} lines, {} groups",
1036        num(family, "total_duplicated_lines"),
1037        arr(family, "groups").count(),
1038    );
1039    if let Some(first_group) = arr(family, "groups").next()
1040        && arr(first_group, "instances").next().is_some()
1041    {
1042        let locations = arr(first_group, "instances")
1043            .map(instance_location)
1044            .collect::<Vec<_>>()
1045            .join(", ");
1046        let _ = write!(entry, "\n  - {locations}");
1047    }
1048    if arr(family, "suggestions").next().is_some() {
1049        let suggestions = arr(family, "suggestions")
1050            .map(|suggestion| {
1051                format!(
1052                    "  - {} (~{} lines)",
1053                    s(suggestion, "description"),
1054                    num(suggestion, "estimated_savings"),
1055                )
1056            })
1057            .collect::<Vec<_>>()
1058            .join("\n");
1059        let _ = write!(entry, "\n{suggestions}");
1060    }
1061    entry
1062}
1063
1064fn instance_location(instance: &Value) -> String {
1065    format!(
1066        "`{}:{}-{}`",
1067        s(instance, "file"),
1068        num(instance, "start_line"),
1069        num(instance, "end_line"),
1070    )
1071}
1072
1073/// jq: `sort_by([line_count, token_count]) | reverse` (stable sort then full
1074/// reverse, so ties land in reverse input order).
1075fn sorted_clone_groups(env: &Value) -> Vec<&Value> {
1076    let mut groups: Vec<&Value> = arr(env, "clone_groups").collect();
1077    groups.sort_by_key(|group| (u(group, "line_count"), u(group, "token_count")));
1078    groups.reverse();
1079    groups
1080}
1081
1082fn dupes_details(env: &Value) -> String {
1083    let families: Vec<&Value> = arr(env, "clone_families").collect();
1084    if families.is_empty() {
1085        let groups = sorted_clone_groups(env);
1086        let rows = groups
1087            .iter()
1088            .take(20)
1089            .map(|group| {
1090                let locations = arr(group, "instances")
1091                    .map(instance_location)
1092                    .collect::<Vec<_>>()
1093                    .join(", ");
1094                format!(
1095                    "- **{} lines, {} tokens**, {locations}",
1096                    num(group, "line_count"),
1097                    num(group, "token_count"),
1098                )
1099            })
1100            .collect::<Vec<_>>()
1101            .join("\n");
1102        let tail = if groups.len() > 20 {
1103            format!("\n- *... and {} more groups*", groups.len() - 20)
1104        } else {
1105            String::new()
1106        };
1107        format!("{rows}{tail}")
1108    } else {
1109        let entries = families
1110            .iter()
1111            .take(15)
1112            .map(|family| dupes_family_entry(family))
1113            .collect::<Vec<_>>()
1114            .join("\n");
1115        let tail = if families.len() > 15 {
1116            format!("\n- *... and {} more families*", families.len() - 15)
1117        } else {
1118            String::new()
1119        };
1120        format!("**Clone Families ({})**\n\n{entries}{tail}", families.len())
1121    }
1122}
1123
1124/// Port of `summary-dupes.jq`.
1125#[must_use]
1126pub fn render_dupes_summary(env: &Value) -> String {
1127    let stats = env.get("stats").cloned().unwrap_or(Value::Null);
1128    let elapsed = num(env, "elapsed_ms");
1129    if u(&stats, "clone_groups") == 0 {
1130        return format!(
1131            "## Fallow - Code Duplication\n\nNo code duplication found.\n\n*Analyzed {} files in {elapsed}ms*",
1132            num(&stats, "total_files"),
1133        );
1134    }
1135    format!(
1136        "## Fallow - Code Duplication\n\nFound **{} clone groups** ({} instances) across {} files in {elapsed}ms\n\n| Metric | Value |\n|--------|-------|\n| Files analyzed | {} |\n| Files with clones | {} |\n| Clone groups | {} |\n| Clone instances | {} |\n| Duplicated lines | {} / {} ({}%) |\n\n<details>\n<summary>View details</summary>\n\n{}\n\n</details>",
1137        num(&stats, "clone_groups"),
1138        num(&stats, "clone_instances"),
1139        num(&stats, "files_with_clones"),
1140        num(&stats, "total_files"),
1141        num(&stats, "files_with_clones"),
1142        num(&stats, "clone_groups"),
1143        num(&stats, "clone_instances"),
1144        num(&stats, "duplicated_lines"),
1145        num(&stats, "total_lines"),
1146        pct(f_or_zero(&stats, "duplication_percentage")),
1147        dupes_details(env),
1148    )
1149}
1150
1151// ---------------------------------------------------------------------------
1152// summary-health.jq
1153// ---------------------------------------------------------------------------
1154
1155fn metric_delta<'v>(score_env: &'v Value, name: &str) -> Option<&'v Value> {
1156    score_env
1157        .get("health_trend")
1158        .and_then(|trend| trend.get("metrics"))
1159        .and_then(Value::as_array)
1160        .and_then(|metrics| metrics.iter().find(|metric| s(metric, "name") == name))
1161}
1162
1163/// The `> **Health: ...**` trend header shared by `summary-health.jq` and
1164/// `summary-combined.jq`. Empty when no `health_score` block is present.
1165fn health_score_header(score_env: &Value) -> String {
1166    let Some(score) = score_env
1167        .get("health_score")
1168        .filter(|value| !value.is_null())
1169    else {
1170        return String::new();
1171    };
1172    let mut header = format!(
1173        "> **Health: {} ({})**",
1174        s(score, "grade"),
1175        pct(f_or_zero(score, "score")),
1176    );
1177    if let Some(score_delta) = metric_delta(score_env, "score") {
1178        let compared = score_env
1179            .get("health_trend")
1180            .and_then(|trend| trend.get("compared_to"))
1181            .cloned()
1182            .unwrap_or(Value::Null);
1183        let _ = write!(
1184            header,
1185            " \u{b7} {} pts vs previous ({} {})",
1186            signed(f_or_zero(score_delta, "delta")),
1187            s(&compared, "grade"),
1188            pct(f_or_zero(&compared, "score")),
1189        );
1190        if let Some(dead_delta) = metric_delta(score_env, "dead_export_pct")
1191            && f_or_zero(dead_delta, "delta") != 0.0
1192        {
1193            let _ = write!(
1194                header,
1195                " \u{b7} {} {}% ({}%)",
1196                s(dead_delta, "label").to_ascii_lowercase(),
1197                pct(f_or_zero(dead_delta, "current")),
1198                signed(f_or_zero(dead_delta, "delta")),
1199            );
1200            if f_or_zero(dead_delta, "delta") > 0.0 {
1201                let _ = write!(header, " [suppress?]({SUPPRESSION_DOCS})");
1202            }
1203        }
1204        if let Some(cx_delta) = metric_delta(score_env, "avg_cyclomatic")
1205            && f_or_zero(cx_delta, "delta") != 0.0
1206        {
1207            let _ = write!(
1208                header,
1209                " \u{b7} {} {} ({})",
1210                s(cx_delta, "label").to_ascii_lowercase(),
1211                pct(f_or_zero(cx_delta, "current")),
1212                signed(f_or_zero(cx_delta, "delta")),
1213            );
1214        }
1215    } else {
1216        header.push_str("\n> _Enable `save-snapshot: true` to track score trends over time._");
1217    }
1218    header.push_str("\n\n");
1219    header
1220}
1221
1222fn exceeded_marker(it: &Value, needles: &[&str]) -> &'static str {
1223    let exceeded = s(it, "exceeded");
1224    if needles.iter().any(|needle| exceeded.contains(needle)) {
1225        " **!**"
1226    } else {
1227        ""
1228    }
1229}
1230
1231fn crap_cell(it: &Value) -> String {
1232    match it.get("crap").filter(|crap| !crap.is_null()) {
1233        None => "-".to_owned(),
1234        Some(crap) => format!("{}{}", fmt_num(crap), exceeded_marker(it, &["crap", "all"])),
1235    }
1236}
1237
1238fn complexity_table_row(it: &Value) -> String {
1239    format!(
1240        "| `{}:{}` | `{}` | {} | {}{} | {}{} | {} | {} |",
1241        s(it, "path"),
1242        num(it, "line"),
1243        s(it, "name"),
1244        str_or(it, "severity", "moderate"),
1245        num(it, "cyclomatic"),
1246        exceeded_marker(it, &["cyclomatic", "both", "all"]),
1247        num(it, "cognitive"),
1248        exceeded_marker(it, &["cognitive", "both", "all"]),
1249        crap_cell(it),
1250        num(it, "line_count"),
1251    )
1252}
1253
1254const COMPLEXITY_TABLE_HEADER: &str = "| File | Function | Severity | Cyclomatic | Cognitive | CRAP | Lines |\n|:-----|:---------|:---------|:-----------|:----------|:-----|:------|\n";
1255
1256fn health_thresholds_footer(env: &Value) -> String {
1257    let summary = env.get("summary").cloned().unwrap_or(Value::Null);
1258    format!(
1259        "\n\n**{}** files, **{}** functions analyzed (thresholds: cyclomatic > {}, cognitive > {}, CRAP >= {})",
1260        num(&summary, "files_analyzed"),
1261        num(&summary, "functions_analyzed"),
1262        num(&summary, "max_cyclomatic_threshold"),
1263        num(&summary, "max_cognitive_threshold"),
1264        threshold_or(&summary, "max_crap_threshold", "30"),
1265    )
1266}
1267
1268fn threshold_or(summary: &Value, key: &str, default: &str) -> String {
1269    summary
1270        .get(key)
1271        .filter(|value| !value.is_null())
1272        .map_or_else(|| default.to_owned(), fmt_num)
1273}
1274
1275fn complexity_rows(findings: &[&Value], cap: usize) -> String {
1276    findings
1277        .iter()
1278        .take(cap)
1279        .map(|finding| complexity_table_row(finding))
1280        .collect::<Vec<_>>()
1281        .join("\n")
1282}
1283
1284fn runtime_finding_row(it: &Value) -> String {
1285    let invocations = it
1286        .get("invocations")
1287        .filter(|value| !value.is_null())
1288        .map_or_else(|| "-".to_owned(), fmt_num);
1289    format!(
1290        "| `{}:{}` | `{}` | `{}` | {invocations} | {} |",
1291        s(it, "path"),
1292        num(it, "line"),
1293        s(it, "function"),
1294        s(it, "verdict"),
1295        s(it, "confidence"),
1296    )
1297}
1298
1299fn render_health_complexity_only(env: &Value, complex: usize, elapsed: &str) -> String {
1300    let summary = env.get("summary").cloned().unwrap_or(Value::Null);
1301    if complex == 0 {
1302        return format!(
1303            "## Fallow - Code Complexity\n\n> [!NOTE]\n> **No functions exceed complexity thresholds** \u{b7} {elapsed}ms\n\n{} functions analyzed (max cyclomatic: {}, max cognitive: {}, max CRAP: {})",
1304            num(&summary, "functions_analyzed"),
1305            num(&summary, "max_cyclomatic_threshold"),
1306            num(&summary, "max_cognitive_threshold"),
1307            threshold_or(&summary, "max_crap_threshold", "30"),
1308        );
1309    }
1310    let above = u(&summary, "functions_above_threshold");
1311    let findings: Vec<&Value> = arr(env, "findings").collect();
1312    let tail = if complex > 25 {
1313        format!(
1314            "\n\n> {} more - run `fallow health` locally for the full list",
1315            complex - 25
1316        )
1317    } else {
1318        String::new()
1319    };
1320    format!(
1321        "## Fallow - Code Complexity\n\n> [!WARNING]\n> **{above} function{} exceed{} thresholds** \u{b7} {elapsed}ms\n\n{COMPLEXITY_TABLE_HEADER}{}{tail}{}",
1322        if above == 1 { "" } else { "s" },
1323        if above == 1 { "s" } else { "" },
1324        complexity_rows(&findings, 25),
1325        health_thresholds_footer(env),
1326    )
1327}
1328
1329fn prod_phrase(complex: usize, prod: usize) -> String {
1330    let complexity = format!(
1331        "{complex} complexity finding{}",
1332        if complex == 1 { "" } else { "s" }
1333    );
1334    let runtime = format!(
1335        "{prod} runtime coverage finding{}",
1336        if prod == 1 { "" } else { "s" }
1337    );
1338    if complex > 0 && prod > 0 {
1339        format!("{complexity} and {runtime}")
1340    } else if complex > 0 {
1341        complexity
1342    } else {
1343        runtime
1344    }
1345}
1346
1347fn render_health_with_runtime(env: &Value, complex: usize, elapsed: &str) -> String {
1348    let runtime = env.get("runtime_coverage").cloned().unwrap_or(Value::Null);
1349    let prod_findings: Vec<&Value> = arr(&runtime, "findings").collect();
1350    let hot_paths: Vec<&Value> = arr(&runtime, "hot_paths").collect();
1351    let prod = prod_findings.len();
1352    let hot = hot_paths.len();
1353    let mut out = String::from("## Fallow - Health\n\n");
1354    if complex == 0 && prod == 0 {
1355        let _ = write!(
1356            out,
1357            "> [!NOTE]\n> **No failing health findings** \u{b7} {elapsed}ms\n\n"
1358        );
1359    } else {
1360        let _ = write!(
1361            out,
1362            "> [!WARNING]\n> **{}** \u{b7} {elapsed}ms\n\n",
1363            prod_phrase(complex, prod),
1364        );
1365    }
1366    if complex > 0 {
1367        let findings: Vec<&Value> = arr(env, "findings").collect();
1368        let _ = write!(
1369            out,
1370            "### Complexity\n\n{COMPLEXITY_TABLE_HEADER}{}",
1371            complexity_rows(&findings, 25),
1372        );
1373        if complex > 25 {
1374            let _ = write!(
1375                out,
1376                "\n\n> {} more complexity findings - run `fallow health` locally for the full list",
1377                complex - 25,
1378            );
1379        }
1380    }
1381    if prod > 0 {
1382        if complex > 0 {
1383            out.push_str("\n\n");
1384        }
1385        out.push_str("### Runtime Coverage\n\n| File | Function | Verdict | Invocations | Confidence |\n|:-----|:---------|:--------|------------:|:-----------|\n");
1386        out.push_str(
1387            &prod_findings
1388                .iter()
1389                .take(25)
1390                .map(|finding| runtime_finding_row(finding))
1391                .collect::<Vec<_>>()
1392                .join("\n"),
1393        );
1394        if prod > 25 {
1395            let _ = write!(
1396                out,
1397                "\n\n> {} more runtime coverage findings - run `fallow health` locally for the full list",
1398                prod - 25,
1399            );
1400        }
1401    }
1402    if hot > 0 {
1403        if complex > 0 || prod > 0 {
1404            out.push_str("\n\n");
1405        }
1406        out.push_str("### Hot Paths\n\n| File | Function | Invocations | Percentile |\n|:-----|:---------|------------:|-----------:|\n");
1407        out.push_str(
1408            &hot_paths
1409                .iter()
1410                .take(10)
1411                .map(|path| {
1412                    format!(
1413                        "| `{}:{}` | `{}` | {} | {} |",
1414                        s(path, "path"),
1415                        num(path, "line"),
1416                        s(path, "function"),
1417                        num(path, "invocations"),
1418                        num(path, "percentile"),
1419                    )
1420                })
1421                .collect::<Vec<_>>()
1422                .join("\n"),
1423        );
1424        if hot > 10 {
1425            let _ = write!(out, "\n\n> {} more hot paths in the full report", hot - 10);
1426        }
1427    }
1428    out.push_str(&health_runtime_footer(env, complex, prod, hot, &runtime));
1429    out
1430}
1431
1432fn health_runtime_footer(
1433    env: &Value,
1434    complex: usize,
1435    prod: usize,
1436    hot: usize,
1437    runtime: &Value,
1438) -> String {
1439    if complex > 0 {
1440        return health_thresholds_footer(env);
1441    }
1442    if prod > 0 {
1443        let summary = runtime.get("summary").cloned().unwrap_or(Value::Null);
1444        return format!(
1445            "\n\n**{}** tracked functions, **{}** hit, **{}** unhit, **{}** untracked",
1446            num(&summary, "functions_tracked"),
1447            num(&summary, "functions_hit"),
1448            num(&summary, "functions_unhit"),
1449            num(&summary, "functions_untracked"),
1450        );
1451    }
1452    format!(
1453        "\n\nObserved **{hot}** hot path{} in runtime coverage.",
1454        if hot == 1 { "" } else { "s" },
1455    )
1456}
1457
1458/// Port of `summary-health.jq`.
1459#[must_use]
1460pub fn render_health_summary(env: &Value) -> String {
1461    let elapsed = num(env, "elapsed_ms");
1462    let complex = arr(env, "findings").count();
1463    let runtime = env.get("runtime_coverage").cloned().unwrap_or(Value::Null);
1464    let prod = arr(&runtime, "findings").count();
1465    let hot = arr(&runtime, "hot_paths").count();
1466    let body = if prod == 0 && hot == 0 {
1467        render_health_complexity_only(env, complex, &elapsed)
1468    } else {
1469        render_health_with_runtime(env, complex, &elapsed)
1470    };
1471    format!("{}{body}", health_score_header(env))
1472}
1473
1474// ---------------------------------------------------------------------------
1475// summary-audit.jq
1476// ---------------------------------------------------------------------------
1477
1478const fn audit_verdict_label(verdict: &str) -> &'static str {
1479    match verdict.as_bytes() {
1480        b"fail" => "[!WARNING]\n> **Audit failed**",
1481        b"warn" => "[!WARNING]\n> **Audit passed with warnings**",
1482        _ => "[!NOTE]\n> **Audit passed**",
1483    }
1484}
1485
1486fn introduced_label(item: &Value) -> &'static str {
1487    match item.get("introduced").and_then(Value::as_bool) {
1488        Some(true) => "new",
1489        Some(false) => "inherited",
1490        None => "-",
1491    }
1492}
1493
1494struct AuditRow {
1495    kind: &'static str,
1496    location: String,
1497    item: String,
1498    status: &'static str,
1499}
1500
1501fn audit_row(kind: &'static str, location: String, item: String, finding: &Value) -> AuditRow {
1502    AuditRow {
1503        kind,
1504        location,
1505        item,
1506        status: introduced_label(finding),
1507    }
1508}
1509
1510type AuditRowSpec = (&'static str, &'static str, fn(&Value) -> String);
1511
1512/// jq order positions 2-11: exports, types, leaks, dependencies, members,
1513/// unresolved imports.
1514const AUDIT_EXPORT_DEP_ROWS: &[AuditRowSpec] = &[
1515    ("Unused export", "unused_exports", |it| {
1516        format!("`{}`", s(it, "export_name"))
1517    }),
1518    ("Unused type", "unused_types", |it| {
1519        format!("`{}`", s(it, "export_name"))
1520    }),
1521    ("Private type leak", "private_type_leaks", |it| {
1522        format!("`{}` -> `{}`", s(it, "export_name"), s(it, "type_name"))
1523    }),
1524    ("Unused dependency", "unused_dependencies", |it| {
1525        format!("`{}`", s(it, "package_name"))
1526    }),
1527    ("Unused devDependency", "unused_dev_dependencies", |it| {
1528        format!("`{}`", s(it, "package_name"))
1529    }),
1530    (
1531        "Unused optionalDependency",
1532        "unused_optional_dependencies",
1533        |it| format!("`{}`", s(it, "package_name")),
1534    ),
1535    ("Unused enum member", "unused_enum_members", member_item),
1536    ("Unused class member", "unused_class_members", member_item),
1537    ("Unused store member", "unused_store_members", member_item),
1538    ("Unresolved import", "unresolved_imports", |it| {
1539        format!("`{}`", s(it, "specifier"))
1540    }),
1541];
1542
1543/// jq order positions 26-33: component-model kinds.
1544const AUDIT_COMPONENT_ROWS: &[AuditRowSpec] = &[
1545    ("Unrendered component", "unrendered_components", |it| {
1546        format!("`{}` ({})", s(it, "component_name"), s(it, "framework"))
1547    }),
1548    ("Unused component prop", "unused_component_props", |it| {
1549        format!("`{}.{}`", s(it, "component_name"), s(it, "prop_name"))
1550    }),
1551    ("Unused component emit", "unused_component_emits", |it| {
1552        format!(
1553            "`{}` emit `{}`",
1554            s(it, "component_name"),
1555            s(it, "emit_name")
1556        )
1557    }),
1558    ("Unused component input", "unused_component_inputs", |it| {
1559        format!("`{}.{}`", s(it, "component_name"), s(it, "input_name"))
1560    }),
1561    (
1562        "Unused component output",
1563        "unused_component_outputs",
1564        |it| {
1565            format!(
1566                "`{}` output `{}`",
1567                s(it, "component_name"),
1568                s(it, "output_name")
1569            )
1570        },
1571    ),
1572    ("Unused Svelte event", "unused_svelte_events", |it| {
1573        format!(
1574            "`{}` event `{}`",
1575            s(it, "component_name"),
1576            s(it, "event_name")
1577        )
1578    }),
1579    ("Unprovided inject", "unprovided_injects", |it| {
1580        format!("`{}` ({})", s(it, "key_name"), s(it, "framework"))
1581    }),
1582    ("Unused load data key", "unused_load_data_keys", |it| {
1583        format!("`{}`", s(it, "key_name"))
1584    }),
1585];
1586
1587/// jq order positions 34-42: dependency hygiene, suppressions, catalog.
1588const AUDIT_HYGIENE_ROWS: &[AuditRowSpec] = &[
1589    ("Type-only dependency", "type_only_dependencies", |it| {
1590        format!("`{}`", s(it, "package_name"))
1591    }),
1592    ("Test-only dependency", "test_only_dependencies", |it| {
1593        format!("`{}`", s(it, "package_name"))
1594    }),
1595    (
1596        "Dev dependency in production",
1597        "dev_dependencies_in_production",
1598        |it| format!("`{}`", s(it, "package_name")),
1599    ),
1600    ("Stale suppression", "stale_suppressions", |it| {
1601        str_or(it, "description", "suppression").to_owned()
1602    }),
1603    ("Unused catalog entry", "unused_catalog_entries", |it| {
1604        format!("`{}` (`{}`)", s(it, "entry_name"), s(it, "catalog_name"))
1605    }),
1606    ("Empty catalog group", "empty_catalog_groups", |it| {
1607        format!("`{}`", s(it, "catalog_name"))
1608    }),
1609    (
1610        "Unresolved catalog reference",
1611        "unresolved_catalog_references",
1612        |it| format!("`{}` -> `{}`", s(it, "entry_name"), s(it, "catalog_name")),
1613    ),
1614    (
1615        "Unused dependency override",
1616        "unused_dependency_overrides",
1617        |it| format!("`{}` (`{}`)", s(it, "raw_key"), s(it, "source")),
1618    ),
1619    (
1620        "Misconfigured dependency override",
1621        "misconfigured_dependency_overrides",
1622        |it| format!("`{}` (`{}`)", s(it, "raw_key"), s(it, "source")),
1623    ),
1624];
1625
1626fn audit_rows_from_table(dead_code: &Value, table: &[AuditRowSpec], rows: &mut Vec<AuditRow>) {
1627    for (kind, key, item_fn) in table {
1628        for finding in arr(dead_code, key) {
1629            rows.push(audit_row(
1630                kind,
1631                path_line(finding),
1632                item_fn(finding),
1633                finding,
1634            ));
1635        }
1636    }
1637}
1638
1639fn member_item(it: &Value) -> String {
1640    format!("`{}.{}`", s(it, "parent_name"), s(it, "member_name"))
1641}
1642
1643fn first_import_site(it: &Value) -> String {
1644    arr(it, "imported_from").next().map_or_else(
1645        || path_line(it),
1646        |site| {
1647            format!(
1648                "`{}:{}`",
1649                rel_path_absolute_only(s(site, "path")),
1650                num(site, "line"),
1651            )
1652        },
1653    )
1654}
1655
1656/// jq order positions 12-15: unlisted dependencies, duplicate exports,
1657/// circular dependencies, re-export cycles.
1658fn audit_rows_graph(dead_code: &Value, rows: &mut Vec<AuditRow>) {
1659    for it in arr(dead_code, "unlisted_dependencies") {
1660        rows.push(audit_row(
1661            "Unlisted dependency",
1662            first_import_site(it),
1663            format!("`{}`", s(it, "package_name")),
1664            it,
1665        ));
1666    }
1667    for it in arr(dead_code, "duplicate_exports") {
1668        let location = arr(it, "locations")
1669            .take(3)
1670            .map(|loc| {
1671                format!(
1672                    "`{}:{}`",
1673                    rel_path_absolute_only(s(loc, "path")),
1674                    num(loc, "line")
1675                )
1676            })
1677            .collect::<Vec<_>>()
1678            .join(", ");
1679        rows.push(audit_row(
1680            "Duplicate export",
1681            location,
1682            format!("`{}`", s(it, "export_name")),
1683            it,
1684        ));
1685    }
1686    for it in arr(dead_code, "circular_dependencies") {
1687        let location = arr(it, "files")
1688            .filter_map(Value::as_str)
1689            .map(|file| format!("`{}`", rel_path_absolute_only(file)))
1690            .collect::<Vec<_>>()
1691            .join(" -> ");
1692        rows.push(audit_row(
1693            "Circular dependency",
1694            location,
1695            "cycle".to_owned(),
1696            it,
1697        ));
1698    }
1699    for it in arr(dead_code, "re_export_cycles") {
1700        let location = arr(it, "files")
1701            .filter_map(Value::as_str)
1702            .map(|file| format!("`{}`", rel_path_absolute_only(file)))
1703            .collect::<Vec<_>>()
1704            .join(" <-> ");
1705        rows.push(audit_row(
1706            "Re-export cycle",
1707            location,
1708            str_or(it, "kind", "cycle").to_owned(),
1709            it,
1710        ));
1711    }
1712}
1713
1714/// jq order positions 16-19: boundary and policy kinds.
1715fn audit_rows_boundaries(dead_code: &Value, rows: &mut Vec<AuditRow>) {
1716    for it in arr(dead_code, "boundary_violations") {
1717        rows.push(audit_row(
1718            "Boundary violation",
1719            format!(
1720                "`{}:{}`",
1721                rel_path_absolute_only(s(it, "from_path")),
1722                num(it, "line")
1723            ),
1724            format!("{} -> {}", s(it, "from_zone"), s(it, "to_zone")),
1725            it,
1726        ));
1727    }
1728    for it in arr(dead_code, "boundary_coverage_violations") {
1729        rows.push(audit_row(
1730            "Boundary coverage",
1731            format!(
1732                "`{}:{}`",
1733                rel_path_absolute_only(s(it, "path")),
1734                num(it, "line")
1735            ),
1736            "no matching zone".to_owned(),
1737            it,
1738        ));
1739    }
1740    for it in arr(dead_code, "boundary_call_violations") {
1741        rows.push(audit_row(
1742            "Boundary call",
1743            format!(
1744                "`{}:{}`",
1745                rel_path_absolute_only(s(it, "path")),
1746                num(it, "line")
1747            ),
1748            format!("`{}` in {}", s(it, "callee"), s(it, "zone")),
1749            it,
1750        ));
1751    }
1752    for it in arr(dead_code, "policy_violations") {
1753        rows.push(audit_row(
1754            "Policy violation",
1755            format!(
1756                "`{}:{}`",
1757                rel_path_absolute_only(s(it, "path")),
1758                num(it, "line")
1759            ),
1760            format!(
1761                "`{}` banned by {}/{}",
1762                s(it, "matched"),
1763                s(it, "pack"),
1764                s(it, "rule_id")
1765            ),
1766            it,
1767        ));
1768    }
1769}
1770
1771/// jq order positions 20-25: RSC/Next.js kinds, including unused server
1772/// actions between misplaced directives and route collisions.
1773fn audit_rows_frameworks(dead_code: &Value, rows: &mut Vec<AuditRow>) {
1774    for it in arr(dead_code, "invalid_client_exports") {
1775        rows.push(audit_row(
1776            "Invalid client export",
1777            format!(
1778                "`{}:{}`",
1779                rel_path_absolute_only(s(it, "path")),
1780                num(it, "line")
1781            ),
1782            format!("`{}` in `\"{}\"`", s(it, "export_name"), s(it, "directive")),
1783            it,
1784        ));
1785    }
1786    for it in arr(dead_code, "mixed_client_server_barrels") {
1787        rows.push(audit_row(
1788            "Mixed client/server barrel",
1789            format!(
1790                "`{}:{}`",
1791                rel_path_absolute_only(s(it, "path")),
1792                num(it, "line")
1793            ),
1794            format!(
1795                "`{}` + `{}`",
1796                s(it, "client_origin"),
1797                s(it, "server_origin")
1798            ),
1799            it,
1800        ));
1801    }
1802    for it in arr(dead_code, "misplaced_directives") {
1803        rows.push(audit_row(
1804            "Misplaced directive",
1805            format!(
1806                "`{}:{}`",
1807                rel_path_absolute_only(s(it, "path")),
1808                num(it, "line")
1809            ),
1810            format!("`\"{}\"`", s(it, "directive")),
1811            it,
1812        ));
1813    }
1814    for it in arr(dead_code, "unused_server_actions") {
1815        rows.push(audit_row(
1816            "Unused server action",
1817            path_line(it),
1818            format!("`{}`", s(it, "action_name")),
1819            it,
1820        ));
1821    }
1822    for it in arr(dead_code, "route_collisions") {
1823        rows.push(audit_row(
1824            "Route collision",
1825            format!("`{}`", rel_path_absolute_only(s(it, "path"))),
1826            format!("`{}`", s(it, "url")),
1827            it,
1828        ));
1829    }
1830    for it in arr(dead_code, "dynamic_segment_name_conflicts") {
1831        rows.push(audit_row(
1832            "Dynamic segment conflict",
1833            format!("`{}`", rel_path_absolute_only(s(it, "path"))),
1834            format!("`{}`", plain_join(it, "conflicting_segments", ", ")),
1835            it,
1836        ));
1837    }
1838}
1839
1840/// Rows in `summary-audit.jq`'s `dead_code_rows` declaration order.
1841fn audit_dead_code_rows(dead_code: &Value) -> Vec<AuditRow> {
1842    let mut rows: Vec<AuditRow> = Vec::new();
1843    for it in arr(dead_code, "unused_files") {
1844        rows.push(audit_row(
1845            "Unused file",
1846            format!("`{}`", rel_path_absolute_only(s(it, "path"))),
1847            "-".to_owned(),
1848            it,
1849        ));
1850    }
1851    audit_rows_from_table(dead_code, AUDIT_EXPORT_DEP_ROWS, &mut rows);
1852    audit_rows_graph(dead_code, &mut rows);
1853    audit_rows_boundaries(dead_code, &mut rows);
1854    audit_rows_frameworks(dead_code, &mut rows);
1855    audit_rows_from_table(dead_code, AUDIT_COMPONENT_ROWS, &mut rows);
1856    audit_rows_from_table(dead_code, AUDIT_HYGIENE_ROWS, &mut rows);
1857    rows
1858}
1859
1860fn audit_complexity_section(env: &Value) -> String {
1861    let complexity = env.get("complexity").cloned().unwrap_or(Value::Null);
1862    let findings: Vec<&Value> = arr(&complexity, "findings").collect();
1863    if findings.is_empty() {
1864        return String::new();
1865    }
1866    let rows = findings
1867        .iter()
1868        .take(15)
1869        .map(|it| {
1870            format!(
1871                "| `{}:{}` | `{}` | {} | {} | {} | {} | {} | {} |",
1872                s(it, "path"),
1873                num(it, "line"),
1874                s(it, "name"),
1875                introduced_label(it),
1876                str_or(it, "severity", "moderate"),
1877                num(it, "cyclomatic"),
1878                num(it, "cognitive"),
1879                str_or(it, "coverage_tier", "-"),
1880                it.get("crap")
1881                    .filter(|crap| !crap.is_null())
1882                    .map_or_else(|| "-".to_owned(), fmt_num),
1883            )
1884        })
1885        .collect::<Vec<_>>()
1886        .join("\n");
1887    let tail = if findings.len() > 15 {
1888        format!(
1889            "\n\n> {} more complexity findings in the full audit report",
1890            findings.len() - 15,
1891        )
1892    } else {
1893        String::new()
1894    };
1895    format!(
1896        "### Complexity\n\n| File | Function | Status | Severity | Cyclomatic | Cognitive | Coverage | CRAP |\n|:-----|:---------|:-------|:---------|:-----------|:----------|:---------|:-----|\n{rows}{tail}{}\n\n",
1897        audit_coverage_model_note(&complexity),
1898    )
1899}
1900
1901fn audit_coverage_model_note(complexity: &Value) -> String {
1902    let summary = complexity.get("summary").cloned().unwrap_or(Value::Null);
1903    let model = summary.get("coverage_model").and_then(Value::as_str);
1904    match model {
1905        Some("istanbul") => {
1906            let matched = summary.get("istanbul_matched").and_then(Value::as_u64);
1907            let total = summary.get("istanbul_total").and_then(Value::as_u64);
1908            match (matched, total) {
1909                (Some(matched), Some(total)) if total > 0 => {
1910                    let suffix = if matched * 100 / total < 50 {
1911                        ". Low match rate; check `--coverage-root` is correct for this checkout."
1912                    } else {
1913                        "."
1914                    };
1915                    format!(
1916                        "\n\n*Coverage model: istanbul. Matched {matched}/{total} functions{suffix}*"
1917                    )
1918                }
1919                _ => "\n\n*Coverage model: istanbul (exact, from `--coverage`).*".to_owned(),
1920            }
1921        }
1922        Some("static_estimated" | "static_binary") => {
1923            "\n\n*Coverage model: static (estimated). Pair with `--coverage <coverage-final.json>` for measured coverage instead of estimates.*".to_owned()
1924        }
1925        _ => String::new(),
1926    }
1927}
1928
1929fn audit_duplication_section(env: &Value) -> String {
1930    let duplication = env.get("duplication").cloned().unwrap_or(Value::Null);
1931    let groups: Vec<&Value> = arr(&duplication, "clone_groups").collect();
1932    if groups.is_empty() {
1933        return String::new();
1934    }
1935    let rows = groups
1936        .iter()
1937        .take(10)
1938        .map(|group| {
1939            let instances: Vec<&Value> = arr(group, "instances").collect();
1940            let location = instances.first().map_or_else(
1941                || "-".to_owned(),
1942                |first| {
1943                    let file = s(first, "file");
1944                    if file.is_empty() {
1945                        "-".to_owned()
1946                    } else {
1947                        let start = first
1948                            .get("start_line")
1949                            .filter(|line| !line.is_null())
1950                            .map_or_else(|| "1".to_owned(), fmt_num);
1951                        format!("`{}:{start}`", rel_path_absolute_only(file))
1952                    }
1953                },
1954            );
1955            let mut files: Vec<String> = instances
1956                .iter()
1957                .map(|instance| rel_path_absolute_only(s(instance, "file")))
1958                .collect();
1959            files.sort();
1960            files.dedup();
1961            let files = files.into_iter().take(3).collect::<Vec<_>>().join(", ");
1962            format!(
1963                "| {location} | {files} | {} lines / {} tokens | {} | {} |",
1964                num(group, "line_count"),
1965                num(group, "token_count"),
1966                instances.len(),
1967                introduced_label(group),
1968            )
1969        })
1970        .collect::<Vec<_>>()
1971        .join("\n");
1972    let tail = if groups.len() > 10 {
1973        format!(
1974            "\n\n> {} more clone groups in the full audit report",
1975            groups.len() - 10
1976        )
1977    } else {
1978        String::new()
1979    };
1980    format!(
1981        "### Duplication\n\n| Location | Files | Size | Instances | Status |\n|:---------|:------|:-----|----------:|:-------|\n{rows}{tail}\n\n"
1982    )
1983}
1984
1985/// Port of `summary-audit.jq`.
1986#[must_use]
1987pub fn render_audit_summary(env: &Value) -> String {
1988    let verdict = str_or(env, "verdict", "pass");
1989    let summary = env.get("summary").cloned().unwrap_or(Value::Null);
1990    let attribution = env.get("attribution").cloned().unwrap_or(Value::Null);
1991    let dead_code = env.get("dead_code").cloned().unwrap_or(Value::Null);
1992    let dead_rows = audit_dead_code_rows(&dead_code);
1993
1994    let mut out = format!(
1995        "## Fallow Audit\n\n> {} \u{b7} {} \u{b7} {}ms\n\n| Category | Findings | Introduced | Inherited |\n|:---------|---------:|-----------:|----------:|\n| Dead code | {} | {} | {} |\n| Complexity | {} | {} | {} |\n| Duplication | {} | {} | {} |\n\n",
1996        audit_verdict_label(verdict),
1997        plural_n(u(env, "changed_files_count") as usize, "changed file"),
1998        num(env, "elapsed_ms"),
1999        num(&summary, "dead_code_issues"),
2000        num(&attribution, "dead_code_introduced"),
2001        num(&attribution, "dead_code_inherited"),
2002        num(&summary, "complexity_findings"),
2003        num(&attribution, "complexity_introduced"),
2004        num(&attribution, "complexity_inherited"),
2005        num(&summary, "duplication_clone_groups"),
2006        num(&attribution, "duplication_introduced"),
2007        num(&attribution, "duplication_inherited"),
2008    );
2009    if !dead_rows.is_empty() {
2010        let rows = dead_rows
2011            .iter()
2012            .take(10)
2013            .map(|row| {
2014                format!(
2015                    "| {} | {} | {} | {} |",
2016                    row.kind, row.location, row.item, row.status
2017                )
2018            })
2019            .collect::<Vec<_>>()
2020            .join("\n");
2021        let tail = if dead_rows.len() > 10 {
2022            format!(
2023                "\n\n> {} more dead-code findings in the full audit report",
2024                dead_rows.len() - 10
2025            )
2026        } else {
2027            String::new()
2028        };
2029        let _ = write!(
2030            out,
2031            "### Dead Code\n\n| Type | Location | Item | Status |\n|:-----|:---------|:-----|:-------|\n{rows}{tail}\n\n"
2032        );
2033    }
2034    out.push_str(&audit_complexity_section(env));
2035    out.push_str(&audit_duplication_section(env));
2036    out.push_str(if s(&attribution, "gate") == "all" {
2037        "*Audit gate: all. Every finding in changed files affects the verdict.*"
2038    } else {
2039        "*Audit gate: new-only. Inherited findings are reported but do not fail the verdict.*"
2040    });
2041    out
2042}
2043
2044// ---------------------------------------------------------------------------
2045// summary-security.jq
2046// ---------------------------------------------------------------------------
2047
2048/// Port of `summary-security.jq`.
2049#[must_use]
2050pub fn render_security_summary(env: &Value) -> String {
2051    let findings: Vec<&Value> = arr(env, "security_findings").collect();
2052    let gate = env.get("gate").filter(|gate| !gate.is_null());
2053    let count = gate.map_or_else(
2054        || {
2055            env.get("summary")
2056                .and_then(|summary| summary.get("security_findings"))
2057                .and_then(Value::as_u64)
2058                .unwrap_or(findings.len() as u64) as usize
2059        },
2060        |gate| u(gate, "new_count") as usize,
2061    );
2062    let mut out = String::from("## Fallow Security\n\n");
2063    if count == 0 {
2064        let _ = write!(
2065            out,
2066            "> [!NOTE]\n> **No security candidates matched** \u{b7} {}ms",
2067            num(env, "elapsed_ms"),
2068        );
2069    } else {
2070        let _ = write!(
2071            out,
2072            "> [!WARNING]\n> **{} matched** \u{b7} {}ms",
2073            plural_n(count, "security candidate"),
2074            num(env, "elapsed_ms"),
2075        );
2076    }
2077    if let Some(gate) = gate {
2078        let _ = write!(
2079            out,
2080            "\n\nSecurity gate: `{}`, verdict: `{}`, matching candidates: **{}**.",
2081            s(gate, "mode"),
2082            s(gate, "verdict"),
2083            num(gate, "new_count"),
2084        );
2085    }
2086    if !findings.is_empty() {
2087        let rows = findings
2088            .iter()
2089            .take(15)
2090            .map(|finding| {
2091                format!(
2092                    "| {} | {} | {} | {} |",
2093                    path_line(finding),
2094                    s(finding, "kind"),
2095                    str_or(finding, "severity", "unknown"),
2096                    finding
2097                        .get("candidate")
2098                        .and_then(|candidate| candidate.get("sink"))
2099                        .and_then(|sink| sink.get("callee"))
2100                        .and_then(Value::as_str)
2101                        .unwrap_or("-"),
2102                )
2103            })
2104            .collect::<Vec<_>>()
2105            .join("\n");
2106        let _ = write!(
2107            out,
2108            "\n\n| Location | Kind | Severity | Sink |\n|:---------|:-----|:---------|:-----|\n{rows}"
2109        );
2110        if findings.len() > 15 {
2111            let _ = write!(
2112                out,
2113                "\n\n> {} more candidates in the full report",
2114                findings.len() - 15,
2115            );
2116        }
2117    }
2118    out.push_str("\n\nTreat these as candidates for verification, not confirmed vulnerabilities.");
2119    out
2120}
2121
2122// ---------------------------------------------------------------------------
2123// summary-fix.jq
2124// ---------------------------------------------------------------------------
2125
2126fn fix_entries<'v>(env: &'v Value, entry_type: &str) -> Vec<&'v Value> {
2127    arr(env, "fixes")
2128        .filter(|fix| s(fix, "type") == entry_type)
2129        .collect()
2130}
2131
2132fn fix_detail_block(label: &str, entries: &[&Value], row: impl Fn(&Value) -> String) -> String {
2133    let rows = entries
2134        .iter()
2135        .take(25)
2136        .map(|entry| row(entry))
2137        .collect::<Vec<_>>()
2138        .join("\n");
2139    let tail = if entries.len() > 25 {
2140        format!("\n- *... and {} more*", entries.len() - 25)
2141    } else {
2142        String::new()
2143    };
2144    format!("**{label} ({})**\n{rows}{tail}", entries.len())
2145}
2146
2147/// Port of `summary-fix.jq`.
2148#[must_use]
2149pub fn render_fix_summary(env: &Value) -> String {
2150    let exports = fix_entries(env, "remove_export");
2151    let dependencies = fix_entries(env, "remove_dependency");
2152    let fix_attempts = arr(env, "fixes").filter(|fix| !b(fix, "skipped")).count();
2153    let content_changed = u(env, "skipped_content_changed") as usize;
2154    let mixed_eol = u(env, "skipped_mixed_line_endings") as usize;
2155    let low_confidence = u(env, "skipped_low_confidence_exports") as usize;
2156    let dry_run = b(env, "dry_run");
2157
2158    if fix_attempts == 0 && content_changed == 0 && mixed_eol == 0 && low_confidence == 0 {
2159        return "## Fallow - Auto-fix\n\nNo fixable issues found.".to_owned();
2160    }
2161
2162    let mut out = String::from("## Fallow - Auto-fix\n\n");
2163    out.push_str(if dry_run {
2164        "**Dry run**: would apply"
2165    } else {
2166        "Applied"
2167    });
2168    let fix_noun = if fix_attempts == 1 { "fix" } else { "fixes" };
2169    let _ = write!(out, " **{fix_attempts} {fix_noun}**");
2170    if !dry_run {
2171        let _ = write!(out, " ({} succeeded)", num(env, "total_fixed"));
2172    }
2173    if content_changed > 0 {
2174        let _ = write!(
2175            out,
2176            ", skipped {content_changed} file(s) that changed since analysis"
2177        );
2178    }
2179    if mixed_eol > 0 {
2180        let _ = write!(out, ", skipped {mixed_eol} file(s) with mixed line endings");
2181    }
2182    if low_confidence > 0 {
2183        let _ = write!(
2184            out,
2185            ", kept exports in {low_confidence} file(s) where consumers may be hidden from static analysis"
2186        );
2187    }
2188    out.push_str("\n\n| Type | Count |\n|------|-------|\n");
2189    if !exports.is_empty() {
2190        let _ = writeln!(out, "| Export removals | {} |", exports.len());
2191    }
2192    if !dependencies.is_empty() {
2193        let _ = writeln!(out, "| Dependency removals | {} |", dependencies.len());
2194    }
2195    out.push_str("\n<details>\n<summary>View details</summary>\n\n");
2196    if !exports.is_empty() {
2197        out.push_str(&fix_detail_block("Export removals", &exports, |it| {
2198            format!(
2199                "- `{}:{}` - `{}`",
2200                s(it, "path"),
2201                num(it, "line"),
2202                s(it, "name")
2203            )
2204        }));
2205        out.push_str("\n\n");
2206    }
2207    if !dependencies.is_empty() {
2208        out.push_str(&fix_detail_block(
2209            "Dependency removals",
2210            &dependencies,
2211            |it| {
2212                format!(
2213                    "- `{}` from {} in `{}`",
2214                    s(it, "package"),
2215                    s(it, "location"),
2216                    s(it, "file"),
2217                )
2218            },
2219        ));
2220        out.push('\n');
2221    }
2222    out.push_str("\n\n</details>");
2223    out
2224}
2225
2226// ---------------------------------------------------------------------------
2227// summary-combined.jq
2228// ---------------------------------------------------------------------------
2229
2230fn file_link(links: &LinkContext, path: &str, start: &str, end: &str) -> String {
2231    let display = last_three_segments(path);
2232    if links.repo.is_empty() || links.sha.is_empty() {
2233        format!("`{display}:{start}-{end}`")
2234    } else {
2235        format!(
2236            "[`{display}:{start}-{end}`](https://github.com/{}/blob/{}/{}{path}#L{start}-L{end})",
2237            links.repo, links.sha, links.prefix,
2238        )
2239    }
2240}
2241
2242fn exceeded_priority(it: &Value) -> u8 {
2243    match s(it, "exceeded") {
2244        "all" => 5,
2245        "cyclomatic_crap" | "cognitive_crap" => 4,
2246        "crap" => 3,
2247        "both" => 2,
2248        "cyclomatic" | "cognitive" => 1,
2249        _ => 0,
2250    }
2251}
2252
2253fn severity_priority(it: &Value) -> u8 {
2254    match s(it, "severity") {
2255        "critical" => 3,
2256        "high" => 2,
2257        "moderate" => 1,
2258        _ => 0,
2259    }
2260}
2261
2262/// jq `ranked_health_findings`: stable ascending sort by the priority tuple,
2263/// then a full reverse (ties land in reverse input order).
2264fn ranked_health_findings(health: &Value) -> Vec<&Value> {
2265    let mut findings: Vec<&Value> = arr(health, "findings").collect();
2266    findings.sort_by_key(|it| {
2267        (
2268            exceeded_priority(it),
2269            severity_priority(it),
2270            it.get("crap").is_some_and(|crap| !crap.is_null()),
2271            u(it, "cyclomatic"),
2272            u(it, "cognitive"),
2273            u(it, "line_count"),
2274        )
2275    });
2276    findings.reverse();
2277    findings
2278}
2279
2280const PROD_FAILING_VERDICTS: &[&str] = &["safe_to_delete", "review_required", "low_traffic"];
2281
2282struct CombinedCounts {
2283    check: usize,
2284    dupes: usize,
2285    complex: usize,
2286    prod_failing: usize,
2287    prod_advisory: usize,
2288    hot_paths: usize,
2289}
2290
2291impl CombinedCounts {
2292    fn health(&self) -> usize {
2293        self.complex + self.prod_failing
2294    }
2295
2296    fn total(&self) -> usize {
2297        self.check + self.dupes + self.health()
2298    }
2299}
2300
2301fn combined_counts(env: &Value) -> CombinedCounts {
2302    let check = env
2303        .get("check")
2304        .map_or(0, |check| u(check, "total_issues") as usize);
2305    let dupes = env
2306        .get("dupes")
2307        .map_or(0, |dupes| arr(dupes, "clone_groups").count());
2308    let health = env.get("health").cloned().unwrap_or(Value::Null);
2309    let complex = health.get("summary").map_or(0, |summary| {
2310        u(summary, "functions_above_threshold") as usize
2311    });
2312    let runtime = health
2313        .get("runtime_coverage")
2314        .cloned()
2315        .unwrap_or(Value::Null);
2316    let prod_failing = arr(&runtime, "findings")
2317        .filter(|finding| PROD_FAILING_VERDICTS.contains(&s(finding, "verdict")))
2318        .count();
2319    let prod_advisory = arr(&runtime, "findings")
2320        .filter(|finding| !PROD_FAILING_VERDICTS.contains(&s(finding, "verdict")))
2321        .count();
2322    let hot_paths = arr(&runtime, "hot_paths").count();
2323    CombinedCounts {
2324        check,
2325        dupes,
2326        complex,
2327        prod_failing,
2328        prod_advisory,
2329        hot_paths,
2330    }
2331}
2332
2333fn hot_path_label(env: &Value, n: usize) -> String {
2334    let touched = env
2335        .get("health")
2336        .and_then(|health| health.get("runtime_coverage"))
2337        .is_some_and(|runtime| s(runtime, "verdict") == "hot-path-touched");
2338    let plural = if n == 1 { "" } else { "s" };
2339    if touched {
2340        format!("hot path{plural} touched")
2341    } else {
2342        format!("hot path{plural}")
2343    }
2344}
2345
2346fn combined_zero_case(env: &Value, counts: &CombinedCounts) -> String {
2347    let vitals = env
2348        .get("health")
2349        .and_then(|health| health.get("vital_signs"))
2350        .cloned()
2351        .unwrap_or(Value::Null);
2352    let mut out = String::from("# \u{1F33F} Fallow\n\n");
2353    if counts.prod_advisory > 0 || counts.hot_paths > 0 {
2354        out.push_str(
2355            "> [!NOTE]\n> **Quality gate passed**\n\n:white_check_mark: No code issues \u{b7} :white_check_mark: No duplication \u{b7} :white_check_mark: No blocking health findings",
2356        );
2357        if counts.prod_advisory > 0 {
2358            let _ = write!(
2359                out,
2360                " \u{b7} :information_source: **{}** runtime coverage advisory finding{}",
2361                counts.prod_advisory,
2362                if counts.prod_advisory == 1 { "" } else { "s" },
2363            );
2364        }
2365        if counts.hot_paths > 0 {
2366            let _ = write!(
2367                out,
2368                " \u{b7} :eyes: **{}** {}",
2369                counts.hot_paths,
2370                hot_path_label(env, counts.hot_paths),
2371            );
2372        }
2373    } else {
2374        out.push_str(
2375            "> [!NOTE]\n> **Quality gate passed**\n\n:white_check_mark: No code issues \u{b7} :white_check_mark: No duplication \u{b7} :white_check_mark: No complex functions",
2376        );
2377    }
2378    if let Some(maintainability) = opt_f(&vitals, "maintainability_avg") {
2379        let _ = write!(
2380            out,
2381            "\n\n| Metric | Value |\n|:-------|------:|\n| [Maintainability]({HEALTH_DOCS}#maintainability-index-mi) | **{}** / 100 |\n",
2382            pct(maintainability),
2383        );
2384    }
2385    out
2386}
2387
2388fn combined_status_line(env: &Value, counts: &CombinedCounts) -> String {
2389    let mut out = String::new();
2390    if counts.check > 0 {
2391        let _ = write!(
2392            out,
2393            ":warning: **{}** code {}",
2394            counts.check,
2395            if counts.check == 1 { "issue" } else { "issues" },
2396        );
2397    } else {
2398        out.push_str(":white_check_mark: No code issues");
2399    }
2400    out.push_str(" \u{b7} ");
2401    if counts.dupes > 0 {
2402        let _ = write!(
2403            out,
2404            ":warning: **{}** clone {}",
2405            counts.dupes,
2406            if counts.dupes == 1 { "group" } else { "groups" },
2407        );
2408    } else {
2409        out.push_str(":white_check_mark: No duplication");
2410    }
2411    out.push_str(" \u{b7} ");
2412    let health = counts.health();
2413    if health > 0 {
2414        let _ = write!(
2415            out,
2416            ":warning: **{health}** health {}",
2417            if health == 1 { "finding" } else { "findings" },
2418        );
2419    } else {
2420        out.push_str(":white_check_mark: No blocking health findings");
2421    }
2422    if counts.prod_advisory > 0 {
2423        let _ = write!(
2424            out,
2425            " \u{b7} :information_source: **{}** coverage advisory finding{}",
2426            counts.prod_advisory,
2427            if counts.prod_advisory == 1 { "" } else { "s" },
2428        );
2429    }
2430    if counts.hot_paths > 0 {
2431        let _ = write!(
2432            out,
2433            " \u{b7} :eyes: **{}** {}",
2434            counts.hot_paths,
2435            hot_path_label(env, counts.hot_paths),
2436        );
2437    }
2438    out.push_str("\n\n");
2439    out
2440}
2441
2442fn combined_check_breakdown(env: &Value, counts: &CombinedCounts) -> String {
2443    if counts.check == 0 {
2444        return String::new();
2445    }
2446    let check = env.get("check").cloned().unwrap_or(Value::Null);
2447    format!(
2448        "<details>\n<summary><strong><a href=\"{DEAD_CODE_DOCS}\">Code issues</a> ({})</strong></summary>\n\n| Category | Count |\n|:---------|------:|\n{}\n\n</details>\n\n",
2449        counts.check,
2450        dead_code_category_table(&check),
2451    )
2452}
2453
2454fn combined_dupes_breakdown(env: &Value, counts: &CombinedCounts, links: &LinkContext) -> String {
2455    if counts.dupes == 0 {
2456        return String::new();
2457    }
2458    let dupes = env.get("dupes").cloned().unwrap_or(Value::Null);
2459    let stats = dupes.get("stats").cloned().unwrap_or(Value::Null);
2460    let groups = sorted_clone_groups(&dupes);
2461    let files_with_clones = u(&stats, "files_with_clones") as usize;
2462    let rows = groups
2463        .iter()
2464        .take(5)
2465        .map(|group| {
2466            let locations = arr(group, "instances")
2467                .map(|instance| {
2468                    file_link(
2469                        links,
2470                        s(instance, "file"),
2471                        &num(instance, "start_line"),
2472                        &num(instance, "end_line"),
2473                    )
2474                })
2475                .collect::<Vec<_>>()
2476                .join("<br>");
2477            format!(
2478                "| {locations} | {} | {} |",
2479                num(group, "line_count"),
2480                num(group, "token_count"),
2481            )
2482        })
2483        .collect::<Vec<_>>()
2484        .join("\n");
2485    let tail = if counts.dupes > 5 {
2486        format!("\n\n*\u{2026} and {} more groups.*", counts.dupes - 5)
2487    } else {
2488        String::new()
2489    };
2490    format!(
2491        "<details>\n<summary><strong><a href=\"{DUPES_DOCS}\">Duplication</a> ({} {} \u{b7} {} lines \u{b7} {}%)</strong></summary>\n\n| Locations | Lines | Tokens |\n|:----------|------:|-------:|\n{rows}{tail}\n\nAcross {files_with_clones} {}.\n\n</details>\n\n",
2492        counts.dupes,
2493        if counts.dupes == 1 { "group" } else { "groups" },
2494        num(&stats, "duplicated_lines"),
2495        pct(f_or_zero(&stats, "duplication_percentage")),
2496        if files_with_clones == 1 {
2497            "file"
2498        } else {
2499            "files"
2500        },
2501    )
2502}
2503
2504fn combined_complexity_breakdown(env: &Value, counts: &CombinedCounts) -> String {
2505    if counts.complex == 0 {
2506        return String::new();
2507    }
2508    let health = env.get("health").cloned().unwrap_or(Value::Null);
2509    let summary = health.get("summary").cloned().unwrap_or(Value::Null);
2510    let findings = ranked_health_findings(&health);
2511    let show_crap = summary
2512        .get("max_crap_threshold")
2513        .is_some_and(|threshold| !threshold.is_null())
2514        || findings
2515            .iter()
2516            .any(|finding| finding.get("crap").is_some_and(|crap| !crap.is_null()));
2517    let cyc_t = threshold_or(&summary, "max_cyclomatic_threshold", "default");
2518    let cog_t = threshold_or(&summary, "max_cognitive_threshold", "default");
2519    let crap_t = threshold_or(&summary, "max_crap_threshold", "default");
2520    let crap_header = if show_crap {
2521        format!(" | [CRAP]({HEALTH_DOCS}#crap-score)")
2522    } else {
2523        String::new()
2524    };
2525    let crap_separator = if show_crap { "|-----:" } else { "" };
2526    let rows = findings
2527        .iter()
2528        .take(5)
2529        .map(|it| {
2530            let crap_column = if show_crap {
2531                format!(" | {}", crap_cell(it))
2532            } else {
2533                String::new()
2534            };
2535            format!(
2536                "| `{}:{}` | `{}` | {} | {}{} | {}{}{crap_column} | {} |",
2537                last_three_segments(s(it, "path")),
2538                num(it, "line"),
2539                s(it, "name"),
2540                str_or(it, "severity", "moderate"),
2541                num(it, "cyclomatic"),
2542                exceeded_marker(it, &["cyclomatic", "both", "all"]),
2543                num(it, "cognitive"),
2544                exceeded_marker(it, &["cognitive", "both", "all"]),
2545                num(it, "line_count"),
2546            )
2547        })
2548        .collect::<Vec<_>>()
2549        .join("\n");
2550    let crap_footer = if show_crap {
2551        format!(", CRAP >= {crap_t}")
2552    } else {
2553        String::new()
2554    };
2555    format!(
2556        "<details>\n<summary><strong><a href=\"{HEALTH_DOCS}#complexity-metrics\">Complexity</a> ({} {} above threshold)</strong></summary>\n\n| File | Function | Severity | [Cyclomatic]({HEALTH_DOCS}#cyclomatic-complexity) | [Cognitive]({HEALTH_DOCS}#cognitive-complexity){crap_header} | Lines |\n|:-----|:---------|:---------|----------:|---------:{crap_separator}|------:|\n{rows}\n\n**{}** files, **{}** functions analyzed (thresholds: cyclomatic > {cyc_t}, cognitive > {cog_t}{crap_footer})\n\n</details>\n\n",
2557        counts.complex,
2558        if counts.complex == 1 {
2559            "function"
2560        } else {
2561            "functions"
2562        },
2563        threshold_or(&summary, "files_analyzed", "unknown"),
2564        threshold_or(&summary, "functions_analyzed", "unknown"),
2565    )
2566}
2567
2568fn combined_runtime_breakdown(env: &Value, counts: &CombinedCounts) -> String {
2569    let prod_total = counts.prod_failing + counts.prod_advisory;
2570    if prod_total == 0 && counts.hot_paths == 0 {
2571        return String::new();
2572    }
2573    let runtime = env
2574        .get("health")
2575        .and_then(|health| health.get("runtime_coverage"))
2576        .cloned()
2577        .unwrap_or(Value::Null);
2578    let hot_suffix = if counts.hot_paths > 0 {
2579        format!(
2580            ", {} {}",
2581            counts.hot_paths,
2582            hot_path_label(env, counts.hot_paths)
2583        )
2584    } else {
2585        String::new()
2586    };
2587    let mut out = format!(
2588        "<details>\n<summary><strong><a href=\"{HEALTH_DOCS}#runtime-coverage\">Runtime coverage</a> ({prod_total} finding{}{hot_suffix})</strong></summary>\n\n",
2589        if prod_total == 1 { "" } else { "s" },
2590    );
2591    if prod_total > 0 {
2592        out.push_str("| File | Function | Verdict | Invocations | Confidence |\n|:-----|:---------|:--------|------------:|:-----------|\n");
2593        out.push_str(
2594            &arr(&runtime, "findings")
2595                .take(5)
2596                .map(|it| {
2597                    let invocations = it
2598                        .get("invocations")
2599                        .filter(|value| !value.is_null())
2600                        .map_or_else(|| "-".to_owned(), fmt_num);
2601                    format!(
2602                        "| `{}:{}` | `{}` | `{}` | {invocations} | {} |",
2603                        last_three_segments(s(it, "path")),
2604                        num(it, "line"),
2605                        s(it, "function"),
2606                        s(it, "verdict"),
2607                        s(it, "confidence"),
2608                    )
2609                })
2610                .collect::<Vec<_>>()
2611                .join("\n"),
2612        );
2613        if counts.hot_paths > 0 {
2614            out.push_str("\n\n");
2615        }
2616    }
2617    if counts.hot_paths > 0 {
2618        out.push_str("| File | Function | Invocations | Percentile |\n|:-----|:---------|------------:|-----------:|\n");
2619        out.push_str(
2620            &arr(&runtime, "hot_paths")
2621                .take(5)
2622                .map(|it| {
2623                    format!(
2624                        "| `{}:{}` | `{}` | {} | {} |",
2625                        last_three_segments(s(it, "path")),
2626                        num(it, "line"),
2627                        s(it, "function"),
2628                        num(it, "invocations"),
2629                        num(it, "percentile"),
2630                    )
2631                })
2632                .collect::<Vec<_>>()
2633                .join("\n"),
2634        );
2635        out.push_str("\n\n");
2636    }
2637    out.push_str("</details>\n\n");
2638    out
2639}
2640
2641fn combined_vitals(env: &Value) -> String {
2642    let health = env.get("health").cloned().unwrap_or(Value::Null);
2643    let vitals = health.get("vital_signs").cloned().unwrap_or(Value::Null);
2644    let has_vitals = vitals.as_object().is_some_and(|vitals| !vitals.is_empty());
2645    if !has_vitals {
2646        return String::new();
2647    }
2648    let scores: Vec<f64> = arr(&health, "file_scores")
2649        .filter_map(|score| opt_f(score, "maintainability_index"))
2650        .collect();
2651    let scoped_maintainability = if scores.is_empty() {
2652        None
2653    } else {
2654        let avg = scores.iter().sum::<f64>() / scores.len() as f64;
2655        Some((avg * 10.0).round() / 10.0)
2656    };
2657    let mut out = format!(
2658        "#### [Codebase health]({HEALTH_DOCS})\n\n| Metric | Value |\n|:-------|------:|\n"
2659    );
2660    let maintainability = opt_f(&vitals, "maintainability_avg");
2661    if let Some(avg) = maintainability {
2662        let _ = writeln!(
2663            out,
2664            "| [Maintainability]({HEALTH_DOCS}#maintainability-index-mi) | **{}** / 100 |",
2665            pct(avg),
2666        );
2667    }
2668    if let Some(scoped) = scoped_maintainability {
2669        let rounded_avg = (maintainability.unwrap_or_default() * 10.0).round() / 10.0;
2670        if (scoped - rounded_avg).abs() > f64::EPSILON {
2671            let _ = writeln!(
2672                out,
2673                "| [Maintainability]({HEALTH_DOCS}#maintainability-index-mi) (changed files) | **{}** / 100 |",
2674                fmt_num(&serde_json::json!(scoped)),
2675            );
2676        }
2677    }
2678    if let Some(avg_cyclomatic) = opt_f(&vitals, "avg_cyclomatic") {
2679        let _ = writeln!(
2680            out,
2681            "| [Avg complexity]({HEALTH_DOCS}#cyclomatic-complexity) | {} |",
2682            pct(avg_cyclomatic),
2683        );
2684    }
2685    out.push('\n');
2686    out
2687}
2688
2689fn combined_tips(env: &Value) -> String {
2690    let check = env.get("check").cloned().unwrap_or(Value::Null);
2691    let fixable = arr(&check, "unused_exports").count()
2692        + arr(&check, "unused_dependencies").count()
2693        + arr(&check, "unused_enum_members").count();
2694    if fixable == 0 {
2695        return String::new();
2696    }
2697    let mut out = String::from("> [!TIP]\n> Run `fallow fix --dry-run` to preview auto-fixes.\n");
2698    if arr(&check, "unused_exports").count() > 0 {
2699        let _ = writeln!(
2700            out,
2701            "> Add [`/** @public */`]({SUPPRESSION_DOCS}) above exports to preserve them."
2702        );
2703    }
2704    out
2705}
2706
2707/// Port of `summary-combined.jq`.
2708#[must_use]
2709pub fn render_combined_summary(env: &Value, links: &LinkContext) -> String {
2710    let counts = combined_counts(env);
2711    let header = health_score_header(&env.get("health").cloned().unwrap_or(Value::Null));
2712    if counts.total() == 0 {
2713        return format!("{header}{}", combined_zero_case(env, &counts));
2714    }
2715    let pointer = if counts.check > 0 || counts.dupes > 0 || counts.health() > 0 {
2716        "See inline review comments for per-finding details.\n\n"
2717    } else {
2718        ""
2719    };
2720    format!(
2721        "{header}# \u{1F33F} Fallow\n\n> [!WARNING]\n> **Review needed**\n\n{}{pointer}{}{}{}{}{}{}",
2722        combined_status_line(env, &counts),
2723        combined_check_breakdown(env, &counts),
2724        combined_dupes_breakdown(env, &counts, links),
2725        combined_complexity_breakdown(env, &counts),
2726        combined_runtime_breakdown(env, &counts),
2727        combined_vitals(env),
2728        combined_tips(env),
2729    )
2730}