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