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