Skip to main content

fallow_cli/report/ci/
pr_comment.rs

1use crate::report::sink::outln;
2use std::fmt::Write as _;
3use std::process::ExitCode;
4use std::sync::OnceLock;
5
6use serde_json::Value;
7
8use crate::output_envelope::{CodeClimateIssue, CodeClimateSeverity};
9
10/// Workspace name, set once by `main()` when the binary is invoked with
11/// `--workspace <name>`. Read by `sticky_marker_id` to auto-suffix the
12/// sticky-comment marker per workspace, which keeps parallel per-workspace
13/// jobs from racing each other's sticky body on the same PR/MR.
14///
15/// `OnceLock` gives us safe cross-function read-after-set without env-var
16/// indirection. Only main writes; readers always observe the post-CLI-parse
17/// state.
18static WORKSPACE_MARKER: OnceLock<String> = OnceLock::new();
19
20/// Set the workspace marker from a `--workspace` selection list.
21///
22/// Single workspace -> the name itself, sanitised for marker grammar.
23/// N>1 workspaces -> a stable 6-char hex hash of the sorted, comma-joined
24/// list, prefixed with `w-`. Sort + join is deterministic so the same
25/// selection produces the same suffix across runs; two jobs with disjoint
26/// selections get distinct markers and don't race.
27#[allow(
28    dead_code,
29    reason = "called from main.rs bin target; lib target sees no caller"
30)]
31pub fn set_workspace_marker_from_list(values: &[String]) {
32    let trimmed: Vec<&str> = values
33        .iter()
34        .map(|value| value.trim())
35        .filter(|value| !value.is_empty())
36        .collect();
37    if trimmed.is_empty() {
38        return;
39    }
40    let marker = if let [single] = trimmed.as_slice() {
41        (*single).to_owned()
42    } else {
43        let mut sorted = trimmed.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>();
44        sorted.sort();
45        let joined = sorted.join(",");
46        format!("w-{}", short_hex_hash(&joined))
47    };
48    let _ = WORKSPACE_MARKER.set(marker);
49}
50
51/// 6-char FNV-1a hex digest. Stable across Rust versions (FNV is content-
52/// determined), short enough for a marker suffix, wide enough that the
53/// chance of two real-world workspace selections colliding is ~1/16M.
54fn short_hex_hash(value: &str) -> String {
55    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
56    for byte in value.bytes() {
57        hash ^= u64::from(byte);
58        hash = hash.wrapping_mul(0x0100_0000_01b3);
59    }
60    format!("{:06x}", (hash & 0x00ff_ffff) as u32)
61}
62
63#[derive(Clone, Copy, Debug, PartialEq, Eq)]
64pub enum Provider {
65    Github,
66    Gitlab,
67}
68
69impl Provider {
70    #[must_use]
71    pub const fn name(self) -> &'static str {
72        match self {
73            Self::Github => "GitHub",
74            Self::Gitlab => "GitLab",
75        }
76    }
77}
78
79#[derive(Clone, Debug, PartialEq, Eq)]
80pub struct CiIssue {
81    pub rule_id: String,
82    pub description: String,
83    pub severity: String,
84    pub path: String,
85    pub line: u64,
86    pub fingerprint: String,
87}
88
89#[must_use]
90pub fn issues_from_codeclimate(value: &Value) -> Vec<CiIssue> {
91    let mut issues = value
92        .as_array()
93        .into_iter()
94        .flatten()
95        .filter_map(issue_from_codeclimate)
96        .collect::<Vec<_>>();
97    sort_ci_issues(&mut issues);
98    issues
99}
100
101#[must_use]
102pub fn issues_from_codeclimate_issues(issues: &[CodeClimateIssue]) -> Vec<CiIssue> {
103    let mut issues = issues
104        .iter()
105        .map(issue_from_codeclimate_issue)
106        .collect::<Vec<_>>();
107    sort_ci_issues(&mut issues);
108    issues
109}
110
111fn issue_from_codeclimate(value: &Value) -> Option<CiIssue> {
112    let path = value.pointer("/location/path")?.as_str()?.to_string();
113    let line = value
114        .pointer("/location/lines/begin")
115        .and_then(Value::as_u64)
116        .unwrap_or(1);
117    Some(CiIssue {
118        rule_id: value
119            .get("check_name")
120            .and_then(Value::as_str)
121            .unwrap_or("fallow/finding")
122            .to_string(),
123        description: value
124            .get("description")
125            .and_then(Value::as_str)
126            .unwrap_or("Fallow finding")
127            .to_string(),
128        severity: value
129            .get("severity")
130            .and_then(Value::as_str)
131            .unwrap_or("minor")
132            .to_string(),
133        fingerprint: value
134            .get("fingerprint")
135            .and_then(Value::as_str)
136            .unwrap_or("")
137            .to_string(),
138        path,
139        line,
140    })
141}
142
143fn issue_from_codeclimate_issue(issue: &CodeClimateIssue) -> CiIssue {
144    CiIssue {
145        rule_id: issue.check_name.clone(),
146        description: issue.description.clone(),
147        severity: codeclimate_severity_label(issue.severity).to_owned(),
148        path: issue.location.path.clone(),
149        line: u64::from(issue.location.lines.begin),
150        fingerprint: issue.fingerprint.clone(),
151    }
152}
153
154const fn codeclimate_severity_label(severity: CodeClimateSeverity) -> &'static str {
155    match severity {
156        CodeClimateSeverity::Info => "info",
157        CodeClimateSeverity::Minor => "minor",
158        CodeClimateSeverity::Major => "major",
159        CodeClimateSeverity::Critical => "critical",
160        CodeClimateSeverity::Blocker => "blocker",
161    }
162}
163
164fn sort_ci_issues(issues: &mut [CiIssue]) {
165    issues
166        .sort_by(|a, b| (&a.path, a.line, &a.fingerprint).cmp(&(&b.path, b.line, &b.fingerprint)));
167}
168
169#[must_use]
170#[expect(clippy::expect_used, reason = "formatting into String is infallible")]
171pub fn render_pr_comment(command: &str, provider: Provider, issues: &[CiIssue]) -> String {
172    let marker_id = sticky_marker_id();
173    let marker = format!("<!-- fallow-id: {marker_id} -->");
174    let max = max_comments();
175    let title = command_title(command);
176    let count = issues.len();
177    let noun = if count == 1 { "finding" } else { "findings" };
178
179    let mut out = String::new();
180    out.push_str(&marker);
181    out.push('\n');
182    write!(&mut out, "### Fallow {title}\n\n").expect("write to string");
183    if count == 0 {
184        writeln!(
185            &mut out,
186            "No {provider} PR/MR findings.",
187            provider = provider.name()
188        )
189        .expect("write to string");
190    } else {
191        write!(&mut out, "Found **{count}** {noun}.\n\n").expect("write to string");
192        let groups = group_by_category(issues);
193        if let [(_, group_issues)] = groups.as_slice() {
194            render_findings_table(&mut out, group_issues, max, "Details");
195        } else {
196            for (category, group_issues) in &groups {
197                let summary_label = summary_label(category, group_issues.len(), max);
198                render_findings_table(&mut out, group_issues, max, &summary_label);
199            }
200        }
201    }
202    out.push_str("\nGenerated by fallow.");
203    out
204}
205
206/// Build the `<details>` summary label for one category section. When the
207/// section is truncated by `max`, the label foreshadows the truncation
208/// (`Duplication (160, showing 50)`) so a reviewer expanding the section
209/// isn't surprised by the missing rows. When not truncated, the bare count
210/// reads as before.
211fn summary_label(category: &str, total: usize, max: usize) -> String {
212    if total > max {
213        format!("{category} ({total}, showing {max})")
214    } else {
215        format!("{category} ({total})")
216    }
217}
218
219#[expect(clippy::expect_used, reason = "formatting into String is infallible")]
220fn render_findings_table(out: &mut String, issues: &[&CiIssue], max: usize, summary: &str) {
221    writeln!(out, "<details>\n<summary>{summary}</summary>\n").expect("write to string");
222    out.push_str("| Severity | Rule | Location | Description |\n");
223    out.push_str("| --- | --- | --- | --- |\n");
224    for issue in issues.iter().take(max) {
225        writeln!(
226            out,
227            "| {} | `{}` | `{}`:{} | {} |",
228            escape_md(&issue.severity),
229            escape_md(&issue.rule_id),
230            escape_md(&issue.path),
231            issue.line,
232            escape_md(&issue.description),
233        )
234        .expect("write to string");
235    }
236    if issues.len() > max {
237        writeln!(
238            out,
239            "\nShowing {max} of {} findings. Run fallow locally or inspect the CI output for the full report.",
240            issues.len(),
241        )
242        .expect("write to string");
243    }
244    out.push_str("\n</details>\n\n");
245}
246
247/// Map a fallow rule id to its category for sticky-comment grouping.
248///
249/// Single source of truth lives on `RuleDef::category` in `explain.rs`. This
250/// helper does the lookup so callers don't need to know about the registry;
251/// the look-up-then-fallback shape also keeps the renderer working for
252/// rules a downstream consumer added without registering (rare; produces
253/// the conservative "Dead code" default).
254#[must_use]
255pub fn category_for_rule(rule_id: &str) -> &'static str {
256    crate::explain::rule_by_id(rule_id).map_or("Dead code", |def| def.category)
257}
258
259/// Rule ids whose findings describe a project-wide config state (dependency
260/// hygiene, catalog state, override hygiene) rather than a change touching a
261/// specific source line. These findings anchor at fixed lines inside
262/// `package.json` / `pnpm-workspace.yaml`; the resolved-tree shifts that
263/// trigger them rarely coincide with a diff on the anchored line, so the
264/// line-based diff filter would silently hide them while CI still exits
265/// non-zero because of the same finding.
266///
267/// `filter_issues_for_summary` consults this list so the PR-comment body
268/// always explains config-anchored findings, matching the typical user
269/// expectation that `comment: true` produces a body covering every
270/// CI-failure reason. The review-envelope path keeps the unconditional
271/// filter because inline review comments must anchor on diff lines.
272const PROJECT_LEVEL_RULE_IDS: &[&str] = &[
273    "fallow/unused-catalog-entry",
274    "fallow/empty-catalog-group",
275    "fallow/unresolved-catalog-reference",
276    "fallow/unused-dependency-override",
277    "fallow/misconfigured-dependency-override",
278    "fallow/unused-dependency",
279    "fallow/unused-dev-dependency",
280    "fallow/unused-optional-dependency",
281    "fallow/type-only-dependency",
282    "fallow/test-only-dependency",
283];
284
285/// True when the rule's findings reflect project-wide config state and
286/// should bypass diff-aware filtering in the typed PR-comment renderer.
287/// See `PROJECT_LEVEL_RULE_IDS` for the full list and rationale.
288#[must_use]
289pub fn is_project_level_rule(rule_id: &str) -> bool {
290    PROJECT_LEVEL_RULE_IDS.contains(&rule_id)
291}
292
293/// Stable category ordering for the sticky comment. Reviewers see categories
294/// in the same order across PRs / runs, which matters for muscle memory.
295const CATEGORY_ORDER: [&str; 6] = [
296    "Dead code",
297    "Dependencies",
298    "Duplication",
299    "Health",
300    "Architecture",
301    "Suppressions",
302];
303
304fn group_by_category(issues: &[CiIssue]) -> Vec<(&'static str, Vec<&CiIssue>)> {
305    let mut buckets: std::collections::BTreeMap<&'static str, Vec<&CiIssue>> =
306        std::collections::BTreeMap::new();
307    for issue in issues {
308        let category = category_for_rule(&issue.rule_id);
309        buckets.entry(category).or_default().push(issue);
310    }
311    let mut ordered: Vec<(&'static str, Vec<&CiIssue>)> = Vec::with_capacity(buckets.len());
312    for category in CATEGORY_ORDER {
313        if let Some(items) = buckets.remove(category) {
314            ordered.push((category, items));
315        }
316    }
317    for (category, items) in buckets {
318        ordered.push((category, items));
319    }
320    ordered
321}
322
323fn max_comments() -> usize {
324    std::env::var("FALLOW_MAX_COMMENTS")
325        .ok()
326        .and_then(|value| value.parse::<usize>().ok())
327        .unwrap_or(50)
328}
329
330/// Compute the sticky-comment marker id. Precedence (highest first):
331///
332/// 1. `FALLOW_COMMENT_ID` set by the user explicitly: use as-is.
333/// 2. `WORKSPACE_MARKER` populated by `main()` from `--workspace <name>`:
334///    suffix the default to avoid colliding with a sibling per-workspace
335///    job's sticky on the same PR/MR.
336/// 3. Plain `fallow-results`.
337///
338/// The collision case (2) is the common monorepo shape: parallel jobs each
339/// run fallow scoped to one workspace package and post their own sticky.
340/// Without a per-workspace suffix every job edits the same marker, racing
341/// each other's bodies on every CI re-run.
342fn sticky_marker_id() -> String {
343    if let Ok(value) = std::env::var("FALLOW_COMMENT_ID")
344        && !value.trim().is_empty()
345    {
346        return value;
347    }
348    let suffix = WORKSPACE_MARKER
349        .get()
350        .map(|value| value.trim())
351        .filter(|value| !value.is_empty())
352        .map(sanitize_marker_segment);
353    match suffix {
354        Some(workspace) => format!("fallow-results-{workspace}"),
355        None => "fallow-results".to_owned(),
356    }
357}
358
359/// Strip characters that would break the HTML-comment marker. The marker
360/// shape is `<!-- fallow-id: <id> -->`; `<`, `>`, and `--` are reserved by
361/// the HTML comment grammar, and whitespace would split the id when the
362/// reader scans for it.
363fn sanitize_marker_segment(value: &str) -> String {
364    value
365        .chars()
366        .map(|ch| {
367            if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' {
368                ch
369            } else {
370                '-'
371            }
372        })
373        .collect::<String>()
374        .trim_matches('-')
375        .to_owned()
376}
377
378#[must_use]
379pub fn print_pr_comment(command: &str, provider: Provider, codeclimate: &Value) -> ExitCode {
380    let issues =
381        super::diff_filter::filter_issues_for_summary(issues_from_codeclimate(codeclimate));
382    print_pr_comment_from_ci_issues(command, provider, &issues)
383}
384
385#[must_use]
386pub fn print_pr_comment_from_codeclimate_issues(
387    command: &str,
388    provider: Provider,
389    codeclimate: &[CodeClimateIssue],
390) -> ExitCode {
391    let issues =
392        super::diff_filter::filter_issues_for_summary(issues_from_codeclimate_issues(codeclimate));
393    print_pr_comment_from_ci_issues(command, provider, &issues)
394}
395
396#[must_use]
397fn print_pr_comment_from_ci_issues(
398    command: &str,
399    provider: Provider,
400    issues: &[CiIssue],
401) -> ExitCode {
402    outln!("{}", render_pr_comment(command, provider, issues));
403    ExitCode::SUCCESS
404}
405
406#[must_use]
407pub fn command_title(command: &str) -> &'static str {
408    match command {
409        "dead-code" | "check" => "dead-code report",
410        "dupes" => "duplication report",
411        "health" => "health report",
412        "audit" => "audit report",
413        "" | "combined" => "combined report",
414        _ => "report",
415    }
416}
417
418/// Escape a string for inclusion in a Markdown table cell.
419///
420/// Table cells render through GitHub-Flavored Markdown and GitLab Flavored
421/// Markdown as inline content, so cell-internal markers can flip the cell to
422/// emphasis, link, image, code, HTML, or strikethrough. Newlines collapse to
423/// spaces because a literal newline terminates the table row. The escape set
424/// covers every CommonMark inline construct that can fire mid-cell:
425///
426/// - `\` (escape character itself)
427/// - `` ` `` (inline code)
428/// - `*` `_` (emphasis / strong)
429/// - `[` `]` `(` `)` (link / image syntax)
430/// - `!` (image when followed by `[`)
431/// - `<` `>` (raw HTML / autolinks)
432/// - `#` (cell rendered as heading when first character of the cell)
433/// - `|` (table cell separator)
434/// - `~` (strikethrough on GFM)
435/// - `&` (HTML numeric / named entity decode: `&#42;` would otherwise
436///   render as `*` after our escape and reintroduce the bypass)
437///
438/// Line-start markers (`.`, `-`, `+`, `1.`) are intentionally NOT escaped:
439/// they are only meaningful at the start of a block-level line, and table
440/// cells render as paragraph-equivalent inline content where these are inert.
441/// Escaping them produces visually noisy output (`fallow/test\-only-dep`)
442/// without correctness benefit.
443#[must_use]
444pub fn escape_md(value: &str) -> String {
445    let collapsed = value.replace('\n', " ");
446    let mut out = String::with_capacity(collapsed.len());
447    for ch in collapsed.chars() {
448        if matches!(
449            ch,
450            '\\' | '`'
451                | '*'
452                | '_'
453                | '['
454                | ']'
455                | '('
456                | ')'
457                | '!'
458                | '<'
459                | '>'
460                | '#'
461                | '|'
462                | '~'
463                | '&'
464        ) {
465            out.push('\\');
466        }
467        out.push(ch);
468    }
469    out.trim().to_owned()
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    #[test]
477    fn extracts_issues_from_codeclimate() {
478        let value = serde_json::json!([{
479            "check_name": "fallow/unused-export",
480            "description": "Export x is never imported",
481            "severity": "minor",
482            "fingerprint": "abc",
483            "location": { "path": "src/a.ts", "lines": { "begin": 7 } }
484        }]);
485        let issues = issues_from_codeclimate(&value);
486        assert_eq!(issues.len(), 1);
487        assert_eq!(issues[0].path, "src/a.ts");
488        assert_eq!(issues[0].line, 7);
489    }
490
491    #[test]
492    fn typed_codeclimate_issues_extract_like_json_codeclimate() {
493        let severities = [
494            (CodeClimateSeverity::Info, "info"),
495            (CodeClimateSeverity::Minor, "minor"),
496            (CodeClimateSeverity::Major, "major"),
497            (CodeClimateSeverity::Critical, "critical"),
498            (CodeClimateSeverity::Blocker, "blocker"),
499        ];
500        let typed = severities
501            .iter()
502            .enumerate()
503            .map(|(index, (severity, _))| CodeClimateIssue {
504                kind: crate::output_envelope::CodeClimateIssueKind::Issue,
505                check_name: format!("fallow/rule-{index}"),
506                description: format!("Finding {index}"),
507                categories: vec!["Complexity".to_owned()],
508                severity: *severity,
509                fingerprint: format!("fp-{index}"),
510                location: crate::output_envelope::CodeClimateLocation {
511                    path: format!("src/{index}.ts"),
512                    lines: crate::output_envelope::CodeClimateLines {
513                        begin: u32::try_from(index + 1).expect("small fixture index"),
514                    },
515                },
516            })
517            .collect::<Vec<_>>();
518        let value = serde_json::to_value(&typed).expect("typed fixture serializes");
519
520        assert_eq!(
521            issues_from_codeclimate_issues(&typed),
522            issues_from_codeclimate(&value)
523        );
524        let typed_labels = issues_from_codeclimate_issues(&typed)
525            .into_iter()
526            .map(|issue| issue.severity)
527            .collect::<Vec<_>>();
528        let expected_labels = severities
529            .iter()
530            .map(|(_, label)| (*label).to_owned())
531            .collect::<Vec<_>>();
532        assert_eq!(typed_labels, expected_labels);
533    }
534
535    #[test]
536    fn sticky_marker_id_default_when_nothing_set() {
537        let body = render_pr_comment("check", Provider::Github, &[]);
538        assert!(body.contains("<!-- fallow-id: fallow-results"));
539        assert!(body.contains("No GitHub PR/MR findings."));
540    }
541
542    #[test]
543    fn short_hex_hash_is_deterministic_and_six_chars() {
544        let a = short_hex_hash("api,worker");
545        assert_eq!(a.len(), 6);
546        assert_eq!(a, short_hex_hash("api,worker"));
547        assert_ne!(a, short_hex_hash("admin,web"));
548    }
549
550    #[test]
551    fn sanitize_marker_segment_collapses_unsafe_chars_to_dashes() {
552        assert_eq!(sanitize_marker_segment("@fallow/runtime"), "fallow-runtime");
553        assert_eq!(
554            sanitize_marker_segment("packages/web ui"),
555            "packages-web-ui"
556        );
557        assert_eq!(sanitize_marker_segment("plain"), "plain");
558        assert_eq!(
559            sanitize_marker_segment("--leading-trailing--"),
560            "leading-trailing"
561        );
562    }
563
564    #[test]
565    fn escape_md_escapes_inline_commonmark_specials() {
566        let raw = "foo*bar_baz [a](u) `c` <h> #x !i ~s | p";
567        let escaped = escape_md(raw);
568        for ch in [
569            '*', '_', '[', ']', '(', ')', '`', '<', '>', '#', '!', '~', '|',
570        ] {
571            let raw_count = raw.chars().filter(|c| c == &ch).count();
572            let escaped_count = escaped.matches(&format!("\\{ch}")).count();
573            assert_eq!(
574                raw_count, escaped_count,
575                "char {ch:?}: raw {raw_count} occurrences, escaped {escaped_count} in {escaped:?}"
576            );
577        }
578    }
579
580    #[test]
581    fn escape_md_escapes_ampersand_to_block_numeric_entity_bypass() {
582        let raw = "value &#42;suspicious&#42; here";
583        let escaped = escape_md(raw);
584        assert!(escaped.contains(r"\&"), "got: {escaped}");
585        assert!(escaped.contains(r"\#"), "got: {escaped}");
586        assert!(!escaped.contains(" *suspicious"), "got: {escaped}");
587    }
588
589    #[test]
590    fn summary_label_foreshadows_truncation() {
591        assert_eq!(
592            summary_label("Duplication", 160, 50),
593            "Duplication (160, showing 50)"
594        );
595        assert_eq!(summary_label("Health", 12, 50), "Health (12)");
596        assert_eq!(summary_label("Dependencies", 50, 50), "Dependencies (50)");
597    }
598
599    #[test]
600    fn escape_md_does_not_escape_block_only_markers() {
601        let raw = "fallow/test-only-dependency package.json:12";
602        let escaped = escape_md(raw);
603        assert!(!escaped.contains("\\-"), "should not escape `-`");
604        assert!(!escaped.contains("\\."), "should not escape `.`");
605        assert_eq!(escaped, raw);
606    }
607
608    #[test]
609    fn escape_md_collapses_newlines_to_spaces() {
610        let raw = "first\nsecond\nthird";
611        assert_eq!(escape_md(raw), "first second third");
612    }
613
614    #[test]
615    fn escape_md_leaves_safe_chars_unchanged() {
616        let raw = "Export 'helperFn' is never imported by other modules";
617        assert_eq!(
618            escape_md(raw),
619            r"Export 'helperFn' is never imported by other modules"
620        );
621    }
622
623    #[test]
624    fn is_project_level_rule_covers_config_anchored_dependency_findings() {
625        for rule_id in PROJECT_LEVEL_RULE_IDS {
626            assert!(
627                is_project_level_rule(rule_id),
628                "{rule_id} must be project-level"
629            );
630        }
631        for rule_id in [
632            "fallow/unused-file",
633            "fallow/unused-export",
634            "fallow/unused-type",
635            "fallow/unused-enum-member",
636            "fallow/unused-class-member",
637            "fallow/unresolved-import",
638            "fallow/unlisted-dependency",
639            "fallow/duplicate-export",
640            "fallow/circular-dependency",
641            "fallow/re-export-cycle",
642            "fallow/boundary-violation",
643            "fallow/stale-suppression",
644            "fallow/private-type-leak",
645            "fallow/high-complexity",
646            "fallow/high-crap-score",
647        ] {
648            assert!(
649                !is_project_level_rule(rule_id),
650                "{rule_id} must NOT be project-level"
651            );
652        }
653    }
654
655    #[test]
656    fn project_level_rule_ids_each_register_in_explain_registry() {
657        for rule_id in PROJECT_LEVEL_RULE_IDS {
658            assert!(
659                crate::explain::rule_by_id(rule_id).is_some(),
660                "{rule_id} listed in PROJECT_LEVEL_RULE_IDS but not in explain registry"
661            );
662        }
663    }
664
665    #[test]
666    fn escape_md_double_apply_is_safe() {
667        let raw = "code with `backticks` and *stars*";
668        let once = escape_md(raw);
669        let twice = escape_md(&once);
670        assert!(twice.contains(r"\\"));
671    }
672}