1use crate::report::sink::outln;
2use std::process::ExitCode;
3use std::sync::OnceLock;
4
5use serde_json::Value;
6
7#[cfg(test)]
8use fallow_output::is_project_level_rule;
9use fallow_output::issues_from_codeclimate_issues;
10pub use fallow_output::{
11 CiIssue, CiProvider as Provider, CodeClimateIssue, PR_DECISION_SCHEMA, PR_DETAILS_SCHEMA,
12 PrCommentEnvelope, PrCommentLayout, PrCommentTruncation, PrDecisionAnnotation,
13 PrDecisionAnnotationLevel, PrDecisionConclusion, PrDecisionDetails, PrDecisionGate,
14 PrDecisionSurface, PrDetailsArtifact, PrDetailsRow, PrDetailsSection, command_title,
15 issues_from_codeclimate,
16};
17
18static WORKSPACE_MARKER: OnceLock<String> = OnceLock::new();
27
28#[allow(
36 dead_code,
37 reason = "called from main.rs bin target; lib target sees no caller"
38)]
39pub fn set_workspace_marker_from_list(values: &[String]) {
40 let trimmed: Vec<&str> = values
41 .iter()
42 .map(|value| value.trim())
43 .filter(|value| !value.is_empty())
44 .collect();
45 if trimmed.is_empty() {
46 return;
47 }
48 let marker = if let [single] = trimmed.as_slice() {
49 (*single).to_owned()
50 } else {
51 let mut sorted = trimmed.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>();
52 sorted.sort();
53 let joined = sorted.join(",");
54 format!("w-{}", short_hex_hash(&joined))
55 };
56 let _ = WORKSPACE_MARKER.set(marker);
57}
58
59fn short_hex_hash(value: &str) -> String {
63 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
64 for byte in value.bytes() {
65 hash ^= u64::from(byte);
66 hash = hash.wrapping_mul(0x0100_0000_01b3);
67 }
68 format!("{:06x}", (hash & 0x00ff_ffff) as u32)
69}
70
71#[must_use]
75pub fn render_pr_comment(
76 command: &str,
77 provider: Provider,
78 issues: &[CiIssue],
79 conclusion: Option<PrDecisionConclusion>,
80) -> String {
81 fallow_output::render_pr_comment_with_verdict(
82 &fallow_output::PrCommentRenderInput {
83 command,
84 provider,
85 issues,
86 marker_id: sticky_marker_id(),
87 max_comments: max_comments(),
88 category_for_rule: &category_for_rule,
89 },
90 conclusion.map(super::review::review_conclusion),
91 )
92}
93
94#[must_use]
104fn category_for_rule(rule_id: &str) -> &'static str {
105 crate::explain::rule_by_id(rule_id).map_or("Other", |def| def.category)
106}
107
108pub(crate) fn max_comments() -> usize {
109 std::env::var("FALLOW_MAX_COMMENTS")
110 .ok()
111 .and_then(|value| value.parse::<usize>().ok())
112 .unwrap_or(50)
113}
114
115#[must_use]
116pub(crate) fn pr_comment_layout_from_env() -> PrCommentLayout {
117 match std::env::var("FALLOW_PR_COMMENT_LAYOUT").as_deref() {
118 Ok("compact") => PrCommentLayout::Compact,
119 Ok("gate-only") => PrCommentLayout::GateOnly,
120 Ok("details") => PrCommentLayout::Details,
121 _ => PrCommentLayout::Default,
122 }
123}
124
125pub(crate) fn sticky_marker_id() -> String {
138 if let Ok(value) = std::env::var("FALLOW_COMMENT_ID")
139 && !value.trim().is_empty()
140 {
141 return value;
142 }
143 let suffix = WORKSPACE_MARKER
144 .get()
145 .map(|value| value.trim())
146 .filter(|value| !value.is_empty())
147 .map(sanitize_marker_segment);
148 match suffix {
149 Some(workspace) => format!("fallow-results-{workspace}"),
150 None => "fallow-results".to_owned(),
151 }
152}
153
154fn sanitize_marker_segment(value: &str) -> String {
159 value
160 .chars()
161 .map(|ch| {
162 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' {
163 ch
164 } else {
165 '-'
166 }
167 })
168 .collect::<String>()
169 .trim_matches('-')
170 .to_owned()
171}
172
173#[must_use]
174pub(crate) fn print_pr_comment(
175 command: &str,
176 provider: Provider,
177 codeclimate: &Value,
178 status_message: Option<&str>,
179) -> ExitCode {
180 let issues = rebase_issue_paths(super::diff_filter::filter_issues_for_summary(
181 issues_from_codeclimate(codeclimate),
182 ));
183 let conclusion = issue_decision_conclusion(issues.is_empty());
184 print_pr_comment_from_ci_issues(command, provider, &issues, conclusion, status_message)
185}
186
187#[must_use]
188pub(crate) fn print_pr_comment_with_status(
189 command: &str,
190 provider: Provider,
191 codeclimate: &Value,
192 conclusion: PrDecisionConclusion,
193 status_message: Option<&str>,
194) -> ExitCode {
195 let issues = rebase_issue_paths(super::diff_filter::filter_issues_for_summary(
196 issues_from_codeclimate(codeclimate),
197 ));
198 print_pr_comment_from_ci_issues(command, provider, &issues, conclusion, status_message)
199}
200
201#[must_use]
202pub(crate) fn print_pr_comment_from_codeclimate_issues(
203 command: &str,
204 provider: Provider,
205 codeclimate: &[CodeClimateIssue],
206 conclusion: Option<PrDecisionConclusion>,
207 status_message: Option<&str>,
208) -> ExitCode {
209 let issues = rebase_issue_paths(super::diff_filter::filter_issues_for_summary(
210 issues_from_codeclimate_issues(codeclimate),
211 ));
212 let conclusion = conclusion.unwrap_or_else(|| issue_decision_conclusion(issues.is_empty()));
213 print_pr_comment_from_ci_issues(command, provider, &issues, conclusion, status_message)
214}
215
216fn rebase_issue_paths(mut issues: Vec<CiIssue>) -> Vec<CiIssue> {
217 let prefix = crate::report::github::report_prefix();
218 if !prefix.is_empty() {
219 for issue in &mut issues {
220 issue.path = fallow_output::apply_path_prefix(prefix, &issue.path);
221 }
222 }
223 issues
224}
225
226#[must_use]
227fn print_pr_comment_from_ci_issues(
228 command: &str,
229 provider: Provider,
230 issues: &[CiIssue],
231 conclusion: PrDecisionConclusion,
232 status_message: Option<&str>,
233) -> ExitCode {
234 let mut body = render_pr_comment(command, provider, issues, Some(conclusion));
235 if let Some(message) = status_message {
236 body.push_str("\n\n> ");
237 body.push_str(message);
238 }
239 let max_comments = max_comments();
240 let envelope = PrCommentEnvelope {
241 marker_id: sticky_marker_id(),
242 body,
243 is_clean: issues.is_empty() && conclusion == PrDecisionConclusion::Success,
244 details_url: None,
245 check_summary: Some(decision_summary_label(conclusion).to_owned()),
246 truncation: PrCommentTruncation {
247 truncated: issues.len() > max_comments,
248 shown_findings: issues.len().min(max_comments),
249 total_findings: issues.len(),
250 },
251 };
252 let decision =
253 build_issue_decision_surface(command, issues, &envelope, conclusion, status_message);
254 let details = build_pr_details_artifact(command, issues);
255 write_pr_comment_envelope_sidecar(&envelope);
256 write_pr_decision_sidecar(&decision);
257 write_pr_details_sidecar(&details);
258 outln!("{}", envelope.body());
259 ExitCode::SUCCESS
260}
261
262#[must_use]
263fn build_issue_decision_surface(
264 command: &str,
265 issues: &[CiIssue],
266 envelope: &PrCommentEnvelope,
267 conclusion: PrDecisionConclusion,
268 status_message: Option<&str>,
269) -> PrDecisionSurface {
270 PrDecisionSurface {
271 schema: PR_DECISION_SCHEMA.to_owned(),
272 title: "Fallow".to_owned(),
273 conclusion,
274 gates: vec![PrDecisionGate {
275 id: command.to_owned(),
276 label: command_title(command).to_owned(),
277 status: conclusion,
278 observed: count_label(issues.len(), "finding", "findings"),
279 threshold: None,
280 scope: "new code".to_owned(),
281 }],
282 annotations: issues
283 .iter()
284 .take(max_comments())
285 .map(decision_annotation_from_issue)
286 .collect(),
287 details: PrDecisionDetails {
288 summary_markdown: decision_summary_markdown(conclusion, issues.len(), status_message),
289 full_report_path: None,
290 details_url: envelope.details_url.clone(),
291 },
292 }
293}
294
295fn issue_decision_conclusion(is_clean: bool) -> PrDecisionConclusion {
302 if is_clean {
303 PrDecisionConclusion::Success
304 } else {
305 PrDecisionConclusion::Neutral
306 }
307}
308
309fn decision_summary_label(conclusion: PrDecisionConclusion) -> &'static str {
310 match conclusion {
311 PrDecisionConclusion::Success => "pass",
312 PrDecisionConclusion::Failure => "fail",
313 PrDecisionConclusion::Neutral => "warn",
314 PrDecisionConclusion::Skipped => "skipped",
315 }
316}
317
318fn decision_summary_markdown(
319 conclusion: PrDecisionConclusion,
320 issue_count: usize,
321 status_message: Option<&str>,
322) -> String {
323 let summary = if issue_count == 0 {
324 match conclusion {
325 PrDecisionConclusion::Failure => {
326 "Fallow quality gates failed without renderable findings.".to_owned()
327 }
328 PrDecisionConclusion::Neutral => {
329 "Fallow needs review without renderable findings.".to_owned()
330 }
331 PrDecisionConclusion::Success | PrDecisionConclusion::Skipped => {
332 "Fallow found no actionable PR findings.".to_owned()
333 }
334 }
335 } else {
336 let findings = count_label(issue_count, "finding", "findings");
337 match conclusion {
338 PrDecisionConclusion::Failure => {
339 format!("Fallow quality gates failed with {findings}.")
340 }
341 PrDecisionConclusion::Neutral => format!("Fallow found {findings} for review."),
342 PrDecisionConclusion::Success | PrDecisionConclusion::Skipped => {
343 format!("Fallow found {findings}.")
344 }
345 }
346 };
347 match status_message {
348 Some(message) => format!("{summary}\n\n> {message}"),
349 None => summary,
350 }
351}
352
353#[must_use]
354pub(crate) fn build_pr_details_artifact(command: &str, issues: &[CiIssue]) -> PrDetailsArtifact {
355 PrDetailsArtifact {
356 schema: PR_DETAILS_SCHEMA.to_owned(),
357 title: format!("Fallow {}", command_title(command)),
358 sections: vec![PrDetailsSection {
359 id: "findings".to_owned(),
360 title: "Findings".to_owned(),
361 rows: issues.iter().map(pr_details_row_from_issue).collect(),
362 }],
363 }
364}
365
366fn pr_details_row_from_issue(issue: &CiIssue) -> PrDetailsRow {
367 PrDetailsRow {
368 location: format!("{}:{}", issue.path, issue.line),
369 rule: issue.rule_id.clone(),
370 description: issue.description.clone(),
371 fix: super::suggestion::fix_intent(issue).map(str::to_owned),
372 fingerprint: (!issue.fingerprint.trim().is_empty()).then(|| issue.fingerprint.clone()),
373 }
374}
375
376#[must_use]
377pub(crate) fn decision_annotation_from_issue(issue: &CiIssue) -> PrDecisionAnnotation {
378 PrDecisionAnnotation {
379 path: issue.path.clone(),
380 line: u32::try_from(issue.line).unwrap_or(u32::MAX),
381 level: decision_level_from_severity(&issue.severity),
382 title: issue.rule_id.clone(),
383 message: issue.description.clone(),
384 raw_details: super::suggestion::fix_intent(issue).map(str::to_owned),
385 }
386}
387
388fn decision_level_from_severity(severity: &str) -> PrDecisionAnnotationLevel {
389 match severity {
390 "blocker" | "critical" | "major" => PrDecisionAnnotationLevel::Failure,
391 "minor" => PrDecisionAnnotationLevel::Warning,
392 _ => PrDecisionAnnotationLevel::Notice,
393 }
394}
395
396fn count_label(count: usize, singular: &str, plural: &str) -> String {
397 let noun = if count == 1 { singular } else { plural };
398 format!("{count} {noun}")
399}
400
401pub(crate) fn write_pr_comment_envelope_sidecar(envelope: &PrCommentEnvelope) {
402 let Ok(path) = std::env::var("FALLOW_PR_COMMENT_ENVELOPE_FILE") else {
403 return;
404 };
405 if path.trim().is_empty() {
406 return;
407 }
408 match serde_json::to_string_pretty(envelope)
409 .map_err(|e| e.to_string())
410 .and_then(|json| std::fs::write(&path, json).map_err(|e| e.to_string()))
411 {
412 Ok(()) => {}
413 Err(e) => eprintln!("warning: failed to write PR comment envelope '{path}': {e}"),
414 }
415}
416
417pub(crate) fn write_pr_decision_sidecar(surface: &PrDecisionSurface) {
418 let Ok(path) = std::env::var("FALLOW_PR_DECISION_FILE") else {
419 return;
420 };
421 if path.trim().is_empty() {
422 return;
423 }
424 match serde_json::to_string_pretty(surface)
425 .map_err(|e| e.to_string())
426 .and_then(|json| std::fs::write(&path, json).map_err(|e| e.to_string()))
427 {
428 Ok(()) => {}
429 Err(e) => eprintln!("warning: failed to write PR decision '{path}': {e}"),
430 }
431}
432
433pub(crate) fn write_pr_details_sidecar(artifact: &PrDetailsArtifact) {
434 let Ok(path) = std::env::var("FALLOW_PR_DETAILS_FILE") else {
435 return;
436 };
437 if path.trim().is_empty() {
438 return;
439 }
440 match serde_json::to_string_pretty(artifact)
441 .map_err(|e| e.to_string())
442 .and_then(|json| std::fs::write(&path, json).map_err(|e| e.to_string()))
443 {
444 Ok(()) => {}
445 Err(e) => eprintln!("warning: failed to write PR details '{path}': {e}"),
446 }
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452 use fallow_output::{
453 CodeClimateIssueKind, CodeClimateLines, CodeClimateLocation, CodeClimateSeverity,
454 };
455
456 #[test]
457 fn typed_codeclimate_issues_extract_like_json_codeclimate() {
458 let severities = [
459 (CodeClimateSeverity::Info, "info"),
460 (CodeClimateSeverity::Minor, "minor"),
461 (CodeClimateSeverity::Major, "major"),
462 (CodeClimateSeverity::Critical, "critical"),
463 (CodeClimateSeverity::Blocker, "blocker"),
464 ];
465 let typed = severities
466 .iter()
467 .enumerate()
468 .map(|(index, (severity, _))| CodeClimateIssue {
469 kind: CodeClimateIssueKind::Issue,
470 check_name: format!("fallow/rule-{index}"),
471 description: format!("Finding {index}"),
472 categories: vec!["Complexity".to_owned()],
473 severity: *severity,
474 fingerprint: format!("fp-{index}"),
475 location: CodeClimateLocation {
476 path: format!("src/{index}.ts"),
477 lines: CodeClimateLines {
478 begin: u32::try_from(index + 1).expect("small fixture index"),
479 end: None,
480 },
481 },
482 other_locations: Vec::new(),
483 owner: None,
484 group: None,
485 })
486 .collect::<Vec<_>>();
487 let value = serde_json::to_value(&typed).expect("typed fixture serializes");
488
489 assert_eq!(
490 issues_from_codeclimate_issues(&typed),
491 issues_from_codeclimate(&value)
492 );
493 let typed_labels = issues_from_codeclimate_issues(&typed)
494 .into_iter()
495 .map(|issue| issue.severity)
496 .collect::<Vec<_>>();
497 let expected_labels = severities
498 .iter()
499 .map(|(_, label)| (*label).to_owned())
500 .collect::<Vec<_>>();
501 assert_eq!(typed_labels, expected_labels);
502 }
503
504 #[test]
505 fn sticky_marker_id_default_when_nothing_set() {
506 let body = render_pr_comment("check", Provider::Github, &[], None);
507 assert!(body.contains("<!-- fallow-id: fallow-results"));
508 assert!(body.contains("No findings for this pull request."));
509 }
510
511 #[test]
512 fn short_hex_hash_is_deterministic_and_six_chars() {
513 let a = short_hex_hash("api,worker");
514 assert_eq!(a.len(), 6);
515 assert_eq!(a, short_hex_hash("api,worker"));
516 assert_ne!(a, short_hex_hash("admin,web"));
517 }
518
519 #[test]
520 fn sanitize_marker_segment_collapses_unsafe_chars_to_dashes() {
521 assert_eq!(sanitize_marker_segment("@fallow/runtime"), "fallow-runtime");
522 assert_eq!(
523 sanitize_marker_segment("packages/web ui"),
524 "packages-web-ui"
525 );
526 assert_eq!(sanitize_marker_segment("plain"), "plain");
527 assert_eq!(
528 sanitize_marker_segment("--leading-trailing--"),
529 "leading-trailing"
530 );
531 }
532
533 #[test]
534 fn is_project_level_rule_covers_config_anchored_dependency_findings() {
535 for rule_id in fallow_output::PROJECT_LEVEL_RULE_IDS {
536 assert!(
537 is_project_level_rule(rule_id),
538 "{rule_id} must be project-level"
539 );
540 }
541 for rule_id in [
542 "fallow/unused-file",
543 "fallow/unused-export",
544 "fallow/unused-type",
545 "fallow/unused-enum-member",
546 "fallow/unused-class-member",
547 "fallow/unused-store-member",
548 "fallow/unresolved-import",
549 "fallow/unlisted-dependency",
550 "fallow/duplicate-export",
551 "fallow/circular-dependency",
552 "fallow/re-export-cycle",
553 "fallow/boundary-violation",
554 "fallow/stale-suppression",
555 "fallow/private-type-leak",
556 "fallow/high-complexity",
557 "fallow/high-crap-score",
558 ] {
559 assert!(
560 !is_project_level_rule(rule_id),
561 "{rule_id} must NOT be project-level"
562 );
563 }
564 }
565
566 #[test]
567 fn decision_surface_preserves_blocking_conclusion_for_issue_output() {
568 let issues = [CiIssue {
569 path: "src/app.ts".to_owned(),
570 line: 12,
571 end_line: None,
572 other_locations: Vec::new(),
573 rule_id: "fallow/high-crap-score".to_owned(),
574 description: "Function is hard to safely change.".to_owned(),
575 severity: "minor".to_owned(),
576 fingerprint: "abc".to_owned(),
577 }];
578 let envelope = PrCommentEnvelope {
579 marker_id: "fallow-results".to_owned(),
580 body: "body".to_owned(),
581 is_clean: false,
582 details_url: None,
583 check_summary: Some("fail".to_owned()),
584 truncation: PrCommentTruncation {
585 truncated: false,
586 shown_findings: 1,
587 total_findings: 1,
588 },
589 };
590
591 let decision = build_issue_decision_surface(
592 "audit",
593 &issues,
594 &envelope,
595 PrDecisionConclusion::Failure,
596 Some(crate::report::ci::TYPE_AWARE_INCOMPLETE_MESSAGE),
597 );
598
599 assert_eq!(decision.conclusion, PrDecisionConclusion::Failure);
600 assert_eq!(decision.gates[0].status, PrDecisionConclusion::Failure);
601 assert!(decision.details.summary_markdown.contains("incomplete"));
602 assert!(
603 decision
604 .details
605 .summary_markdown
606 .contains("quality gates failed")
607 );
608 }
609
610 #[test]
611 fn project_level_rule_ids_each_register_in_explain_registry() {
612 for rule_id in fallow_output::PROJECT_LEVEL_RULE_IDS {
613 assert!(
614 crate::explain::rule_by_id(rule_id).is_some(),
615 "{rule_id} listed in PROJECT_LEVEL_RULE_IDS but not in explain registry"
616 );
617 }
618 }
619}