use crate::git::{Diff, FileStatus};
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct SemanticFinding {
pub rule_id: &'static str,
pub severity: &'static str,
pub confidence: &'static str,
pub title: String,
pub description: String,
pub evidence: Vec<EvidenceItem>,
}
#[derive(Debug, Clone, Serialize)]
pub struct EvidenceItem {
pub file: String,
pub line: Option<usize>,
pub role: &'static str,
pub snippet: String,
}
#[allow(dead_code)]
const PATH_COLUMN_INDICATORS: &[&str] = &[
"file_path",
"file_url",
"storage_path",
"attachment_path",
"asset_path",
"blob_path",
"media_url",
"image_url",
"avatar_url",
"thumbnail_path",
"upload_path",
"artifact_path",
"s3_key",
"object_key",
];
const DELETE_INDICATORS: &[&str] = &[
"delete",
"remove",
"destroy",
"drop",
"purge",
"truncate",
"soft_delete",
"hard_delete",
];
#[allow(dead_code)]
const CLEANUP_INDICATORS: &[&str] = &[
"unlink",
"remove_file",
"delete_file",
"delete_object",
"delete_blob",
"remove_blob",
"fs::remove",
"std::fs::remove",
"os.remove",
"os.unlink",
"shutil.rmtree",
"s3.delete",
"s3_client.delete",
"storage.delete",
"blob.delete",
"cleanup",
"clean_up",
];
pub fn detect_orphaned_resource_delete(diffs: &[Diff]) -> Vec<SemanticFinding> {
let mut findings = Vec::new();
let mut delete_evidence: Vec<(String, usize, String)> = Vec::new(); let mut has_path_columns: Vec<(String, usize, String)> = Vec::new();
let mut cleanup_evidence: Vec<(String, usize, String)> = Vec::new();
for diff in diffs {
for file in &diff.files {
if file.status == FileStatus::Deleted {
continue;
}
let lower = file.path.to_lowercase();
let is_model_file = lower.contains("model")
|| lower.contains("schema")
|| lower.contains("migration")
|| lower.contains("entity")
|| lower.contains("table");
let is_service_file = lower.contains("service")
|| lower.contains("handler")
|| lower.contains("controller")
|| lower.contains("repository")
|| lower.contains("repo")
|| lower.contains("manager");
let is_cleanup_file = lower.contains("cleanup")
|| lower.contains("storage")
|| lower.contains("upload")
|| lower.contains("s3")
|| lower.contains("blob")
|| lower.contains("gc");
if is_service_file {
for indicator in DELETE_INDICATORS {
if lower.contains(indicator) {
delete_evidence.push((
file.path.clone(),
0,
format!("File name suggests delete operation: {}", indicator),
));
}
}
}
if is_model_file {
has_path_columns.push((
file.path.clone(),
0,
"Model/schema file may contain resource path references".to_string(),
));
}
if is_cleanup_file {
cleanup_evidence.push((
file.path.clone(),
0,
"File suggests resource cleanup capability".to_string(),
));
}
}
}
if !delete_evidence.is_empty() && !has_path_columns.is_empty() && cleanup_evidence.is_empty() {
let mut evidence = Vec::new();
for (file, line, snippet) in &delete_evidence {
evidence.push(EvidenceItem {
file: file.clone(),
line: if *line > 0 { Some(*line) } else { None },
role: "delete_operation",
snippet: snippet.clone(),
});
}
for (file, line, snippet) in &has_path_columns {
evidence.push(EvidenceItem {
file: file.clone(),
line: if *line > 0 { Some(*line) } else { None },
role: "resource_reference",
snippet: snippet.clone(),
});
}
findings.push(SemanticFinding {
rule_id: "orphaned-resource-after-delete",
severity: "warning",
confidence: "hypothesis",
title: "Delete flow may leave orphaned resources".to_string(),
description: format!(
"This PR modifies {} file(s) with delete operations and {} file(s) with \
resource path references, but no cleanup of physical resources (files, S3 \
objects, blobs) was detected. If these models store file paths, the \
corresponding resources may become orphaned after deletion.",
delete_evidence.len(),
has_path_columns.len()
),
evidence,
});
}
findings
}
#[cfg(test)]
mod tests {
use super::*;
use crate::artifacts::signal::test_helpers::{mock_diff, mock_file_change};
use crate::git::FileStatus;
#[test]
fn no_findings_for_clean_pr() {
let diff = mock_diff(vec![
mock_file_change("src/lib.rs", FileStatus::Modified, 10, 5),
mock_file_change("src/utils.rs", FileStatus::Modified, 5, 2),
]);
let findings = detect_orphaned_resource_delete(&[diff]);
assert!(findings.is_empty());
}
#[test]
fn detects_delete_without_cleanup() {
let diff = mock_diff(vec![
mock_file_change(
"src/services/delete_user_service.rs",
FileStatus::Modified,
20,
5,
),
mock_file_change("src/models/user_model.rs", FileStatus::Modified, 10, 2),
]);
let findings = detect_orphaned_resource_delete(&[diff]);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].rule_id, "orphaned-resource-after-delete");
assert_eq!(findings[0].confidence, "hypothesis");
assert!(!findings[0].evidence.is_empty());
}
#[test]
fn no_finding_when_cleanup_present() {
let diff = mock_diff(vec![
mock_file_change(
"src/services/delete_user_service.rs",
FileStatus::Modified,
20,
5,
),
mock_file_change("src/models/user_model.rs", FileStatus::Modified, 10, 2),
mock_file_change("src/storage/cleanup.rs", FileStatus::Modified, 15, 3),
]);
let findings = detect_orphaned_resource_delete(&[diff]);
assert!(findings.is_empty(), "Cleanup file should suppress finding");
}
}