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