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