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