Skip to main content

fallow_cli/report/
github_summary.rs

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