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 were first ported from the bundled
5//! action's jq renderers (`action/jq/summary-{check,dupes,health,audit,
6//! security,fix,combined}.jq`). This renderer is now the source of truth for
7//! fallow 3.4.2 and later. The action keeps the jq files as frozen legacy
8//! renderers for older binaries, so new issue kinds go here and not into the
9//! jq files (`summary-fix.jq` still serves `fix` on every version). Deviations
10//! from the jq layer: em dashes in
11//! the jq templates render as plain hyphens (repo style rule), and the
12//! combined view's dupes file links read `GH_REPO` / `GITHUB_REPOSITORY` and
13//! `PR_HEAD_SHA` / `GITHUB_SHA` (the jq layer read only the action-set
14//! `GH_REPO` / `PR_HEAD_SHA`), with the link path prefix coming from the
15//! path-rebase resolution instead of the action-set `PREFIX` env var.
16//!
17//! Like the annotations renderer, this is value-driven over the `--format
18//! json` envelope, which keeps `fallow report --from` output byte-identical
19//! to the direct format run.
20
21use std::fmt::Write as _;
22use std::path::Path;
23use std::process::ExitCode;
24
25use fallow_output::{markdown_code_span, markdown_table_code_span, markdown_table_text};
26use fallow_types::output_dead_code::caveat_labels_for_tokens;
27use serde_json::Value;
28
29use super::github::{PathRebase, arr, b, fmt_num, num, resolve_render_options, s, u};
30use super::github_annotations::EnvelopeKind;
31use crate::report::sink::outln;
32
33const DEAD_CODE_DOCS: &str = "https://fallow.tools/docs/explanations/dead-code/";
34const HEALTH_DOCS: &str = "https://fallow.tools/docs/explanations/health/";
35const DUPES_DOCS: &str = "https://fallow.tools/docs/explanations/duplication/";
36const SUPPRESSION_DOCS: &str = "https://fallow.tools/docs/configuration/suppression/";
37
38/// Environment-derived context for the combined view's dupes file links.
39#[derive(Debug, Default, Clone)]
40pub struct LinkContext {
41    /// Repo-root path prefix for link targets (empty or `dir/` with a
42    /// trailing slash).
43    pub prefix: String,
44    /// `owner/repo`; links render as plain code when empty.
45    pub repo: String,
46    /// Head commit SHA; links render as plain code when empty.
47    pub sha: String,
48}
49
50impl LinkContext {
51    /// Resolve from the workflow environment plus the path-rebase offset.
52    #[must_use]
53    pub(crate) fn from_env(rebase: &PathRebase) -> Self {
54        let env = |primary: &str, fallback: &str| {
55            std::env::var(primary)
56                .or_else(|_| std::env::var(fallback))
57                .unwrap_or_default()
58        };
59        let prefix = match rebase {
60            PathRebase::None => String::new(),
61            PathRebase::Prefix(prefix) => format!("{prefix}/"),
62        };
63        Self {
64            prefix,
65            repo: env("GH_REPO", "GITHUB_REPOSITORY"),
66            sha: env("PR_HEAD_SHA", "GITHUB_SHA"),
67        }
68    }
69}
70
71/// Render and print the job-summary markdown for one envelope.
72pub(crate) fn print_summary(kind: EnvelopeKind, envelope: &Value, root: &Path) -> ExitCode {
73    let options = resolve_render_options(root);
74    let links = LinkContext::from_env(&options.rebase);
75    outln!("{}", render_summary(kind, envelope, &links));
76    // The unmatched config patterns follow the findings, as in `--format
77    // markdown`. The section reads the envelope, so a live summary and one
78    // from `fallow report --from` list the same patterns.
79    if let Some(section) = crate::report::config_pattern_text::markdown_section(
80        &crate::report::config_pattern_text::envelope_diagnostics(envelope),
81    ) {
82        outln!("{section}");
83    }
84    // Appended rather than folded into each per-kind renderer: the verdict is
85    // one fact about the run, not a section of the report, and every kind
86    // reports it the same way.
87    if let Some(line) = crate::report::gate_outcome_text::summary_line(envelope) {
88        outln!("");
89        outln!("{line}");
90    }
91    // Same reasoning, one fact further: whether the run did what it was asked
92    // belongs beside what it concluded, not inside a per-kind section.
93    if let Some(line) = crate::report::request_outcome_text::summary_line(envelope) {
94        outln!("");
95        outln!("{line}");
96    }
97    ExitCode::SUCCESS
98}
99
100/// Render the fix envelope's job summary directly from `fallow fix`. The fix
101/// envelope has no `kind` field; `fallow report --from` reaches the same
102/// renderer via [`EnvelopeKind::Fix`] (resolved by field detection), while the
103/// live `fallow fix` command calls this entry point.
104pub(crate) fn print_fix_summary(envelope: &Value) -> ExitCode {
105    outln!("{}", render_fix_summary(envelope));
106    ExitCode::SUCCESS
107}
108
109/// Pure renderer, dispatching on the envelope family.
110#[must_use]
111pub fn render_summary(kind: EnvelopeKind, envelope: &Value, links: &LinkContext) -> String {
112    match kind {
113        EnvelopeKind::DeadCode | EnvelopeKind::Architecture => render_check_summary(envelope),
114        EnvelopeKind::Dupes => render_dupes_summary(envelope),
115        EnvelopeKind::Health => render_health_summary(envelope),
116        EnvelopeKind::Audit => render_audit_summary(envelope),
117        EnvelopeKind::Security => render_security_summary(envelope),
118        EnvelopeKind::Combined => render_combined_summary(envelope, links),
119        EnvelopeKind::Fix => render_fix_summary(envelope),
120    }
121}
122
123// ---------------------------------------------------------------------------
124// Shared numeric / path helpers (jq `pct`, `signed`, `rel_path`).
125// ---------------------------------------------------------------------------
126
127/// jq `pct`: `. * 10 | round / 10`, interpolated without a trailing `.0`.
128fn pct(value: f64) -> String {
129    let rounded = (value * 10.0).round() / 10.0;
130    fmt_num(&serde_json::json!(rounded))
131}
132
133/// jq `signed`: `+x` for positive, plain for negative, `0.0` for zero.
134fn signed(value: f64) -> String {
135    if value > 0.0 {
136        format!("+{}", pct(value))
137    } else if value < 0.0 {
138        pct(value)
139    } else {
140        "0.0".to_owned()
141    }
142}
143
144fn opt_f(value: &Value, key: &str) -> Option<f64> {
145    value.get(key).and_then(Value::as_f64)
146}
147
148fn f_or_zero(value: &Value, key: &str) -> f64 {
149    opt_f(value, key).unwrap_or_default()
150}
151
152/// jq `rel_path` (audit/security flavor): shorten only absolute paths to
153/// their last three segments.
154fn rel_path_absolute_only(path: &str) -> String {
155    if path.starts_with('/') {
156        last_three_segments(path)
157    } else {
158        path.to_owned()
159    }
160}
161
162/// jq `rel_path` (combined flavor): always shorten to the last three
163/// segments.
164fn last_three_segments(path: &str) -> String {
165    let segments: Vec<&str> = path.split('/').collect();
166    if segments.len() > 3 {
167        segments[segments.len() - 3..].join("/")
168    } else {
169        segments.join("/")
170    }
171}
172
173/// jq `plural(n; word)`.
174fn plural_n(n: usize, word: &str) -> String {
175    let suffix = if n == 1 { "" } else { "s" };
176    format!("{n} {word}{suffix}")
177}
178
179fn str_or<'v>(value: &'v Value, key: &str, default: &'v str) -> &'v str {
180    value.get(key).and_then(Value::as_str).unwrap_or(default)
181}
182
183/// `` `path` `` / `` `path:line` `` with the jq truthiness gate: the `:line`
184/// suffix renders whenever `line` is present and non-null (0 included).
185fn path_line(item: &Value) -> String {
186    let path = rel_path_absolute_only(s(item, "path"));
187    match item.get("line").filter(|line| !line.is_null()) {
188        Some(line) => markdown_table_code_span(&format!("{path}:{}", fmt_num(line))),
189        None => markdown_table_code_span(&path),
190    }
191}
192
193/// Escaped code-span table cell for an untrusted envelope string.
194fn code_cell(item: &Value, key: &str) -> String {
195    markdown_table_code_span(s(item, key))
196}
197
198/// The italic caveat marker appended inside a dead-code table cell when the
199/// verdict behind the row rests on a file this run never fully read. Empty when
200/// the finding carries no `reachability_caveats[]`, so a clean run's summary is
201/// byte-identical to what it was before this hedge existed.
202///
203/// The job summary is where a reviewer decides what to delete, so the row that
204/// names the finding is the row that has to carry the qualifier. It rides
205/// inside an existing cell rather than in a new column: adding a column would
206/// change every table's shape, including the rows that carry no caveat.
207fn caveat_cell_suffix(item: &Value) -> String {
208    caveat_labels_for_tokens(arr(item, "reachability_caveats").filter_map(Value::as_str))
209        .map_or_else(String::new, |labels| {
210            format!(" *(caveat: {})*", markdown_table_text(&labels))
211        })
212}
213
214/// `` `path:line` `` cell with the audit-flavor path shortening.
215fn rel_path_line_cell(item: &Value, path_key: &str) -> String {
216    markdown_table_code_span(&format!(
217        "{}:{}",
218        rel_path_absolute_only(s(item, path_key)),
219        num(item, "line")
220    ))
221}
222
223fn backtick_join(item: &Value, key: &str) -> String {
224    arr(item, key)
225        .filter_map(Value::as_str)
226        .map(markdown_table_code_span)
227        .collect::<Vec<_>>()
228        .join(", ")
229}
230
231// ---------------------------------------------------------------------------
232// Dead-code category table (shared by the check summary and the combined
233// code-issues breakdown). Labels, result keys, and docs anchors come from the
234// issue registry; a unit test below holds the two equal.
235// ---------------------------------------------------------------------------
236
237const DEAD_CODE_CATEGORIES: &[(&str, &str, &str)] = &[
238    ("Prop drilling", "prop_drilling_chains", "prop-drilling"),
239    ("Unused files", "unused_files", "unused-files"),
240    ("Unused exports", "unused_exports", "unused-exports"),
241    ("Unused types", "unused_types", "unused-types"),
242    (
243        "Private type leaks",
244        "private_type_leaks",
245        "private-type-leaks",
246    ),
247    (
248        "Deprecated exports in use",
249        "deprecated_exports_in_use",
250        "deprecated-exports-in-use",
251    ),
252    (
253        "Unused dependencies",
254        "unused_dependencies",
255        "unused-dependencies",
256    ),
257    (
258        "Unused devDependencies",
259        "unused_dev_dependencies",
260        "unused-dependencies",
261    ),
262    (
263        "Unused optionalDependencies",
264        "unused_optional_dependencies",
265        "unused-dependencies",
266    ),
267    (
268        "Unused enum members",
269        "unused_enum_members",
270        "unused-enum-members",
271    ),
272    (
273        "Unused class members",
274        "unused_class_members",
275        "unused-class-members",
276    ),
277    (
278        "Unused store members",
279        "unused_store_members",
280        "unused-store-members",
281    ),
282    (
283        "Unresolved imports",
284        "unresolved_imports",
285        "unresolved-imports",
286    ),
287    (
288        "Unlisted dependencies",
289        "unlisted_dependencies",
290        "unlisted-dependencies",
291    ),
292    (
293        "Duplicate exports",
294        "duplicate_exports",
295        "duplicate-exports",
296    ),
297    (
298        "Circular dependencies",
299        "circular_dependencies",
300        "circular-dependencies",
301    ),
302    ("Re-export cycles", "re_export_cycles", "re-export-cycles"),
303    ("Package cycles", "package_cycles", "package-cycles"),
304    (
305        "Boundary violations",
306        "boundary_violations",
307        "boundary-violations",
308    ),
309    (
310        "Boundary coverage",
311        "boundary_coverage_violations",
312        "boundary-violations",
313    ),
314    (
315        "Boundary calls",
316        "boundary_call_violations",
317        "boundary-violations",
318    ),
319    (
320        "Policy violations",
321        "policy_violations",
322        "policy-violations",
323    ),
324    (
325        "Invalid client exports",
326        "invalid_client_exports",
327        "invalid-client-exports",
328    ),
329    (
330        "Mixed client/server barrels",
331        "mixed_client_server_barrels",
332        "mixed-client-server-barrels",
333    ),
334    (
335        "Misplaced directives",
336        "misplaced_directives",
337        "misplaced-directives",
338    ),
339    (
340        "Unused server actions",
341        "unused_server_actions",
342        "unused-server-actions",
343    ),
344    ("Route collisions", "route_collisions", "route-collisions"),
345    (
346        "Dynamic segment conflicts",
347        "dynamic_segment_name_conflicts",
348        "dynamic-segment-name-conflicts",
349    ),
350    (
351        "Unrendered components",
352        "unrendered_components",
353        "unrendered-components",
354    ),
355    (
356        "Unused component props",
357        "unused_component_props",
358        "unused-component-props",
359    ),
360    (
361        "Absent optional component props",
362        "absent_component_props",
363        "absent-component-props",
364    ),
365    (
366        "Unused component emits",
367        "unused_component_emits",
368        "unused-component-emits",
369    ),
370    (
371        "Unused component inputs",
372        "unused_component_inputs",
373        "unused-component-inputs",
374    ),
375    (
376        "Unused component outputs",
377        "unused_component_outputs",
378        "unused-component-outputs",
379    ),
380    (
381        "Unused Svelte events",
382        "unused_svelte_events",
383        "unused-svelte-events",
384    ),
385    (
386        "Unused Effect layers",
387        "unused_effect_layers",
388        "unused-effect-layers",
389    ),
390    (
391        "Unused tagged errors",
392        "unused_tagged_errors",
393        "unused-tagged-errors",
394    ),
395    (
396        "Unproduced catch tags",
397        "unproduced_catch_tags",
398        "unproduced-catch-tags",
399    ),
400    (
401        "Unprovided injects",
402        "unprovided_injects",
403        "unprovided-injects",
404    ),
405    (
406        "Unused load data keys",
407        "unused_load_data_keys",
408        "unused-load-data-keys",
409    ),
410    (
411        "Type-only dependencies",
412        "type_only_dependencies",
413        "type-only-dependencies",
414    ),
415    (
416        "Test-only dependencies",
417        "test_only_dependencies",
418        "test-only-dependencies",
419    ),
420    (
421        "Dev dependencies used in production",
422        "dev_dependencies_in_production",
423        "dev-dependencies-in-production",
424    ),
425    (
426        "Stale suppressions",
427        "stale_suppressions",
428        "stale-suppressions",
429    ),
430    (
431        "Unused catalog entries",
432        "unused_catalog_entries",
433        "unused-catalog-entries",
434    ),
435    (
436        "Empty catalog groups",
437        "empty_catalog_groups",
438        "empty-catalog-groups",
439    ),
440    (
441        "Unresolved catalog references",
442        "unresolved_catalog_references",
443        "unresolved-catalog-references",
444    ),
445    (
446        "Unused dependency overrides",
447        "unused_dependency_overrides",
448        "unused-dependency-overrides",
449    ),
450    (
451        "Misconfigured dependency overrides",
452        "misconfigured_dependency_overrides",
453        "misconfigured-dependency-overrides",
454    ),
455];
456
457fn dead_code_docs(anchor: &str) -> String {
458    format!("{DEAD_CODE_DOCS}#{anchor}")
459}
460
461fn dead_code_category_table(env: &Value) -> String {
462    DEAD_CODE_CATEGORIES
463        .iter()
464        .filter_map(|(name, key, anchor)| {
465            let n = arr(env, key)
466                .filter(|item| {
467                    *key != "prop_drilling_chains" || s(item, "effective_severity") == "error"
468                })
469                .count();
470            (n > 0).then(|| format!("| [{name}]({}) | {n} |", dead_code_docs(anchor)))
471        })
472        .collect::<Vec<_>>()
473        .join("\n")
474}
475
476// ---------------------------------------------------------------------------
477// summary-check.jq
478// ---------------------------------------------------------------------------
479
480struct SectionSpec {
481    name: &'static str,
482    key: &'static str,
483    header: &'static str,
484    row: fn(&Value) -> String,
485}
486
487fn render_check_section(env: &Value, spec: &SectionSpec) -> String {
488    // The job summary is one flat document, so its rows follow the flat path
489    // order of the live render. A saved `--group-by` envelope lists its items
490    // group by group, and `report --from` flattens them in that order. The sort
491    // gives both renders one order. The serialized item breaks ties, so the
492    // order is total and does not depend on the input order.
493    let items = rows_in_path_order(env, spec.key);
494    let n = items.len();
495    if n == 0 {
496        return String::new();
497    }
498    let rows = items
499        .iter()
500        .take(CHECK_SECTION_ROW_LIMIT)
501        .map(|item| (spec.row)(item))
502        .collect::<Vec<_>>()
503        .join("\n");
504    let tail = if n > CHECK_SECTION_ROW_LIMIT {
505        format!(
506            "\n\n> {} more - run `fallow` locally for the full list",
507            n - CHECK_SECTION_ROW_LIMIT
508        )
509    } else {
510        String::new()
511    };
512    format!(
513        "\n<details><summary><strong>{} ({n})</strong></summary>\n\n{}{rows}{tail}\n\n</details>\n",
514        spec.name, spec.header,
515    )
516}
517
518/// Rows a check section renders before it prints the count of the rest.
519const CHECK_SECTION_ROW_LIMIT: usize = 25;
520
521/// The items of `key` in (path, line, serialized item) order.
522///
523/// Path and line are read once per item, not once per comparison. The
524/// serialized item only breaks a tie on path and line, and only for the tie
525/// groups that reach the rendered rows, because serializing is costly and
526/// the rows past [`CHECK_SECTION_ROW_LIMIT`] are only counted.
527fn rows_in_path_order<'a>(env: &'a Value, key: &str) -> Vec<&'a Value> {
528    let mut keyed: Vec<(Option<&str>, Option<u64>, &Value)> = arr(env, key)
529        .map(|item| {
530            (
531                row_sort_path(item),
532                item.get("line").and_then(Value::as_u64),
533                item,
534            )
535        })
536        .collect();
537    keyed.sort_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1)));
538    let mut start = 0;
539    for run in keyed.chunk_by_mut(|a, b| (a.0, a.1) == (b.0, b.1)) {
540        if start >= CHECK_SECTION_ROW_LIMIT {
541            break;
542        }
543        if run.len() > 1 {
544            run.sort_by_cached_key(|(_, _, item)| item.to_string());
545        }
546        start += run.len();
547    }
548    keyed.into_iter().map(|(_, _, item)| item).collect()
549}
550
551/// The path a dead-code row sorts on: `path`, the source file of a boundary
552/// violation, or the first file of a cycle. Rows with none (a duplicate export
553/// or an unlisted dependency) sort on the serialized item, which starts with
554/// the name.
555fn row_sort_path(item: &Value) -> Option<&str> {
556    item.get("path")
557        .or_else(|| item.get("from_path"))
558        .or_else(|| item.get("files").and_then(|files| files.get(0)))
559        .and_then(Value::as_str)
560}
561
562fn check_workspace_context(item: &Value) -> String {
563    let workspaces = backtick_join(item, "used_in_workspaces");
564    if !workspaces.is_empty() {
565        return workspaces;
566    }
567    let declaring = backtick_join(item, "declared_and_imported_in");
568    if declaring.is_empty() {
569        String::new()
570    } else {
571        format!("declared and imported in {declaring}")
572    }
573}
574
575#[expect(
576    clippy::too_many_lines,
577    reason = "a flat data table of 14 per-kind row templates ported 1:1 from summary-check.jq; splitting would obscure the correspondence"
578)]
579fn check_sections_core() -> Vec<SectionSpec> {
580    vec![
581        SectionSpec {
582            name: "Unused files",
583            key: "unused_files",
584            header: "Files not reachable from any entry point.\n\n| File |\n|------|\n",
585            row: |it| format!("| {}{} |", code_cell(it, "path"), caveat_cell_suffix(it)),
586        },
587        SectionSpec {
588            name: "Unused exports",
589            key: "unused_exports",
590            header: "Exported symbols with no known consumers.\n\n| File | Line | Export |\n|------|-----:|--------|\n",
591            row: |it| {
592                format!(
593                    "| {} | {} | {}{}{} |",
594                    code_cell(it, "path"),
595                    num(it, "line"),
596                    code_cell(it, "export_name"),
597                    if b(it, "is_re_export") {
598                        " *(re-export)*"
599                    } else {
600                        ""
601                    },
602                    caveat_cell_suffix(it),
603                )
604            },
605        },
606        SectionSpec {
607            name: "Unused types",
608            key: "unused_types",
609            header: "Type exports with no known consumers.\n\n| File | Line | Type |\n|------|-----:|------|\n",
610            row: |it| {
611                format!(
612                    "| {} | {} | {}{} |",
613                    code_cell(it, "path"),
614                    num(it, "line"),
615                    code_cell(it, "export_name"),
616                    caveat_cell_suffix(it),
617                )
618            },
619        },
620        SectionSpec {
621            name: "Private type leaks",
622            key: "private_type_leaks",
623            header: "Exported signatures that reference same-file private types.\n\n| File | Line | Export | Private type |\n|------|-----:|--------|--------------|\n",
624            row: |it| {
625                format!(
626                    "| {} | {} | {} | {} |",
627                    code_cell(it, "path"),
628                    num(it, "line"),
629                    code_cell(it, "export_name"),
630                    code_cell(it, "type_name"),
631                )
632            },
633        },
634        SectionSpec {
635            name: "Deprecated exports in use",
636            key: "deprecated_exports_in_use",
637            header: "Exports marked `@deprecated` that still have consumers.\n\n| File | Line | Export | Consumers |\n|------|-----:|--------|----------:|\n",
638            row: |it| {
639                format!(
640                    "| {} | {} | {} | {} |",
641                    code_cell(it, "path"),
642                    num(it, "line"),
643                    code_cell(it, "export_name"),
644                    num(it, "consumer_count"),
645                )
646            },
647        },
648        SectionSpec {
649            name: "Unused dependencies",
650            key: "unused_dependencies",
651            header: "Listed in `dependencies` but never imported by the declaring workspace.\n\n| Package | Imported elsewhere |\n|---------|--------------------|\n",
652            row: |it| {
653                format!(
654                    "| {}{} | {} |",
655                    code_cell(it, "package_name"),
656                    caveat_cell_suffix(it),
657                    check_workspace_context(it),
658                )
659            },
660        },
661        SectionSpec {
662            name: "Unused devDependencies",
663            key: "unused_dev_dependencies",
664            header: "Listed in `devDependencies` but never imported or referenced by the declaring workspace.\n\n| Package | Imported elsewhere |\n|---------|--------------------|\n",
665            row: |it| {
666                format!(
667                    "| {}{} | {} |",
668                    code_cell(it, "package_name"),
669                    caveat_cell_suffix(it),
670                    check_workspace_context(it),
671                )
672            },
673        },
674        SectionSpec {
675            name: "Unused optionalDependencies",
676            key: "unused_optional_dependencies",
677            header: "Listed in `optionalDependencies` but never imported by the declaring workspace.\n\n| Package | Imported elsewhere |\n|---------|--------------------|\n",
678            row: |it| {
679                format!(
680                    "| {}{} | {} |",
681                    code_cell(it, "package_name"),
682                    caveat_cell_suffix(it),
683                    check_workspace_context(it),
684                )
685            },
686        },
687        SectionSpec {
688            name: "Unused enum members",
689            key: "unused_enum_members",
690            header: "Enum members never referenced outside their declaration.\n\n| File | Line | Enum | Member |\n|------|-----:|------|--------|\n",
691            row: member_row,
692        },
693        SectionSpec {
694            name: "Unused class members",
695            key: "unused_class_members",
696            header: "Class methods or properties never referenced outside their class.\n\n| File | Line | Class | Member |\n|------|-----:|-------|--------|\n",
697            row: member_row,
698        },
699        SectionSpec {
700            name: "Unused store members",
701            key: "unused_store_members",
702            header: "Pinia store members (state, getter, action) never accessed by any consumer.\n\n| File | Line | Store | Member |\n|------|-----:|-------|--------|\n",
703            row: member_row,
704        },
705        SectionSpec {
706            name: "Unresolved imports",
707            key: "unresolved_imports",
708            header: "Import paths that could not be resolved - check for missing packages or broken paths.\n\n| File | Line | Import |\n|------|-----:|--------|\n",
709            row: |it| {
710                format!(
711                    "| {} | {} | {} |",
712                    code_cell(it, "path"),
713                    num(it, "line"),
714                    code_cell(it, "specifier"),
715                )
716            },
717        },
718        SectionSpec {
719            name: "Unlisted dependencies",
720            key: "unlisted_dependencies",
721            header: "Packages imported in code but missing from `package.json`.\n\n| Package | Used in |\n|---------|--------|\n",
722            row: |it| {
723                let sites: Vec<&Value> = arr(it, "imported_from").collect();
724                let cell = if sites.is_empty() {
725                    String::new()
726                } else {
727                    let shown = sites
728                        .iter()
729                        .take(3)
730                        .map(|site| {
731                            markdown_table_code_span(&format!(
732                                "{}:{}",
733                                s(site, "path"),
734                                num(site, "line")
735                            ))
736                        })
737                        .collect::<Vec<_>>()
738                        .join(", ");
739                    let more = if sites.len() > 3 {
740                        format!(" *+{} more*", sites.len() - 3)
741                    } else {
742                        String::new()
743                    };
744                    format!("{shown}{more}")
745                };
746                format!("| {} | {cell} |", code_cell(it, "package_name"))
747            },
748        },
749        SectionSpec {
750            name: "Duplicate exports",
751            key: "duplicate_exports",
752            header: "Same export name defined in multiple files - barrel re-exports may resolve ambiguously.\n\n| Export | Locations |\n|--------|-----------|\n",
753            row: |it| {
754                let locations: Vec<&Value> = arr(it, "locations").collect();
755                let shown = locations
756                    .iter()
757                    .take(3)
758                    .map(|location| {
759                        markdown_table_code_span(&format!(
760                            "{}:{}",
761                            s(location, "path"),
762                            num(location, "line")
763                        ))
764                    })
765                    .collect::<Vec<_>>()
766                    .join(", ");
767                let more = if locations.len() > 3 {
768                    format!(" *+{} more*", locations.len() - 3)
769                } else {
770                    String::new()
771                };
772                format!("| {} | {shown}{more} |", code_cell(it, "export_name"))
773            },
774        },
775        SectionSpec {
776            name: "Circular dependencies",
777            key: "circular_dependencies",
778            header: "Import cycles that can cause initialization failures and prevent tree-shaking.\n\n| Cycle | Length |\n|-------|-------:|\n",
779            row: |it| {
780                let cycle = arr(it, "files")
781                    .filter_map(Value::as_str)
782                    .map(markdown_table_code_span)
783                    .collect::<Vec<_>>()
784                    .join(" \u{2192} ");
785                format!("| {cycle} | {} |", num(it, "length"))
786            },
787        },
788    ]
789}
790
791/// Shared by the enum, class, and store member sections. `caveat_cell_suffix`
792/// renders nothing for an array the analysis pass does not stamp, so the three
793/// sections stay correct without each knowing which set it belongs to, and a
794/// future array that starts carrying caveats needs no change here.
795fn member_row(it: &Value) -> String {
796    format!(
797        "| {} | {} | {} | {}{} |",
798        code_cell(it, "path"),
799        num(it, "line"),
800        code_cell(it, "parent_name"),
801        code_cell(it, "member_name"),
802        caveat_cell_suffix(it),
803    )
804}
805
806fn plain_join(item: &Value, key: &str, separator: &str) -> String {
807    arr(item, key)
808        .filter_map(Value::as_str)
809        .collect::<Vec<_>>()
810        .join(separator)
811}
812
813fn path_line_cell(it: &Value) -> String {
814    markdown_table_code_span(&format!("{}:{}", s(it, "path"), num(it, "line")))
815}
816
817#[expect(
818    clippy::too_many_lines,
819    reason = "a flat data table of 14 per-kind row templates ported 1:1 from summary-check.jq; splitting would obscure the correspondence"
820)]
821fn check_sections_architecture() -> Vec<SectionSpec> {
822    vec![
823        SectionSpec {
824            name: "Re-export cycles",
825            key: "re_export_cycles",
826            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",
827            row: |it| {
828                let cycle = arr(it, "files")
829                    .filter_map(Value::as_str)
830                    .map(markdown_table_code_span)
831                    .collect::<Vec<_>>()
832                    .join(" <-> ");
833                format!(
834                    "| {cycle} | {} | {} |",
835                    markdown_table_text(s(it, "kind")),
836                    arr(it, "files").count()
837                )
838            },
839        },
840        SectionSpec {
841            name: "Package cycles",
842            key: "package_cycles",
843            header: "Workspace packages that import each other in a loop. Packages in a cycle cannot be built in dependency order.\n\n| Cycle | Example import | Packages |\n|-------|----------------|---------:|\n",
844            row: |it| {
845                let mut chain: Vec<String> = arr(it, "packages")
846                    .filter_map(Value::as_str)
847                    .map(markdown_table_code_span)
848                    .collect();
849                if let Some(first) = chain.first().cloned() {
850                    chain.push(first);
851                }
852                let example = arr(it, "edges")
853                    .next()
854                    .map_or_else(String::new, path_line_cell);
855                let note = if it.get("group_truncated").and_then(Value::as_bool) == Some(true) {
856                    format!(
857                        " ({})",
858                        fallow_types::results::PackageCycle::GROUP_TRUNCATED_NOTE
859                    )
860                } else {
861                    String::new()
862                };
863                format!(
864                    "| {}{note} | {example} | {} |",
865                    chain.join(" \u{2192} "),
866                    arr(it, "packages").count()
867                )
868            },
869        },
870        SectionSpec {
871            name: "Boundary violations",
872            key: "boundary_violations",
873            header: "Imports that cross defined architecture zone boundaries.\n\n| From | To | Zones |\n|------|-----|-------|\n",
874            row: |it| {
875                format!(
876                    "| {} | {} | {} \u{2192} {} |",
877                    markdown_table_code_span(&format!(
878                        "{}:{}",
879                        s(it, "from_path"),
880                        num(it, "line")
881                    )),
882                    code_cell(it, "to_path"),
883                    markdown_table_code_span(s(it, "from_zone")),
884                    markdown_table_code_span(s(it, "to_zone")),
885                )
886            },
887        },
888        SectionSpec {
889            name: "Boundary coverage",
890            key: "boundary_coverage_violations",
891            header: "Files that match no configured architecture boundary zone.\n\n| File |\n|------|\n",
892            row: |it| format!("| {} |", path_line_cell(it)),
893        },
894        SectionSpec {
895            name: "Boundary calls",
896            key: "boundary_call_violations",
897            header: "Calls from zoned files to callees forbidden for that zone.\n\n| File | Callee | Zone | Pattern |\n|------|--------|------|---------|\n",
898            row: |it| {
899                format!(
900                    "| {} | {} | {} | {} |",
901                    path_line_cell(it),
902                    code_cell(it, "callee"),
903                    markdown_table_code_span(s(it, "zone")),
904                    code_cell(it, "pattern"),
905                )
906            },
907        },
908        SectionSpec {
909            name: "Policy violations",
910            key: "policy_violations",
911            header: "Banned calls, imports, and catalogue-derived effects matched by configured rule packs.\n\n| File | Matched | Rule | Severity |\n|------|---------|------|----------|\n",
912            row: |it| {
913                format!(
914                    "| {} | {} | {} | {} |",
915                    path_line_cell(it),
916                    code_cell(it, "matched"),
917                    markdown_table_code_span(&format!("{}/{}", s(it, "pack"), s(it, "rule_id"))),
918                    markdown_table_text(s(it, "severity")),
919                )
920            },
921        },
922        SectionSpec {
923            name: "Invalid client exports",
924            key: "invalid_client_exports",
925            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",
926            row: |it| {
927                format!(
928                    "| {} | {} | {} |",
929                    path_line_cell(it),
930                    code_cell(it, "export_name"),
931                    markdown_table_code_span(&format!("\"{}\"", s(it, "directive"))),
932                )
933            },
934        },
935        SectionSpec {
936            name: "Mixed client/server barrels",
937            key: "mixed_client_server_barrels",
938            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",
939            row: |it| {
940                format!(
941                    "| {} | {} | {} |",
942                    path_line_cell(it),
943                    code_cell(it, "client_origin"),
944                    code_cell(it, "server_origin"),
945                )
946            },
947        },
948        SectionSpec {
949            name: "Misplaced directives",
950            key: "misplaced_directives",
951            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",
952            row: |it| {
953                format!(
954                    "| {} | {} |",
955                    path_line_cell(it),
956                    markdown_table_code_span(&format!("\"{}\"", s(it, "directive"))),
957                )
958            },
959        },
960        SectionSpec {
961            name: "Unused server actions",
962            key: "unused_server_actions",
963            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",
964            row: |it| {
965                format!(
966                    "| {} | {} |",
967                    path_line_cell(it),
968                    code_cell(it, "action_name")
969                )
970            },
971        },
972        SectionSpec {
973            name: "Route collisions",
974            key: "route_collisions",
975            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",
976            row: |it| format!("| {} | {} |", code_cell(it, "path"), code_cell(it, "url")),
977        },
978        SectionSpec {
979            name: "Dynamic segment conflicts",
980            key: "dynamic_segment_name_conflicts",
981            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",
982            row: |it| {
983                format!(
984                    "| {} | {} | {} |",
985                    code_cell(it, "path"),
986                    code_cell(it, "position"),
987                    markdown_table_code_span(&plain_join(it, "conflicting_segments", ", ")),
988                )
989            },
990        },
991    ]
992}
993
994#[expect(
995    clippy::too_many_lines,
996    reason = "a flat data table of 14 per-kind row templates ported 1:1 from summary-check.jq; splitting would obscure the correspondence"
997)]
998fn check_sections_frameworks_and_hygiene() -> Vec<SectionSpec> {
999    vec![
1000        SectionSpec {
1001            name: "Unrendered components",
1002            key: "unrendered_components",
1003            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",
1004            row: |it| {
1005                format!(
1006                    "| {} | {} | {} |",
1007                    path_line_cell(it),
1008                    code_cell(it, "component_name"),
1009                    markdown_table_text(s(it, "framework")),
1010                )
1011            },
1012        },
1013        SectionSpec {
1014            name: "Absent optional component props",
1015            key: "absent_component_props",
1016            header: "Known reachable callers omit this used optional prop. Review defaults and API intent manually; static analysis does not prove runtime unreachability.\n\n| File | Component prop | Default | Inspected callers |\n|------|----------------|---------|-------------------|\n",
1017            row: |it| {
1018                let callers = arr(it, "inspected_call_sites")
1019                    .map(path_line_cell)
1020                    .collect::<Vec<_>>()
1021                    .join(", ");
1022                format!(
1023                    "| {} | {} | {} | {} |",
1024                    path_line_cell(it),
1025                    component_member_item(it, "prop_name"),
1026                    if it.get("has_default").and_then(Value::as_bool) == Some(true) {
1027                        "present"
1028                    } else {
1029                        "absent"
1030                    },
1031                    callers
1032                )
1033            },
1034        },
1035        SectionSpec {
1036            name: "Unused component props",
1037            key: "unused_component_props",
1038            header: "Vue `defineProps` props referenced nowhere inside their own single-file component (neither script nor template).\n\n| File | Component | Prop |\n|------|-----------|------|\n",
1039            row: |it| component_detail_row(it, "prop_name"),
1040        },
1041        SectionSpec {
1042            name: "Unused component emits",
1043            key: "unused_component_emits",
1044            header: "Vue `defineEmits` events emitted nowhere inside their own single-file component (no matching `emit()` call).\n\n| File | Component | Event |\n|------|-----------|-------|\n",
1045            row: |it| component_detail_row(it, "emit_name"),
1046        },
1047        SectionSpec {
1048            name: "Unused component inputs",
1049            key: "unused_component_inputs",
1050            header: "Angular `@Input()` / signal `input()` declarations read nowhere inside their own component (neither class body nor template).\n\n| File | Component | Input |\n|------|-----------|-------|\n",
1051            row: |it| component_detail_row(it, "input_name"),
1052        },
1053        SectionSpec {
1054            name: "Unused component outputs",
1055            key: "unused_component_outputs",
1056            header: "Angular `@Output()` / signal `output()` declarations emitted nowhere inside their own component (no matching `emit()` call).\n\n| File | Component | Output |\n|------|-----------|--------|\n",
1057            row: |it| component_detail_row(it, "output_name"),
1058        },
1059        SectionSpec {
1060            name: "Unused Svelte events",
1061            key: "unused_svelte_events",
1062            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",
1063            row: |it| component_detail_row(it, "event_name"),
1064        },
1065        SectionSpec {
1066            name: "Unused Effect layers",
1067            key: "unused_effect_layers",
1068            header: "Effect layers composed only into layers that nothing uses.\n\n| File | Layer |\n|------|-------|\n",
1069            row: |it| format!("| {} | {} |", path_line_cell(it), code_cell(it, "name")),
1070        },
1071        SectionSpec {
1072            name: "Unused tagged errors",
1073            key: "unused_tagged_errors",
1074            header: "Effect tagged errors that no code constructs or fails with.\n\n| File | Class | Tag |\n|------|-------|-----|\n",
1075            row: |it| {
1076                format!(
1077                    "| {} | {} | {} |",
1078                    path_line_cell(it),
1079                    code_cell(it, "name"),
1080                    code_cell(it, "tag")
1081                )
1082            },
1083        },
1084        SectionSpec {
1085            name: "Unproduced catch tags",
1086            key: "unproduced_catch_tags",
1087            header: "`catchTag` handlers for a project tag that no code produces.\n\n| File | Tag |\n|------|-----|\n",
1088            row: |it| format!("| {} | {} |", path_line_cell(it), code_cell(it, "tag")),
1089        },
1090        SectionSpec {
1091            name: "Unprovided injects",
1092            key: "unprovided_injects",
1093            header: "Vue `inject` / Svelte `getContext` calls for a key that no ancestor `provide` / `setContext` supplies.\n\n| File | Key | Framework |\n|------|-----|-----------|\n",
1094            row: |it| {
1095                format!(
1096                    "| {} | {} | {} |",
1097                    path_line_cell(it),
1098                    code_cell(it, "key_name"),
1099                    markdown_table_text(s(it, "framework")),
1100                )
1101            },
1102        },
1103        SectionSpec {
1104            name: "Unused load data keys",
1105            key: "unused_load_data_keys",
1106            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",
1107            row: |it| {
1108                format!(
1109                    "| {} | {} | {} |",
1110                    path_line_cell(it),
1111                    code_cell(it, "route_dir"),
1112                    code_cell(it, "key_name"),
1113                )
1114            },
1115        },
1116        SectionSpec {
1117            name: "Type-only dependencies",
1118            key: "type_only_dependencies",
1119            header: "Dependencies only used for type imports - consider moving to `devDependencies`.\n\n| Package |\n|---------|\n",
1120            row: package_row,
1121        },
1122        SectionSpec {
1123            name: "Test-only dependencies",
1124            key: "test_only_dependencies",
1125            header: "Production dependencies only imported by test files - consider moving to `devDependencies`.\n\n| Package |\n|---------|\n",
1126            row: package_row,
1127        },
1128        SectionSpec {
1129            name: "Dev dependencies used in production",
1130            key: "dev_dependencies_in_production",
1131            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",
1132            row: package_row,
1133        },
1134        SectionSpec {
1135            name: "Stale suppressions",
1136            key: "stale_suppressions",
1137            header: "Suppression comments or JSDoc tags that no longer match any active issue.\n\n| File | Line | Description |\n|------|-----:|-------------|\n",
1138            row: |it| {
1139                format!(
1140                    "| {} | {} | {} |",
1141                    code_cell(it, "path"),
1142                    num(it, "line"),
1143                    stale_suppression_description(it),
1144                )
1145            },
1146        },
1147    ]
1148}
1149
1150fn component_detail_row(it: &Value, detail_key: &str) -> String {
1151    format!(
1152        "| {} | {} | {} |",
1153        path_line_cell(it),
1154        code_cell(it, "component_name"),
1155        code_cell(it, detail_key),
1156    )
1157}
1158
1159fn package_row(it: &Value) -> String {
1160    format!("| {} |", code_cell(it, "package_name"))
1161}
1162
1163fn stale_suppression_description(it: &Value) -> String {
1164    let origin = it.get("origin").cloned().unwrap_or(Value::Null);
1165    if s(&origin, "type") == "jsdoc_tag" {
1166        return format!(
1167            "`@expected-unused` on {}",
1168            code_cell(&origin, "export_name")
1169        );
1170    }
1171    if origin.get("kind_known").and_then(Value::as_bool) == Some(false) {
1172        return format!("unknown kind {}", code_cell(&origin, "issue_kind"));
1173    }
1174    match origin.get("issue_kind").and_then(Value::as_str) {
1175        Some(kind) => markdown_table_code_span(kind),
1176        None => "blanket".to_owned(),
1177    }
1178}
1179
1180fn check_sections_catalog() -> Vec<SectionSpec> {
1181    vec![
1182        SectionSpec {
1183            name: "Unused catalog entries",
1184            key: "unused_catalog_entries",
1185            header: "pnpm catalog entries not referenced by any workspace package.\n\n| Entry | Catalog | Location | Hardcoded consumers |\n|-------|---------|----------|---------------------|\n",
1186            row: |it| {
1187                format!(
1188                    "| {} | {} | {} | {} |",
1189                    code_cell(it, "entry_name"),
1190                    code_cell(it, "catalog_name"),
1191                    path_line_cell(it),
1192                    backtick_join(it, "hardcoded_consumers"),
1193                )
1194            },
1195        },
1196        SectionSpec {
1197            name: "Empty catalog groups",
1198            key: "empty_catalog_groups",
1199            header: "Named pnpm catalog groups with no entries.\n\n| Catalog | Location |\n|---------|----------|\n",
1200            row: |it| {
1201                format!(
1202                    "| {} | {} |",
1203                    code_cell(it, "catalog_name"),
1204                    path_line_cell(it)
1205                )
1206            },
1207        },
1208        SectionSpec {
1209            name: "Unresolved catalog references",
1210            key: "unresolved_catalog_references",
1211            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",
1212            row: |it| {
1213                format!(
1214                    "| {} | {} | {} | {} |",
1215                    code_cell(it, "entry_name"),
1216                    code_cell(it, "catalog_name"),
1217                    path_line_cell(it),
1218                    backtick_join(it, "available_in_catalogs"),
1219                )
1220            },
1221        },
1222        SectionSpec {
1223            name: "Unused dependency overrides",
1224            key: "unused_dependency_overrides",
1225            header: "Package-manager override 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",
1226            row: |it| {
1227                format!(
1228                    "| {} | {} -> {} | {} | {} | {} |",
1229                    code_cell(it, "raw_key"),
1230                    code_cell(it, "target_package"),
1231                    code_cell(it, "version_range"),
1232                    code_cell(it, "source"),
1233                    path_line_cell(it),
1234                    markdown_table_text(str_or(it, "hint", "")),
1235                )
1236            },
1237        },
1238        SectionSpec {
1239            name: "Misconfigured dependency overrides",
1240            key: "misconfigured_dependency_overrides",
1241            header: "Package-manager override entries with an unparsable key or empty value. The active package manager will reject or ignore these.\n\n| Override | Value | Source | Location | Reason |\n|----------|-------|--------|----------|--------|\n",
1242            row: |it| {
1243                format!(
1244                    "| {} | {} | {} | {} | {} |",
1245                    markdown_table_code_span(str_or(it, "raw_key", "")),
1246                    markdown_table_code_span(str_or(it, "raw_value", "")),
1247                    code_cell(it, "source"),
1248                    path_line_cell(it),
1249                    markdown_table_text(str_or(it, "reason", "unparsable")),
1250                )
1251            },
1252        },
1253    ]
1254}
1255
1256fn check_tips(env: &Value) -> String {
1257    let fixable = arr(env, "unused_exports").count()
1258        + arr(env, "unused_dependencies").count()
1259        + arr(env, "unused_enum_members").count();
1260    let mut tips = String::from("\n\n> [!TIP]\n");
1261    if fixable > 0 {
1262        tips.push_str("> Run `fallow fix --dry-run` to preview safe auto-fixes.\n");
1263    }
1264    if arr(env, "unused_exports").count() > 0 {
1265        let _ = writeln!(
1266            tips,
1267            "> Intentionally public? Add [`/** @public */`]({SUPPRESSION_DOCS}) above exports to preserve them."
1268        );
1269    }
1270    let _ = write!(
1271        tips,
1272        "> Add [`// fallow-ignore-next-line`]({SUPPRESSION_DOCS}) above a line to suppress a specific finding."
1273    );
1274    tips
1275}
1276
1277#[must_use]
1278fn prop_drilling_section(env: &Value) -> String {
1279    let rows = arr(env, "prop_drilling_chains")
1280        .filter(|item| s(item, "effective_severity") == "error")
1281        .filter_map(|item| {
1282            let source = arr(item, "hops").next()?;
1283            Some(format!(
1284                "| {} | {} | {} |",
1285                markdown_table_code_span(s(source, "file")),
1286                code_cell(item, "prop"),
1287                u(item, "depth")
1288            ))
1289        })
1290        .collect::<Vec<_>>();
1291    if rows.is_empty() {
1292        return String::new();
1293    }
1294    format!(
1295        "\n### Prop drilling\n\n| Source | Prop | Components |\n|:-------|:-----|-----------:|\n{}\n\nReview composition, colocation or context, or suppress the source prop declaration.\n",
1296        rows.join("\n")
1297    )
1298}
1299
1300fn render_check_summary(env: &Value) -> String {
1301    let elapsed = num(env, "elapsed_ms");
1302    let total_issues = u(env, "total_issues");
1303    if total_issues == 0 {
1304        return format!(
1305            "# 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."
1306        );
1307    }
1308    let mut sections = String::new();
1309    for group in [
1310        check_sections_core(),
1311        check_sections_architecture(),
1312        check_sections_frameworks_and_hygiene(),
1313        check_sections_catalog(),
1314    ] {
1315        for spec in &group {
1316            sections.push_str(&render_check_section(env, spec));
1317        }
1318    }
1319    sections.push_str(&prop_drilling_section(env));
1320    let issue_noun = if total_issues == 1 { "issue" } else { "issues" };
1321    format!(
1322        "# 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}{}",
1323        dead_code_category_table(env),
1324        check_tips(env),
1325    )
1326}
1327
1328// ---------------------------------------------------------------------------
1329// summary-dupes.jq
1330// ---------------------------------------------------------------------------
1331
1332fn dupes_family_entry(family: &Value) -> String {
1333    let files: Vec<String> = arr(family, "files")
1334        .filter_map(Value::as_str)
1335        .map(markdown_code_span)
1336        .collect();
1337    let shown = files[..files.len().min(3)].join(", ");
1338    let more = if files.len() > 3 {
1339        format!(" (+{} more)", files.len() - 3)
1340    } else {
1341        String::new()
1342    };
1343    let mut entry = format!(
1344        "- **{shown}{more}** - {} lines, {} groups",
1345        num(family, "total_duplicated_lines"),
1346        arr(family, "groups").count(),
1347    );
1348    if let Some(best_group) = best_clone_group(family)
1349        && arr(best_group, "instances").next().is_some()
1350    {
1351        let locations = arr(best_group, "instances")
1352            .map(instance_location)
1353            .collect::<Vec<_>>()
1354            .join(", ");
1355        let _ = write!(entry, "\n  - {locations}");
1356    }
1357    if arr(family, "suggestions").next().is_some() {
1358        let suggestions = arr(family, "suggestions")
1359            .map(|suggestion| {
1360                format!(
1361                    "  - {} (~{} lines)",
1362                    markdown_table_text(s(suggestion, "description")),
1363                    num(suggestion, "estimated_savings"),
1364                )
1365            })
1366            .collect::<Vec<_>>()
1367            .join("\n");
1368        let _ = write!(entry, "\n{suggestions}");
1369    }
1370    entry
1371}
1372
1373fn instance_location(instance: &Value) -> String {
1374    markdown_code_span(&format!(
1375        "{}:{}-{}",
1376        s(instance, "file"),
1377        num(instance, "start_line"),
1378        num(instance, "end_line"),
1379    ))
1380}
1381
1382type CloneGroupJsonRankKey = (
1383    std::cmp::Reverse<u128>,
1384    std::cmp::Reverse<u64>,
1385    std::cmp::Reverse<u64>,
1386    std::cmp::Reverse<usize>,
1387    std::cmp::Reverse<u64>,
1388    String,
1389    u64,
1390);
1391
1392fn clone_group_rank_key(group: &Value) -> CloneGroupJsonRankKey {
1393    const WEIGHTS: [u64; 9] = [
1394        1_000_000_000,
1395        1_047_319_732,
1396        1_075_000_000,
1397        1_094_639_463,
1398        1_109_873_014,
1399        1_122_319_732,
1400        1_132_843_281,
1401        1_141_959_195,
1402        1_150_000_000,
1403    ];
1404    let spread = u(group, "spread");
1405    let token_count = u(group, "token_count");
1406    let instances: Vec<&Value> = arr(group, "instances").collect();
1407    let first = instances
1408        .iter()
1409        .min_by_key(|instance| (s(instance, "file").to_string(), u(instance, "start_line")));
1410    let weight = WEIGHTS[usize::try_from(spread.min(8)).unwrap_or(8)];
1411    let score = u128::from(token_count)
1412        .saturating_mul(u128::try_from(instances.len()).unwrap_or(u128::MAX))
1413        .saturating_mul(u128::from(weight));
1414    (
1415        std::cmp::Reverse(score),
1416        std::cmp::Reverse(spread),
1417        std::cmp::Reverse(token_count),
1418        std::cmp::Reverse(instances.len()),
1419        std::cmp::Reverse(u(group, "line_count")),
1420        first.map_or_else(String::new, |instance| s(instance, "file").to_string()),
1421        first.map_or(0, |instance| u(instance, "start_line")),
1422    )
1423}
1424
1425/// Reapply the engine's canonical clone ranking from public JSON fields.
1426fn sorted_clone_groups(env: &Value) -> Vec<&Value> {
1427    let mut groups: Vec<&Value> = arr(env, "clone_groups").collect();
1428    groups.sort_by_cached_key(|group| clone_group_rank_key(group));
1429    groups
1430}
1431
1432fn best_clone_group(family: &Value) -> Option<&Value> {
1433    arr(family, "groups").min_by_key(|group| clone_group_rank_key(group))
1434}
1435
1436type CloneFamilyJsonRankKey = (bool, Option<CloneGroupJsonRankKey>, Vec<String>);
1437
1438fn clone_family_rank_key(family: &Value) -> CloneFamilyJsonRankKey {
1439    let best_group = best_clone_group(family).map(clone_group_rank_key);
1440    let files = arr(family, "files")
1441        .filter_map(Value::as_str)
1442        .map(str::to_owned)
1443        .collect();
1444    (best_group.is_none(), best_group, files)
1445}
1446
1447fn sorted_clone_families(env: &Value) -> Vec<&Value> {
1448    let mut families: Vec<&Value> = arr(env, "clone_families").collect();
1449    families.sort_by_cached_key(|family| clone_family_rank_key(family));
1450    families
1451}
1452
1453/// What the measured corpus holds, which is what the headline beside this block
1454/// counts: the entries that reached the envelope plus the ones a presentation
1455/// cap such as `--top` withheld before it got here.
1456///
1457/// `clone_groups_omitted` / `clone_families_omitted` are `0` on an untruncated
1458/// run, so this is the plain array length in the common case.
1459fn dupes_corpus_total(env: &Value, listed: usize, omitted_key: &str) -> usize {
1460    listed.saturating_add(u(env, omitted_key) as usize)
1461}
1462
1463/// Name what this listing does not show, so the corpus total in the headline is
1464/// never read as the size of the list below it.
1465///
1466/// `withheld` is measured against the corpus, not against the envelope array:
1467/// two caps stack here, `--top` before the envelope was built and this block's
1468/// own display limit after, and a reader who acts on the listing needs the
1469/// total of both. Empty when the listing is complete, which keeps an
1470/// untruncated run's summary byte-identical.
1471fn dupes_omission_tail(withheld: usize, capped_by_top: usize, noun: &str) -> String {
1472    if withheld == 0 {
1473        return String::new();
1474    }
1475    if capped_by_top == 0 {
1476        return format!("\n- *... and {withheld} more {noun}*");
1477    }
1478    format!(
1479        "\n- *... and {withheld} more {noun}, {capped_by_top} of them withheld by a display limit before this report*"
1480    )
1481}
1482
1483fn dupes_details(env: &Value) -> String {
1484    let families = sorted_clone_families(env);
1485    if families.is_empty() {
1486        let groups = sorted_clone_groups(env);
1487        let rows = groups
1488            .iter()
1489            .take(20)
1490            .map(|group| {
1491                let locations = arr(group, "instances")
1492                    .map(instance_location)
1493                    .collect::<Vec<_>>()
1494                    .join(", ");
1495                format!(
1496                    "- **{} lines, {} tokens**, {locations}",
1497                    num(group, "line_count"),
1498                    num(group, "token_count"),
1499                )
1500            })
1501            .collect::<Vec<_>>()
1502            .join("\n");
1503        // Subtract what this listing actually rendered, not the display limit:
1504        // a `--top` below the limit leaves fewer rows than 20, and measuring
1505        // the withholding against the limit would under-report it.
1506        let total = dupes_corpus_total(env, groups.len(), "clone_groups_omitted");
1507        let tail = dupes_omission_tail(
1508            total.saturating_sub(groups.len().min(20)),
1509            u(env, "clone_groups_omitted") as usize,
1510            "groups",
1511        );
1512        format!("{rows}{tail}")
1513    } else {
1514        let entries = families
1515            .iter()
1516            .take(15)
1517            .map(|family| dupes_family_entry(family))
1518            .collect::<Vec<_>>()
1519            .join("\n");
1520        let total = dupes_corpus_total(env, families.len(), "clone_families_omitted");
1521        let tail = dupes_omission_tail(
1522            total.saturating_sub(families.len().min(15)),
1523            u(env, "clone_families_omitted") as usize,
1524            "families",
1525        );
1526        format!("**Clone Families ({total})**\n\n{entries}{tail}")
1527    }
1528}
1529
1530/// Port of `summary-dupes.jq`.
1531#[must_use]
1532fn render_dupes_summary(env: &Value) -> String {
1533    let stats = env.get("stats").cloned().unwrap_or(Value::Null);
1534    let elapsed = num(env, "elapsed_ms");
1535    if u(&stats, "clone_groups") == 0 {
1536        return format!(
1537            "## Fallow - Code Duplication\n\nNo code duplication found.\n\n*Analyzed {} files in {elapsed}ms*",
1538            num(&stats, "total_files"),
1539        );
1540    }
1541    format!(
1542        "## 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>",
1543        num(&stats, "clone_groups"),
1544        num(&stats, "clone_instances"),
1545        num(&stats, "files_with_clones"),
1546        num(&stats, "total_files"),
1547        num(&stats, "files_with_clones"),
1548        num(&stats, "clone_groups"),
1549        num(&stats, "clone_instances"),
1550        num(&stats, "duplicated_lines"),
1551        num(&stats, "total_lines"),
1552        pct(f_or_zero(&stats, "duplication_percentage")),
1553        dupes_details(env),
1554    )
1555}
1556
1557// ---------------------------------------------------------------------------
1558// summary-health.jq
1559// ---------------------------------------------------------------------------
1560
1561fn metric_delta<'v>(score_env: &'v Value, name: &str) -> Option<&'v Value> {
1562    score_env
1563        .get("health_trend")
1564        .and_then(|trend| trend.get("metrics"))
1565        .and_then(Value::as_array)
1566        .and_then(|metrics| metrics.iter().find(|metric| s(metric, "name") == name))
1567}
1568
1569/// The `> **Health: ...**` trend header shared by `summary-health.jq` and
1570/// `summary-combined.jq`. Empty when no `health_score` block is present.
1571fn health_score_header(score_env: &Value) -> String {
1572    let Some(score) = score_env
1573        .get("health_score")
1574        .filter(|value| !value.is_null())
1575    else {
1576        return String::new();
1577    };
1578    let mut header = format!(
1579        "> **Health: {} ({})**",
1580        s(score, "grade"),
1581        pct(f_or_zero(score, "score")),
1582    );
1583    if let Some(score_delta) = metric_delta(score_env, "score") {
1584        let compared = score_env
1585            .get("health_trend")
1586            .and_then(|trend| trend.get("compared_to"))
1587            .cloned()
1588            .unwrap_or(Value::Null);
1589        let _ = write!(
1590            header,
1591            " \u{b7} {} pts vs previous ({} {})",
1592            signed(f_or_zero(score_delta, "delta")),
1593            s(&compared, "grade"),
1594            pct(f_or_zero(&compared, "score")),
1595        );
1596        if let Some(dead_delta) = metric_delta(score_env, "dead_export_pct")
1597            && f_or_zero(dead_delta, "delta") != 0.0
1598        {
1599            let _ = write!(
1600                header,
1601                " \u{b7} {} {}% ({}%)",
1602                s(dead_delta, "label").to_ascii_lowercase(),
1603                pct(f_or_zero(dead_delta, "current")),
1604                signed(f_or_zero(dead_delta, "delta")),
1605            );
1606            if f_or_zero(dead_delta, "delta") > 0.0 {
1607                let _ = write!(header, " [suppress?]({SUPPRESSION_DOCS})");
1608            }
1609        }
1610        if let Some(cx_delta) = metric_delta(score_env, "avg_cyclomatic")
1611            && f_or_zero(cx_delta, "delta") != 0.0
1612        {
1613            let _ = write!(
1614                header,
1615                " \u{b7} {} {} ({})",
1616                s(cx_delta, "label").to_ascii_lowercase(),
1617                pct(f_or_zero(cx_delta, "current")),
1618                signed(f_or_zero(cx_delta, "delta")),
1619            );
1620        }
1621    } else if let Some(compared) = score_env
1622        .get("health_trend")
1623        .and_then(|trend| trend.get("compared_to"))
1624    {
1625        let previous_formula = compared
1626            .get("score_formula_version")
1627            .and_then(Value::as_u64)
1628            .and_then(|version| u32::try_from(version).ok());
1629        let current_formula = score
1630            .get("formula_version")
1631            .and_then(Value::as_u64)
1632            .and_then(|version| u32::try_from(version).ok());
1633        let note = compared
1634            .get("score")
1635            .and_then(Value::as_f64)
1636            .and_then(|_| {
1637                fallow_output::health_score_comparison_note(previous_formula, current_formula)
1638            })
1639            .unwrap_or("Score comparison unavailable for this snapshot.");
1640        let _ = write!(header, "\n> _{note}_");
1641    } else {
1642        header.push_str("\n> _Enable `save-snapshot: true` to track score trends over time._");
1643    }
1644    header.push_str("\n\n");
1645    header
1646}
1647
1648fn exceeded_marker(it: &Value, needles: &[&str]) -> &'static str {
1649    let exceeded = s(it, "exceeded");
1650    if needles.iter().any(|needle| exceeded.contains(needle)) {
1651        " **!**"
1652    } else {
1653        ""
1654    }
1655}
1656
1657fn crap_cell(it: &Value) -> String {
1658    match it.get("crap").filter(|crap| !crap.is_null()) {
1659        None => "-".to_owned(),
1660        Some(crap) => format!("{}{}", fmt_num(crap), exceeded_marker(it, &["crap", "all"])),
1661    }
1662}
1663
1664fn complexity_table_row(it: &Value) -> String {
1665    format!(
1666        "| {} | {} | {} | {}{} | {}{} | {} | {} |",
1667        path_line_cell(it),
1668        code_cell(it, "name"),
1669        markdown_table_text(str_or(it, "severity", "moderate")),
1670        num(it, "cyclomatic"),
1671        exceeded_marker(it, &["cyclomatic", "both", "all"]),
1672        num(it, "cognitive"),
1673        exceeded_marker(it, &["cognitive", "both", "all"]),
1674        crap_cell(it),
1675        num(it, "line_count"),
1676    )
1677}
1678
1679const COMPLEXITY_TABLE_HEADER: &str = "| File | Function | Severity | Cyclomatic | Cognitive | CRAP | Lines |\n|:-----|:---------|:---------|:-----------|:----------|:-----|:------|\n";
1680
1681fn health_thresholds_footer(env: &Value) -> String {
1682    let summary = env.get("summary").cloned().unwrap_or(Value::Null);
1683    format!(
1684        "\n\n**!** marks the dimension that breached.\n\n**{}** files, **{}** functions analyzed (thresholds: cyclomatic > {}, cognitive > {}, CRAP >= {})",
1685        num(&summary, "files_analyzed"),
1686        num(&summary, "functions_analyzed"),
1687        num(&summary, "max_cyclomatic_threshold"),
1688        num(&summary, "max_cognitive_threshold"),
1689        threshold_or(&summary, "max_crap_threshold", "30"),
1690    )
1691}
1692
1693fn threshold_or(summary: &Value, key: &str, default: &str) -> String {
1694    summary
1695        .get(key)
1696        .filter(|value| !value.is_null())
1697        .map_or_else(|| default.to_owned(), fmt_num)
1698}
1699
1700fn complexity_rows(findings: &[&Value], cap: usize) -> String {
1701    findings
1702        .iter()
1703        .take(cap)
1704        .map(|finding| complexity_table_row(finding))
1705        .collect::<Vec<_>>()
1706        .join("\n")
1707}
1708
1709fn runtime_finding_row(it: &Value) -> String {
1710    let invocations = it
1711        .get("invocations")
1712        .filter(|value| !value.is_null())
1713        .map_or_else(|| "-".to_owned(), fmt_num);
1714    format!(
1715        "| {} | {} | {} | {invocations} | {} |",
1716        path_line_cell(it),
1717        code_cell(it, "function"),
1718        code_cell(it, "verdict"),
1719        markdown_table_text(s(it, "confidence")),
1720    )
1721}
1722
1723/// The functions above a threshold after a loaded health baseline, and the
1724/// functions that the baseline accepts.
1725///
1726/// `summary.functions_above_threshold` counts the functions before the
1727/// baseline. `baseline_staleness.remaining_findings` counts them after it.
1728/// `None` without a baseline, and in an envelope from a fallow version before
1729/// `remaining_findings`, so those runs render as before.
1730fn baselined_complexity(summary: &Value) -> Option<(u64, u64)> {
1731    let remaining = summary
1732        .get("baseline_staleness")?
1733        .get("remaining_findings")?
1734        .as_u64()?;
1735    let accepted = u(summary, "functions_above_threshold").saturating_sub(remaining);
1736    Some((remaining, accepted))
1737}
1738
1739fn render_health_complexity_only(env: &Value, complex: usize, elapsed: &str) -> String {
1740    let summary = env.get("summary").cloned().unwrap_or(Value::Null);
1741    let above = u(&summary, "functions_above_threshold");
1742    let listing = complexity_listing(env, &summary, complex, above);
1743    if listing.count_unlisted() {
1744        return render_health_complexity_not_listed(&summary, above, &listing, elapsed);
1745    }
1746    let baselined = baselined_complexity(&summary).filter(|&(_, accepted)| accepted > 0);
1747    if complex == 0 {
1748        // Only when the baseline accepts every function. A run that lists none
1749        // of its remaining functions keeps the old line.
1750        let headline = baselined
1751            .filter(|&(remaining, _)| remaining == 0)
1752            .map_or_else(
1753                || "No functions exceed complexity thresholds".to_owned(),
1754                |(_, accepted)| {
1755                    format!(
1756                        "No new functions exceed complexity thresholds ({accepted} in the baseline)"
1757                    )
1758                },
1759            );
1760        return format!(
1761            "## Fallow - Code Complexity\n\n> [!NOTE]\n> **{headline}** \u{b7} {elapsed}ms\n\n{} functions analyzed (max cyclomatic: {}, max cognitive: {}, max CRAP: {})",
1762            num(&summary, "functions_analyzed"),
1763            num(&summary, "max_cyclomatic_threshold"),
1764            num(&summary, "max_cognitive_threshold"),
1765            threshold_or(&summary, "max_crap_threshold", "30"),
1766        );
1767    }
1768    let findings: Vec<&Value> = arr(env, "findings").collect();
1769    let tail = if complex > 25 {
1770        format!(
1771            "\n\n> {} more - run `fallow health` locally for the full list",
1772            complex - 25
1773        )
1774    } else {
1775        String::new()
1776    };
1777    let (shown, new, baseline_note) = baselined
1778        .map_or((above, "", String::new()), |(remaining, accepted)| {
1779            (remaining, "new ", format!(" ({accepted} in the baseline)"))
1780        });
1781    format!(
1782        "## Fallow - Code Complexity\n\n> [!WARNING]\n> **{shown} {new}function{} exceed{} thresholds{baseline_note}** \u{b7} {elapsed}ms\n\n{COMPLEXITY_TABLE_HEADER}{}{tail}{}",
1783        if shown == 1 { "" } else { "s" },
1784        if shown == 1 { "s" } else { "" },
1785        complexity_rows(&findings, 25),
1786        health_thresholds_footer(env),
1787    )
1788}
1789
1790/// The complexity listing facts of a health envelope. The Markdown section
1791/// builds the same facts from the typed report.
1792fn complexity_listing(
1793    env: &Value,
1794    summary: &Value,
1795    complex: usize,
1796    above: u64,
1797) -> fallow_api::ComplexityListing {
1798    let staleness = summary
1799        .get("baseline_staleness")
1800        .filter(|value| !value.is_null());
1801    fallow_api::ComplexityListing {
1802        listed: complex,
1803        above: usize::try_from(above).unwrap_or(usize::MAX),
1804        // An envelope from an older fallow has no `sections`.
1805        complexity_section: env
1806            .get("sections")
1807            .and_then(Value::as_array)
1808            .map(|sections| sections.iter().any(|s| s.as_str() == Some("complexity"))),
1809        // Only a score-only run omits `vital_signs`.
1810        score_only: env.get("vital_signs").is_none_or(Value::is_null),
1811        baselined: staleness.is_some(),
1812        remaining: staleness
1813            .and_then(|staleness| staleness.get("remaining_findings"))
1814            .and_then(Value::as_u64)
1815            .map(|remaining| usize::try_from(remaining).unwrap_or(usize::MAX)),
1816    }
1817}
1818
1819/// The run counted functions above a threshold but did not list them, for
1820/// example a `--score` or a `--hotspots` run. The summary gives the count,
1821/// not a clean result.
1822fn render_health_complexity_not_listed(
1823    summary: &Value,
1824    above: u64,
1825    listing: &fallow_api::ComplexityListing,
1826    elapsed: &str,
1827) -> String {
1828    format!(
1829        "## Fallow - Code Complexity\n\n> [!NOTE]\n> **{above} function{} exceed{} thresholds** \u{b7} {elapsed}ms\n\n{} functions analyzed (max cyclomatic: {}, max cognitive: {}, max CRAP: {}). {}",
1830        if above == 1 { "" } else { "s" },
1831        if above == 1 { "s" } else { "" },
1832        num(summary, "functions_analyzed"),
1833        num(summary, "max_cyclomatic_threshold"),
1834        num(summary, "max_cognitive_threshold"),
1835        threshold_or(summary, "max_crap_threshold", "30"),
1836        listing.not_listed_note(),
1837    )
1838}
1839
1840fn prod_phrase(complex: usize, prod: usize) -> String {
1841    let complexity = format!(
1842        "{complex} complexity finding{}",
1843        if complex == 1 { "" } else { "s" }
1844    );
1845    let runtime = format!(
1846        "{prod} runtime coverage finding{}",
1847        if prod == 1 { "" } else { "s" }
1848    );
1849    if complex > 0 && prod > 0 {
1850        format!("{complexity} and {runtime}")
1851    } else if complex > 0 {
1852        complexity
1853    } else {
1854        runtime
1855    }
1856}
1857
1858fn render_health_with_runtime(env: &Value, complex: usize, elapsed: &str) -> String {
1859    let runtime = env.get("runtime_coverage").cloned().unwrap_or(Value::Null);
1860    let prod_findings: Vec<&Value> = arr(&runtime, "findings").collect();
1861    let hot_paths: Vec<&Value> = arr(&runtime, "hot_paths").collect();
1862    let prod = prod_findings.len();
1863    let hot = hot_paths.len();
1864    let mut out = String::from("## Fallow - Health\n\n");
1865    if complex == 0 && prod == 0 {
1866        let _ = write!(
1867            out,
1868            "> [!NOTE]\n> **No failing health findings** \u{b7} {elapsed}ms\n\n"
1869        );
1870    } else {
1871        let _ = write!(
1872            out,
1873            "> [!WARNING]\n> **{}** \u{b7} {elapsed}ms\n\n",
1874            prod_phrase(complex, prod),
1875        );
1876    }
1877    if complex > 0 {
1878        let findings: Vec<&Value> = arr(env, "findings").collect();
1879        let _ = write!(
1880            out,
1881            "### Complexity\n\n{COMPLEXITY_TABLE_HEADER}{}",
1882            complexity_rows(&findings, 25),
1883        );
1884        if complex > 25 {
1885            let _ = write!(
1886                out,
1887                "\n\n> {} more complexity findings - run `fallow health` locally for the full list",
1888                complex - 25,
1889            );
1890        }
1891    }
1892    if prod > 0 {
1893        if complex > 0 {
1894            out.push_str("\n\n");
1895        }
1896        out.push_str("### Runtime Coverage\n\n| File | Function | Verdict | Invocations | Confidence |\n|:-----|:---------|:--------|------------:|:-----------|\n");
1897        out.push_str(
1898            &prod_findings
1899                .iter()
1900                .take(25)
1901                .map(|finding| runtime_finding_row(finding))
1902                .collect::<Vec<_>>()
1903                .join("\n"),
1904        );
1905        if prod > 25 {
1906            let _ = write!(
1907                out,
1908                "\n\n> {} more runtime coverage findings - run `fallow health` locally for the full list",
1909                prod - 25,
1910            );
1911        }
1912    }
1913    if hot > 0 {
1914        if complex > 0 || prod > 0 {
1915            out.push_str("\n\n");
1916        }
1917        out.push_str("### Hot Paths\n\n| File | Function | Invocations | Percentile |\n|:-----|:---------|------------:|-----------:|\n");
1918        out.push_str(
1919            &hot_paths
1920                .iter()
1921                .take(10)
1922                .map(|path| {
1923                    format!(
1924                        "| {} | {} | {} | {} |",
1925                        path_line_cell(path),
1926                        code_cell(path, "function"),
1927                        num(path, "invocations"),
1928                        num(path, "percentile"),
1929                    )
1930                })
1931                .collect::<Vec<_>>()
1932                .join("\n"),
1933        );
1934        if hot > 10 {
1935            let _ = write!(out, "\n\n> {} more hot paths in the full report", hot - 10);
1936        }
1937    }
1938    out.push_str(&health_runtime_footer(env, complex, prod, hot, &runtime));
1939    out
1940}
1941
1942fn health_runtime_footer(
1943    env: &Value,
1944    complex: usize,
1945    prod: usize,
1946    hot: usize,
1947    runtime: &Value,
1948) -> String {
1949    if complex > 0 {
1950        return health_thresholds_footer(env);
1951    }
1952    if prod > 0 {
1953        let summary = runtime.get("summary").cloned().unwrap_or(Value::Null);
1954        return format!(
1955            "\n\n**{}** tracked functions, **{}** hit, **{}** unhit, **{}** untracked",
1956            num(&summary, "functions_tracked"),
1957            num(&summary, "functions_hit"),
1958            num(&summary, "functions_unhit"),
1959            num(&summary, "functions_untracked"),
1960        );
1961    }
1962    format!(
1963        "\n\nObserved **{hot}** hot path{} in runtime coverage.",
1964        if hot == 1 { "" } else { "s" },
1965    )
1966}
1967
1968/// Port of `summary-health.jq`.
1969#[must_use]
1970fn render_health_summary(env: &Value) -> String {
1971    let elapsed = num(env, "elapsed_ms");
1972    let complex = arr(env, "findings").count();
1973    let runtime = env.get("runtime_coverage").cloned().unwrap_or(Value::Null);
1974    let prod = arr(&runtime, "findings").count();
1975    let hot = arr(&runtime, "hot_paths").count();
1976    let body = if prod == 0 && hot == 0 {
1977        render_health_complexity_only(env, complex, &elapsed)
1978    } else {
1979        render_health_with_runtime(env, complex, &elapsed)
1980    };
1981    let groups = fallow_api::build_health_groups_markdown(env, "");
1982    if groups.is_empty() {
1983        format!("{}{body}", health_score_header(env))
1984    } else {
1985        format!("{}{body}\n\n{groups}", health_score_header(env))
1986    }
1987}
1988
1989// ---------------------------------------------------------------------------
1990// summary-audit.jq
1991// ---------------------------------------------------------------------------
1992
1993const fn audit_verdict_label(verdict: &str) -> &'static str {
1994    match verdict.as_bytes() {
1995        b"fail" => "[!WARNING]\n> **Audit failed**",
1996        b"warn" => "[!WARNING]\n> **Audit passed with warnings**",
1997        _ => "[!NOTE]\n> **Audit passed**",
1998    }
1999}
2000
2001fn introduced_label(item: &Value) -> &'static str {
2002    match item.get("introduced").and_then(Value::as_bool) {
2003        Some(true) => "new",
2004        Some(false) => "inherited",
2005        None => "unknown",
2006    }
2007}
2008
2009fn audit_rule_severity(item: &Value) -> &'static str {
2010    match item
2011        .get("effective_severity")
2012        .or_else(|| item.get("severity"))
2013        .and_then(Value::as_str)
2014    {
2015        Some("error") => "error",
2016        Some("warn") => "warn",
2017        _ => "unknown",
2018    }
2019}
2020
2021fn audit_priority(env: &Value, severity: &str, status: &str) -> (u8, u8) {
2022    let severity = match severity {
2023        "error" => 0,
2024        "warn" => 1,
2025        _ => 2,
2026    };
2027    let status = if env["attribution"]["gate"] == "new-only" {
2028        match status {
2029            "new" => 0,
2030            "inherited" => 1,
2031            _ => 2,
2032        }
2033    } else {
2034        0
2035    };
2036    (severity, status)
2037}
2038
2039fn sorted_audit_findings<'a>(env: &Value, section: &'a Value, key: &str) -> Vec<&'a Value> {
2040    let mut findings: Vec<&Value> = arr(section, key).collect();
2041    findings.sort_by(|a, b| {
2042        audit_priority(env, audit_rule_severity(a), introduced_label(a))
2043            .cmp(&audit_priority(
2044                env,
2045                audit_rule_severity(b),
2046                introduced_label(b),
2047            ))
2048            .then_with(|| s(a, "path").cmp(s(b, "path")))
2049            .then_with(|| u(a, "line").cmp(&u(b, "line")))
2050            .then_with(|| s(a, "name").cmp(s(b, "name")))
2051            .then_with(|| s(a, "code").cmp(s(b, "code")))
2052            .then_with(|| s(a, "value").cmp(s(b, "value")))
2053    });
2054    findings
2055}
2056
2057struct AuditRow {
2058    kind: &'static str,
2059    location: String,
2060    item: String,
2061    status: &'static str,
2062    severity: &'static str,
2063}
2064
2065fn audit_row(kind: &'static str, location: String, item: String, finding: &Value) -> AuditRow {
2066    AuditRow {
2067        kind,
2068        location,
2069        item,
2070        status: introduced_label(finding),
2071        severity: audit_rule_severity(finding),
2072    }
2073}
2074
2075type AuditRowSpec = (&'static str, &'static str, fn(&Value) -> String);
2076
2077/// jq order positions 2-11: exports, types, leaks, dependencies, members,
2078/// unresolved imports.
2079const AUDIT_EXPORT_DEP_ROWS: &[AuditRowSpec] = &[
2080    ("Unused export", "unused_exports", |it| {
2081        code_cell(it, "export_name")
2082    }),
2083    ("Unused type", "unused_types", |it| {
2084        code_cell(it, "export_name")
2085    }),
2086    ("Private type leak", "private_type_leaks", |it| {
2087        format!(
2088            "{} -> {}",
2089            code_cell(it, "export_name"),
2090            code_cell(it, "type_name")
2091        )
2092    }),
2093    (
2094        "Deprecated export in use",
2095        "deprecated_exports_in_use",
2096        |it| code_cell(it, "export_name"),
2097    ),
2098    ("Unused dependency", "unused_dependencies", |it| {
2099        code_cell(it, "package_name")
2100    }),
2101    ("Unused devDependency", "unused_dev_dependencies", |it| {
2102        code_cell(it, "package_name")
2103    }),
2104    (
2105        "Unused optionalDependency",
2106        "unused_optional_dependencies",
2107        |it| code_cell(it, "package_name"),
2108    ),
2109    ("Unused enum member", "unused_enum_members", member_item),
2110    ("Unused class member", "unused_class_members", member_item),
2111    ("Unused store member", "unused_store_members", member_item),
2112    ("Unresolved import", "unresolved_imports", |it| {
2113        code_cell(it, "specifier")
2114    }),
2115];
2116
2117/// jq order positions 26-33: component-model kinds.
2118const AUDIT_COMPONENT_ROWS: &[AuditRowSpec] = &[
2119    ("Unrendered component", "unrendered_components", |it| {
2120        format!(
2121            "{} ({})",
2122            code_cell(it, "component_name"),
2123            markdown_table_text(s(it, "framework"))
2124        )
2125    }),
2126    ("Unused component prop", "unused_component_props", |it| {
2127        component_member_item(it, "prop_name")
2128    }),
2129    (
2130        "Absent optional component prop",
2131        "absent_component_props",
2132        |it| component_member_item(it, "prop_name"),
2133    ),
2134    ("Unused component emit", "unused_component_emits", |it| {
2135        format!(
2136            "{} emit {}",
2137            code_cell(it, "component_name"),
2138            code_cell(it, "emit_name")
2139        )
2140    }),
2141    ("Unused component input", "unused_component_inputs", |it| {
2142        component_member_item(it, "input_name")
2143    }),
2144    (
2145        "Unused component output",
2146        "unused_component_outputs",
2147        |it| {
2148            format!(
2149                "{} output {}",
2150                code_cell(it, "component_name"),
2151                code_cell(it, "output_name")
2152            )
2153        },
2154    ),
2155    ("Unused Svelte event", "unused_svelte_events", |it| {
2156        format!(
2157            "{} event {}",
2158            code_cell(it, "component_name"),
2159            code_cell(it, "event_name")
2160        )
2161    }),
2162    ("Unused Effect layer", "unused_effect_layers", |it| {
2163        format!("layer {}", code_cell(it, "name"))
2164    }),
2165    ("Unused tagged error", "unused_tagged_errors", |it| {
2166        format!("{} tag {}", code_cell(it, "name"), code_cell(it, "tag"))
2167    }),
2168    ("Unproduced catch tag", "unproduced_catch_tags", |it| {
2169        format!("catchTag {}", code_cell(it, "tag"))
2170    }),
2171    ("Unprovided inject", "unprovided_injects", |it| {
2172        format!(
2173            "{} ({})",
2174            code_cell(it, "key_name"),
2175            markdown_table_text(s(it, "framework"))
2176        )
2177    }),
2178    ("Unused load data key", "unused_load_data_keys", |it| {
2179        code_cell(it, "key_name")
2180    }),
2181];
2182
2183/// jq order positions 34-42: dependency hygiene, suppressions, catalog.
2184const AUDIT_HYGIENE_ROWS: &[AuditRowSpec] = &[
2185    ("Type-only dependency", "type_only_dependencies", |it| {
2186        code_cell(it, "package_name")
2187    }),
2188    ("Test-only dependency", "test_only_dependencies", |it| {
2189        code_cell(it, "package_name")
2190    }),
2191    (
2192        "Dev dependency in production",
2193        "dev_dependencies_in_production",
2194        |it| code_cell(it, "package_name"),
2195    ),
2196    ("Stale suppression", "stale_suppressions", |it| {
2197        // The description is a literal suppression directive carrying
2198        // user-authored comment text, so it renders as a code span.
2199        markdown_table_code_span(str_or(it, "description", "suppression"))
2200    }),
2201    ("Unused catalog entry", "unused_catalog_entries", |it| {
2202        format!(
2203            "{} ({})",
2204            code_cell(it, "entry_name"),
2205            code_cell(it, "catalog_name")
2206        )
2207    }),
2208    ("Empty catalog group", "empty_catalog_groups", |it| {
2209        code_cell(it, "catalog_name")
2210    }),
2211    (
2212        "Unresolved catalog reference",
2213        "unresolved_catalog_references",
2214        |it| {
2215            format!(
2216                "{} -> {}",
2217                code_cell(it, "entry_name"),
2218                code_cell(it, "catalog_name")
2219            )
2220        },
2221    ),
2222    (
2223        "Unused dependency override",
2224        "unused_dependency_overrides",
2225        |it| format!("{} ({})", code_cell(it, "raw_key"), code_cell(it, "source")),
2226    ),
2227    (
2228        "Misconfigured dependency override",
2229        "misconfigured_dependency_overrides",
2230        |it| format!("{} ({})", code_cell(it, "raw_key"), code_cell(it, "source")),
2231    ),
2232];
2233
2234fn audit_rows_from_table(dead_code: &Value, table: &[AuditRowSpec], rows: &mut Vec<AuditRow>) {
2235    for (kind, key, item_fn) in table {
2236        for finding in arr(dead_code, key) {
2237            rows.push(audit_row(
2238                kind,
2239                path_line(finding),
2240                item_fn(finding),
2241                finding,
2242            ));
2243        }
2244    }
2245}
2246
2247fn member_item(it: &Value) -> String {
2248    markdown_table_code_span(&format!(
2249        "{}.{}",
2250        s(it, "parent_name"),
2251        s(it, "member_name")
2252    ))
2253}
2254
2255fn component_member_item(it: &Value, member_key: &str) -> String {
2256    markdown_table_code_span(&format!(
2257        "{}.{}",
2258        s(it, "component_name"),
2259        s(it, member_key)
2260    ))
2261}
2262
2263fn first_import_site(it: &Value) -> String {
2264    arr(it, "imported_from").next().map_or_else(
2265        || path_line(it),
2266        |site| {
2267            markdown_table_code_span(&format!(
2268                "{}:{}",
2269                rel_path_absolute_only(s(site, "path")),
2270                num(site, "line"),
2271            ))
2272        },
2273    )
2274}
2275
2276/// jq order positions 12-15: unlisted dependencies, duplicate exports,
2277/// circular dependencies, re-export cycles.
2278fn audit_rows_graph(dead_code: &Value, rows: &mut Vec<AuditRow>) {
2279    for it in arr(dead_code, "unlisted_dependencies") {
2280        rows.push(audit_row(
2281            "Unlisted dependency",
2282            first_import_site(it),
2283            code_cell(it, "package_name"),
2284            it,
2285        ));
2286    }
2287    for it in arr(dead_code, "duplicate_exports") {
2288        let location = arr(it, "locations")
2289            .take(3)
2290            .map(|loc| {
2291                markdown_table_code_span(&format!(
2292                    "{}:{}",
2293                    rel_path_absolute_only(s(loc, "path")),
2294                    num(loc, "line")
2295                ))
2296            })
2297            .collect::<Vec<_>>()
2298            .join(", ");
2299        rows.push(audit_row(
2300            "Duplicate export",
2301            location,
2302            code_cell(it, "export_name"),
2303            it,
2304        ));
2305    }
2306    for it in arr(dead_code, "circular_dependencies") {
2307        let location = arr(it, "files")
2308            .filter_map(Value::as_str)
2309            .map(|file| markdown_table_code_span(&rel_path_absolute_only(file)))
2310            .collect::<Vec<_>>()
2311            .join(" -> ");
2312        rows.push(audit_row(
2313            "Circular dependency",
2314            location,
2315            "cycle".to_owned(),
2316            it,
2317        ));
2318    }
2319    for it in arr(dead_code, "re_export_cycles") {
2320        let location = arr(it, "files")
2321            .filter_map(Value::as_str)
2322            .map(|file| markdown_table_code_span(&rel_path_absolute_only(file)))
2323            .collect::<Vec<_>>()
2324            .join(" <-> ");
2325        rows.push(audit_row(
2326            "Re-export cycle",
2327            location,
2328            markdown_table_text(str_or(it, "kind", "cycle")),
2329            it,
2330        ));
2331    }
2332    for it in arr(dead_code, "package_cycles") {
2333        let location = arr(it, "packages")
2334            .filter_map(Value::as_str)
2335            .map(markdown_table_code_span)
2336            .collect::<Vec<_>>()
2337            .join(" -> ");
2338        let detail = if it.get("group_truncated").and_then(Value::as_bool) == Some(true) {
2339            format!(
2340                "cycle ({})",
2341                fallow_types::results::PackageCycle::GROUP_TRUNCATED_NOTE
2342            )
2343        } else {
2344            "cycle".to_owned()
2345        };
2346        rows.push(audit_row("Package cycle", location, detail, it));
2347    }
2348}
2349
2350/// jq order positions 16-19: boundary and policy kinds.
2351fn audit_rows_boundaries(dead_code: &Value, rows: &mut Vec<AuditRow>) {
2352    for it in arr(dead_code, "boundary_violations") {
2353        rows.push(audit_row(
2354            "Boundary violation",
2355            rel_path_line_cell(it, "from_path"),
2356            format!(
2357                "{} -> {}",
2358                markdown_table_code_span(s(it, "from_zone")),
2359                markdown_table_code_span(s(it, "to_zone"))
2360            ),
2361            it,
2362        ));
2363    }
2364    for it in arr(dead_code, "boundary_coverage_violations") {
2365        rows.push(audit_row(
2366            "Boundary coverage",
2367            rel_path_line_cell(it, "path"),
2368            "no matching zone".to_owned(),
2369            it,
2370        ));
2371    }
2372    for it in arr(dead_code, "boundary_call_violations") {
2373        rows.push(audit_row(
2374            "Boundary call",
2375            rel_path_line_cell(it, "path"),
2376            format!(
2377                "{} in {}",
2378                code_cell(it, "callee"),
2379                markdown_table_code_span(s(it, "zone"))
2380            ),
2381            it,
2382        ));
2383    }
2384    for it in arr(dead_code, "policy_violations") {
2385        rows.push(audit_row(
2386            "Policy violation",
2387            rel_path_line_cell(it, "path"),
2388            format!(
2389                "{} banned by {}",
2390                code_cell(it, "matched"),
2391                markdown_table_code_span(&format!("{}/{}", s(it, "pack"), s(it, "rule_id")))
2392            ),
2393            it,
2394        ));
2395    }
2396}
2397
2398/// jq order positions 20-25: RSC/Next.js kinds, including unused server
2399/// actions between misplaced directives and route collisions.
2400fn audit_rows_frameworks(dead_code: &Value, rows: &mut Vec<AuditRow>) {
2401    for it in arr(dead_code, "invalid_client_exports") {
2402        rows.push(audit_row(
2403            "Invalid client export",
2404            rel_path_line_cell(it, "path"),
2405            format!(
2406                "{} in {}",
2407                code_cell(it, "export_name"),
2408                markdown_table_code_span(&format!("\"{}\"", s(it, "directive")))
2409            ),
2410            it,
2411        ));
2412    }
2413    for it in arr(dead_code, "mixed_client_server_barrels") {
2414        rows.push(audit_row(
2415            "Mixed client/server barrel",
2416            rel_path_line_cell(it, "path"),
2417            format!(
2418                "{} + {}",
2419                code_cell(it, "client_origin"),
2420                code_cell(it, "server_origin")
2421            ),
2422            it,
2423        ));
2424    }
2425    for it in arr(dead_code, "misplaced_directives") {
2426        rows.push(audit_row(
2427            "Misplaced directive",
2428            rel_path_line_cell(it, "path"),
2429            markdown_table_code_span(&format!("\"{}\"", s(it, "directive"))),
2430            it,
2431        ));
2432    }
2433    for it in arr(dead_code, "unused_server_actions") {
2434        rows.push(audit_row(
2435            "Unused server action",
2436            path_line(it),
2437            code_cell(it, "action_name"),
2438            it,
2439        ));
2440    }
2441    for it in arr(dead_code, "route_collisions") {
2442        rows.push(audit_row(
2443            "Route collision",
2444            markdown_table_code_span(&rel_path_absolute_only(s(it, "path"))),
2445            code_cell(it, "url"),
2446            it,
2447        ));
2448    }
2449    for it in arr(dead_code, "dynamic_segment_name_conflicts") {
2450        rows.push(audit_row(
2451            "Dynamic segment conflict",
2452            markdown_table_code_span(&rel_path_absolute_only(s(it, "path"))),
2453            markdown_table_code_span(&plain_join(it, "conflicting_segments", ", ")),
2454            it,
2455        ));
2456    }
2457}
2458
2459/// Rows in `summary-audit.jq`'s `dead_code_rows` declaration order.
2460fn audit_dead_code_rows(dead_code: &Value) -> Vec<AuditRow> {
2461    let mut rows: Vec<AuditRow> = Vec::new();
2462    for it in arr(dead_code, "unused_files") {
2463        rows.push(audit_row(
2464            "Unused file",
2465            markdown_table_code_span(&rel_path_absolute_only(s(it, "path"))),
2466            "-".to_owned(),
2467            it,
2468        ));
2469    }
2470    audit_rows_from_table(dead_code, AUDIT_EXPORT_DEP_ROWS, &mut rows);
2471    audit_rows_graph(dead_code, &mut rows);
2472    audit_rows_boundaries(dead_code, &mut rows);
2473    audit_rows_frameworks(dead_code, &mut rows);
2474    audit_rows_from_table(dead_code, AUDIT_COMPONENT_ROWS, &mut rows);
2475    audit_rows_from_table(dead_code, AUDIT_HYGIENE_ROWS, &mut rows);
2476    for item in arr(dead_code, "prop_drilling_chains")
2477        .filter(|item| s(item, "effective_severity") == "error")
2478    {
2479        let Some(source) = arr(item, "hops").next() else {
2480            continue;
2481        };
2482        rows.push(audit_row(
2483            "Prop drilling",
2484            markdown_table_code_span(s(source, "file")),
2485            code_cell(item, "prop"),
2486            item,
2487        ));
2488    }
2489    rows
2490}
2491
2492fn audit_complexity_section(env: &Value) -> String {
2493    let complexity = env.get("complexity").cloned().unwrap_or(Value::Null);
2494    let findings = sorted_audit_findings(env, &complexity, "findings");
2495    if findings.is_empty() {
2496        return String::new();
2497    }
2498    let rows = findings
2499        .iter()
2500        .take(15)
2501        .map(|it| {
2502            format!(
2503                "| {} | {} | {} | {} | {} | {} | {} | {} | {} |",
2504                path_line_cell(it),
2505                code_cell(it, "name"),
2506                introduced_label(it),
2507                audit_rule_severity(it),
2508                markdown_table_text(str_or(it, "severity", "moderate")),
2509                num(it, "cyclomatic"),
2510                num(it, "cognitive"),
2511                markdown_table_text(str_or(it, "coverage_tier", "-")),
2512                it.get("crap")
2513                    .filter(|crap| !crap.is_null())
2514                    .map_or_else(|| "-".to_owned(), fmt_num),
2515            )
2516        })
2517        .collect::<Vec<_>>()
2518        .join("\n");
2519    let tail = if findings.len() > 15 {
2520        format!(
2521            "\n\n> {} more complexity findings in the full audit report",
2522            findings.len() - 15,
2523        )
2524    } else {
2525        String::new()
2526    };
2527    format!(
2528        "### Complexity\n\n| File | Function | Status | Rule severity | Complexity band | Cyclomatic | Cognitive | Coverage | CRAP |\n|:-----|:---------|:-------|:--------------|:----------------|:-----------|:----------|:---------|:-----|\n{rows}{tail}{}\n\n",
2529        audit_coverage_model_note(&complexity),
2530    )
2531}
2532
2533/// Point at the coverage root only when the coverage file failed to join.
2534///
2535/// A low function match rate is ordinary: a coverage file covers the files its
2536/// test run touched, so a project whose suite exercises a fraction of its
2537/// modules matches a fraction of its functions with nothing wrong. The signal
2538/// that a path is misconfigured is files in the coverage file that no analyzed
2539/// file matched.
2540fn audit_coverage_join_suffix(summary: &Value) -> String {
2541    let matched = summary
2542        .get("istanbul_files_matched")
2543        .and_then(Value::as_u64);
2544    let total = summary.get("istanbul_files_total").and_then(Value::as_u64);
2545    match (matched, total) {
2546        (Some(matched), Some(total)) if total > 0 && matched == 0 => format!(
2547            ", from a coverage file describing {total} files that none of the analyzed files matched; check `--coverage-root` is correct for this checkout."
2548        ),
2549        (Some(matched), Some(total)) if total > 0 && matched < total => format!(
2550            ", from {matched} of {total} files in the coverage file; the rest matched no analyzed file."
2551        ),
2552        _ => ".".to_owned(),
2553    }
2554}
2555
2556fn audit_coverage_model_note(complexity: &Value) -> String {
2557    let summary = complexity.get("summary").cloned().unwrap_or(Value::Null);
2558    let model = summary.get("coverage_model").and_then(Value::as_str);
2559    match model {
2560        Some("istanbul") => {
2561            let matched = summary.get("istanbul_matched").and_then(Value::as_u64);
2562            let total = summary.get("istanbul_total").and_then(Value::as_u64);
2563            match (matched, total) {
2564                (Some(matched), Some(total)) if total > 0 => {
2565                    format!(
2566                        "\n\n*Coverage model: istanbul. Matched {matched}/{total} functions{}*",
2567                        audit_coverage_join_suffix(&summary)
2568                    )
2569                }
2570                _ => "\n\n*Coverage model: istanbul (exact, from `--coverage`).*".to_owned(),
2571            }
2572        }
2573        Some("static_estimated" | "static_binary") => {
2574            "\n\n*Coverage model: static (estimated). Pair with `--coverage <coverage-final.json>` for measured coverage instead of estimates.*".to_owned()
2575        }
2576        _ => String::new(),
2577    }
2578}
2579
2580fn audit_duplication_section(env: &Value) -> String {
2581    let duplication = env.get("duplication").cloned().unwrap_or(Value::Null);
2582    let mut groups: Vec<&Value> = arr(&duplication, "clone_groups").collect();
2583    groups.sort_by(|a, b| {
2584        audit_priority(env, "unknown", introduced_label(a))
2585            .cmp(&audit_priority(env, "unknown", introduced_label(b)))
2586            .then_with(|| s(&a["instances"][0], "file").cmp(s(&b["instances"][0], "file")))
2587            .then_with(|| {
2588                u(&a["instances"][0], "start_line").cmp(&u(&b["instances"][0], "start_line"))
2589            })
2590            .then_with(|| u(a, "token_count").cmp(&u(b, "token_count")))
2591    });
2592    if groups.is_empty() {
2593        return String::new();
2594    }
2595    let rows = groups
2596        .iter()
2597        .take(10)
2598        .map(|group| {
2599            let instances: Vec<&Value> = arr(group, "instances").collect();
2600            let location = instances.first().map_or_else(
2601                || "-".to_owned(),
2602                |first| {
2603                    let file = s(first, "file");
2604                    if file.is_empty() {
2605                        "-".to_owned()
2606                    } else {
2607                        let start = first
2608                            .get("start_line")
2609                            .filter(|line| !line.is_null())
2610                            .map_or_else(|| "1".to_owned(), fmt_num);
2611                        markdown_table_code_span(&format!(
2612                            "{}:{start}",
2613                            rel_path_absolute_only(file)
2614                        ))
2615                    }
2616                },
2617            );
2618            let mut files: Vec<String> = instances
2619                .iter()
2620                .map(|instance| {
2621                    markdown_table_code_span(&rel_path_absolute_only(s(instance, "file")))
2622                })
2623                .collect();
2624            files.sort();
2625            files.dedup();
2626            let files = files.into_iter().take(3).collect::<Vec<_>>().join(", ");
2627            format!(
2628                "| {location} | {files} | {} lines / {} tokens | {} | {} |",
2629                num(group, "line_count"),
2630                num(group, "token_count"),
2631                instances.len(),
2632                introduced_label(group),
2633            )
2634        })
2635        .collect::<Vec<_>>()
2636        .join("\n");
2637    let tail = if groups.len() > 10 {
2638        format!(
2639            "\n\n> {} more clone groups in the full audit report",
2640            groups.len() - 10
2641        )
2642    } else {
2643        String::new()
2644    };
2645    format!(
2646        "### Duplication\n\n| Location | Files | Size | Instances | Status |\n|:---------|:------|:-----|----------:|:-------|\n{rows}{tail}\n\n"
2647    )
2648}
2649
2650fn audit_styling_section(env: &Value) -> String {
2651    let findings = sorted_audit_findings(env, &env["complexity"], "styling_findings");
2652    if findings.is_empty() {
2653        return String::new();
2654    }
2655    let rows = findings
2656        .iter()
2657        .take(15)
2658        .map(|finding| {
2659            format!(
2660                "| {} | {} | {} | {} | {} |",
2661                code_cell(finding, "code"),
2662                path_line(finding),
2663                code_cell(finding, "value"),
2664                introduced_label(finding),
2665                audit_rule_severity(finding),
2666            )
2667        })
2668        .collect::<Vec<_>>()
2669        .join("\n");
2670    let tail = if findings.len() > 15 {
2671        format!(
2672            "\n\n> {} more styling findings in the full audit report",
2673            findings.len() - 15,
2674        )
2675    } else {
2676        String::new()
2677    };
2678    format!(
2679        "### Styling\n\n| Rule | Location | Value | Status | Rule severity |\n|:-----|:---------|:------|:-------|:--------------|\n{rows}{tail}\n\n"
2680    )
2681}
2682
2683/// The diff-risk findings of an audit. Every finding is introduced by the
2684/// change and can set the verdict, so the summary lists each one.
2685fn audit_diff_risk_section(env: &Value) -> String {
2686    let diff_risk = env.get("diff_risk").cloned().unwrap_or(Value::Null);
2687    let findings = sorted_audit_findings(env, &diff_risk, "findings");
2688    if findings.is_empty() {
2689        return String::new();
2690    }
2691    let rows = findings
2692        .iter()
2693        .take(15)
2694        .map(|finding| {
2695            let path = rel_path_absolute_only(s(finding, "path"));
2696            let line = u(finding, "line");
2697            let location = if line > 0 {
2698                format!("{path}:{line}")
2699            } else {
2700                path
2701            };
2702            format!(
2703                "| {} | {} | {} | {} |",
2704                markdown_table_code_span(s(finding, "rule")),
2705                markdown_table_code_span(&location),
2706                markdown_table_code_span(s(finding, "evidence")),
2707                str_or(finding, "severity", "warn"),
2708            )
2709        })
2710        .collect::<Vec<_>>()
2711        .join("\n");
2712    let tail = if findings.len() > 15 {
2713        format!(
2714            "\n\n> {} more diff-risk findings in the full audit report",
2715            findings.len() - 15
2716        )
2717    } else {
2718        String::new()
2719    };
2720    format!(
2721        "### Diff Risk\n\n| Rule | Location | Evidence | Severity |\n|:-----|:---------|:---------|:---------|\n{rows}{tail}\n\n"
2722    )
2723}
2724
2725/// One row of the audit category table. A skipped sub-analysis prints
2726/// `skipped`, because its 0 counts do not mean a clean result.
2727fn audit_category_row(
2728    label: &str,
2729    summary: &Value,
2730    skipped_key: &str,
2731    findings: &str,
2732    attribution: &Value,
2733    attribution_prefix: &str,
2734) -> String {
2735    if summary
2736        .get(skipped_key)
2737        .and_then(Value::as_bool)
2738        .unwrap_or(false)
2739    {
2740        return format!("| {label} | skipped | - | - |");
2741    }
2742    let count = |suffix: &str| {
2743        attribution
2744            .get(format!("{attribution_prefix}_{suffix}"))
2745            .and_then(Value::as_u64)
2746            .map_or_else(|| "unknown".to_owned(), |count| count.to_string())
2747    };
2748    format!(
2749        "| {label} | {findings} | {} | {} |",
2750        count("introduced"),
2751        count("inherited"),
2752    )
2753}
2754
2755/// Port of `summary-audit.jq`.
2756#[must_use]
2757fn render_audit_summary(env: &Value) -> String {
2758    let verdict = str_or(env, "verdict", "pass");
2759    let summary = env.get("summary").cloned().unwrap_or(Value::Null);
2760    let attribution = env.get("attribution").cloned().unwrap_or(Value::Null);
2761    let dead_code = env.get("dead_code").cloned().unwrap_or(Value::Null);
2762    let mut dead_rows = audit_dead_code_rows(&dead_code);
2763    dead_rows.sort_by(|a, b| {
2764        audit_priority(env, a.severity, a.status)
2765            .cmp(&audit_priority(env, b.severity, b.status))
2766            .then_with(|| a.location.cmp(&b.location))
2767            .then_with(|| a.kind.cmp(b.kind))
2768            .then_with(|| a.item.cmp(&b.item))
2769    });
2770
2771    let styling_count = env
2772        .get("complexity")
2773        .and_then(|complexity| complexity.get("styling_findings"))
2774        .and_then(Value::as_array)
2775        .map_or(0, Vec::len)
2776        .to_string();
2777    let rows = [
2778        audit_category_row(
2779            "Dead code",
2780            &summary,
2781            "dead_code_skipped",
2782            &num(&summary, "dead_code_issues"),
2783            &attribution,
2784            "dead_code",
2785        ),
2786        audit_category_row(
2787            "Complexity",
2788            &summary,
2789            "complexity_skipped",
2790            &num(&summary, "complexity_findings"),
2791            &attribution,
2792            "complexity",
2793        ),
2794        audit_category_row(
2795            "Duplication",
2796            &summary,
2797            "duplication_skipped",
2798            &num(&summary, "duplication_clone_groups"),
2799            &attribution,
2800            "duplication",
2801        ),
2802        audit_category_row(
2803            "Styling",
2804            &summary,
2805            "complexity_skipped",
2806            &styling_count,
2807            &attribution,
2808            "styling",
2809        ),
2810    ]
2811    .join("\n");
2812
2813    let mut out = format!(
2814        "## Fallow Audit\n\n> {} \u{b7} {} \u{b7} {}ms\n\n| Category | Findings | Introduced | Inherited |\n|:---------|---------:|-----------:|----------:|\n{rows}\n\n",
2815        audit_verdict_label(verdict),
2816        plural_n(u(env, "changed_files_count") as usize, "changed file"),
2817        num(env, "elapsed_ms"),
2818    );
2819    if !dead_rows.is_empty() {
2820        let rows = dead_rows
2821            .iter()
2822            .take(10)
2823            .map(|row| {
2824                format!(
2825                    "| {} | {} | {} | {} | {} |",
2826                    row.kind, row.location, row.item, row.status, row.severity,
2827                )
2828            })
2829            .collect::<Vec<_>>()
2830            .join("\n");
2831        let tail = if dead_rows.len() > 10 {
2832            format!(
2833                "\n\n> {} more dead-code findings in the full audit report",
2834                dead_rows.len() - 10
2835            )
2836        } else {
2837            String::new()
2838        };
2839        let _ = write!(
2840            out,
2841            "### Dead Code\n\n| Type | Location | Item | Status | Rule severity |\n|:-----|:---------|:-----|:-------|:--------------|\n{rows}{tail}\n\n"
2842        );
2843    }
2844    out.push_str(&audit_complexity_section(env));
2845    out.push_str(&audit_duplication_section(env));
2846    out.push_str(&audit_styling_section(env));
2847    out.push_str(&audit_diff_risk_section(env));
2848    out.push_str(match s(&attribution, "gate") {
2849        "all" => "*Audit gate: all. Every finding in changed files affects the verdict.*",
2850        "new-only" => {
2851            "*Audit gate: new-only. Inherited findings are reported but do not fail the verdict.*"
2852        }
2853        _ => "*Audit gate: unknown. New-versus-inherited attribution is unavailable.*",
2854    });
2855    out
2856}
2857
2858// ---------------------------------------------------------------------------
2859// summary-security.jq
2860// ---------------------------------------------------------------------------
2861
2862/// Port of `summary-security.jq`.
2863#[must_use]
2864fn render_security_summary(env: &Value) -> String {
2865    let findings: Vec<&Value> = arr(env, "security_findings").collect();
2866    let gate = env.get("gate").filter(|gate| !gate.is_null());
2867    let count = gate.map_or_else(
2868        || {
2869            env.get("summary")
2870                .and_then(|summary| summary.get("security_findings"))
2871                .and_then(Value::as_u64)
2872                .unwrap_or(findings.len() as u64) as usize
2873        },
2874        |gate| u(gate, "new_count") as usize,
2875    );
2876    let mut out = String::from("## Fallow Security\n\n");
2877    if count == 0 {
2878        let _ = write!(
2879            out,
2880            "> [!NOTE]\n> **No security candidates matched** \u{b7} {}ms",
2881            num(env, "elapsed_ms"),
2882        );
2883    } else {
2884        let _ = write!(
2885            out,
2886            "> [!WARNING]\n> **{} matched** \u{b7} {}ms",
2887            plural_n(count, "security candidate"),
2888            num(env, "elapsed_ms"),
2889        );
2890    }
2891    if let Some(gate) = gate {
2892        let _ = write!(
2893            out,
2894            "\n\nSecurity gate: `{}`, verdict: `{}`, matching candidates: **{}**.",
2895            s(gate, "mode"),
2896            s(gate, "verdict"),
2897            num(gate, "new_count"),
2898        );
2899    }
2900    if !findings.is_empty() {
2901        let rows = findings
2902            .iter()
2903            .take(15)
2904            .map(|finding| {
2905                format!(
2906                    "| {} | {} | {} | {} |",
2907                    path_line(finding),
2908                    markdown_table_text(s(finding, "kind")),
2909                    markdown_table_text(str_or(finding, "severity", "unknown")),
2910                    markdown_table_code_span(
2911                        finding
2912                            .get("candidate")
2913                            .and_then(|candidate| candidate.get("sink"))
2914                            .and_then(|sink| sink.get("callee"))
2915                            .and_then(Value::as_str)
2916                            .unwrap_or("-")
2917                    ),
2918                )
2919            })
2920            .collect::<Vec<_>>()
2921            .join("\n");
2922        let _ = write!(
2923            out,
2924            "\n\n| Location | Kind | Severity | Sink |\n|:---------|:-----|:---------|:-----|\n{rows}"
2925        );
2926        if findings.len() > 15 {
2927            let _ = write!(
2928                out,
2929                "\n\n> {} more candidates in the full report",
2930                findings.len() - 15,
2931            );
2932        }
2933    }
2934    out.push_str("\n\nTreat these as candidates for verification, not confirmed vulnerabilities.");
2935    out
2936}
2937
2938// ---------------------------------------------------------------------------
2939// summary-fix.jq
2940// ---------------------------------------------------------------------------
2941
2942/// The entries of one fix type that actually landed (or, in a dry run, would).
2943/// A withheld entry keeps its `type` so a caller can see which finding was
2944/// declined, so the skipped flag is what separates the two; listing a withheld
2945/// removal under "Dependencies removed" would report a write that never
2946/// happened.
2947fn fix_entries<'v>(env: &'v Value, entry_type: &str) -> Vec<&'v Value> {
2948    arr(env, "fixes")
2949        .filter(|fix| s(fix, "type") == entry_type && !b(fix, "skipped"))
2950        .collect()
2951}
2952
2953fn fix_detail_block(label: &str, entries: &[&Value], row: impl Fn(&Value) -> String) -> String {
2954    let rows = entries
2955        .iter()
2956        .take(25)
2957        .map(|entry| row(entry))
2958        .collect::<Vec<_>>()
2959        .join("\n");
2960    let tail = if entries.len() > 25 {
2961        format!("\n- *... and {} more*", entries.len() - 25)
2962    } else {
2963        String::new()
2964    };
2965    format!("**{label} ({})**\n{rows}{tail}", entries.len())
2966}
2967
2968/// Port of `summary-fix.jq`.
2969#[must_use]
2970pub fn render_fix_summary(env: &Value) -> String {
2971    let exports = fix_entries(env, "remove_export");
2972    let dependencies = fix_entries(env, "remove_dependency");
2973    let fix_attempts = arr(env, "fixes").filter(|fix| !b(fix, "skipped")).count();
2974    let content_changed = u(env, "skipped_content_changed") as usize;
2975    let mixed_eol = u(env, "skipped_mixed_line_endings") as usize;
2976    let low_confidence = u(env, "skipped_low_confidence_exports") as usize;
2977    let low_confidence_deps = u(env, "skipped_low_confidence_dependencies") as usize;
2978    let low_confidence_members = u(env, "skipped_low_confidence_members") as usize;
2979    let dry_run = b(env, "dry_run");
2980
2981    if fix_attempts == 0
2982        && content_changed == 0
2983        && mixed_eol == 0
2984        && low_confidence == 0
2985        && low_confidence_deps == 0
2986        && low_confidence_members == 0
2987    {
2988        return "## Fallow - Auto-fix\n\nNo fixable issues found.".to_owned();
2989    }
2990
2991    let mut out = String::from("## Fallow - Auto-fix\n\n");
2992    out.push_str(if dry_run {
2993        "**Dry run**: would apply"
2994    } else {
2995        "Applied"
2996    });
2997    let fix_noun = if fix_attempts == 1 { "fix" } else { "fixes" };
2998    let _ = write!(out, " **{fix_attempts} {fix_noun}**");
2999    if !dry_run {
3000        let _ = write!(out, " ({} succeeded)", num(env, "total_fixed"));
3001    }
3002    if content_changed > 0 {
3003        let _ = write!(
3004            out,
3005            ", skipped {content_changed} file(s) that changed since analysis"
3006        );
3007    }
3008    if mixed_eol > 0 {
3009        let _ = write!(out, ", skipped {mixed_eol} file(s) with mixed line endings");
3010    }
3011    if low_confidence > 0 {
3012        let _ = write!(
3013            out,
3014            ", kept exports in {low_confidence} file(s) where consumers may be hidden from static analysis"
3015        );
3016    }
3017    if low_confidence_deps > 0 {
3018        let _ = write!(
3019            out,
3020            ", kept {low_confidence_deps} declared package(s) whose only import may sit in a file this run did not fully read"
3021        );
3022    }
3023    if low_confidence_members > 0 {
3024        let _ = write!(
3025            out,
3026            ", kept {low_confidence_members} unused enum member(s) whose only reference may sit in a file this run did not fully analyze"
3027        );
3028    }
3029    out.push_str("\n\n| Type | Count |\n|------|-------|\n");
3030    if !exports.is_empty() {
3031        let _ = writeln!(out, "| Export removals | {} |", exports.len());
3032    }
3033    if !dependencies.is_empty() {
3034        let _ = writeln!(out, "| Dependency removals | {} |", dependencies.len());
3035    }
3036    out.push_str("\n<details>\n<summary>View details</summary>\n\n");
3037    if !exports.is_empty() {
3038        out.push_str(&fix_detail_block("Export removals", &exports, |it| {
3039            format!(
3040                "- {} - {}",
3041                markdown_code_span(&format!("{}:{}", s(it, "path"), num(it, "line"))),
3042                markdown_code_span(s(it, "name"))
3043            )
3044        }));
3045        out.push_str("\n\n");
3046    }
3047    if !dependencies.is_empty() {
3048        out.push_str(&fix_detail_block(
3049            "Dependency removals",
3050            &dependencies,
3051            |it| {
3052                format!(
3053                    "- {} from {} in {}",
3054                    markdown_code_span(s(it, "package")),
3055                    markdown_code_span(s(it, "location")),
3056                    markdown_code_span(s(it, "file")),
3057                )
3058            },
3059        ));
3060        out.push('\n');
3061    }
3062    out.push_str("\n\n</details>");
3063    out
3064}
3065
3066// ---------------------------------------------------------------------------
3067// summary-combined.jq
3068// ---------------------------------------------------------------------------
3069
3070fn file_link(links: &LinkContext, path: &str, start: &str, end: &str) -> String {
3071    let display = markdown_table_code_span(&format!("{}:{start}-{end}", last_three_segments(path)));
3072    if links.repo.is_empty() || links.sha.is_empty() {
3073        display
3074    } else {
3075        format!(
3076            "[{display}](https://github.com/{}/blob/{}/{}{}#L{start}-L{end})",
3077            links.repo,
3078            links.sha,
3079            links.prefix,
3080            encode_link_path(path),
3081        )
3082    }
3083}
3084
3085/// Percent-encode the characters that would break a Markdown link destination
3086/// or the surrounding table cell.
3087fn encode_link_path(path: &str) -> String {
3088    path.replace('%', "%25")
3089        .replace(' ', "%20")
3090        .replace('(', "%28")
3091        .replace(')', "%29")
3092        .replace('<', "%3C")
3093        .replace('>', "%3E")
3094        .replace('|', "%7C")
3095}
3096
3097fn exceeded_priority(it: &Value) -> u8 {
3098    match s(it, "exceeded") {
3099        "all" => 5,
3100        "cyclomatic_crap" | "cognitive_crap" => 4,
3101        "crap" => 3,
3102        "both" => 2,
3103        "cyclomatic" | "cognitive" => 1,
3104        _ => 0,
3105    }
3106}
3107
3108fn severity_priority(it: &Value) -> u8 {
3109    match s(it, "severity") {
3110        "critical" => 3,
3111        "high" => 2,
3112        "moderate" => 1,
3113        _ => 0,
3114    }
3115}
3116
3117/// jq `ranked_health_findings`: stable ascending sort by the priority tuple,
3118/// then a full reverse (ties land in reverse input order).
3119fn ranked_health_findings(health: &Value) -> Vec<&Value> {
3120    let mut findings: Vec<&Value> = arr(health, "findings").collect();
3121    findings.sort_by_key(|it| {
3122        (
3123            exceeded_priority(it),
3124            severity_priority(it),
3125            it.get("crap").is_some_and(|crap| !crap.is_null()),
3126            u(it, "cyclomatic"),
3127            u(it, "cognitive"),
3128            u(it, "line_count"),
3129        )
3130    });
3131    findings.reverse();
3132    findings
3133}
3134
3135const PROD_FAILING_VERDICTS: &[&str] = &["safe_to_delete", "review_required", "low_traffic"];
3136
3137struct CombinedCounts {
3138    check: usize,
3139    dupes: usize,
3140    complex: usize,
3141    prod_failing: usize,
3142    prod_advisory: usize,
3143    hot_paths: usize,
3144}
3145
3146impl CombinedCounts {
3147    fn health(&self) -> usize {
3148        self.complex + self.prod_failing
3149    }
3150
3151    fn total(&self) -> usize {
3152        self.check + self.dupes + self.health()
3153    }
3154}
3155
3156fn combined_counts(env: &Value) -> CombinedCounts {
3157    let check = env
3158        .get("check")
3159        .map_or(0, |check| u(check, "total_issues") as usize);
3160    // Visible groups plus what a presentation cap withheld, NOT
3161    // `stats.clone_groups`. The two differ for two unrelated reasons and only
3162    // one of them belongs here: a filtered combined run leaves `stats`
3163    // describing the unfiltered corpus while `clone_groups[]` holds the
3164    // actionable set, and counting `stats` there reports issues a reader
3165    // cannot inspect (issue #1250); a presentation cap such as `--top`
3166    // truncates the array while `stats` stays right. `clone_groups_omitted`
3167    // counts only the second, and no combined envelope carries it today
3168    // because the bare command takes no `--top`.
3169    let dupes = env.get("dupes").map_or(0, |dupes| {
3170        arr(dupes, "clone_groups")
3171            .count()
3172            .saturating_add(u(dupes, "clone_groups_omitted") as usize)
3173    });
3174    let health = env.get("health").cloned().unwrap_or(Value::Null);
3175    // After the baseline when one is loaded: the functions it accepts are
3176    // not health findings of this run.
3177    let complex = health.get("summary").map_or(0, |summary| {
3178        baselined_complexity(summary).map_or_else(
3179            || u(summary, "functions_above_threshold"),
3180            |(remaining, _)| remaining,
3181        ) as usize
3182    });
3183    let runtime = health
3184        .get("runtime_coverage")
3185        .cloned()
3186        .unwrap_or(Value::Null);
3187    let prod_failing = arr(&runtime, "findings")
3188        .filter(|finding| PROD_FAILING_VERDICTS.contains(&s(finding, "verdict")))
3189        .count();
3190    let prod_advisory = arr(&runtime, "findings")
3191        .filter(|finding| !PROD_FAILING_VERDICTS.contains(&s(finding, "verdict")))
3192        .count();
3193    let hot_paths = arr(&runtime, "hot_paths").count();
3194    CombinedCounts {
3195        check,
3196        dupes,
3197        complex,
3198        prod_failing,
3199        prod_advisory,
3200        hot_paths,
3201    }
3202}
3203
3204fn hot_path_label(env: &Value, n: usize) -> String {
3205    let touched = env
3206        .get("health")
3207        .and_then(|health| health.get("runtime_coverage"))
3208        .is_some_and(|runtime| s(runtime, "verdict") == "hot-path-touched");
3209    let plural = if n == 1 { "" } else { "s" };
3210    if touched {
3211        format!("hot path{plural} touched")
3212    } else {
3213        format!("hot path{plural}")
3214    }
3215}
3216
3217fn combined_zero_case(env: &Value, counts: &CombinedCounts) -> String {
3218    let vitals = env
3219        .get("health")
3220        .and_then(|health| health.get("vital_signs"))
3221        .cloned()
3222        .unwrap_or(Value::Null);
3223    let mut out = String::from("# \u{1F33F} Fallow\n\n");
3224    if counts.prod_advisory > 0 || counts.hot_paths > 0 {
3225        out.push_str(
3226            "> [!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",
3227        );
3228        if counts.prod_advisory > 0 {
3229            let _ = write!(
3230                out,
3231                " \u{b7} :information_source: **{}** runtime coverage advisory finding{}",
3232                counts.prod_advisory,
3233                if counts.prod_advisory == 1 { "" } else { "s" },
3234            );
3235        }
3236        if counts.hot_paths > 0 {
3237            let _ = write!(
3238                out,
3239                " \u{b7} :eyes: **{}** {}",
3240                counts.hot_paths,
3241                hot_path_label(env, counts.hot_paths),
3242            );
3243        }
3244    } else {
3245        out.push_str(
3246            "> [!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: ",
3247        );
3248        // A health baseline that accepts functions gets them as context, so
3249        // the line does not read as a project with no complex functions.
3250        let accepted = env
3251            .get("health")
3252            .and_then(|health| health.get("summary"))
3253            .and_then(baselined_complexity)
3254            .map_or(0, |(_, accepted)| accepted);
3255        if accepted > 0 {
3256            let _ = write!(out, "No new complex functions ({accepted} in the baseline)");
3257        } else {
3258            out.push_str("No complex functions");
3259        }
3260    }
3261    if let Some(maintainability) = opt_f(&vitals, "maintainability_avg") {
3262        let _ = write!(
3263            out,
3264            "\n\n| Metric | Value |\n|:-------|------:|\n| [Maintainability]({HEALTH_DOCS}#maintainability-index-mi) | **{}** / 100 |\n",
3265            pct(maintainability),
3266        );
3267    }
3268    out
3269}
3270
3271fn combined_status_line(env: &Value, counts: &CombinedCounts) -> String {
3272    let mut out = String::new();
3273    if counts.check > 0 {
3274        let _ = write!(
3275            out,
3276            ":warning: **{}** code {}",
3277            counts.check,
3278            if counts.check == 1 { "issue" } else { "issues" },
3279        );
3280    } else {
3281        out.push_str(":white_check_mark: No code issues");
3282    }
3283    out.push_str(" \u{b7} ");
3284    if counts.dupes > 0 {
3285        let _ = write!(
3286            out,
3287            ":warning: **{}** clone {}",
3288            counts.dupes,
3289            if counts.dupes == 1 { "group" } else { "groups" },
3290        );
3291    } else {
3292        out.push_str(":white_check_mark: No duplication");
3293    }
3294    out.push_str(" \u{b7} ");
3295    let health = counts.health();
3296    if health > 0 {
3297        let _ = write!(
3298            out,
3299            ":warning: **{health}** health {}",
3300            if health == 1 { "finding" } else { "findings" },
3301        );
3302    } else {
3303        out.push_str(":white_check_mark: No blocking health findings");
3304    }
3305    if counts.prod_advisory > 0 {
3306        let _ = write!(
3307            out,
3308            " \u{b7} :information_source: **{}** coverage advisory finding{}",
3309            counts.prod_advisory,
3310            if counts.prod_advisory == 1 { "" } else { "s" },
3311        );
3312    }
3313    if counts.hot_paths > 0 {
3314        let _ = write!(
3315            out,
3316            " \u{b7} :eyes: **{}** {}",
3317            counts.hot_paths,
3318            hot_path_label(env, counts.hot_paths),
3319        );
3320    }
3321    out.push_str("\n\n");
3322    out
3323}
3324
3325fn combined_check_breakdown(env: &Value, counts: &CombinedCounts) -> String {
3326    if counts.check == 0 {
3327        return String::new();
3328    }
3329    let check = env.get("check").cloned().unwrap_or(Value::Null);
3330    format!(
3331        "<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",
3332        counts.check,
3333        dead_code_category_table(&check),
3334    )
3335}
3336
3337fn combined_dupes_breakdown(env: &Value, counts: &CombinedCounts, links: &LinkContext) -> String {
3338    if counts.dupes == 0 {
3339        return String::new();
3340    }
3341    let dupes = env.get("dupes").cloned().unwrap_or(Value::Null);
3342    let stats = dupes.get("stats").cloned().unwrap_or(Value::Null);
3343    let groups = sorted_clone_groups(&dupes);
3344    let files_with_clones = u(&stats, "files_with_clones") as usize;
3345    let rows = groups
3346        .iter()
3347        .take(5)
3348        .map(|group| {
3349            let locations = arr(group, "instances")
3350                .map(|instance| {
3351                    file_link(
3352                        links,
3353                        s(instance, "file"),
3354                        &num(instance, "start_line"),
3355                        &num(instance, "end_line"),
3356                    )
3357                })
3358                .collect::<Vec<_>>()
3359                .join("<br>");
3360            format!(
3361                "| {locations} | {} | {} |",
3362                num(group, "line_count"),
3363                num(group, "token_count"),
3364            )
3365        })
3366        .collect::<Vec<_>>()
3367        .join("\n");
3368    let tail = if counts.dupes > 5 {
3369        format!("\n\n*\u{2026} and {} more groups.*", counts.dupes - 5)
3370    } else {
3371        String::new()
3372    };
3373    format!(
3374        "<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",
3375        counts.dupes,
3376        if counts.dupes == 1 { "group" } else { "groups" },
3377        num(&stats, "duplicated_lines"),
3378        pct(f_or_zero(&stats, "duplication_percentage")),
3379        if files_with_clones == 1 {
3380            "file"
3381        } else {
3382            "files"
3383        },
3384    )
3385}
3386
3387fn combined_complexity_breakdown(env: &Value, counts: &CombinedCounts) -> String {
3388    if counts.complex == 0 {
3389        return String::new();
3390    }
3391    let health = env.get("health").cloned().unwrap_or(Value::Null);
3392    let summary = health.get("summary").cloned().unwrap_or(Value::Null);
3393    let findings = ranked_health_findings(&health);
3394    let show_crap = summary
3395        .get("max_crap_threshold")
3396        .is_some_and(|threshold| !threshold.is_null())
3397        || findings
3398            .iter()
3399            .any(|finding| finding.get("crap").is_some_and(|crap| !crap.is_null()));
3400    let cyc_t = threshold_or(&summary, "max_cyclomatic_threshold", "default");
3401    let cog_t = threshold_or(&summary, "max_cognitive_threshold", "default");
3402    let crap_t = threshold_or(&summary, "max_crap_threshold", "default");
3403    let crap_header = if show_crap {
3404        format!(" | [CRAP]({HEALTH_DOCS}#crap-score)")
3405    } else {
3406        String::new()
3407    };
3408    let crap_separator = if show_crap { "|-----:" } else { "" };
3409    let rows = findings
3410        .iter()
3411        .take(5)
3412        .map(|it| {
3413            let crap_column = if show_crap {
3414                format!(" | {}", crap_cell(it))
3415            } else {
3416                String::new()
3417            };
3418            format!(
3419                "| {} | {} | {} | {}{} | {}{}{crap_column} | {} |",
3420                markdown_table_code_span(&format!(
3421                    "{}:{}",
3422                    last_three_segments(s(it, "path")),
3423                    num(it, "line")
3424                )),
3425                code_cell(it, "name"),
3426                markdown_table_text(str_or(it, "severity", "moderate")),
3427                num(it, "cyclomatic"),
3428                exceeded_marker(it, &["cyclomatic", "both", "all"]),
3429                num(it, "cognitive"),
3430                exceeded_marker(it, &["cognitive", "both", "all"]),
3431                num(it, "line_count"),
3432            )
3433        })
3434        .collect::<Vec<_>>()
3435        .join("\n");
3436    let crap_footer = if show_crap {
3437        format!(", CRAP >= {crap_t}")
3438    } else {
3439        String::new()
3440    };
3441    let label = match baselined_complexity(&summary).filter(|&(_, accepted)| accepted > 0) {
3442        Some((_, accepted)) => format!(
3443            "{} new {} above threshold, {accepted} in the baseline",
3444            counts.complex,
3445            if counts.complex == 1 {
3446                "function"
3447            } else {
3448                "functions"
3449            },
3450        ),
3451        None => format!(
3452            "{} {} above threshold",
3453            counts.complex,
3454            if counts.complex == 1 {
3455                "function"
3456            } else {
3457                "functions"
3458            },
3459        ),
3460    };
3461    format!(
3462        "<details>\n<summary><strong><a href=\"{HEALTH_DOCS}#complexity-metrics\">Complexity</a> ({label})</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",
3463        threshold_or(&summary, "files_analyzed", "unknown"),
3464        threshold_or(&summary, "functions_analyzed", "unknown"),
3465    )
3466}
3467
3468fn combined_runtime_breakdown(env: &Value, counts: &CombinedCounts) -> String {
3469    let prod_total = counts.prod_failing + counts.prod_advisory;
3470    if prod_total == 0 && counts.hot_paths == 0 {
3471        return String::new();
3472    }
3473    let runtime = env
3474        .get("health")
3475        .and_then(|health| health.get("runtime_coverage"))
3476        .cloned()
3477        .unwrap_or(Value::Null);
3478    let hot_suffix = if counts.hot_paths > 0 {
3479        format!(
3480            ", {} {}",
3481            counts.hot_paths,
3482            hot_path_label(env, counts.hot_paths)
3483        )
3484    } else {
3485        String::new()
3486    };
3487    let mut out = format!(
3488        "<details>\n<summary><strong><a href=\"{HEALTH_DOCS}#runtime-coverage\">Runtime coverage</a> ({prod_total} finding{}{hot_suffix})</strong></summary>\n\n",
3489        if prod_total == 1 { "" } else { "s" },
3490    );
3491    if prod_total > 0 {
3492        out.push_str("| File | Function | Verdict | Invocations | Confidence |\n|:-----|:---------|:--------|------------:|:-----------|\n");
3493        out.push_str(
3494            &arr(&runtime, "findings")
3495                .take(5)
3496                .map(|it| {
3497                    let invocations = it
3498                        .get("invocations")
3499                        .filter(|value| !value.is_null())
3500                        .map_or_else(|| "-".to_owned(), fmt_num);
3501                    format!(
3502                        "| {} | {} | {} | {invocations} | {} |",
3503                        markdown_table_code_span(&format!(
3504                            "{}:{}",
3505                            last_three_segments(s(it, "path")),
3506                            num(it, "line")
3507                        )),
3508                        code_cell(it, "function"),
3509                        code_cell(it, "verdict"),
3510                        markdown_table_text(s(it, "confidence")),
3511                    )
3512                })
3513                .collect::<Vec<_>>()
3514                .join("\n"),
3515        );
3516        if counts.hot_paths > 0 {
3517            out.push_str("\n\n");
3518        }
3519    }
3520    if counts.hot_paths > 0 {
3521        out.push_str("| File | Function | Invocations | Percentile |\n|:-----|:---------|------------:|-----------:|\n");
3522        out.push_str(
3523            &arr(&runtime, "hot_paths")
3524                .take(5)
3525                .map(|it| {
3526                    format!(
3527                        "| {} | {} | {} | {} |",
3528                        markdown_table_code_span(&format!(
3529                            "{}:{}",
3530                            last_three_segments(s(it, "path")),
3531                            num(it, "line")
3532                        )),
3533                        code_cell(it, "function"),
3534                        num(it, "invocations"),
3535                        num(it, "percentile"),
3536                    )
3537                })
3538                .collect::<Vec<_>>()
3539                .join("\n"),
3540        );
3541        out.push_str("\n\n");
3542    }
3543    out.push_str("</details>\n\n");
3544    out
3545}
3546
3547fn combined_vitals(env: &Value) -> String {
3548    let health = env.get("health").cloned().unwrap_or(Value::Null);
3549    let vitals = health.get("vital_signs").cloned().unwrap_or(Value::Null);
3550    let has_vitals = vitals.as_object().is_some_and(|vitals| !vitals.is_empty());
3551    if !has_vitals {
3552        return String::new();
3553    }
3554    let scores: Vec<f64> = arr(&health, "file_scores")
3555        .filter_map(|score| opt_f(score, "maintainability_index"))
3556        .collect();
3557    let scoped_maintainability = if scores.is_empty() {
3558        None
3559    } else {
3560        let avg = scores.iter().sum::<f64>() / scores.len() as f64;
3561        Some((avg * 10.0).round() / 10.0)
3562    };
3563    let mut out = format!(
3564        "#### [Codebase health]({HEALTH_DOCS})\n\n| Metric | Value |\n|:-------|------:|\n"
3565    );
3566    let maintainability = opt_f(&vitals, "maintainability_avg");
3567    if let Some(avg) = maintainability {
3568        let _ = writeln!(
3569            out,
3570            "| [Maintainability]({HEALTH_DOCS}#maintainability-index-mi) | **{}** / 100 |",
3571            pct(avg),
3572        );
3573    }
3574    if let Some(scoped) = scoped_maintainability {
3575        let rounded_avg = (maintainability.unwrap_or_default() * 10.0).round() / 10.0;
3576        if (scoped - rounded_avg).abs() > f64::EPSILON {
3577            let _ = writeln!(
3578                out,
3579                "| [Maintainability]({HEALTH_DOCS}#maintainability-index-mi) (changed files) | **{}** / 100 |",
3580                fmt_num(&serde_json::json!(scoped)),
3581            );
3582        }
3583    }
3584    if let Some(avg_cyclomatic) = opt_f(&vitals, "avg_cyclomatic") {
3585        let _ = writeln!(
3586            out,
3587            "| [Avg complexity]({HEALTH_DOCS}#cyclomatic-complexity) | {} |",
3588            pct(avg_cyclomatic),
3589        );
3590    }
3591    if let Some(population) = vitals
3592        .get("cyclomatic_population")
3593        .filter(|value| value.is_object())
3594    {
3595        let _ = writeln!(
3596            out,
3597            "| Cyclomatic units | Functions: {}, module scopes: {}, templates: {} |",
3598            num(&population["functions"], "count"),
3599            num(&population["modules"], "count"),
3600            num(&population["templates"], "count")
3601        );
3602        if let Some(max) = population["modules"]["max"].as_u64() {
3603            let _ = writeln!(
3604                out,
3605                "| Module-scope max cyclomatic (aggregate only) | {max} |"
3606            );
3607        }
3608    }
3609    out.push('\n');
3610    out
3611}
3612
3613fn combined_tips(env: &Value) -> String {
3614    let check = env.get("check").cloned().unwrap_or(Value::Null);
3615    let fixable = arr(&check, "unused_exports").count()
3616        + arr(&check, "unused_dependencies").count()
3617        + arr(&check, "unused_enum_members").count();
3618    if fixable == 0 {
3619        return String::new();
3620    }
3621    let mut out = String::from("> [!TIP]\n> Run `fallow fix --dry-run` to preview auto-fixes.\n");
3622    if arr(&check, "unused_exports").count() > 0 {
3623        let _ = writeln!(
3624            out,
3625            "> Add [`/** @public */`]({SUPPRESSION_DOCS}) above exports to preserve them."
3626        );
3627    }
3628    out
3629}
3630
3631/// Port of `summary-combined.jq`.
3632#[must_use]
3633fn render_combined_summary(env: &Value, links: &LinkContext) -> String {
3634    let counts = combined_counts(env);
3635    let header = health_score_header(&env.get("health").cloned().unwrap_or(Value::Null));
3636    if counts.total() == 0 {
3637        return format!("{header}{}", combined_zero_case(env, &counts));
3638    }
3639    let pointer = if counts.check > 0 || counts.dupes > 0 || counts.health() > 0 {
3640        "See inline review comments for per-finding details.\n\n"
3641    } else {
3642        ""
3643    };
3644    format!(
3645        "{header}# \u{1F33F} Fallow\n\n> [!WARNING]\n> **Review needed**\n\n{}{pointer}{}{}{}{}{}{}",
3646        combined_status_line(env, &counts),
3647        combined_check_breakdown(env, &counts),
3648        combined_dupes_breakdown(env, &counts, links),
3649        combined_complexity_breakdown(env, &counts),
3650        combined_runtime_breakdown(env, &counts),
3651        combined_vitals(env),
3652        combined_tips(env),
3653    )
3654}
3655
3656#[cfg(test)]
3657mod tests {
3658    use std::collections::BTreeSet;
3659
3660    use fallow_types::issue_meta::counted_result_issue_metas;
3661
3662    use super::{
3663        DEAD_CODE_CATEGORIES, LinkContext, render_audit_summary, render_combined_summary,
3664        render_health_summary,
3665    };
3666
3667    /// One listed complexity finding.
3668    fn complexity_finding(name: &str) -> serde_json::Value {
3669        serde_json::json!({
3670            "path": "src/a.ts", "name": name, "line": 1, "col": 0,
3671            "cyclomatic": 25, "cognitive": 30, "line_count": 40,
3672            "exceeded": "both", "severity": "high"
3673        })
3674    }
3675
3676    /// A health summary with a loaded baseline: `above` functions exceed a
3677    /// threshold before the baseline, and `remaining` after it.
3678    fn health_with_baseline(above: u64, remaining: u64) -> serde_json::Value {
3679        let findings: Vec<serde_json::Value> = (0..remaining)
3680            .map(|index| complexity_finding(&format!("f{index}")))
3681            .collect();
3682        serde_json::json!({
3683            "elapsed_ms": 1,
3684            "findings": findings,
3685            "vital_signs": { "avg_cyclomatic": 2.0 },
3686            "summary": {
3687                "files_analyzed": 3,
3688                "functions_analyzed": 90,
3689                "functions_above_threshold": above,
3690                "max_cyclomatic_threshold": 20,
3691                "max_cognitive_threshold": 15,
3692                "max_crap_threshold": 30.0,
3693                "baseline_staleness": {
3694                    "baseline_entries": above,
3695                    "matched_entries": above,
3696                    "stale_entries": 0,
3697                    "current_findings": above,
3698                    "remaining_findings": remaining,
3699                    "stale": false
3700                }
3701            }
3702        })
3703    }
3704
3705    fn no_links() -> LinkContext {
3706        LinkContext {
3707            prefix: String::new(),
3708            repo: String::new(),
3709            sha: String::new(),
3710        }
3711    }
3712
3713    /// A skipped dead-code pass reports 0 issues, which must not read as a
3714    /// clean row.
3715    #[test]
3716    fn audit_summary_marks_skipped_sub_analyses() {
3717        let audit = |dead_code: bool, complexity: bool, duplication: bool| {
3718            serde_json::json!({
3719                "command": "audit",
3720                "verdict": "warn",
3721                "changed_files_count": 1,
3722                "elapsed_ms": 3,
3723                "summary": {
3724                    "dead_code_issues": 0,
3725                    "dead_code_skipped": dead_code,
3726                    "complexity_skipped": complexity,
3727                    "duplication_skipped": duplication,
3728                    "complexity_findings": 1,
3729                    "duplication_clone_groups": 0
3730                },
3731                "attribution": {
3732                    "gate": "new-only",
3733                    "dead_code_introduced": 0,
3734                    "dead_code_inherited": 0,
3735                    "complexity_introduced": 1,
3736                    "complexity_inherited": 0,
3737                    "duplication_introduced": 0,
3738                    "duplication_inherited": 0,
3739                    "styling_introduced": 0,
3740                    "styling_inherited": 0
3741                }
3742            })
3743        };
3744        let out = render_audit_summary(&audit(true, false, false));
3745        assert!(out.contains("| Dead code | skipped | - | - |"), "{out}");
3746        assert!(out.contains("| Complexity | 1 | 1 | 0 |"), "{out}");
3747        assert!(out.contains("| Styling | 0 | 0 | 0 |"), "{out}");
3748        let out = render_audit_summary(&audit(false, true, true));
3749        assert!(out.contains("| Dead code | 0 | 0 | 0 |"), "{out}");
3750        assert!(out.contains("| Complexity | skipped | - | - |"), "{out}");
3751        assert!(out.contains("| Duplication | skipped | - | - |"), "{out}");
3752        assert!(out.contains("| Styling | skipped | - | - |"), "{out}");
3753    }
3754
3755    /// With a baseline, the headline counts the functions after the
3756    /// baseline and gives the accepted functions as context.
3757    #[test]
3758    fn health_summary_headline_counts_after_the_baseline() {
3759        let out = render_health_summary(&health_with_baseline(76, 0));
3760        assert!(
3761            out.contains("**No new functions exceed complexity thresholds (76 in the baseline)**"),
3762            "{out}"
3763        );
3764        let out = render_health_summary(&health_with_baseline(76, 2));
3765        assert!(
3766            out.contains("**2 new functions exceed thresholds (74 in the baseline)**"),
3767            "{out}"
3768        );
3769        assert!(!out.contains("76 functions"), "{out}");
3770    }
3771
3772    /// The bare-run summary counts health findings after the baseline too.
3773    #[test]
3774    fn combined_summary_counts_health_after_the_baseline() {
3775        let combined = |health: serde_json::Value| {
3776            serde_json::json!({
3777                "check": { "total_issues": 0 },
3778                "dupes": { "clone_groups": [], "stats": {} },
3779                "health": health
3780            })
3781        };
3782        let out = render_combined_summary(&combined(health_with_baseline(76, 0)), &no_links());
3783        assert!(
3784            out.contains(":white_check_mark: No new complex functions (76 in the baseline)"),
3785            "{out}"
3786        );
3787        assert!(!out.contains("above threshold"), "{out}");
3788
3789        let out = render_combined_summary(&combined(health_with_baseline(76, 2)), &no_links());
3790        assert!(out.contains(":warning: **2** health findings"), "{out}");
3791        assert!(
3792            out.contains("(2 new functions above threshold, 74 in the baseline)"),
3793            "{out}"
3794        );
3795    }
3796
3797    /// Without `remaining_findings` the combined count stays the summary count.
3798    #[test]
3799    fn combined_summary_without_a_baseline_keeps_the_summary_count() {
3800        let mut health = health_with_baseline(3, 3);
3801        health["summary"]
3802            .as_object_mut()
3803            .expect("summary")
3804            .remove("baseline_staleness");
3805        let env = serde_json::json!({
3806            "check": { "total_issues": 0 },
3807            "dupes": { "clone_groups": [], "stats": {} },
3808            "health": health
3809        });
3810        let out = render_combined_summary(&env, &no_links());
3811        assert!(out.contains(":warning: **3** health findings"), "{out}");
3812        assert!(out.contains("(3 functions above threshold)"), "{out}");
3813        assert!(!out.contains("baseline"), "{out}");
3814    }
3815
3816    /// A run without a finding list (for example `--score`) still counts the
3817    /// functions above a threshold. The summary must give that count.
3818    #[test]
3819    fn health_summary_counts_unlisted_functions() {
3820        let envelope = |above: u64| {
3821            serde_json::json!({
3822                "elapsed_ms": 1,
3823                "summary": {
3824                    "functions_analyzed": 5,
3825                    "functions_above_threshold": above,
3826                    "max_cyclomatic_threshold": 20,
3827                    "max_cognitive_threshold": 15,
3828                    "max_crap_threshold": 30.0
3829                }
3830            })
3831        };
3832        let out = render_health_summary(&envelope(1));
3833        assert!(out.contains("**1 function exceeds thresholds**"), "{out}");
3834        assert!(out.contains("`fallow health --complexity`"), "{out}");
3835        assert!(!out.contains("baseline"), "{out}");
3836        assert!(!out.contains("No functions exceed"), "{out}");
3837        let out = render_health_summary(&envelope(0));
3838        assert!(
3839            out.contains("**No functions exceed complexity thresholds**"),
3840            "{out}"
3841        );
3842    }
3843
3844    /// A baselined envelope. A score-only run omits `vital_signs`; a run that
3845    /// lists findings carries it.
3846    fn baselined_envelope(lists_findings: bool) -> serde_json::Value {
3847        let mut envelope = serde_json::json!({
3848            "elapsed_ms": 1,
3849            "findings": [],
3850            "summary": {
3851                "functions_analyzed": 5,
3852                "functions_above_threshold": 1,
3853                "max_cyclomatic_threshold": 20,
3854                "max_cognitive_threshold": 15,
3855                "max_crap_threshold": 30.0,
3856                "baseline_staleness": {
3857                    "baseline_entries": 2,
3858                    "matched_entries": 2,
3859                    "stale_entries": 0,
3860                    "current_findings": 1,
3861                    "stale": false
3862                }
3863            }
3864        });
3865        if lists_findings {
3866            envelope["vital_signs"] = serde_json::json!({ "avg_cyclomatic": 2.0 });
3867        }
3868        envelope
3869    }
3870
3871    /// A run that lists findings and has a baseline that accepts every
3872    /// finding is clean. The summary must not give the count or ask for a
3873    /// rerun.
3874    #[test]
3875    fn health_summary_keeps_a_baselined_listed_run_clean() {
3876        let out = render_health_summary(&baselined_envelope(true));
3877        assert!(
3878            out.contains("**No functions exceed complexity thresholds**"),
3879            "{out}"
3880        );
3881        assert!(!out.contains("--complexity"), "{out}");
3882    }
3883
3884    /// A score-only run does not list findings, so a baseline does not make
3885    /// it clean. The count comes before the baseline, and the summary says so.
3886    #[test]
3887    fn health_summary_counts_unlisted_functions_on_a_baselined_score_run() {
3888        let out = render_health_summary(&baselined_envelope(false));
3889        assert!(out.contains("**1 function exceeds thresholds**"), "{out}");
3890        assert!(
3891            out.contains("The count includes the functions that the baseline accepts."),
3892            "{out}"
3893        );
3894        assert!(
3895            out.contains("Run `fallow health --complexity` with the same `--baseline`"),
3896            "{out}"
3897        );
3898        assert!(!out.contains("No functions exceed"), "{out}");
3899    }
3900
3901    /// A run with `sections` that does not name `complexity` does not list
3902    /// findings, also when it has `vital_signs` and a baseline. The summary
3903    /// gives the count and the number of new functions.
3904    #[test]
3905    fn health_summary_reads_the_sections_member() {
3906        let mut env = baselined_envelope(true);
3907        env["sections"] = serde_json::json!(["vital-signs", "hotspots"]);
3908        env["summary"]["functions_above_threshold"] = serde_json::json!(3);
3909        env["summary"]["baseline_staleness"]["remaining_findings"] = serde_json::json!(1);
3910        let out = render_health_summary(&env);
3911        assert!(out.contains("**3 functions exceed thresholds**"), "{out}");
3912        assert!(out.contains("The baseline accepts 2. 1 is new."), "{out}");
3913        assert!(
3914            out.contains("Run `fallow health --complexity` with the same `--baseline`"),
3915            "{out}"
3916        );
3917        assert!(!out.contains("No functions exceed"), "{out}");
3918        assert!(!out.contains("The count includes"), "{out}");
3919
3920        env["sections"] = serde_json::json!(["complexity", "vital-signs", "hotspots"]);
3921        let out = render_health_summary(&env);
3922        assert!(
3923            out.contains("**No functions exceed complexity thresholds**"),
3924            "{out}"
3925        );
3926    }
3927
3928    /// When the baseline accepts every function, the note says so and does
3929    /// not ask for a rerun.
3930    #[test]
3931    fn health_summary_with_no_new_function_does_not_ask_for_a_rerun() {
3932        let mut env = baselined_envelope(false);
3933        env["sections"] = serde_json::json!(["score"]);
3934        env["summary"]["baseline_staleness"]["remaining_findings"] = serde_json::json!(0);
3935        let out = render_health_summary(&env);
3936        assert!(out.contains("**1 function exceeds thresholds**"), "{out}");
3937        assert!(out.contains("The baseline accepts 1. 0 are new."), "{out}");
3938        assert!(!out.contains("--complexity"), "{out}");
3939    }
3940
3941    /// Every counted dead-code kind needs a summary row with the registry
3942    /// label and docs anchor, and every summary row needs a registry kind.
3943    #[test]
3944    fn dead_code_categories_match_counted_result_metadata() {
3945        let native: BTreeSet<(&str, &str, &str)> = DEAD_CODE_CATEGORIES.iter().copied().collect();
3946        assert_eq!(
3947            native.len(),
3948            DEAD_CODE_CATEGORIES.len(),
3949            "DEAD_CODE_CATEGORIES has a duplicate row"
3950        );
3951        let registry: BTreeSet<(&str, &str, &str)> = counted_result_issue_metas()
3952            .map(|meta| (meta.summary_label, meta.result_key, meta.docs_anchor))
3953            .collect();
3954        let missing: Vec<_> = registry.difference(&native).collect();
3955        let extra: Vec<_> = native.difference(&registry).collect();
3956        assert!(
3957            missing.is_empty() && extra.is_empty(),
3958            "DEAD_CODE_CATEGORIES must match the counted result metadata \
3959             (label, result key, docs anchor).\nMissing rows: {missing:?}\nRows not in the registry: {extra:?}"
3960        );
3961    }
3962
3963    /// Rows without a path or line tie on the first two keys. The serialized
3964    /// row breaks the tie, so the rendered order does not depend on the input
3965    /// order. The rows past the rendered limit are counted, not listed.
3966    #[test]
3967    fn pathless_rows_render_in_one_order_for_any_input_order() {
3968        let envelope = |names: &[String]| {
3969            serde_json::json!({
3970                "total_issues": names.len(),
3971                "elapsed_ms": 1,
3972                "unlisted_dependencies": names
3973                    .iter()
3974                    .map(|name| serde_json::json!({ "package_name": name, "imported_from": [] }))
3975                    .collect::<Vec<_>>(),
3976            })
3977        };
3978        let names: Vec<String> = (0..40).map(|index| format!("pkg-{index:02}")).collect();
3979        let mut reversed = names.clone();
3980        reversed.reverse();
3981        let ordered = super::render_check_summary(&envelope(&names));
3982        assert_eq!(super::render_check_summary(&envelope(&reversed)), ordered);
3983        let first = ordered.find("pkg-00").expect("first row");
3984        let second = ordered.find("pkg-01").expect("second row");
3985        assert!(
3986            first < second,
3987            "rows follow the serialized order: {ordered}"
3988        );
3989        assert!(
3990            !ordered.contains("pkg-39"),
3991            "rows past the limit are counted only"
3992        );
3993        assert!(
3994            ordered.contains("15 more"),
3995            "the tail counts the rest: {ordered}"
3996        );
3997    }
3998
3999    /// A saved `--group-by` envelope lists rows group by group. The summary
4000    /// renders them in one order whatever the input order is.
4001    #[test]
4002    fn dead_code_rows_render_in_one_order_for_any_input_order() {
4003        let envelope = |paths: [&str; 3]| {
4004            serde_json::json!({
4005                "total_issues": 3,
4006                "elapsed_ms": 1,
4007                "unused_files": paths.map(|path| serde_json::json!({ "path": path })),
4008            })
4009        };
4010        let flat = super::render_check_summary(&envelope(["src/a.ts", "src/b.ts", "src/c.ts"]));
4011        let grouped = super::render_check_summary(&envelope(["src/a.ts", "src/c.ts", "src/b.ts"]));
4012        assert_eq!(grouped, flat);
4013        let a = flat.find("`src/a.ts`").expect("a");
4014        let b = flat.find("`src/b.ts`").expect("b");
4015        let c = flat.find("`src/c.ts`").expect("c");
4016        assert!(a < b && b < c, "rows follow path order: {flat}");
4017    }
4018}