Skip to main content

fallow_cli/report/
github_summary.rs

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