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