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