Skip to main content

fallow_cli/report/ci/
pr_comment.rs

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