Skip to main content

fallow_cli/report/
github_summary.rs

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