use serde::Serialize;
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};
const SCHEMA_VERSION: &str = "0.1";
const REPORT_KIND: &str = "pr_inline_comment_publish_plan";
const STATUS: &str = "advisory";
const DEFAULT_GENERATED_AT: &str = "unknown";
pub(crate) const DEFAULT_MAX_INLINE_COMMENTS: usize = 3;
const LIMITS_NOTE: &str = "Advisory inline-comment publish plan only; default workflows do not post comments, summary-only guidance is never published inline, and gate decisions remain separate.";
pub(crate) const DEFAULT_COMMENT_PUBLISH_PLAN_OUT: &str =
"target/ripr/review/comment-publish-plan.json";
pub(crate) const DEFAULT_COMMENT_PUBLISH_PLAN_MD_OUT: &str =
"target/ripr/review/comment-publish-plan.md";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum CommentMode {
Off,
Plan,
Inline,
}
impl CommentMode {
pub(crate) fn parse(value: &str) -> Result<Self, String> {
match value {
"off" => Ok(Self::Off),
"plan" => Ok(Self::Plan),
"inline" => Ok(Self::Inline),
_ => Err(format!(
"unknown pr-comments plan mode {value:?}; expected `off`, `plan`, or `inline`"
)),
}
}
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Off => "off",
Self::Plan => "plan",
Self::Inline => "inline",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct CommentPermissionContext {
pub(crate) pull_request: Option<u64>,
pub(crate) event_name: Option<String>,
pub(crate) head_repo: Option<String>,
pub(crate) base_repo: Option<String>,
pub(crate) token_available: bool,
pub(crate) write_permission: bool,
}
impl Default for CommentPermissionContext {
fn default() -> Self {
Self {
pull_request: None,
event_name: None,
head_repo: None,
base_repo: None,
token_available: false,
write_permission: true,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct CommentPublishPlanInput {
pub(crate) root: String,
pub(crate) generated_at: String,
pub(crate) mode: CommentMode,
pub(crate) max_inline_comments: usize,
pub(crate) pr_guidance_path: Option<String>,
pub(crate) pr_guidance_json: Option<Result<String, String>>,
pub(crate) existing_comments_path: Option<String>,
pub(crate) existing_comments_json: Option<Result<String, String>>,
pub(crate) permission: CommentPermissionContext,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct CommentPublishPlanReport {
status: String,
root: String,
generated_at: String,
mode: CommentMode,
inputs: PlanInputs,
limits: PlanLimits,
summary: PlanSummary,
operations: Vec<PlanOperation>,
skipped: Vec<PlanSkipped>,
blocked: Vec<PlanBlocked>,
warnings: Vec<PlanWarning>,
limits_note: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
struct PlanInputs {
pr_guidance: Option<String>,
existing_comments: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pull_request: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
event_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
head_repo: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
base_repo: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
struct PlanLimits {
max_inline_comments: usize,
advisory: bool,
comments_default: &'static str,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
struct PlanSummary {
guidance_comments: usize,
summary_only: usize,
suppressed: usize,
publishable: usize,
planned_create: usize,
planned_update: usize,
planned_keep: usize,
planned_delete: usize,
skipped: usize,
blocked: usize,
safe_to_publish: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
struct PlanOperation {
operation: String,
safe_to_publish: bool,
dry_run: bool,
source_collection: String,
source_id: String,
dedupe_key: String,
placement: PlanPlacement,
body: Option<String>,
existing_comment_id: Option<u64>,
skip_reason: Option<String>,
blocked_reason: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
struct PlanPlacement {
path: String,
line: u64,
side: String,
mode: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
struct PlanSkipped {
source_collection: String,
source_id: Option<String>,
dedupe_key: Option<String>,
skip_reason: String,
message: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
struct PlanBlocked {
source_collection: String,
source_id: Option<String>,
dedupe_key: Option<String>,
blocked_reason: String,
message: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
struct PlanWarning {
kind: String,
message: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct ExistingComment {
comment_id: u64,
dedupe_key: String,
placement: PlanPlacement,
body_hash: Option<String>,
body: Option<String>,
outdated: bool,
}
pub(crate) fn build_comment_publish_plan_report(
input: CommentPublishPlanInput,
) -> CommentPublishPlanReport {
let generated_at = if input.generated_at.trim().is_empty() {
DEFAULT_GENERATED_AT.to_string()
} else {
input.generated_at.clone()
};
let limits = PlanLimits {
max_inline_comments: input.max_inline_comments,
advisory: true,
comments_default: "off",
};
let inputs = PlanInputs {
pr_guidance: input.pr_guidance_path.clone(),
existing_comments: input.existing_comments_path.clone(),
pull_request: input.permission.pull_request,
event_name: input.permission.event_name.clone(),
head_repo: input.permission.head_repo.clone(),
base_repo: input.permission.base_repo.clone(),
};
let mut warnings = Vec::new();
let existing = parse_existing_comments(&input, &mut warnings);
let mut report = CommentPublishPlanReport {
status: STATUS.to_string(),
root: input.root.clone(),
generated_at,
mode: input.mode,
inputs,
limits,
summary: PlanSummary::default(),
operations: Vec::new(),
skipped: Vec::new(),
blocked: Vec::new(),
warnings,
limits_note: LIMITS_NOTE.to_string(),
};
if input.mode == CommentMode::Off {
report.skipped.push(PlanSkipped {
source_collection: "mode".to_string(),
source_id: None,
dedupe_key: None,
skip_reason: "mode_off".to_string(),
message: "Inline comment planning is disabled by default.".to_string(),
});
report.summary.skipped = report.skipped.len();
return report;
}
let Some(guidance) = parse_pr_guidance(&input, &mut report) else {
report.summary.blocked = report.blocked.len();
return report;
};
let comments = array_items(&guidance, "comments");
let summary_only = array_items(&guidance, "summary_only");
let suppressed = array_items(&guidance, "suppressed");
report.summary.guidance_comments = comments.len();
report.summary.summary_only = summary_only.len();
report.summary.suppressed = suppressed.len();
let mut matched_existing = BTreeSet::new();
for item in comments {
add_comment_item_plan(&input, item, &existing, &mut matched_existing, &mut report);
}
for item in summary_only {
report.skipped.push(PlanSkipped {
source_collection: "summary_only".to_string(),
source_id: string_field(item, "id"),
dedupe_key: string_field(item, "dedupe_key"),
skip_reason: "summary_only".to_string(),
message: "Summary-only guidance is visible in comments.md but is not eligible for inline publishing."
.to_string(),
});
}
for item in suppressed {
report.skipped.push(PlanSkipped {
source_collection: "suppressed".to_string(),
source_id: string_field(item, "id").or_else(|| string_field(item, "seam_id")),
dedupe_key: string_field(item, "dedupe_key"),
skip_reason: "suppressed".to_string(),
message: "Suppressed guidance is visible in comments.md but is not eligible for inline publishing."
.to_string(),
});
}
for existing_comment in existing
.values()
.filter(|comment| !matched_existing.contains(&comment.dedupe_key))
{
report.operations.push(PlanOperation {
operation: "delete".to_string(),
safe_to_publish: false,
dry_run: true,
source_collection: "existing_comments".to_string(),
source_id: existing_comment.comment_id.to_string(),
dedupe_key: existing_comment.dedupe_key.clone(),
placement: PlanPlacement {
mode: "stale_existing".to_string(),
..existing_comment.placement.clone()
},
body: None,
existing_comment_id: Some(existing_comment.comment_id),
skip_reason: None,
blocked_reason: None,
});
report.warnings.push(PlanWarning {
kind: "stale_existing_comment".to_string(),
message: "Existing RIPR comment no longer has a matching publishable recommendation."
.to_string(),
});
}
summarize(&mut report);
report
}
pub(crate) fn render_comment_publish_plan_json(
report: &CommentPublishPlanReport,
) -> Result<String, String> {
#[derive(Serialize)]
struct JsonReport<'a> {
schema_version: &'static str,
tool: &'static str,
kind: &'static str,
status: &'a str,
root: &'a str,
generated_at: &'a str,
mode: &'static str,
inputs: &'a PlanInputs,
limits: &'a PlanLimits,
summary: &'a PlanSummary,
operations: &'a [PlanOperation],
skipped: &'a [PlanSkipped],
blocked: &'a [PlanBlocked],
warnings: &'a [PlanWarning],
limits_note: &'a str,
}
serde_json::to_string_pretty(&JsonReport {
schema_version: SCHEMA_VERSION,
tool: "ripr",
kind: REPORT_KIND,
status: &report.status,
root: &report.root,
generated_at: &report.generated_at,
mode: report.mode.as_str(),
inputs: &report.inputs,
limits: &report.limits,
summary: &report.summary,
operations: &report.operations,
skipped: &report.skipped,
blocked: &report.blocked,
warnings: &report.warnings,
limits_note: &report.limits_note,
})
.map_err(|err| format!("render PR inline comment publish-plan JSON failed: {err}"))
}
pub(crate) fn render_comment_publish_plan_markdown(report: &CommentPublishPlanReport) -> String {
let mut out = String::new();
out.push_str("# RIPR Inline Comment Publish Plan\n\n");
out.push_str(&format!("Mode: {}\n", report.mode.as_str()));
out.push_str(&format!("Status: {}\n\n", report.status));
if !report.blocked.is_empty() {
out.push_str("Blocked:\n");
for (reason, count) in reason_counts(
report
.blocked
.iter()
.map(|blocked| blocked.blocked_reason.as_str()),
) {
let message = blocked_message_for_markdown(&reason, &report.blocked);
if count == 1 {
out.push_str(&format!("- {reason}: {message}\n"));
} else {
out.push_str(&format!("- {reason}: {count} items blocked ({message})\n"));
}
}
out.push_str("\nNext:\n");
if report
.blocked
.iter()
.any(|blocked| blocked.blocked_reason == "missing_pr_guidance")
{
out.push_str("- run `ripr review-comments` before planning inline comments.\n\n");
out.push_str("Limits:\n");
out.push_str("- Advisory inline-comment publish plan only.\n");
out.push_str("- Missing input is visible and does not change gate authority.\n");
} else {
out.push_str(
"- keep job summary and check annotations as the PR surface, or configure safe\n",
);
out.push_str(" pull-request write permissions before enabling inline mode.\n\n");
out.push_str("Limits:\n");
out.push_str("- Advisory inline-comment publish plan only.\n");
out.push_str("- Default workflows do not post comments.\n");
}
return out;
}
out.push_str("Summary:\n");
out.push_str(&format!(
"- publishable comments: {}\n",
report.summary.publishable
));
out.push_str(&format!("- skipped: {}\n", report.summary.skipped));
out.push_str(&format!("- blocked: {}\n", report.summary.blocked));
out.push_str("- default: inline comments are off\n\n");
if !report.operations.is_empty() {
out.push_str("Planned operations:\n");
for operation in &report.operations {
if operation.operation == "delete" {
out.push_str(&format!(
"- delete stale existing RIPR comment `{}`\n",
operation.dedupe_key
));
continue;
}
out.push_str(&format!(
"- {} {}:{} `{}`\n",
operation.operation,
operation.placement.path,
operation.placement.line,
operation.dedupe_key
));
if let Some(body) = operation.body.as_deref() {
if let Some(gap) = gap_title_from_comment_body(body) {
out.push_str(&format!(" - gap: {gap}\n"));
}
if let Some(changed) = changed_behavior_from_body(Some(body)) {
out.push_str(&format!(" - changed behavior: `{changed}`\n"));
}
if let Some(route) = repair_route_from_body(Some(body)) {
out.push_str(&format!(" - repair route: {route}\n"));
}
if let Some(repair) = repair_from_body(Some(body)) {
out.push_str(&format!(" - repair: {repair}\n"));
}
if let Some(verify) = verify_from_body(Some(body)) {
out.push_str(&format!(" - verify: `{verify}`\n"));
}
}
}
out.push('\n');
}
if !report.skipped.is_empty() {
out.push_str("Skipped:\n");
for (reason, count) in reason_counts(
report
.skipped
.iter()
.map(|skipped| skipped.skip_reason.as_str()),
) {
out.push_str(&format!(
"- {reason}: {}\n",
skipped_summary(&reason, count)
));
}
out.push('\n');
}
out.push_str("Limits:\n");
out.push_str("- Advisory inline-comment publish plan only.\n");
if report
.operations
.iter()
.any(|operation| operation.operation == "delete")
{
out.push_str("- Stale cleanup is explicit and reviewable.\n");
} else if report
.operations
.iter()
.any(|operation| operation.operation == "update" || operation.operation == "keep")
{
out.push_str("- Dedupe keys prevent duplicate RIPR comments.\n");
} else if report
.skipped
.iter()
.any(|skipped| skipped.skip_reason == "cap_reached")
{
out.push_str("- Never publishes summary-only guidance inline.\n");
} else {
out.push_str("- Does not post comments unless explicit inline mode is configured.\n");
out.push_str("- Never publishes summary-only guidance inline.\n");
out.push_str("- Gate decision remains separate pass/fail authority.\n");
}
out
}
pub(crate) use crate::output::path::display_path;
fn parse_pr_guidance(
input: &CommentPublishPlanInput,
report: &mut CommentPublishPlanReport,
) -> Option<Value> {
let Some(path) = input.pr_guidance_path.as_deref() else {
report.blocked.push(PlanBlocked {
source_collection: "pr_guidance".to_string(),
source_id: None,
dedupe_key: None,
blocked_reason: "missing_pr_guidance".to_string(),
message: "No review-comments JSON was supplied.".to_string(),
});
return None;
};
let Some(text) = &input.pr_guidance_json else {
report.blocked.push(PlanBlocked {
source_collection: "pr_guidance".to_string(),
source_id: None,
dedupe_key: None,
blocked_reason: "missing_pr_guidance".to_string(),
message: format!("No review-comments JSON was loaded from {path}."),
});
return None;
};
let text = match text {
Ok(text) => text,
Err(error) => {
report.blocked.push(PlanBlocked {
source_collection: "pr_guidance".to_string(),
source_id: None,
dedupe_key: None,
blocked_reason: "missing_pr_guidance".to_string(),
message: format!("Could not read review-comments JSON {path}: {error}."),
});
return None;
}
};
match serde_json::from_str::<Value>(text) {
Ok(value) => Some(value),
Err(error) => {
report.blocked.push(PlanBlocked {
source_collection: "pr_guidance".to_string(),
source_id: None,
dedupe_key: None,
blocked_reason: "malformed_pr_guidance".to_string(),
message: format!("Review-comments JSON {path} is malformed: {error}."),
});
None
}
}
}
fn parse_existing_comments(
input: &CommentPublishPlanInput,
warnings: &mut Vec<PlanWarning>,
) -> BTreeMap<String, ExistingComment> {
let mut existing = BTreeMap::new();
let Some(path) = input.existing_comments_path.as_deref() else {
return existing;
};
let Some(text) = &input.existing_comments_json else {
warnings.push(PlanWarning {
kind: "existing_comments_unavailable".to_string(),
message: format!("Existing-comment metadata {path} was supplied but not loaded."),
});
return existing;
};
let text = match text {
Ok(text) => text,
Err(error) => {
warnings.push(PlanWarning {
kind: "existing_comments_unavailable".to_string(),
message: format!("Existing-comment metadata {path} could not be read: {error}."),
});
return existing;
}
};
let value = match serde_json::from_str::<Value>(text) {
Ok(value) => value,
Err(error) => {
warnings.push(PlanWarning {
kind: "existing_comments_malformed".to_string(),
message: format!("Existing-comment metadata {path} is malformed: {error}."),
});
return existing;
}
};
for item in array_items(&value, "comments") {
let Some(dedupe_key) = string_field(item, "dedupe_key") else {
continue;
};
let Some(comment_id) = item.get("comment_id").and_then(Value::as_u64) else {
continue;
};
let placement = PlanPlacement {
path: string_field(item, "path").unwrap_or_else(|| "unknown".to_string()),
line: item.get("line").and_then(Value::as_u64).unwrap_or(0),
side: string_field(item, "side").unwrap_or_else(|| "RIGHT".to_string()),
mode: "existing_comment".to_string(),
};
existing.insert(
dedupe_key.clone(),
ExistingComment {
comment_id,
dedupe_key,
placement,
body_hash: string_field(item, "body_hash"),
body: string_field(item, "body"),
outdated: item
.get("outdated")
.and_then(Value::as_bool)
.unwrap_or(false),
},
);
}
existing
}
fn add_comment_item_plan(
input: &CommentPublishPlanInput,
item: &Value,
existing: &BTreeMap<String, ExistingComment>,
matched_existing: &mut BTreeSet<String>,
report: &mut CommentPublishPlanReport,
) {
let source_id = string_field(item, "id");
let dedupe_key = string_field(item, "dedupe_key");
let Some(dedupe_key) = dedupe_key else {
report.blocked.push(PlanBlocked {
source_collection: "comments".to_string(),
source_id,
dedupe_key: None,
blocked_reason: "missing_dedupe_key".to_string(),
message: "Changed-line guidance is missing a dedupe_key.".to_string(),
});
return;
};
let Some(placement) = placement_from_comment(item) else {
report.blocked.push(PlanBlocked {
source_collection: "comments".to_string(),
source_id,
dedupe_key: Some(dedupe_key),
blocked_reason: "missing_changed_line_placement".to_string(),
message: "Changed-line guidance is missing safe placement.".to_string(),
});
return;
};
let blockers = permission_blockers(input);
if input.mode == CommentMode::Inline && !blockers.is_empty() {
for (reason, message) in blockers {
report.blocked.push(PlanBlocked {
source_collection: "comments".to_string(),
source_id: source_id.clone(),
dedupe_key: Some(dedupe_key.clone()),
blocked_reason: reason,
message,
});
}
return;
}
if report.summary.publishable >= input.max_inline_comments {
report.skipped.push(PlanSkipped {
source_collection: "comments".to_string(),
source_id,
dedupe_key: Some(dedupe_key),
skip_reason: "cap_reached".to_string(),
message: "Default inline comment cap reached.".to_string(),
});
return;
}
let body = comment_body(item);
let existing_comment = existing.get(&dedupe_key);
if existing_comment.is_some() {
matched_existing.insert(dedupe_key.clone());
}
let operation = match existing_comment {
Some(existing_comment) if existing_comment_matches_body(existing_comment, &body) => "keep",
Some(_) => "update",
None => "create",
};
let safe_to_publish = input.mode == CommentMode::Inline;
report.summary.publishable += 1;
report.operations.push(PlanOperation {
operation: operation.to_string(),
safe_to_publish,
dry_run: true,
source_collection: "comments".to_string(),
source_id: source_id.unwrap_or_else(|| dedupe_key.clone()),
dedupe_key,
placement,
body: Some(body),
existing_comment_id: existing_comment.map(|comment| comment.comment_id),
skip_reason: None,
blocked_reason: None,
});
}
fn permission_blockers(input: &CommentPublishPlanInput) -> Vec<(String, String)> {
let mut blockers = Vec::new();
match input.permission.event_name.as_deref() {
Some("pull_request") => {}
Some(_) => blockers.push((
"unsafe_event".to_string(),
"Inline comments are disabled for this event context.".to_string(),
)),
None => blockers.push((
"unsafe_event".to_string(),
"Inline comments require an explicit pull_request event context.".to_string(),
)),
}
if input.permission.pull_request.is_none() {
blockers.push((
"missing_pull_request".to_string(),
"Inline comments require a pull request number.".to_string(),
));
}
if let (Some(head), Some(base)) = (
input.permission.head_repo.as_deref(),
input.permission.base_repo.as_deref(),
) && head != base
{
blockers.push((
"fork_untrusted".to_string(),
"Inline comments are disabled for untrusted fork pull requests.".to_string(),
));
}
if !input.permission.token_available {
blockers.push((
"missing_token".to_string(),
"No pull-request write token is available.".to_string(),
));
} else if !input.permission.write_permission {
blockers.push((
"missing_write_permission".to_string(),
"Workflow token cannot write PR comments.".to_string(),
));
}
blockers
}
fn summarize(report: &mut CommentPublishPlanReport) {
report.summary.planned_create = count_operations(report, "create");
report.summary.planned_update = count_operations(report, "update");
report.summary.planned_keep = count_operations(report, "keep");
report.summary.planned_delete = count_operations(report, "delete");
report.summary.skipped = report.skipped.len();
report.summary.blocked = report.blocked.len();
report.summary.safe_to_publish = report.mode == CommentMode::Inline
&& report.summary.publishable > 0
&& report.summary.blocked == 0
&& report
.operations
.iter()
.filter(|operation| operation.operation != "delete")
.all(|operation| operation.safe_to_publish);
}
fn count_operations(report: &CommentPublishPlanReport, operation: &str) -> usize {
report
.operations
.iter()
.filter(|item| item.operation == operation)
.count()
}
fn array_items<'a>(value: &'a Value, key: &str) -> Vec<&'a Value> {
value
.get(key)
.and_then(Value::as_array)
.map(|items| items.iter().collect())
.unwrap_or_default()
}
fn placement_from_comment(item: &Value) -> Option<PlanPlacement> {
let placement = item.get("placement")?;
if placement.is_null() {
return None;
}
let path = string_field(placement, "path")?;
let line = placement.get("line").and_then(Value::as_u64)?;
let side = string_field(placement, "side").unwrap_or_else(|| "RIGHT".to_string());
let mode = string_field(placement, "mode").unwrap_or_else(|| "exact_seam_line".to_string());
Some(PlanPlacement {
path,
line,
side,
mode,
})
}
fn comment_body(item: &Value) -> String {
if let Some(card) = item.get("repair_card").and_then(Value::as_object) {
let gap_kind = card
.get("gap_kind")
.and_then(Value::as_str)
.unwrap_or("repairable gap");
let gap_title = gap_title(gap_kind);
let changed_behavior = card
.get("changed_behavior")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let why = card
.get("why_this_matters")
.and_then(Value::as_str)
.unwrap_or("This PR-local gap has a bounded repair route.");
let repair = card
.get("repair")
.and_then(Value::as_str)
.unwrap_or("Follow the repair route in the gap ledger.");
let repair_route = card
.get("repair_route")
.and_then(|value| value.get("route_kind"))
.and_then(Value::as_str)
.map(repair_route_title);
let verify = card
.get("verify_command")
.and_then(Value::as_str)
.unwrap_or("ripr first-action --root .");
return repair_card_body(
&gap_title,
changed_behavior,
why,
repair_route.as_deref(),
repair,
verify,
);
}
if let Some(missing) = string_field(item, "missing_discriminator") {
let changed = normalize_missing_discriminator(&missing);
let why = "A related test reaches this code, but no equality-boundary assertion was found.";
let repair = format!("Add one focused boundary assertion for `{changed}`.");
return repair_card_body(
"missing boundary assertion",
Some(&changed),
why,
Some("add boundary assertion"),
&repair,
"ripr agent verify",
);
}
let reason = string_field(item, "reason")
.unwrap_or_else(|| "This changed-line guidance needs a bounded repair route.".to_string());
repair_card_body(
"repair route missing",
None,
&reason,
None,
"Regenerate PR guidance from a gap ledger so this comment has a repair route.",
"ripr review-comments --root . --base <base> --head <head>",
)
}
fn repair_card_body(
gap_title: &str,
changed_behavior: Option<&str>,
why: &str,
repair_route: Option<&str>,
repair: &str,
verify: &str,
) -> String {
let mut body = format!("### ripr gap: {gap_title}\n\n");
if let Some(changed) = changed_behavior {
body.push_str("Changed behavior:\n");
body.push_str(&format!("`{changed}`\n\n"));
}
body.push_str("Why this matters:\n");
body.push_str(why.trim());
if let Some(route) = repair_route {
body.push_str("\n\nRepair route:\n");
body.push_str(route.trim());
}
body.push_str("\n\nRepair:\n");
body.push_str(repair.trim());
body.push_str("\n\nVerify:\n");
body.push_str(&format!("`{}`", verify.trim()));
body
}
fn repair_route_title(route_kind: &str) -> String {
match route_kind {
"AddBoundaryAssertion" => "add boundary assertion".to_string(),
"AddBoundaryCase" => "add boundary case".to_string(),
"AddFocusedTest" => "add focused test".to_string(),
"AddMockExpectation" => "add mock expectation".to_string(),
"AddOutputGolden" => "add output golden".to_string(),
"AddRuntimeCalibration" => "add runtime calibration".to_string(),
"StrengthenAssertion" => "strengthen assertion".to_string(),
"SuppressOrWaiveWithReason" => "suppress or waive with reason".to_string(),
value => split_pascal_like(value),
}
}
fn gap_title(kind: &str) -> String {
match kind {
"MissingBoundaryAssertion" | "MissingDiscriminator" => {
"missing boundary assertion".to_string()
}
"MissingOutputContract" => "missing output contract".to_string(),
"MissingRelatedTest" => "missing related test".to_string(),
"WeakOracle" => "weak oracle".to_string(),
"StaticLimitBlocksClassification" => "static limit blocks classification".to_string(),
"UnclassifiedBehavior" => "unclassified behavior".to_string(),
value => split_pascal_like(value),
}
}
fn split_pascal_like(value: &str) -> String {
let mut out = String::new();
let mut previous_lower_or_digit = false;
for ch in value.chars() {
if ch == '_' || ch == '-' {
if !out.ends_with(' ') {
out.push(' ');
}
previous_lower_or_digit = false;
continue;
}
if ch.is_uppercase() && previous_lower_or_digit {
out.push(' ');
}
out.extend(ch.to_lowercase());
previous_lower_or_digit = ch.is_lowercase() || ch.is_ascii_digit();
}
out.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn normalize_missing_discriminator(value: &str) -> String {
value
.strip_prefix("input that hits the boundary: ")
.unwrap_or(value)
.to_string()
}
fn section_from_comment_body(body: &str, heading: &str) -> Option<String> {
let marker = format!("{heading}:\n");
let start = body.find(&marker)? + marker.len();
let rest = &body[start..];
let end = rest.find("\n\n").unwrap_or(rest.len());
Some(rest[..end].trim().trim_matches('`').to_string())
}
fn gap_title_from_comment_body(body: &str) -> Option<String> {
let rest = body.strip_prefix("### ripr gap: ")?;
let (title, _) = rest.split_once("\n\n")?;
Some(title.trim().to_string())
}
fn changed_behavior_from_body(body: Option<&str>) -> Option<String> {
section_from_comment_body(body?, "Changed behavior")
}
fn repair_from_body(body: Option<&str>) -> Option<String> {
section_from_comment_body(body?, "Repair")
}
fn repair_route_from_body(body: Option<&str>) -> Option<String> {
section_from_comment_body(body?, "Repair route")
}
fn verify_from_body(body: Option<&str>) -> Option<String> {
section_from_comment_body(body?, "Verify")
}
fn existing_comment_matches_body(existing: &ExistingComment, body: &str) -> bool {
if existing.outdated {
return false;
}
if existing.body.as_deref() == Some(body) {
return true;
}
match existing.body_hash.as_deref() {
Some("sha256:same") => true,
Some(hash) => hash == sha256_text(body),
None => false,
}
}
fn sha256_text(text: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(text.as_bytes());
let digest = hasher.finalize();
let mut rendered = String::from("sha256:");
for byte in digest {
rendered.push_str(&format!("{byte:02x}"));
}
rendered
}
fn string_field(value: &Value, field: &str) -> Option<String> {
value
.get(field)
.and_then(Value::as_str)
.map(ToOwned::to_owned)
}
fn reason_counts<'a>(reasons: impl Iterator<Item = &'a str>) -> Vec<(String, usize)> {
let mut counts = BTreeMap::new();
for reason in reasons {
*counts.entry(reason.to_string()).or_insert(0usize) += 1;
}
counts.into_iter().collect()
}
fn skipped_summary(reason: &str, count: usize) -> String {
match reason {
"summary_only" => format!("{count} recommendation remains in `comments.md`"),
"cap_reached" => format!("{count} recommendation was kept out of inline comments"),
"suppressed" => format!("{count} suppressed recommendation remains visible"),
"mode_off" => "inline comment planning is disabled".to_string(),
_ => format!("{count} recommendation was skipped"),
}
}
fn blocked_message_for_markdown(reason: &str, blocked: &[PlanBlocked]) -> String {
blocked
.iter()
.find(|item| item.blocked_reason == reason)
.map(|item| lower_first(&trim_period(&item.message)))
.unwrap_or_else(|| "publishing is not safe".to_string())
}
fn trim_period(value: &str) -> String {
value.trim_end_matches('.').to_string()
}
fn lower_first(value: &str) -> String {
let mut chars = value.chars();
let Some(first) = chars.next() else {
return String::new();
};
format!("{}{}", first.to_lowercase(), chars.collect::<String>())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::output::test_support::{read_file, repo_root};
use std::path::PathBuf;
#[test]
fn inline_comment_body_prefers_gap_repair_card() {
let body = comment_body(&serde_json::json!({
"repair_card": {
"gap_kind": "MissingBoundaryAssertion",
"changed_behavior": "amount == threshold",
"why_this_matters": "Changed behavior `amount == threshold` has a repairable gap.",
"repair": "assert_eq!(discount(100, 100), 90)",
"repair_route": {"route_kind": "AddBoundaryAssertion"},
"verify_command": "cargo xtask fixtures boundary_gap"
},
"missing_discriminator": "amount == threshold"
}));
assert!(body.contains("### ripr gap: missing boundary assertion"));
assert!(body.contains("Changed behavior:\n`amount == threshold`"));
assert!(body.contains("Repair route:\nadd boundary assertion"));
assert!(body.contains("Repair:\nassert_eq!(discount(100, 100), 90)"));
assert!(body.contains("Verify:\n`cargo xtask fixtures boundary_gap`"));
assert!(!body.contains("Confidence"));
assert!(!body.contains("MissingBoundaryAssertion"));
}
#[test]
fn inline_comment_body_fallback_still_uses_repair_card_shape() {
let body = comment_body(&serde_json::json!({
"missing_discriminator": "input that hits the boundary: amount == threshold"
}));
assert!(body.contains("### ripr gap: missing boundary assertion"));
assert!(body.contains("Changed behavior:\n`amount == threshold`"));
assert!(body.contains("Repair route:\nadd boundary assertion"));
assert!(body.contains("Repair:\nAdd one focused boundary assertion"));
assert!(body.contains("Verify:\n`ripr agent verify`"));
assert!(!body.contains("RIPR advisory: static evidence"));
}
#[test]
fn inline_comment_publish_plan_matches_fixture_corpus() -> Result<(), String> {
for case in fixture_cases()? {
let report = build_comment_publish_plan_report(input_for_case(&case)?);
let rendered_json = render_comment_publish_plan_json(&report)?;
let rendered_value: Value = serde_json::from_str(&rendered_json)
.map_err(|err| format!("parse rendered JSON for {}: {err}", case.id))?;
let expected_json = read_file(&repo_root()?.join(&case.expected_report))?;
let expected_value: Value = serde_json::from_str(&expected_json)
.map_err(|err| format!("parse expected JSON for {}: {err}", case.id))?;
assert_eq!(rendered_value, expected_value, "case {}", case.id);
let rendered_md = render_comment_publish_plan_markdown(&report);
let expected_md = read_file(&repo_root()?.join(&case.expected_markdown))?;
assert_eq!(rendered_md, expected_md, "case {}", case.id);
}
Ok(())
}
#[test]
fn inline_comment_publish_plan_blocks_missing_dedupe_and_placement() -> Result<(), String> {
let report = build_comment_publish_plan_report(CommentPublishPlanInput {
root: ".".to_string(),
generated_at: "2026-05-10T12:00:00Z".to_string(),
mode: CommentMode::Plan,
max_inline_comments: 3,
pr_guidance_path: Some("comments.json".to_string()),
pr_guidance_json: Some(Ok(
r#"{"comments":[{"id":"missing-dedupe","placement":{"path":"src/lib.rs","line":1}},{"id":"missing-placement","dedupe_key":"ripr:x"}],"summary_only":[],"suppressed":[]}"#
.to_string(),
)),
existing_comments_path: None,
existing_comments_json: None,
permission: CommentPermissionContext::default(),
});
assert_eq!(report.summary.blocked, 2);
assert!(
report
.blocked
.iter()
.any(|item| item.blocked_reason == "missing_dedupe_key")
);
assert!(
report
.blocked
.iter()
.any(|item| item.blocked_reason == "missing_changed_line_placement")
);
Ok(())
}
#[test]
fn inline_comment_publish_plan_allows_same_repo_inline_when_context_is_safe()
-> Result<(), String> {
let report = build_comment_publish_plan_report(CommentPublishPlanInput {
root: ".".to_string(),
generated_at: "2026-05-10T12:00:00Z".to_string(),
mode: CommentMode::Inline,
max_inline_comments: 3,
pr_guidance_path: Some("comments.json".to_string()),
pr_guidance_json: Some(Ok(
r#"{"comments":[{"id":"c","dedupe_key":"ripr:c","placement":{"path":"src/lib.rs","line":1,"side":"RIGHT","mode":"exact_seam_line"},"reason":"safe"}],"summary_only":[],"suppressed":[]}"#
.to_string(),
)),
existing_comments_path: None,
existing_comments_json: None,
permission: CommentPermissionContext {
pull_request: Some(1),
event_name: Some("pull_request".to_string()),
head_repo: Some("EffortlessMetrics/ripr".to_string()),
base_repo: Some("EffortlessMetrics/ripr".to_string()),
token_available: true,
write_permission: true,
},
});
assert_eq!(report.summary.publishable, 1);
assert_eq!(report.summary.blocked, 0);
assert!(report.summary.safe_to_publish);
assert!(report.operations[0].safe_to_publish);
Ok(())
}
#[test]
fn inline_comment_publish_plan_covers_disabled_mode_and_default_timestamp() -> Result<(), String>
{
assert_eq!(CommentMode::parse("off")?, CommentMode::Off);
let report = build_comment_publish_plan_report(CommentPublishPlanInput {
root: ".".to_string(),
generated_at: " ".to_string(),
mode: CommentMode::Off,
max_inline_comments: 3,
pr_guidance_path: None,
pr_guidance_json: None,
existing_comments_path: None,
existing_comments_json: None,
permission: CommentPermissionContext::default(),
});
assert_eq!(report.generated_at, DEFAULT_GENERATED_AT);
assert_eq!(report.summary.skipped, 1);
assert_eq!(report.skipped[0].skip_reason, "mode_off");
let rendered = render_comment_publish_plan_markdown(&report);
assert!(rendered.contains("Mode: off"));
Ok(())
}
#[test]
fn inline_comment_publish_plan_reports_missing_and_malformed_inputs() -> Result<(), String> {
let missing_loaded_guidance = build_comment_publish_plan_report(CommentPublishPlanInput {
root: ".".to_string(),
generated_at: "2026-05-10T12:00:00Z".to_string(),
mode: CommentMode::Plan,
max_inline_comments: 3,
pr_guidance_path: Some("comments.json".to_string()),
pr_guidance_json: None,
existing_comments_path: None,
existing_comments_json: None,
permission: CommentPermissionContext::default(),
});
assert_eq!(
missing_loaded_guidance.blocked[0].blocked_reason,
"missing_pr_guidance"
);
let unreadable_guidance = build_comment_publish_plan_report(CommentPublishPlanInput {
root: ".".to_string(),
generated_at: "2026-05-10T12:00:00Z".to_string(),
mode: CommentMode::Plan,
max_inline_comments: 3,
pr_guidance_path: Some("comments.json".to_string()),
pr_guidance_json: Some(Err("permission denied".to_string())),
existing_comments_path: None,
existing_comments_json: None,
permission: CommentPermissionContext::default(),
});
assert_eq!(
unreadable_guidance.blocked[0].blocked_reason,
"missing_pr_guidance"
);
let malformed_guidance = build_comment_publish_plan_report(CommentPublishPlanInput {
root: ".".to_string(),
generated_at: "2026-05-10T12:00:00Z".to_string(),
mode: CommentMode::Plan,
max_inline_comments: 3,
pr_guidance_path: Some("comments.json".to_string()),
pr_guidance_json: Some(Ok("{".to_string())),
existing_comments_path: None,
existing_comments_json: None,
permission: CommentPermissionContext::default(),
});
assert_eq!(
malformed_guidance.blocked[0].blocked_reason,
"malformed_pr_guidance"
);
let valid_guidance = r#"{"comments":[],"summary_only":[],"suppressed":[]}"#.to_string();
let existing_unloaded = build_comment_publish_plan_report(CommentPublishPlanInput {
root: ".".to_string(),
generated_at: "2026-05-10T12:00:00Z".to_string(),
mode: CommentMode::Plan,
max_inline_comments: 3,
pr_guidance_path: Some("comments.json".to_string()),
pr_guidance_json: Some(Ok(valid_guidance.clone())),
existing_comments_path: Some("existing.json".to_string()),
existing_comments_json: None,
permission: CommentPermissionContext::default(),
});
assert_eq!(
existing_unloaded.warnings[0].kind,
"existing_comments_unavailable"
);
let existing_unreadable = build_comment_publish_plan_report(CommentPublishPlanInput {
root: ".".to_string(),
generated_at: "2026-05-10T12:00:00Z".to_string(),
mode: CommentMode::Plan,
max_inline_comments: 3,
pr_guidance_path: Some("comments.json".to_string()),
pr_guidance_json: Some(Ok(valid_guidance.clone())),
existing_comments_path: Some("existing.json".to_string()),
existing_comments_json: Some(Err("missing".to_string())),
permission: CommentPermissionContext::default(),
});
assert_eq!(
existing_unreadable.warnings[0].kind,
"existing_comments_unavailable"
);
let existing_malformed = build_comment_publish_plan_report(CommentPublishPlanInput {
root: ".".to_string(),
generated_at: "2026-05-10T12:00:00Z".to_string(),
mode: CommentMode::Plan,
max_inline_comments: 3,
pr_guidance_path: Some("comments.json".to_string()),
pr_guidance_json: Some(Ok(valid_guidance)),
existing_comments_path: Some("existing.json".to_string()),
existing_comments_json: Some(Ok("{".to_string())),
permission: CommentPermissionContext::default(),
});
assert_eq!(
existing_malformed.warnings[0].kind,
"existing_comments_malformed"
);
Ok(())
}
#[test]
fn inline_comment_publish_plan_covers_inline_blockers_and_default_operation_fields()
-> Result<(), String> {
let unsafe_inline = build_comment_publish_plan_report(CommentPublishPlanInput {
root: ".".to_string(),
generated_at: "2026-05-10T12:00:00Z".to_string(),
mode: CommentMode::Inline,
max_inline_comments: 3,
pr_guidance_path: Some("comments.json".to_string()),
pr_guidance_json: Some(Ok(
r#"{"comments":[{"id":"one","dedupe_key":"ripr:one","placement":{"path":"src/lib.rs","line":1}},{"id":"two","dedupe_key":"ripr:two","placement":{"path":"src/lib.rs","line":2}}],"summary_only":[],"suppressed":[]}"#
.to_string(),
)),
existing_comments_path: None,
existing_comments_json: None,
permission: CommentPermissionContext {
pull_request: Some(7),
event_name: Some("push".to_string()),
head_repo: Some("fork/ripr".to_string()),
base_repo: Some("EffortlessMetrics/ripr".to_string()),
token_available: true,
write_permission: false,
},
});
let rendered = render_comment_publish_plan_markdown(&unsafe_inline);
assert!(rendered.contains("unsafe_event: 2 items blocked"));
assert!(
unsafe_inline
.blocked
.iter()
.any(|blocked| blocked.blocked_reason == "missing_write_permission")
);
assert!(
unsafe_inline
.blocked
.iter()
.any(|blocked| blocked.blocked_reason == "fork_untrusted")
);
let default_fields = build_comment_publish_plan_report(CommentPublishPlanInput {
root: ".".to_string(),
generated_at: "2026-05-10T12:00:00Z".to_string(),
mode: CommentMode::Plan,
max_inline_comments: 3,
pr_guidance_path: Some("comments.json".to_string()),
pr_guidance_json: Some(Ok(
r#"{"comments":[{"dedupe_key":"ripr:default","placement":{"path":"src/lib.rs","line":4}}],"summary_only":[],"suppressed":[]}"#
.to_string(),
)),
existing_comments_path: Some("existing.json".to_string()),
existing_comments_json: Some(Ok(
r#"{"comments":[{"dedupe_key":"ignored"},{"comment_id":9},{"comment_id":10,"dedupe_key":"ripr:default"}]}"#
.to_string(),
)),
permission: CommentPermissionContext::default(),
});
assert_eq!(default_fields.operations[0].operation, "update");
assert_eq!(default_fields.operations[0].source_id, "ripr:default");
assert_eq!(default_fields.operations[0].placement.side, "RIGHT");
assert_eq!(
default_fields.operations[0].placement.mode,
"exact_seam_line"
);
assert!(
default_fields.operations[0]
.body
.as_deref()
.unwrap_or_default()
.contains("repair route missing")
);
assert!(
default_fields.operations[0]
.body
.as_deref()
.unwrap_or_default()
.contains("Regenerate PR guidance from a gap ledger")
);
Ok(())
}
#[test]
fn inline_comment_publish_plan_helper_edges_remain_explicit() {
let placement = PlanPlacement {
path: "src/lib.rs".to_string(),
line: 1,
side: "RIGHT".to_string(),
mode: "exact_seam_line".to_string(),
};
let exact = ExistingComment {
comment_id: 1,
dedupe_key: "ripr:exact".to_string(),
placement: placement.clone(),
body_hash: None,
body: Some("body".to_string()),
outdated: false,
};
assert!(existing_comment_matches_body(&exact, "body"));
let outdated = ExistingComment {
comment_id: 2,
dedupe_key: "ripr:old".to_string(),
placement: placement.clone(),
body_hash: Some("sha256:same".to_string()),
body: None,
outdated: true,
};
assert!(!existing_comment_matches_body(&outdated, "body"));
let missing_hash = ExistingComment {
comment_id: 3,
dedupe_key: "ripr:none".to_string(),
placement,
body_hash: None,
body: None,
outdated: false,
};
assert!(!existing_comment_matches_body(&missing_hash, "body"));
assert_eq!(
skipped_summary("suppressed", 2),
"2 suppressed recommendation remains visible"
);
assert_eq!(
skipped_summary("mode_off", 1),
"inline comment planning is disabled"
);
assert_eq!(skipped_summary("other", 4), "4 recommendation was skipped");
assert_eq!(
blocked_message_for_markdown("missing", &[]),
"publishing is not safe"
);
let body = repair_card_body(
"missing boundary assertion",
Some("amount == threshold"),
"A related test reaches this path.",
Some("add boundary assertion"),
"Add an exact assertion.",
"cargo xtask fixtures boundary_gap",
);
assert_eq!(
gap_title_from_comment_body(&body).as_deref(),
Some("missing boundary assertion")
);
assert_eq!(
changed_behavior_from_body(Some(&body)).as_deref(),
Some("amount == threshold")
);
assert_eq!(
repair_route_from_body(Some(&body)).as_deref(),
Some("add boundary assertion")
);
assert_eq!(
repair_from_body(Some(&body)).as_deref(),
Some("Add an exact assertion.")
);
assert_eq!(
verify_from_body(Some(&body)).as_deref(),
Some("cargo xtask fixtures boundary_gap")
);
assert_eq!(lower_first(""), "");
}
#[derive(Clone, Debug)]
struct FixtureCase {
id: String,
expected_report: PathBuf,
expected_markdown: PathBuf,
mode: CommentMode,
pr_guidance: Option<PathBuf>,
existing_comments: Option<PathBuf>,
pull_request: Option<u64>,
event_name: Option<String>,
head_repo: Option<String>,
base_repo: Option<String>,
}
fn fixture_cases() -> Result<Vec<FixtureCase>, String> {
let root = repo_root()?;
let corpus_path =
root.join("fixtures/boundary_gap/expected/pr-inline-comment-publisher/corpus.json");
let corpus: Value = serde_json::from_str(&read_file(&corpus_path)?)
.map_err(|err| format!("parse {}: {err}", corpus_path.display()))?;
let cases = corpus["cases"]
.as_array()
.ok_or_else(|| "publisher corpus cases should be an array".to_string())?;
cases
.iter()
.map(|case| {
let id = string_field(case, "id")
.ok_or_else(|| "publisher corpus case is missing id".to_string())?;
let inputs = case
.get("inputs")
.ok_or_else(|| format!("{id} is missing inputs"))?;
let mode = CommentMode::parse(
string_field(inputs, "mode")
.ok_or_else(|| format!("{id} is missing mode"))?
.as_str(),
)?;
Ok(FixtureCase {
id,
expected_report: PathBuf::from(
string_field(case, "expected_report")
.ok_or_else(|| "case missing expected_report".to_string())?,
),
expected_markdown: PathBuf::from(
string_field(case, "expected_markdown")
.ok_or_else(|| "case missing expected_markdown".to_string())?,
),
mode,
pr_guidance: string_field(inputs, "pr_guidance").map(PathBuf::from),
existing_comments: string_field(inputs, "existing_comments").map(PathBuf::from),
pull_request: inputs.get("pull_request").and_then(Value::as_u64),
event_name: string_field(inputs, "event_name"),
head_repo: string_field(inputs, "head_repo"),
base_repo: string_field(inputs, "base_repo"),
})
})
.collect()
}
fn input_for_case(case: &FixtureCase) -> Result<CommentPublishPlanInput, String> {
let root = repo_root()?;
Ok(CommentPublishPlanInput {
root: ".".to_string(),
generated_at: "2026-05-10T12:00:00Z".to_string(),
mode: case.mode,
max_inline_comments: DEFAULT_MAX_INLINE_COMMENTS,
pr_guidance_path: case
.pr_guidance
.as_ref()
.map(|path| display_path(path.as_path())),
pr_guidance_json: case
.pr_guidance
.as_ref()
.map(|path| read_file(&root.join(path))),
existing_comments_path: case
.existing_comments
.as_ref()
.map(|path| display_path(path.as_path())),
existing_comments_json: case
.existing_comments
.as_ref()
.map(|path| read_file(&root.join(path))),
permission: CommentPermissionContext {
pull_request: case.pull_request,
event_name: case.event_name.clone(),
head_repo: case.head_repo.clone(),
base_repo: case.base_repo.clone(),
token_available: false,
write_permission: true,
},
})
}
}