use super::actions::code_action_response;
use super::capabilities::{initialize_result, root_from_initialize_params};
use super::config::LspAnalysisConfig;
use super::diagnostics::{
DiagnosticBatch, DiagnosticRefreshPlan, WorkspaceDiagnostics, diagnostic_refresh_plan,
take_all_uris, workspace_diagnostics_with_config,
};
use super::hover::{
classified_seam_hover_response, diagnostic_at_position, diagnostic_covers_position,
diagnostic_hover_response, finding_hover_response, hover_response, hover_with_snapshot_status,
};
use super::lens::code_lens_response;
use super::state::{AnalysisSnapshot, DocumentStore, format_duration};
use super::{
COLLECT_CONTEXT_COMMAND, COLLECT_EVIDENCE_CONTEXT_COMMAND, COLLECT_RECEIPT_STATUS_COMMAND,
COLLECT_REPAIR_PACKET_COMMAND, COLLECT_TOP_LIMITATION_COMMAND,
COLLECT_WORKSPACE_STATUS_COMMAND, REFRESH_COMMAND,
};
use crate::agent::loop_commands;
use crate::analysis::ClassifiedSeam;
use crate::domain::context_packet::ContextPacket;
use crate::domain::{StageEvidence, StageState};
use crate::output::agent_seam_packets::{
render_agent_gap_record_packet_json, suggested_assertion_for_classified_seam,
targeted_test_brief_outline_for_classified_seam, validate_agent_gap_record_packet,
};
use crate::output::first_useful_action::DEFAULT_FIRST_USEFUL_ACTION_OUT;
use crate::output::gap_decision_ledger::{
DEFAULT_GAP_DECISION_LEDGER_OUT, GapRecord, parse_gap_records_json,
};
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::Duration;
use std::time::Instant;
use tokio::sync::Mutex as AsyncMutex;
use tower_lsp_server::jsonrpc::Result as LspResult;
use tower_lsp_server::ls_types::{
CodeActionParams, CodeActionResponse, CodeLens, CodeLensParams, Diagnostic,
DidChangeTextDocumentParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams,
DidSaveTextDocumentParams, ExecuteCommandParams, Hover, HoverParams, InitializeParams,
InitializeResult, LSPAny, MessageType, Uri,
};
use tower_lsp_server::{Client, LanguageServer};
pub(super) struct Backend {
client: Client,
root: Mutex<PathBuf>,
documents: Mutex<DocumentStore>,
analysis_config: Mutex<LspAnalysisConfig>,
last_diagnostic_uris: Mutex<BTreeSet<Uri>>,
last_diagnostics: Mutex<BTreeMap<Uri, Vec<Diagnostic>>>,
latest_analysis: Mutex<Option<AnalysisSnapshot>>,
refresh_generation: Mutex<u64>,
refresh_in_flight: AsyncMutex<()>,
}
impl Backend {
pub(super) fn new(client: Client, root: PathBuf) -> Self {
Self {
client,
root: Mutex::new(root),
documents: Mutex::new(DocumentStore::default()),
analysis_config: Mutex::new(LspAnalysisConfig::default()),
last_diagnostic_uris: Mutex::new(BTreeSet::new()),
last_diagnostics: Mutex::new(BTreeMap::new()),
latest_analysis: Mutex::new(None),
refresh_generation: Mutex::new(0),
refresh_in_flight: AsyncMutex::new(()),
}
}
pub(super) async fn refresh_diagnostics(&self, defer_seam_inventory: bool) {
let Some(generation) = self.next_refresh_generation() else {
return;
};
self.log_refresh_queued(generation).await;
let _refresh_guard = self.refresh_in_flight.lock().await;
if !self.is_current_refresh_generation(generation) {
return;
}
let Some(root) = self.root() else {
return;
};
let Some(config) = self.analysis_config() else {
return;
};
let enabled_languages = config.repo_config().languages().enabled().to_vec();
let started = Instant::now();
self.log_refresh_started(generation).await;
let diagnostics = match tokio::task::spawn_blocking(move || {
workspace_diagnostics_with_config(&root, &config, defer_seam_inventory)
})
.await
{
Ok(Ok(mut diagnostics)) => {
diagnostics
.snapshot
.refresh
.record_duration(started.elapsed());
diagnostics
}
Ok(Err(err)) => {
self.report_refresh_failure_after(err, started.elapsed())
.await;
return;
}
Err(err) => {
self.report_refresh_failure_after(
format!("analysis task failed: {err}"),
started.elapsed(),
)
.await;
return;
}
};
if !self.is_current_refresh_generation(generation) {
return;
}
let summary = RefreshLogSummary::from_snapshot(generation, &diagnostics.snapshot)
.with_enabled_languages(&enabled_languages);
let Some(refresh) = self.refresh_plan(diagnostics) else {
self.report_refresh_failure_after(
"diagnostic snapshot was inconsistent with publish batches".to_string(),
started.elapsed(),
)
.await;
return;
};
let published_uri_count = refresh.publish_batches.len();
let cleared_uri_count = refresh.clear_uris.len();
for batch in refresh.publish_batches {
self.client
.publish_diagnostics(batch.uri, batch.diagnostics, None)
.await;
}
for uri in refresh.clear_uris {
self.client.publish_diagnostics(uri, Vec::new(), None).await;
}
self.log_refresh_completed(summary, published_uri_count, cleared_uri_count)
.await;
}
pub(super) async fn report_refresh_failure_after(&self, message: String, duration: Duration) {
self.client
.log_message(
MessageType::WARNING,
refresh_failed_log_message(&message, duration),
)
.await;
for uri in self.clear_all_diagnostic_uris() {
self.client.publish_diagnostics(uri, Vec::new(), None).await;
}
}
pub(super) fn refresh_plan(
&self,
diagnostics: WorkspaceDiagnostics,
) -> Option<DiagnosticRefreshPlan> {
let WorkspaceDiagnostics { snapshot, batches } = diagnostics;
let Ok(mut last_diagnostic_uris) = self.last_diagnostic_uris.lock() else {
return None;
};
let Ok(mut last_diagnostics) = self.last_diagnostics.lock() else {
return None;
};
let Ok(mut latest_analysis) = self.latest_analysis.lock() else {
return None;
};
if snapshot.diagnostics_by_uri != diagnostics_by_uri_from_batches(&batches) {
return None;
}
let refresh = diagnostic_refresh_plan(&last_diagnostic_uris, batches);
debug_assert!(snapshot.is_consistent());
*last_diagnostics = refresh
.publish_batches
.iter()
.map(|batch| (batch.uri.clone(), batch.diagnostics.clone()))
.collect();
*last_diagnostic_uris = refresh.current_uris.clone();
*latest_analysis = Some(snapshot);
Some(refresh)
}
pub(super) fn clear_all_diagnostic_uris(&self) -> Vec<Uri> {
let Ok(mut last_diagnostic_uris) = self.last_diagnostic_uris.lock() else {
return Vec::new();
};
if let Ok(mut last_diagnostics) = self.last_diagnostics.lock() {
last_diagnostics.clear();
}
if let Ok(mut latest_analysis) = self.latest_analysis.lock() {
*latest_analysis = None;
}
take_all_uris(&mut last_diagnostic_uris)
}
pub(super) fn next_refresh_generation(&self) -> Option<u64> {
let Ok(mut generation) = self.refresh_generation.lock() else {
return None;
};
*generation = generation.saturating_add(1);
Some(*generation)
}
pub(super) fn is_current_refresh_generation(&self, generation: u64) -> bool {
let Ok(current) = self.refresh_generation.lock() else {
return false;
};
*current == generation
}
fn root(&self) -> Option<PathBuf> {
let Ok(root) = self.root.lock() else {
return None;
};
Some(root.clone())
}
pub(super) fn analysis_config(&self) -> Option<LspAnalysisConfig> {
let Ok(config) = self.analysis_config.lock() else {
return None;
};
Some(config.clone())
}
#[cfg(test)]
pub(super) fn latest_analysis_snapshot(&self) -> Option<AnalysisSnapshot> {
let Ok(snapshot) = self.latest_analysis.lock() else {
return None;
};
snapshot.clone()
}
fn set_root(&self, root: PathBuf) {
let Ok(mut current_root) = self.root.lock() else {
return;
};
*current_root = root;
}
fn set_analysis_config(&self, config: LspAnalysisConfig) {
let Ok(mut current_config) = self.analysis_config.lock() else {
return;
};
*current_config = config;
}
fn open_document(&self, params: DidOpenTextDocumentParams) {
let Ok(mut documents) = self.documents.lock() else {
return;
};
documents.open(params);
}
fn change_document(&self, params: DidChangeTextDocumentParams) {
let Ok(mut documents) = self.documents.lock() else {
return;
};
documents.change(params);
}
fn close_document(&self, params: DidCloseTextDocumentParams) {
let Ok(mut documents) = self.documents.lock() else {
return;
};
documents.close(params);
}
pub(super) fn hover_for_position(&self, params: &HoverParams) -> Option<Hover> {
let uri = ¶ms.text_document_position_params.text_document.uri;
let position = ¶ms.text_document_position_params.position;
if let Ok(snapshot) = self.latest_analysis.lock()
&& let Some(snapshot) = snapshot.as_ref()
&& let Some(diagnostics) = snapshot.diagnostics_for_uri(uri)
{
let overlapping: Vec<&Diagnostic> = diagnostics
.iter()
.filter(|d| diagnostic_covers_position(d, position))
.collect();
for diagnostic in &overlapping {
if let Some(seam) = snapshot.classified_seam_for_diagnostic(diagnostic) {
return Some(hover_with_snapshot_status(
classified_seam_hover_response(seam, diagnostic, Some(snapshot)),
snapshot,
));
}
}
for diagnostic in &overlapping {
if let Some(finding) = snapshot.finding_for_diagnostic(diagnostic) {
return Some(hover_with_snapshot_status(
finding_hover_response(finding, diagnostic),
snapshot,
));
}
}
if let Some(diagnostic) = overlapping.first() {
return Some(hover_with_snapshot_status(
diagnostic_hover_response(diagnostic),
snapshot,
));
}
}
let Ok(last_diagnostics) = self.last_diagnostics.lock() else {
return None;
};
let diagnostics = last_diagnostics.get(uri)?;
diagnostic_at_position(diagnostics, position).map(diagnostic_hover_response)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct RefreshLogSummary {
generation: u64,
duration: Duration,
diagnostics: usize,
files: usize,
findings: usize,
preview_findings: usize,
static_limits: usize,
seam_diagnostics: usize,
gap_artifacts: usize,
actionable_gap_artifacts: usize,
preview_gap_artifacts: usize,
no_action_gap_artifacts: usize,
gap_static_limits: usize,
gap_artifact_rejections: usize,
gap_artifact_rejection_kinds: Vec<&'static str>,
enabled_languages: usize,
enabled_language_names: Vec<&'static str>,
}
impl RefreshLogSummary {
pub(super) fn from_snapshot(generation: u64, snapshot: &AnalysisSnapshot) -> Self {
let duration = match snapshot.refresh.duration {
Some(duration) => duration,
None => Duration::ZERO,
};
Self {
generation,
duration,
diagnostics: snapshot.diagnostic_count(),
files: snapshot.diagnostic_uri_count(),
findings: snapshot.finding_count(),
preview_findings: snapshot
.findings
.iter()
.filter(|finding| {
finding
.language_status
.as_ref()
.is_some_and(|status| status.as_str() == "preview")
})
.count(),
static_limits: snapshot
.findings
.iter()
.filter(|finding| finding.static_limit_kind.is_some())
.count(),
seam_diagnostics: snapshot.seam_diagnostic_count(),
gap_artifacts: snapshot.gap_artifacts.len(),
actionable_gap_artifacts: snapshot
.gap_artifacts
.iter()
.filter(|artifact| artifact.is_actionable_gap())
.count(),
preview_gap_artifacts: snapshot
.gap_artifacts
.iter()
.filter(|artifact| artifact.is_preview())
.count(),
no_action_gap_artifacts: snapshot
.gap_artifacts
.iter()
.filter(|artifact| artifact.is_no_action_gap())
.count(),
gap_static_limits: snapshot
.gap_artifacts
.iter()
.filter(|artifact| artifact.has_static_limit())
.count(),
gap_artifact_rejections: snapshot.gap_artifact_rejections.len(),
gap_artifact_rejection_kinds: snapshot
.gap_artifact_rejections
.iter()
.map(|rejection| rejection.as_str())
.collect::<BTreeSet<_>>()
.into_iter()
.collect(),
enabled_languages: 1,
enabled_language_names: vec!["rust"],
}
}
pub(super) fn with_enabled_languages(
mut self,
enabled_languages: &[crate::domain::LanguageId],
) -> Self {
self.enabled_languages = enabled_languages.len();
self.enabled_language_names = enabled_languages
.iter()
.map(crate::domain::LanguageId::as_str)
.collect();
self
}
}
impl Backend {
async fn log_refresh_queued(&self, generation: u64) {
self.client
.log_message(
MessageType::INFO,
format!("ripr analysis refresh queued: generation={generation}"),
)
.await;
}
async fn log_refresh_started(&self, generation: u64) {
self.client
.log_message(
MessageType::INFO,
format!("ripr analysis refresh started: generation={generation}"),
)
.await;
}
async fn log_refresh_completed(
&self,
summary: RefreshLogSummary,
published_uri_count: usize,
cleared_uri_count: usize,
) {
self.client
.log_message(
MessageType::INFO,
refresh_completed_log_message(&summary, published_uri_count, cleared_uri_count),
)
.await;
}
}
pub(super) fn refresh_completed_log_message(
summary: &RefreshLogSummary,
published_uri_count: usize,
cleared_uri_count: usize,
) -> String {
let duration = format_duration(summary.duration);
format!(
"ripr analysis refresh completed in {duration}: generation={}, diagnostics={}, files={}, findings={}, preview_findings={}, static_limits={}, seam_diagnostics={}, gap_artifacts={}, actionable_gap_artifacts={}, preview_gap_artifacts={}, no_action_gap_artifacts={}, gap_static_limits={}, gap_artifact_rejections={}, gap_artifact_rejection_kinds={}, enabled_languages={}, enabled_language_names={}, published_files={}, cleared_files={}",
summary.generation,
summary.diagnostics,
summary.files,
summary.findings,
summary.preview_findings,
summary.static_limits,
summary.seam_diagnostics,
summary.gap_artifacts,
summary.actionable_gap_artifacts,
summary.preview_gap_artifacts,
summary.no_action_gap_artifacts,
summary.gap_static_limits,
summary.gap_artifact_rejections,
summary.gap_artifact_rejection_kinds.join("|"),
summary.enabled_languages,
summary.enabled_language_names.join("|"),
published_uri_count,
cleared_uri_count
)
}
pub(super) fn refresh_failed_log_message(message: &str, duration: Duration) -> String {
format!(
"ripr analysis refresh failed after {}: {message}",
format_duration(duration)
)
}
fn diagnostics_by_uri_from_batches(batches: &[DiagnosticBatch]) -> BTreeMap<Uri, Vec<Diagnostic>> {
batches
.iter()
.map(|batch| (batch.uri.clone(), batch.diagnostics.clone()))
.collect()
}
impl LanguageServer for Backend {
async fn initialize(&self, params: InitializeParams) -> LspResult<InitializeResult> {
let fallback_root = self
.root()
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
let root = root_from_initialize_params(¶ms, &fallback_root);
let repo_config = match crate::config::load_for_root(&root) {
Ok(config) => config,
Err(err) => {
self.client
.log_message(
MessageType::WARNING,
format!("ripr config load failed; using defaults: {err}"),
)
.await;
crate::config::RiprConfig::default()
}
};
self.set_root(root);
self.set_analysis_config(LspAnalysisConfig::from_initialize_params(
¶ms,
repo_config,
));
Ok(initialize_result())
}
async fn shutdown(&self) -> LspResult<()> {
Ok(())
}
async fn did_open(&self, params: DidOpenTextDocumentParams) {
self.open_document(params);
self.refresh_diagnostics(true).await;
}
async fn did_change(&self, params: DidChangeTextDocumentParams) {
self.change_document(params);
}
async fn did_close(&self, params: DidCloseTextDocumentParams) {
self.close_document(params);
self.refresh_diagnostics(true).await;
}
async fn did_save(&self, _: DidSaveTextDocumentParams) {
self.refresh_diagnostics(true).await;
}
async fn hover(&self, params: HoverParams) -> LspResult<Option<Hover>> {
Ok(Some(
self.hover_for_position(¶ms)
.unwrap_or_else(hover_response),
))
}
async fn code_action(&self, params: CodeActionParams) -> LspResult<Option<CodeActionResponse>> {
let snapshot = self
.latest_analysis
.lock()
.ok()
.and_then(|value| value.clone());
Ok(Some(code_action_response(¶ms, snapshot.as_ref())))
}
async fn code_lens(&self, params: CodeLensParams) -> LspResult<Option<Vec<CodeLens>>> {
let snapshot = self
.latest_analysis
.lock()
.ok()
.and_then(|value| value.clone());
let uri = ¶ms.text_document.uri;
Ok(Some(code_lens_response(uri, snapshot.as_ref())))
}
async fn execute_command(&self, params: ExecuteCommandParams) -> LspResult<Option<LSPAny>> {
if params.command == REFRESH_COMMAND {
self.refresh_diagnostics(false).await;
return Ok(None);
}
if params.command == COLLECT_CONTEXT_COMMAND {
return Ok(self.collect_context_packet(¶ms.arguments));
}
if params.command == COLLECT_EVIDENCE_CONTEXT_COMMAND {
return Ok(self.collect_evidence_context_packet(¶ms.arguments));
}
if params.command == COLLECT_WORKSPACE_STATUS_COMMAND {
return Ok(self.collect_workspace_status());
}
if params.command == COLLECT_REPAIR_PACKET_COMMAND {
return Ok(self.collect_repair_packet(¶ms.arguments));
}
if params.command == COLLECT_TOP_LIMITATION_COMMAND {
return Ok(self.collect_top_limitation());
}
if params.command == COLLECT_RECEIPT_STATUS_COMMAND {
return Ok(self.collect_receipt_status());
}
Ok(None)
}
}
fn context_arguments(arguments: &[LSPAny]) -> Option<&serde_json::Map<String, serde_json::Value>> {
let first = arguments.first()?;
first.as_object()
}
impl Backend {
fn collect_context_packet(&self, arguments: &[LSPAny]) -> Option<LSPAny> {
let args = context_arguments(arguments)?;
let snapshot = self.latest_analysis.lock().ok()?.clone()?;
if let Some(gap_id) = args.get("gap_id").and_then(|v| v.as_str()) {
return collect_gap_record_context_packet(&snapshot.root, args, gap_id);
}
if let Some(seam_id) = args.get("seam_id").and_then(|v| v.as_str()) {
let seam = snapshot.classified_seam_by_id(seam_id)?;
let packet = crate::output::agent_seam_packets::render_agent_seam_packets_json(
std::slice::from_ref(seam),
None,
);
return serde_json::from_str(&packet).ok();
}
let finding_id = args.get("finding_id").and_then(|v| v.as_str())?;
let finding = snapshot.finding_by_id(finding_id)?;
let max_related_tests = self
.analysis_config()
.map(|config| config.repo_config().reports().max_related_tests())
.unwrap_or(crate::config::DEFAULT_CONTEXT_RELATED_TESTS);
let stop_reasons = finding
.effective_stop_reasons()
.iter()
.map(|reason| reason.as_str().to_string())
.collect();
let packet = ContextPacket::from_finding(finding, max_related_tests, stop_reasons);
let rendered = crate::output::json::render_context_packet_dto(&packet);
serde_json::from_str(&rendered).ok()
}
fn collect_evidence_context_packet(&self, arguments: &[LSPAny]) -> Option<LSPAny> {
let args = context_arguments(arguments)?;
let snapshot = self.latest_analysis.lock().ok()?.clone()?;
let seam_id = args.get("seam_id").and_then(|v| v.as_str())?;
let seam = snapshot.classified_seam_by_id(seam_id)?;
Some(evidence_context_packet(&snapshot, seam))
}
fn collect_workspace_status(&self) -> Option<LSPAny> {
let snapshot = match self.latest_analysis.lock().ok()? {
guard if guard.is_none() => {
return Some(serde_json::json!({
"schema_version": "0.1",
"tool": "ripr",
"kind": "workspace_status",
"run_status": "no_snapshot",
"snapshot_age_ms": serde_json::Value::Null,
"snapshot_duration_ms": serde_json::Value::Null,
"diagnostics": serde_json::Value::Null,
"top_actionable_packet": serde_json::Value::Null,
"top_limitation": serde_json::Value::Null,
"report_paths": workspace_status_report_paths(),
"refresh_command": REFRESH_COMMAND,
"limits_note": "Static evidence only; advisory, not a gate decision.",
}));
}
guard => guard.clone()?,
};
let age_ms = snapshot
.refresh
.age()
.map(|d| serde_json::Value::from(d.as_millis() as u64))
.unwrap_or(serde_json::Value::Null);
let duration_ms = snapshot
.refresh
.duration
.map(|d| serde_json::Value::from(d.as_millis() as u64))
.unwrap_or(serde_json::Value::Null);
let total_diagnostics = snapshot.diagnostic_count();
let files = snapshot.diagnostic_uri_count();
let findings = snapshot.finding_count();
let seam_diagnostics = snapshot.seam_diagnostic_count();
let gap_artifacts = snapshot.gap_artifacts.len();
let actionable_gap_artifacts = snapshot
.gap_artifacts
.iter()
.filter(|a| a.is_actionable_gap())
.count();
let gap_artifact_rejections = snapshot.gap_artifact_rejections.len();
let top_actionable_packet = workspace_status_top_actionable_packet(&snapshot);
let top_limitation = workspace_status_top_limitation(&snapshot);
let run_status = workspace_status_run_status(&snapshot);
let root = match self.root.lock().ok() {
Some(r) => r.clone(),
None => {
return Some(serde_json::json!({
"schema_version": "0.1",
"tool": "ripr",
"kind": "workspace_status",
"run_status": run_status,
"snapshot_age_ms": age_ms,
"snapshot_duration_ms": duration_ms,
"diagnostics": {
"total": total_diagnostics,
"files": files,
"findings": findings,
"seam_diagnostics": seam_diagnostics,
"gap_artifacts": gap_artifacts,
"actionable_gap_artifacts": actionable_gap_artifacts,
"gap_artifact_rejections": gap_artifact_rejections,
},
"top_actionable_packet": top_actionable_packet,
"top_limitation": top_limitation,
"receipt_status_summary": serde_json::Value::Null,
"report_paths": workspace_status_report_paths(),
"refresh_command": REFRESH_COMMAND,
"limits_note": "Static evidence only; advisory, not a gate decision.",
}));
}
};
let receipt_status_summary = workspace_status_receipt_summary(&root, &snapshot);
Some(serde_json::json!({
"schema_version": "0.1",
"tool": "ripr",
"kind": "workspace_status",
"run_status": run_status,
"snapshot_age_ms": age_ms,
"snapshot_duration_ms": duration_ms,
"diagnostics": {
"total": total_diagnostics,
"files": files,
"findings": findings,
"seam_diagnostics": seam_diagnostics,
"gap_artifacts": gap_artifacts,
"actionable_gap_artifacts": actionable_gap_artifacts,
"gap_artifact_rejections": gap_artifact_rejections,
},
"top_actionable_packet": top_actionable_packet,
"top_limitation": top_limitation,
"receipt_status_summary": receipt_status_summary,
"report_paths": workspace_status_report_paths(),
"refresh_command": REFRESH_COMMAND,
"limits_note": "Static evidence only; advisory, not a gate decision.",
}))
}
}
fn workspace_status_report_paths() -> serde_json::Value {
serde_json::json!({
"actionable_gaps": "target/ripr/reports/actionable-gaps.json",
"first_useful_action": DEFAULT_FIRST_USEFUL_ACTION_OUT,
"gap_decision_ledger": DEFAULT_GAP_DECISION_LEDGER_OUT,
"start_here": "target/ripr/reports/start-here.json",
})
}
fn workspace_status_receipt_summary(
root: &std::path::Path,
snapshot: &AnalysisSnapshot,
) -> serde_json::Value {
let top_gap = snapshot
.gap_artifacts
.iter()
.find(|a| a.is_safe_projection_input() && a.is_actionable_gap());
let ledger_path = root.join(DEFAULT_GAP_DECISION_LEDGER_OUT);
let receipt_movement = top_gap
.map(|artifact| receipt_status_from_ledger(&ledger_path, artifact).0)
.unwrap_or_else(|| serde_json::Value::String("not_available".to_string()));
let attempt_ledger_path = root.join("target/ripr/reports/swarm-attempt-ledger.json");
let latest_attempt_outcome = read_latest_attempt_outcome(&attempt_ledger_path, top_gap);
let route_quality_path = root.join("target/ripr/reports/route-quality.json");
let route_quality_summary = read_route_quality_summary(&route_quality_path);
serde_json::json!({
"receipt_movement": receipt_movement,
"latest_attempt_outcome": latest_attempt_outcome,
"route_quality_summary": route_quality_summary,
})
}
fn workspace_status_run_status(snapshot: &AnalysisSnapshot) -> &'static str {
if snapshot
.gap_artifact_rejections
.iter()
.any(|r| matches!(r, super::gap_artifacts::GapArtifactRejection::StaleArtifact))
{
return "stale";
}
if !snapshot.gap_artifact_rejections.is_empty() {
return "cache_limited";
}
let has_static_limit = snapshot
.findings
.iter()
.any(|f| f.static_limit_kind.is_some())
|| snapshot.gap_artifacts.iter().any(|a| a.has_static_limit());
if has_static_limit {
return "limited";
}
if snapshot.seams_deferred {
return "seams_deferred";
}
"full"
}
fn workspace_status_top_actionable_packet(snapshot: &AnalysisSnapshot) -> serde_json::Value {
let artifact = snapshot
.gap_artifacts
.iter()
.find(|a| a.is_safe_projection_input() && a.is_actionable_gap());
let Some(artifact) = artifact else {
return serde_json::Value::Null;
};
let canonical_gap_id = artifact
.identities
.first()
.and_then(|id| id.canonical_gap_id.as_deref())
.unwrap_or("");
let verify_command = artifact
.verify_commands
.first()
.map(String::as_str)
.unwrap_or("");
let receipt_command = artifact
.receipt_commands
.first()
.map(String::as_str)
.unwrap_or("");
let file = artifact
.related_paths
.first()
.map(String::as_str)
.unwrap_or("");
let repair_kind = artifact.gap_state.as_deref().unwrap_or("actionable");
serde_json::json!({
"canonical_gap_id": canonical_gap_id,
"file": file,
"line": serde_json::Value::Null,
"repair_kind": repair_kind,
"verify_command": verify_command,
"receipt_command": receipt_command,
})
}
fn workspace_status_top_limitation(snapshot: &AnalysisSnapshot) -> serde_json::Value {
let rejection = snapshot.gap_artifact_rejections.first();
let Some(rejection) = rejection else {
return serde_json::Value::Null;
};
let category = rejection.as_str();
let (repair_route, why_not_actionable) = workspace_status_rejection_repair(rejection);
serde_json::json!({
"category": category,
"repair_route": repair_route,
"why_not_actionable": why_not_actionable,
})
}
fn workspace_status_rejection_repair(
rejection: &super::gap_artifacts::GapArtifactRejection,
) -> (&'static str, &'static str) {
use super::gap_artifacts::GapArtifactRejection;
match rejection {
GapArtifactRejection::StaleArtifact => (
"regenerate_gap_artifacts",
"gap artifacts are stale; rerun ripr check to refresh",
),
GapArtifactRejection::WrongRoot(_) => (
"verify_workspace_root",
"gap artifact root does not match workspace root",
),
GapArtifactRejection::UnsupportedSchema(_) => (
"upgrade_ripr",
"gap artifact schema version is not supported by this ripr version",
),
GapArtifactRejection::MalformedArtifact(_) => (
"regenerate_gap_artifacts",
"gap artifact is malformed; rerun ripr check to regenerate",
),
GapArtifactRejection::MissingIdentity => (
"regenerate_gap_artifacts",
"gap artifact is missing a canonical identity; rerun ripr check",
),
GapArtifactRejection::MalformedCommandPayload(_) => (
"regenerate_gap_artifacts",
"gap artifact command payload is malformed; rerun ripr check",
),
GapArtifactRejection::OutOfWorkspacePath(_) => (
"verify_workspace_root",
"gap artifact references a path outside the workspace",
),
GapArtifactRejection::DisabledLanguage(_) => (
"enable_language_in_config",
"gap artifact language is not enabled in ripr config",
),
GapArtifactRejection::UnavailableLanguage(_) => (
"upgrade_ripr",
"gap artifact language is not available in this ripr build",
),
GapArtifactRejection::UnsupportedStaticLimitKind(_) => (
"upgrade_ripr",
"gap artifact static_limit_kind is not recognized by this ripr version",
),
GapArtifactRejection::UnsupportedKind(_) => (
"upgrade_ripr",
"gap artifact kind is not supported by this ripr version",
),
}
}
fn limitation_sample_sources(
rejection: &super::gap_artifacts::GapArtifactRejection,
) -> Vec<String> {
use super::gap_artifacts::GapArtifactRejection;
match rejection {
GapArtifactRejection::DisabledLanguage(s)
| GapArtifactRejection::MalformedCommandPayload(s)
| GapArtifactRejection::OutOfWorkspacePath(s)
| GapArtifactRejection::UnavailableLanguage(s)
| GapArtifactRejection::UnsupportedKind(s)
| GapArtifactRejection::UnsupportedSchema(s)
| GapArtifactRejection::UnsupportedStaticLimitKind(s)
| GapArtifactRejection::WrongRoot(s) => vec![s.clone()],
GapArtifactRejection::MalformedArtifact(_)
| GapArtifactRejection::MissingIdentity
| GapArtifactRejection::StaleArtifact => vec![],
}
}
fn limitation_non_claims(category: &str) -> Vec<&'static str> {
match category {
"disabled_language" | "unavailable_language" => vec![
"not a Rust repair packet",
"does not indicate the behavior is reachable",
"does not indicate tests are absent",
],
"wrong_root" | "out_of_workspace_path" => vec![
"not a repair packet",
"path resolution required before exposure can be assessed",
],
"stale_artifact"
| "missing_identity"
| "malformed_artifact"
| "malformed_command_payload" => vec![
"not a repair packet",
"artifact regeneration required before exposure can be assessed",
],
"unsupported_schema" | "unsupported_kind" | "unsupported_static_limit_kind" => vec![
"not a repair packet",
"ripr upgrade required before exposure can be assessed",
],
_ => vec!["not a repair packet"],
}
}
fn collect_gap_record_context_packet(
root: &Path,
args: &serde_json::Map<String, serde_json::Value>,
gap_id: &str,
) -> Option<LSPAny> {
let gap_id = gap_id.trim();
if gap_id.is_empty() {
return None;
}
let ledger_arg = args
.get("gap_ledger")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_GAP_DECISION_LEDGER_OUT);
let ledger_path = absolute_context_path(root, Path::new(ledger_arg));
let contents = fs::read_to_string(&ledger_path).ok()?;
let records = parse_gap_records_json(&contents).ok()?;
let record = records
.iter()
.find(|record| gap_record_matches(record, gap_id))?;
let rendered =
render_agent_gap_record_packet_json(&display_lsp_path(&ledger_path), record).ok()?;
serde_json::from_str(&rendered).ok()
}
const DEFAULT_ACTIONABLE_GAPS_OUT: &str = "target/ripr/reports/actionable-gaps.json";
impl Backend {
fn collect_repair_packet(&self, arguments: &[LSPAny]) -> Option<LSPAny> {
let root = self.root.lock().ok()?.clone();
let gap_id_arg = arguments
.first()
.and_then(|v| v.as_object())
.and_then(|obj| obj.get("gap_id"))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned);
let actionable_path = absolute_context_path(&root, Path::new(DEFAULT_ACTIONABLE_GAPS_OUT));
if let Some(result) =
collect_repair_packet_from_actionable_gaps(&actionable_path, gap_id_arg.as_deref())
{
return Some(result);
}
let ledger_path = absolute_context_path(&root, Path::new(DEFAULT_GAP_DECISION_LEDGER_OUT));
collect_repair_packet_from_ledger(&ledger_path, gap_id_arg.as_deref())
}
fn collect_top_limitation(&self) -> Option<LSPAny> {
let snapshot = self.latest_analysis.lock().ok()?.clone();
let Some(snapshot) = snapshot else {
return Some(serde_json::json!({
"schema_version": "0.1",
"tool": "ripr",
"kind": "top_limitation",
"status": "no_limitation",
}));
};
let rejection = snapshot.gap_artifact_rejections.first()?;
let category = rejection.as_str();
let (repair_route, why_not_actionable) = workspace_status_rejection_repair(rejection);
let sample_sources = limitation_sample_sources(rejection);
let unlock_condition = repair_route;
let non_claims = limitation_non_claims(category);
Some(serde_json::json!({
"schema_version": "0.1",
"tool": "ripr",
"kind": "top_limitation",
"limitation_category": category,
"repair_route": repair_route,
"why_not_actionable": why_not_actionable,
"sample_sources": sample_sources,
"unlock_condition": unlock_condition,
"non_claims": non_claims,
"limits_note": "Static evidence only; advisory, not a gate decision.",
}))
}
fn collect_receipt_status(&self) -> Option<LSPAny> {
let root = self.root.lock().ok()?.clone();
let snapshot = match self.latest_analysis.lock().ok()? {
guard if guard.is_none() => {
return Some(serde_json::json!({
"schema_version": "0.1",
"tool": "ripr",
"kind": "receipt_status",
"status": "no_snapshot",
"receipt_status": "not_available",
"missing_receipt_reason": "not_available",
"copy_receipt_command": "not_available",
"open_attempt_ledger": "not_available",
"latest_attempt_outcome": "not_available",
"route_quality_summary": "not_available",
"limits_note": "Static evidence only; advisory, not a gate decision.",
}));
}
guard => guard.clone()?,
};
let top_gap = snapshot
.gap_artifacts
.iter()
.find(|a| a.is_safe_projection_input() && a.is_actionable_gap());
let (receipt_status_val, missing_receipt_reason_val, copy_receipt_cmd_val) =
collect_receipt_status_fields(&root, top_gap, &snapshot);
let attempt_ledger_path = root.join("target/ripr/reports/swarm-attempt-ledger.json");
let open_attempt_ledger_val = if attempt_ledger_path.is_file() {
serde_json::Value::String(display_lsp_path(&attempt_ledger_path))
} else {
serde_json::Value::String("not_available".to_string())
};
let latest_attempt_outcome_val = read_latest_attempt_outcome(&attempt_ledger_path, top_gap);
let route_quality_path = root.join("target/ripr/reports/route-quality.json");
let route_quality_summary_val = read_route_quality_summary(&route_quality_path);
Some(serde_json::json!({
"schema_version": "0.1",
"tool": "ripr",
"kind": "receipt_status",
"receipt_status": receipt_status_val,
"missing_receipt_reason": missing_receipt_reason_val,
"copy_receipt_command": copy_receipt_cmd_val,
"open_attempt_ledger": open_attempt_ledger_val,
"latest_attempt_outcome": latest_attempt_outcome_val,
"route_quality_summary": route_quality_summary_val,
"report_paths": workspace_receipt_status_report_paths(),
"limits_note": "Static evidence only; advisory, not a gate decision.",
}))
}
}
fn collect_receipt_status_fields(
root: &std::path::Path,
top_gap: Option<&super::gap_artifacts::ValidatedGapArtifact>,
snapshot: &super::state::AnalysisSnapshot,
) -> (serde_json::Value, serde_json::Value, serde_json::Value) {
let not_available = serde_json::Value::String("not_available".to_string());
let has_limitation = !snapshot.gap_artifact_rejections.is_empty();
let Some(artifact) = top_gap else {
return (not_available.clone(), not_available.clone(), not_available);
};
let ledger_path =
root.join(crate::output::gap_decision_ledger::DEFAULT_GAP_DECISION_LEDGER_OUT);
let (receipt_status_val, missing_receipt_reason_val) =
receipt_status_from_ledger(&ledger_path, artifact);
let copy_receipt_cmd_val = if has_limitation {
not_available
} else {
let has_verify = !artifact.verify_commands.is_empty();
let has_receipt = !artifact.receipt_commands.is_empty();
if has_verify && has_receipt {
let cmd = artifact
.receipt_commands
.first()
.map(String::as_str)
.unwrap_or("");
if super::gap_artifacts::command_payload_is_safe(root, cmd) {
serde_json::Value::String(cmd.to_string())
} else {
serde_json::Value::String("not_available".to_string())
}
} else {
serde_json::Value::String("not_available".to_string())
}
};
(
receipt_status_val,
missing_receipt_reason_val,
copy_receipt_cmd_val,
)
}
fn receipt_status_from_ledger(
ledger_path: &std::path::Path,
artifact: &super::gap_artifacts::ValidatedGapArtifact,
) -> (serde_json::Value, serde_json::Value) {
let not_available = "not_available".to_string();
let contents = match fs::read_to_string(ledger_path) {
Ok(c) => c,
Err(_) => {
return (
serde_json::Value::String(not_available.clone()),
serde_json::Value::String(not_available),
);
}
};
let records = match crate::output::gap_decision_ledger::parse_gap_records_json(&contents) {
Ok(r) => r,
Err(_) => {
return (
serde_json::Value::String(not_available.clone()),
serde_json::Value::String(not_available),
);
}
};
let canonical_gap_id = artifact
.identities
.first()
.and_then(|id| id.canonical_gap_id.as_deref())
.unwrap_or("");
let record = records.iter().find(|r| {
r.canonical_gap_id == canonical_gap_id
|| (!canonical_gap_id.is_empty() && r.gap_id == canonical_gap_id)
});
let Some(record) = record else {
return (
serde_json::Value::String(not_available.clone()),
serde_json::Value::String(not_available),
);
};
let movement_val = match record.receipt.as_ref().and_then(|r| r.movement.as_deref()) {
Some(m) => serde_json::Value::String(m.to_string()),
None => serde_json::Value::String(not_available.clone()),
};
let missing_receipt_reason_val = if record.gap_state == "actionable"
&& record
.receipt
.as_ref()
.and_then(|r| r.movement.as_deref())
.is_none()
&& record.receipt_command.is_some()
{
serde_json::Value::String("no_attempt_recorded".to_string())
} else {
serde_json::Value::String(not_available)
};
(movement_val, missing_receipt_reason_val)
}
fn read_latest_attempt_outcome(
ledger_path: &std::path::Path,
top_gap: Option<&super::gap_artifacts::ValidatedGapArtifact>,
) -> serde_json::Value {
let not_available = serde_json::Value::String("not_available".to_string());
let contents = match fs::read_to_string(ledger_path) {
Ok(c) => c,
Err(_) => return not_available,
};
let report: serde_json::Value = match serde_json::from_str(&contents) {
Ok(v) => v,
Err(_) => return not_available,
};
let canonical_gap_id = top_gap
.and_then(|a| a.identities.first())
.and_then(|id| id.canonical_gap_id.as_deref())
.unwrap_or("");
let attempts_val = report
.get("latest_attempts")
.and_then(|v| v.as_array())
.or_else(|| report.get("attempts").and_then(|v| v.as_array()));
let Some(attempts) = attempts_val else {
return not_available;
};
let entry = if canonical_gap_id.is_empty() {
attempts.first()
} else {
attempts
.iter()
.find(|entry| {
entry
.get("canonical_gap_id")
.and_then(|v| v.as_str())
.is_some_and(|cid| cid == canonical_gap_id)
})
.or_else(|| attempts.first())
};
entry
.and_then(|e| e.get("outcome"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| serde_json::Value::String(s.to_string()))
.unwrap_or(not_available)
}
fn read_route_quality_summary(path: &std::path::Path) -> serde_json::Value {
let not_available = serde_json::Value::String("not_available".to_string());
let contents = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return not_available,
};
let report: serde_json::Value = match serde_json::from_str(&contents) {
Ok(v) => v,
Err(_) => return not_available,
};
if report.get("status").and_then(|v| v.as_str()) == Some("blocked") {
return not_available;
}
let rows = report
.get("repair_route_quality_latest")
.and_then(|v| v.as_array());
let Some(rows) = rows else {
return not_available;
};
if rows.is_empty() {
return not_available;
}
let summary_rows: Vec<serde_json::Value> = rows
.iter()
.take(3)
.map(|row| {
let repair_kind = row
.get("repair_kind")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let success_rate = row
.get("repair_kind_success_rate")
.cloned()
.unwrap_or(serde_json::Value::Null);
let attempted = row
.get("repair_kind_attempted")
.cloned()
.unwrap_or(serde_json::Value::Null);
serde_json::json!({
"repair_kind": repair_kind,
"attempted": attempted,
"success_rate": success_rate,
})
})
.collect();
serde_json::json!({
"report": "route-quality",
"status": report.get("status").and_then(|v| v.as_str()).unwrap_or("unknown"),
"top_repair_kind_rows": summary_rows,
})
}
fn workspace_receipt_status_report_paths() -> serde_json::Value {
serde_json::json!({
"gap_decision_ledger": crate::output::gap_decision_ledger::DEFAULT_GAP_DECISION_LEDGER_OUT,
"swarm_attempt_ledger": "target/ripr/reports/swarm-attempt-ledger.json",
"route_quality": "target/ripr/reports/route-quality.json",
})
}
fn collect_repair_packet_from_actionable_gaps(path: &Path, gap_id: Option<&str>) -> Option<LSPAny> {
let contents = fs::read_to_string(path).ok()?;
let report: serde_json::Value = serde_json::from_str(&contents).ok()?;
let packets = report.get("packets").and_then(|v| v.as_array())?;
let packet = if let Some(id) = gap_id {
packets
.iter()
.find(|p| {
p.get("canonical_gap_id")
.and_then(|v| v.as_str())
.is_some_and(|cid| cid == id)
})
.or_else(|| packets.first())?
} else {
packets
.iter()
.find(|p| {
p.get("gap_state")
.and_then(|v| v.as_str())
.is_some_and(|s| s == "actionable")
})
.or_else(|| packets.first())?
};
if packet
.get("gap_state")
.and_then(|v| v.as_str())
.unwrap_or("")
!= "actionable"
{
return Some(repair_packet_sentinel(
"gap is not actionable in actionable-gaps.json",
));
}
validate_and_render_actionable_gap_packet(packet)
}
fn validate_and_render_actionable_gap_packet(packet: &serde_json::Value) -> Option<LSPAny> {
let str_field = |key: &str| -> Option<String> {
packet
.get(key)
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned)
};
let canonical_gap_id = match str_field("canonical_gap_id") {
Some(v) => v,
None => {
return Some(repair_packet_sentinel(
"actionable packet is missing canonical_gap_id",
));
}
};
let repair_kind = match str_field("repair_kind") {
Some(v) => v,
None => {
return Some(repair_packet_sentinel(
"actionable packet is missing repair_kind",
));
}
};
let verify_command = match str_field("verify_command") {
Some(v) => v,
None => {
return Some(repair_packet_sentinel(
"actionable packet is missing verify_command",
));
}
};
let receipt_command = match str_field("receipt_command") {
Some(v) => v,
None => {
return Some(repair_packet_sentinel(
"actionable packet is missing receipt_command",
));
}
};
let allowed_edit_surface: Vec<serde_json::Value> = packet
.get("allowed_edit_surface")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
if allowed_edit_surface.is_empty() {
return Some(repair_packet_sentinel(
"actionable packet is missing allowed_edit_surface",
));
}
let must_not_change: Vec<serde_json::Value> = packet
.get("must_not_change")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
if must_not_change.is_empty() {
return Some(repair_packet_sentinel(
"actionable packet is missing must_not_change",
));
}
let raw_evidence_refs: Vec<serde_json::Value> = packet
.get("raw_evidence_refs")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
if raw_evidence_refs.is_empty() {
return Some(repair_packet_sentinel(
"actionable packet is missing raw_evidence_refs",
));
}
let confidence = packet
.get("confidence_basis")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.unwrap_or("static_only")
.to_owned();
let language = raw_evidence_refs
.iter()
.find_map(|r| {
r.get("language")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned)
})
.unwrap_or_default();
let anchor = packet.get("primary_anchor");
let anchor_file = anchor
.and_then(|a| a.get("file"))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned);
let anchor_line = anchor
.and_then(|a| a.get("line"))
.and_then(|v| v.as_u64())
.filter(|&n| n > 0);
let source_location = match (anchor_file, anchor_line) {
(Some(file), Some(line)) => serde_json::json!({ "file": file, "line": line }),
(Some(file), None) => serde_json::json!({
"status": "source_location_unresolved",
"file": file,
}),
_ => serde_json::json!({ "status": "source_location_unresolved" }),
};
let result = serde_json::json!({
"schema_version": "0.1",
"tool": "ripr",
"kind": "repair_packet",
"canonical_gap_id": canonical_gap_id,
"language": language,
"repair_kind": repair_kind,
"source_location": source_location,
"allowed_edit_surface": allowed_edit_surface,
"verify_command": verify_command,
"receipt_command": receipt_command,
"must_not_change": must_not_change,
"raw_evidence_refs": raw_evidence_refs,
"confidence": confidence,
"limits_note": "Static evidence only; advisory, not a gate decision.",
});
serde_json::from_value(result).ok()
}
fn collect_repair_packet_from_ledger(path: &Path, gap_id: Option<&str>) -> Option<LSPAny> {
let contents = fs::read_to_string(path).ok()?;
let records = parse_gap_records_json(&contents).ok()?;
let record = if let Some(id) = gap_id {
records
.iter()
.find(|r| r.gap_id == id || r.canonical_gap_id == id)?
} else {
records.iter().find(|r| r.gap_state == "actionable")?
};
if let Err(reason) = validate_agent_gap_record_packet(record) {
return Some(serde_json::json!({
"schema_version": "0.1",
"tool": "ripr",
"kind": "repair_packet",
"status": "not_actionable_or_incomplete",
"reason": reason,
}));
}
let route = record.repair_route.as_ref()?;
let verify_command = record.verification_commands.first()?.clone();
let receipt_command = record.receipt_command.as_deref().map(ToOwned::to_owned)?;
let allowed_edit_surface =
crate::output::agent_seam_packets::allowed_edit_surface_for_gap_route(route);
let must_not_change: Vec<String> =
crate::output::agent_seam_packets::gap_record_packet_do_not_do(record);
let anchor = record.anchor.as_ref();
let anchor_file = anchor
.and_then(|a| a.file.as_deref())
.filter(|s| !s.is_empty())
.map(crate::output::path::display_path_text);
let anchor_line = anchor.and_then(|a| a.line).filter(|&n| n > 0);
let source_location = match (anchor_file, anchor_line) {
(Some(file), Some(line)) => serde_json::json!({ "file": file, "line": line }),
(Some(file), None) => serde_json::json!({
"status": "source_location_unresolved",
"file": file,
}),
_ => serde_json::json!({ "status": "source_location_unresolved" }),
};
let canonical_gap_id = if record.canonical_gap_id.trim().is_empty() {
&record.gap_id
} else {
&record.canonical_gap_id
};
let result = serde_json::json!({
"schema_version": "0.1",
"tool": "ripr",
"kind": "repair_packet",
"canonical_gap_id": canonical_gap_id,
"language": &record.language,
"repair_kind": route.route_kind.as_str(),
"source_location": source_location,
"allowed_edit_surface": allowed_edit_surface,
"verify_command": verify_command,
"receipt_command": receipt_command,
"must_not_change": must_not_change,
"raw_evidence_refs": &record.evidence_ids,
"confidence": "static_only",
"limits_note": "Static evidence only; advisory, not a gate decision.",
});
serde_json::from_value(result).ok()
}
fn repair_packet_sentinel(reason: &str) -> LSPAny {
serde_json::json!({
"schema_version": "0.1",
"tool": "ripr",
"kind": "repair_packet",
"status": "not_actionable_or_incomplete",
"reason": reason,
})
}
fn absolute_context_path(root: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
root.join(path)
}
}
fn gap_record_matches(record: &GapRecord, gap_id: &str) -> bool {
record.gap_id == gap_id || record.canonical_gap_id == gap_id
}
fn evidence_context_packet(snapshot: &AnalysisSnapshot, entry: &ClassifiedSeam) -> LSPAny {
let seam = &entry.seam;
let evidence = &entry.evidence;
let seam_id = seam.id().as_str();
let outline = targeted_test_brief_outline_for_classified_seam(entry);
let related_test = evidence.related_tests.first();
let missing_discriminator = evidence
.missing_discriminators
.first()
.map(|missing| missing.value.as_str());
serde_json::json!({
"schema_version": "0.1",
"tool": "ripr",
"root": ".",
"base": snapshot.base.as_deref(),
"mode": snapshot.mode.as_str(),
"seam_id": seam_id,
"file": display_lsp_path(seam.file()),
"range": {
"start": seam.display_line(),
"end": seam.display_line(),
},
"class": entry.class.as_str(),
"seam_kind": seam.kind().as_str(),
"owner": seam.owner(),
"expression": seam.expression(),
"required_discriminator": seam.required_discriminator().as_str(),
"expected_sink": seam.expected_sink().as_str(),
"evidence_path": {
"reach": evidence_stage_status(&evidence.reach),
"activate": evidence_stage_status(&evidence.activate),
"propagate": evidence_stage_status(&evidence.propagate),
"observe": evidence_stage_status(&evidence.observe),
"discriminate": evidence_stage_status(&evidence.discriminate),
},
"evidence_summaries": {
"reach": evidence.reach.summary.as_str(),
"activate": evidence.activate.summary.as_str(),
"propagate": evidence.propagate.summary.as_str(),
"observe": evidence.observe.summary.as_str(),
"discriminate": evidence.discriminate.summary.as_str(),
},
"missing_discriminator": missing_discriminator,
"missing_discriminators": evidence.missing_discriminators.iter().map(|missing| {
serde_json::json!({
"value": missing.value.as_str(),
"reason": missing.reason.as_str(),
})
}).collect::<Vec<_>>(),
"related_test": related_test.map(|test| {
format!("{}::{}", display_lsp_path(&test.file), test.test_name)
}),
"related_test_location": related_test.map(|test| {
serde_json::json!({
"file": display_lsp_path(&test.file),
"line": test.line,
"test_name": test.test_name.as_str(),
"oracle_kind": test.oracle_kind.as_str(),
"oracle_strength": test.oracle_strength.as_str(),
})
}),
"suggested_assertion": suggested_assertion_for_classified_seam(entry),
"suggested_test": {
"file": outline.suggested_file,
"name": outline.suggested_name,
"candidate_value": outline.candidate_value,
"assertion_shape": outline.assertion_shape,
},
"agent_packet_command": loop_commands::agent_packet_command(
".",
seam_id,
loop_commands::EDITOR_AGENT_PACKET_ARTIFACT,
),
"agent_brief_command": loop_commands::agent_brief_command(
".",
seam_id,
loop_commands::EDITOR_AGENT_BRIEF_ARTIFACT,
),
"after_snapshot_command": loop_commands::check_repo_exposure_command_with_base(
".",
snapshot.base.as_deref(),
snapshot.mode.as_str(),
loop_commands::PILOT_AFTER_SNAPSHOT_ARTIFACT,
),
"verify_command": loop_commands::agent_verify_command(
".",
loop_commands::PILOT_BEFORE_SNAPSHOT_ARTIFACT,
loop_commands::PILOT_AFTER_SNAPSHOT_ARTIFACT,
Some(loop_commands::EDITOR_AGENT_VERIFY_ARTIFACT),
),
"receipt_command": loop_commands::agent_receipt_command(
".",
loop_commands::EDITOR_AGENT_VERIFY_ARTIFACT,
seam_id,
Some(loop_commands::EDITOR_AGENT_RECEIPT_ARTIFACT),
),
"limits_note": "Static evidence only; no runtime mutation execution.",
})
}
fn evidence_stage_status(evidence: &StageEvidence) -> &'static str {
match evidence.state {
StageState::Yes => "present",
StageState::Weak => "weak",
StageState::No => "missing",
StageState::Unknown => "unknown",
StageState::Opaque => "opaque",
StageState::NotApplicable => "not_applicable",
}
}
fn display_lsp_path(path: &std::path::Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
#[cfg(test)]
mod gap_record_context_tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static TEMP_ROOT_SEQUENCE: AtomicUsize = AtomicUsize::new(0);
#[test]
fn collect_context_packet_for_gap_id_reads_explicit_ledger() -> Result<(), String> {
let root = temp_root()?;
write_gap_ledger(&root)?;
let args_value = serde_json::json!({
"gap_id": "gap:pr:pricing:threshold-boundary",
"gap_ledger": DEFAULT_GAP_DECISION_LEDGER_OUT,
});
let args = args_value
.as_object()
.ok_or_else(|| "expected object args".to_string())?;
let packet =
collect_gap_record_context_packet(&root, args, "gap:pr:pricing:threshold-boundary")
.ok_or_else(|| "expected gap packet".to_string())?;
assert_eq!(packet["source"], "gap_decision_ledger");
let gap_packet = &packet["packets"][0];
assert_eq!(gap_packet["gap_id"], "gap:pr:pricing:threshold-boundary");
assert_eq!(
gap_packet["repair_route"]["route_kind"],
"AddBoundaryAssertion"
);
assert_eq!(
gap_packet["verification_commands"][0],
"cargo xtask fixtures boundary_gap"
);
fs::remove_dir_all(&root)
.map_err(|err| format!("remove temp root {} failed: {err}", root.display()))?;
Ok(())
}
#[test]
fn collect_context_packet_for_gap_id_matches_canonical_gap_id() -> Result<(), String> {
let root = temp_root()?;
write_gap_ledger(&root)?;
let args_value = serde_json::json!({
"gap_id": "gap:rust:pricing:threshold-boundary",
"gap_ledger": DEFAULT_GAP_DECISION_LEDGER_OUT,
});
let args = args_value
.as_object()
.ok_or_else(|| "expected object args".to_string())?;
let packet =
collect_gap_record_context_packet(&root, args, "gap:rust:pricing:threshold-boundary")
.ok_or_else(|| "expected gap packet".to_string())?;
assert_eq!(
packet["packets"][0]["gap_id"],
"gap:pr:pricing:threshold-boundary"
);
fs::remove_dir_all(&root)
.map_err(|err| format!("remove temp root {} failed: {err}", root.display()))?;
Ok(())
}
#[test]
fn collect_context_packet_for_gap_id_uses_default_ledger_path() -> Result<(), String> {
let root = temp_root()?;
write_gap_ledger(&root)?;
let args_value = serde_json::json!({
"gap_id": "gap:pr:pricing:threshold-boundary",
});
let args = args_value
.as_object()
.ok_or_else(|| "expected object args".to_string())?;
let packet =
collect_gap_record_context_packet(&root, args, "gap:pr:pricing:threshold-boundary")
.ok_or_else(|| "expected gap packet".to_string())?;
assert_eq!(packet["source"], "gap_decision_ledger");
assert_eq!(
packet["packets"][0]["repair_card"]["source_artifact"],
display_lsp_path(&root.join(DEFAULT_GAP_DECISION_LEDGER_OUT))
);
fs::remove_dir_all(&root)
.map_err(|err| format!("remove temp root {} failed: {err}", root.display()))?;
Ok(())
}
fn temp_root() -> Result<PathBuf, String> {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|err| format!("system clock before UNIX_EPOCH: {err}"))?
.as_nanos();
let sequence = TEMP_ROOT_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let process_id = std::process::id();
let root = std::env::temp_dir().join(format!(
"ripr-lsp-gap-record-context-{process_id}-{stamp}-{sequence}"
));
fs::create_dir_all(root.join("target/ripr/reports"))
.map_err(|err| format!("create temp root {} failed: {err}", root.display()))?;
Ok(root)
}
fn write_gap_ledger(root: &Path) -> Result<(), String> {
let path = root.join(DEFAULT_GAP_DECISION_LEDGER_OUT);
fs::write(path, gap_ledger_json())
.map_err(|err| format!("write gap ledger in {} failed: {err}", root.display()))
}
fn gap_ledger_json() -> &'static str {
r#"{
"records": [
{
"gap_id": "gap:pr:pricing:threshold-boundary",
"canonical_gap_id": "gap:rust:pricing:threshold-boundary",
"kind": "MissingBoundaryAssertion",
"language": "rust",
"language_status": "stable",
"scope": "pr_local",
"evidence_class": "static_exposure",
"gap_state": "actionable",
"policy_state": "new",
"repairability": "repairable",
"repair_route": {
"route_kind": "AddBoundaryAssertion",
"target_file": "tests/pricing.rs",
"target_line": 33,
"related_test": "tests/pricing.rs::discount_threshold",
"assertion_shape": "assert_eq!(price(threshold), expected)",
"changed_behavior": "amount >= threshold",
"stop_conditions": ["Stop if the target owner moved."]
},
"anchor": {
"file": "src/pricing.rs",
"line": 42,
"owner": "pricing::discounted_total",
"dedupe_fingerprint": "gap:rust:pricing:threshold-boundary"
},
"evidence_ids": ["evidence:pricing"],
"projection_eligibility": {
"agent_packet": { "eligible": true, "reason": "bounded_repair_route" }
},
"verification_commands": ["cargo xtask fixtures boundary_gap"],
"receipt_command": "ripr outcome --before target/ripr/workflow/before.json --after target/ripr/workflow/after.json --out target/ripr/receipts/gap-pr-pricing.targeted-test-outcome.json",
"authority_boundary": "advisory"
}
]
}"#
}
#[test]
fn collect_gap_record_context_packet_with_blank_gap_id_returns_none() -> Result<(), String> {
let root = temp_root()?;
write_gap_ledger(&root)?;
let args_value = serde_json::json!({
"gap_id": " ",
"gap_ledger": DEFAULT_GAP_DECISION_LEDGER_OUT,
});
let args = args_value
.as_object()
.ok_or_else(|| "expected object args".to_string())?;
assert!(collect_gap_record_context_packet(&root, args, " ").is_none());
fs::remove_dir_all(&root)
.map_err(|err| format!("remove temp root {} failed: {err}", root.display()))?;
Ok(())
}
#[test]
fn collect_gap_record_context_packet_with_missing_ledger_file_returns_none()
-> Result<(), String> {
let root = temp_root()?;
let args_value = serde_json::json!({
"gap_id": "gap:pr:pricing:threshold-boundary",
"gap_ledger": DEFAULT_GAP_DECISION_LEDGER_OUT,
});
let args = args_value
.as_object()
.ok_or_else(|| "expected object args".to_string())?;
assert!(
collect_gap_record_context_packet(&root, args, "gap:pr:pricing:threshold-boundary")
.is_none()
);
fs::remove_dir_all(&root)
.map_err(|err| format!("remove temp root {} failed: {err}", root.display()))?;
Ok(())
}
#[test]
fn collect_gap_record_context_packet_with_malformed_ledger_returns_none() -> Result<(), String>
{
let root = temp_root()?;
let path = root.join(DEFAULT_GAP_DECISION_LEDGER_OUT);
fs::write(path, "{ not valid json")
.map_err(|err| format!("write malformed ledger in {} failed: {err}", root.display()))?;
let args_value = serde_json::json!({
"gap_id": "gap:pr:pricing:threshold-boundary",
"gap_ledger": DEFAULT_GAP_DECISION_LEDGER_OUT,
});
let args = args_value
.as_object()
.ok_or_else(|| "expected object args".to_string())?;
assert!(
collect_gap_record_context_packet(&root, args, "gap:pr:pricing:threshold-boundary")
.is_none()
);
fs::remove_dir_all(&root)
.map_err(|err| format!("remove temp root {} failed: {err}", root.display()))?;
Ok(())
}
#[test]
fn collect_gap_record_context_packet_with_unknown_gap_id_returns_none() -> Result<(), String> {
let root = temp_root()?;
write_gap_ledger(&root)?;
let args_value = serde_json::json!({
"gap_id": "gap:pr:unknown:missing",
"gap_ledger": DEFAULT_GAP_DECISION_LEDGER_OUT,
});
let args = args_value
.as_object()
.ok_or_else(|| "expected object args".to_string())?;
assert!(collect_gap_record_context_packet(&root, args, "gap:pr:unknown:missing").is_none());
fs::remove_dir_all(&root)
.map_err(|err| format!("remove temp root {} failed: {err}", root.display()))?;
Ok(())
}
#[test]
fn collect_gap_record_context_packet_with_blank_ledger_arg_falls_back_to_default()
-> Result<(), String> {
let root = temp_root()?;
write_gap_ledger(&root)?;
let args_value = serde_json::json!({
"gap_id": "gap:pr:pricing:threshold-boundary",
"gap_ledger": " ",
});
let args = args_value
.as_object()
.ok_or_else(|| "expected object args".to_string())?;
let packet =
collect_gap_record_context_packet(&root, args, "gap:pr:pricing:threshold-boundary")
.ok_or_else(|| "expected gap packet".to_string())?;
assert_eq!(
packet["packets"][0]["repair_card"]["source_artifact"],
display_lsp_path(&root.join(DEFAULT_GAP_DECISION_LEDGER_OUT))
);
fs::remove_dir_all(&root)
.map_err(|err| format!("remove temp root {} failed: {err}", root.display()))?;
Ok(())
}
#[test]
fn context_arguments_returns_none_for_empty_argument_list() {
assert!(context_arguments(&[]).is_none());
}
#[test]
fn context_arguments_returns_none_when_first_argument_is_not_an_object() {
let arg = serde_json::Value::String("not-an-object".to_string());
assert!(context_arguments(std::slice::from_ref(&arg)).is_none());
}
#[test]
fn gap_record_matches_compares_pr_local_and_canonical_ids() -> Result<(), String> {
let records = parse_gap_records_json(gap_ledger_json())
.map_err(|err| format!("parse fixture ledger failed: {err}"))?;
let record = records
.first()
.ok_or_else(|| "expected fixture to contain one record".to_string())?;
assert!(gap_record_matches(
record,
"gap:pr:pricing:threshold-boundary"
));
assert!(gap_record_matches(
record,
"gap:rust:pricing:threshold-boundary"
));
assert!(!gap_record_matches(record, "gap:pr:other:missing"));
Ok(())
}
#[test]
fn absolute_context_path_keeps_absolute_paths_and_joins_relative_paths() -> Result<(), String> {
let root = std::env::temp_dir();
let already_absolute = root.join("already-absolute.json");
if !already_absolute.is_absolute() {
return Err(format!(
"expected temp_dir-derived path to be absolute, got {}",
already_absolute.display()
));
}
assert_eq!(
absolute_context_path(&root, &already_absolute),
already_absolute
);
assert_eq!(
absolute_context_path(&root, Path::new("nested/file.json")),
root.join("nested/file.json")
);
Ok(())
}
#[test]
fn evidence_stage_status_maps_every_stage_state_variant() {
use crate::domain::Confidence;
let cases = [
(StageState::Yes, "present"),
(StageState::Weak, "weak"),
(StageState::No, "missing"),
(StageState::Unknown, "unknown"),
(StageState::Opaque, "opaque"),
(StageState::NotApplicable, "not_applicable"),
];
for (state, expected) in cases {
let label = format!("{state:?}");
let evidence = StageEvidence::new(state, Confidence::Medium, "");
assert_eq!(
evidence_stage_status(&evidence),
expected,
"unexpected status for stage state {label}"
);
}
}
#[test]
fn display_lsp_path_normalizes_backslashes_to_forward_slashes() {
let path = std::path::PathBuf::from(r"a\b\c.rs");
assert_eq!(display_lsp_path(&path), "a/b/c.rs");
}
#[test]
fn refresh_failed_log_message_formats_actionable_duration_and_message() {
let formatted =
refresh_failed_log_message("analysis crashed", std::time::Duration::from_millis(420));
assert!(
formatted.starts_with("ripr analysis refresh failed after "),
"missing prefix in {formatted}"
);
assert!(
formatted.ends_with(": analysis crashed"),
"missing message suffix in {formatted}"
);
}
}