use std::collections::BTreeSet;
use std::rc::Rc;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use sha2::Digest;
use crate::event_log::{active_event_log, install_memory_for_current_thread, AnyEventLog};
use crate::llm::{execute_llm_call, extract_llm_options, vm_value_to_json};
use crate::stdlib::secret_scan::{audit_secret_scan_active, scan_content, SecretFinding};
use crate::triggers::dispatcher::current_dispatch_context;
use crate::trust_graph::{append_trust_record, AutonomyTier, TrustOutcome, TrustRecord};
use crate::value::{VmError, VmValue};
use crate::vm::Vm;
const DEFAULT_MAX_ROUNDS: usize = 1;
const MAX_MAX_ROUNDS: usize = 5;
const REVIEW_EVENT_LOG_QUEUE_DEPTH: usize = 128;
const REVIEW_ACTION: &str = "pr.self_review";
const DEFAULT_MODEL_TIER: &str = "small";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReviewFinding {
pub id: String,
pub severity: String,
pub category: String,
pub title: String,
pub detail: String,
pub suggestion: Option<String>,
pub file: Option<String>,
pub line_start: Option<i64>,
pub line_end: Option<i64>,
pub source: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReviewRound {
pub round: i64,
pub summary: String,
pub findings: Vec<ReviewFinding>,
pub has_blocking_findings: bool,
pub model: Option<String>,
pub provider: Option<String>,
pub input_tokens: Option<i64>,
pub output_tokens: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ReviewResult {
pub rubric: String,
pub rubric_preset: Option<String>,
pub max_rounds: i64,
pub summary: String,
pub findings: Vec<ReviewFinding>,
pub has_blocking_findings: bool,
pub rounds: Vec<ReviewRound>,
pub secret_scan_findings: Vec<SecretFinding>,
pub trust_record: Option<TrustRecord>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
struct ReviewRoundPayload {
#[serde(default)]
summary: String,
#[serde(default)]
findings: Vec<ReviewFindingPayload>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
struct ReviewFindingPayload {
#[serde(default)]
severity: String,
#[serde(default)]
category: String,
#[serde(default)]
title: String,
#[serde(default)]
detail: String,
#[serde(default)]
suggestion: Option<String>,
#[serde(default)]
file: Option<String>,
#[serde(default)]
line_start: Option<i64>,
#[serde(default)]
line_end: Option<i64>,
}
struct ReviewTrustInput<'a> {
diff: &'a str,
rubric_text: &'a str,
rubric_preset: Option<&'a str>,
completed_rounds: usize,
max_rounds: usize,
findings: &'a [ReviewFinding],
secret_scan_findings: &'a [SecretFinding],
summary: &'a str,
}
pub(crate) fn register_review_builtins(vm: &mut Vm) {
vm.register_async_builtin(
"self_review",
|args| async move { self_review_impl(args).await },
);
}
async fn self_review_impl(args: Vec<VmValue>) -> Result<VmValue, VmError> {
let diff = args
.first()
.map(VmValue::display)
.filter(|text| !text.trim().is_empty())
.ok_or_else(|| VmError::Runtime("self_review: diff text is required".to_string()))?;
let (rubric_text, rubric_preset) = resolve_rubric(args.get(1));
let max_rounds = resolve_max_rounds(args.get(2))?;
let event_log = ensure_review_event_log();
let secret_scan_findings = scan_content(&diff);
audit_secret_scan_active("stdlib.self_review", diff.len(), &secret_scan_findings).await;
let secret_review_findings = review_findings_from_secret_scan(&secret_scan_findings);
let mut rounds = Vec::new();
let mut final_findings = secret_review_findings.clone();
let mut final_summary = if secret_scan_findings.is_empty() {
"No blocking findings.".to_string()
} else {
format!(
"Secret scan found {} blocking finding(s).",
secret_scan_findings.len()
)
};
let mut previous_findings: Vec<ReviewFinding> = Vec::new();
for round_index in 0..max_rounds {
let prompt = build_review_prompt(
&diff,
&rubric_text,
&rubric_preset,
&secret_scan_findings,
round_index + 1,
max_rounds,
&previous_findings,
);
let system = build_review_system_prompt();
let options = build_review_llm_options();
let options_dict = options
.as_dict()
.cloned()
.ok_or_else(|| VmError::Runtime("self_review: invalid llm options".to_string()))?;
let extracted = extract_llm_options(&[
VmValue::String(Rc::from(prompt.as_str())),
VmValue::String(Rc::from(system.as_str())),
options.clone(),
])?;
let response = execute_llm_call(extracted, Some(options_dict)).await?;
let response_dict = response.as_dict().ok_or_else(|| {
VmError::Runtime("self_review: expected llm response dict".to_string())
})?;
let data = response_dict.get("data").ok_or_else(|| {
VmError::Runtime("self_review: llm response missing structured data".to_string())
})?;
let round_payload = serde_json::from_value::<ReviewRoundPayload>(vm_value_to_json(data))
.map_err(|error| VmError::Runtime(format!("self_review: {error}")))?;
let llm_findings = normalize_llm_findings(round_payload.findings);
let merged_findings = dedupe_findings(
llm_findings
.iter()
.cloned()
.chain(secret_review_findings.iter().cloned())
.collect(),
);
let round = ReviewRound {
round: (round_index + 1) as i64,
summary: clean_summary(
round_payload.summary,
merged_findings.is_empty(),
secret_scan_findings.len(),
),
has_blocking_findings: has_blocking_findings(&merged_findings),
findings: merged_findings.clone(),
model: response_dict.get("model").map(VmValue::display),
provider: response_dict.get("provider").map(VmValue::display),
input_tokens: response_dict.get("input_tokens").and_then(VmValue::as_int),
output_tokens: response_dict.get("output_tokens").and_then(VmValue::as_int),
};
let is_stable = !previous_findings.is_empty() && previous_findings == merged_findings;
previous_findings = merged_findings.clone();
final_summary = round.summary.clone();
final_findings = merged_findings;
rounds.push(round);
if is_stable {
break;
}
}
if rounds.is_empty() {
rounds.push(ReviewRound {
round: 1,
summary: final_summary.clone(),
findings: final_findings.clone(),
has_blocking_findings: has_blocking_findings(&final_findings),
model: None,
provider: None,
input_tokens: None,
output_tokens: None,
});
}
let trust_record = append_review_trust_record(
&event_log,
ReviewTrustInput {
diff: &diff,
rubric_text: &rubric_text,
rubric_preset: rubric_preset.as_deref(),
completed_rounds: rounds.len(),
max_rounds,
findings: &final_findings,
secret_scan_findings: &secret_scan_findings,
summary: &final_summary,
},
)
.await?;
let result = ReviewResult {
rubric: rubric_text,
rubric_preset,
max_rounds: max_rounds as i64,
summary: final_summary,
findings: final_findings.clone(),
has_blocking_findings: has_blocking_findings(&final_findings),
rounds,
secret_scan_findings,
trust_record: Some(trust_record),
};
let value = serde_json::to_value(result)
.map_err(|error| VmError::Runtime(format!("self_review: {error}")))?;
Ok(crate::stdlib::json_to_vm_value(&value))
}
fn resolve_rubric(value: Option<&VmValue>) -> (String, Option<String>) {
let raw = value
.map(VmValue::display)
.unwrap_or_else(|| "default".to_string());
let trimmed = raw.trim();
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("default") {
return (
rubric_preset_body("default").to_string(),
Some("default".to_string()),
);
}
if let Some(preset) = rubric_preset_name(trimmed) {
return (
rubric_preset_body(preset).to_string(),
Some(preset.to_string()),
);
}
(trimmed.to_string(), None)
}
fn resolve_max_rounds(value: Option<&VmValue>) -> Result<usize, VmError> {
match value {
None | Some(VmValue::Nil) => Ok(DEFAULT_MAX_ROUNDS),
Some(VmValue::Int(number)) if *number > 0 => Ok((*number as usize).min(MAX_MAX_ROUNDS)),
Some(VmValue::Int(_)) => Err(VmError::Runtime(
"self_review: max_rounds must be greater than 0".to_string(),
)),
Some(other) => Err(VmError::Runtime(format!(
"self_review: expected integer max_rounds, got {}",
other.type_name()
))),
}
}
fn rubric_preset_name(value: &str) -> Option<&'static str> {
match value.to_ascii_lowercase().as_str() {
"default" => Some("default"),
"code" => Some("code"),
"docs" => Some("docs"),
"infra" => Some("infra"),
"security" => Some("security"),
_ => None,
}
}
fn rubric_preset_body(name: &str) -> &'static str {
match name {
"code" => {
"Review for correctness, regressions, missing tests, unsafe assumptions, and API compatibility. Block if the diff is likely wrong or under-tested."
}
"docs" => {
"Review for factual accuracy, drift from implementation, broken examples, missing migration notes, and unclear wording that could mislead users."
}
"infra" => {
"Review for rollout safety, observability, failure modes, config drift, missing rollback notes, and operational regressions."
}
"security" => {
"Review for credential exposure, authz/authn gaps, unsafe data handling, injection risk, and high-signal hardening gaps."
}
_ => {
"Review for correctness, test coverage, security, and style conformance. Prefer high-signal findings only. Block on correctness bugs, missing coverage for risky changes, or credential exposure."
}
}
}
fn build_review_system_prompt() -> String {
"You are a strict self-reviewer for a git diff before pull request open. Return only valid JSON that matches the requested schema. Prefer high-signal findings. Do not invent issues without evidence in the diff.".to_string()
}
fn build_review_prompt(
diff: &str,
rubric_text: &str,
rubric_preset: &Option<String>,
secret_findings: &[SecretFinding],
round: usize,
max_rounds: usize,
prior_findings: &[ReviewFinding],
) -> String {
let rubric_label = rubric_preset.as_deref().unwrap_or("custom");
let secret_context = if secret_findings.is_empty() {
"Secret scan: clean.".to_string()
} else {
let findings = secret_findings
.iter()
.map(|finding| {
format!(
"- {} on line {} ({}) [{}]",
finding.title, finding.line, finding.redacted, finding.detector
)
})
.collect::<Vec<_>>()
.join("\n");
format!("Secret scan findings:\n{findings}")
};
let prior_context = if prior_findings.is_empty() {
"Prior round findings: none.".to_string()
} else {
let findings = prior_findings
.iter()
.map(|finding| {
format!(
"- [{}] {}: {}",
finding.severity, finding.title, finding.detail
)
})
.collect::<Vec<_>>()
.join("\n");
format!("Prior round findings:\n{findings}")
};
format!(
"Self-review round {round} of {max_rounds}.\n\
Rubric preset: {rubric_label}\n\
Rubric:\n{rubric_text}\n\n\
Rules:\n\
- Only report issues supported by the diff.\n\
- Use severity `blocking` for issues that should stop PR open.\n\
- Use severity `warning` for non-blocking but important follow-up.\n\
- Use severity `info` sparingly.\n\
- Prefer the smallest set of high-signal findings.\n\
- If the diff is clean, return an empty findings list.\n\n\
{secret_context}\n\n\
{prior_context}\n\n\
Diff:\n```diff\n{diff}\n```"
)
}
fn build_review_llm_options() -> VmValue {
let schema = serde_json::json!({
"type": "object",
"required": ["summary", "findings"],
"additionalProperties": false,
"properties": {
"summary": {"type": "string"},
"findings": {
"type": "array",
"items": {
"type": "object",
"required": ["severity", "category", "title", "detail"],
"additionalProperties": false,
"properties": {
"severity": {"type": "string", "enum": ["blocking", "warning", "info"]},
"category": {"type": "string"},
"title": {"type": "string"},
"detail": {"type": "string"},
"suggestion": {"type": ["string", "null"]},
"file": {"type": ["string", "null"]},
"line_start": {"type": ["integer", "null"]},
"line_end": {"type": ["integer", "null"]}
}
}
}
}
});
crate::stdlib::json_to_vm_value(&serde_json::json!({
"provider": "auto",
"model_tier": DEFAULT_MODEL_TIER,
"temperature": 0.1,
"response_format": "json",
"output_schema": schema,
"output_validation": "error",
"schema_retries": 1,
}))
}
fn normalize_llm_findings(payloads: Vec<ReviewFindingPayload>) -> Vec<ReviewFinding> {
let mut findings = Vec::new();
for payload in payloads {
let severity = match payload.severity.as_str() {
"blocking" | "warning" | "info" => payload.severity,
_ => "warning".to_string(),
};
let category = if payload.category.trim().is_empty() {
"general".to_string()
} else {
payload.category.trim().to_string()
};
let title = if payload.title.trim().is_empty() {
"Review finding".to_string()
} else {
payload.title.trim().to_string()
};
let detail = if payload.detail.trim().is_empty() {
title.clone()
} else {
payload.detail.trim().to_string()
};
let file = payload
.file
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let line_start = payload.line_start.filter(|line| *line > 0);
let line_end = payload
.line_end
.filter(|line| *line > 0)
.or(line_start)
.filter(|line| line_start.map(|start| *line >= start).unwrap_or(true));
let mut finding = ReviewFinding {
id: String::new(),
severity,
category,
title,
detail,
suggestion: payload
.suggestion
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
file,
line_start,
line_end,
source: "llm".to_string(),
};
finding.id = finding_id(&finding);
findings.push(finding);
}
dedupe_findings(findings)
}
fn review_findings_from_secret_scan(findings: &[SecretFinding]) -> Vec<ReviewFinding> {
findings
.iter()
.map(|finding| {
let mut review = ReviewFinding {
id: String::new(),
severity: "blocking".to_string(),
category: "security".to_string(),
title: finding.title.clone(),
detail: format!(
"Secret scan detected a candidate credential with detector `{}` at line {}.",
finding.detector, finding.line
),
suggestion: Some(
"Remove the secret from the diff and rotate it if it is real.".to_string(),
),
file: None,
line_start: Some(finding.line as i64),
line_end: Some(finding.line as i64),
source: "secret_scan".to_string(),
};
review.id = format!("secret-{}", finding.fingerprint);
review
})
.collect()
}
fn dedupe_findings(findings: Vec<ReviewFinding>) -> Vec<ReviewFinding> {
let mut seen = BTreeSet::new();
let mut deduped = Vec::new();
for finding in findings {
let key = (
finding.source.clone(),
finding.severity.clone(),
finding.category.clone(),
finding.title.clone(),
finding.detail.clone(),
finding.file.clone(),
finding.line_start,
finding.line_end,
);
if seen.insert(key) {
deduped.push(finding);
}
}
deduped
}
fn has_blocking_findings(findings: &[ReviewFinding]) -> bool {
findings
.iter()
.any(|finding| finding.severity == "blocking")
}
fn clean_summary(summary: String, clean: bool, secret_count: usize) -> String {
let trimmed = summary.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
if secret_count > 0 {
return format!("Secret scan found {secret_count} blocking finding(s).");
}
if clean {
"No high-signal findings.".to_string()
} else {
"Review completed with findings.".to_string()
}
}
fn finding_id(finding: &ReviewFinding) -> String {
let seed = format!(
"{}|{}|{}|{}|{}|{:?}|{:?}|{:?}",
finding.source,
finding.severity,
finding.category,
finding.title,
finding.detail,
finding.file,
finding.line_start,
finding.line_end
);
let digest = sha2::Sha256::digest(seed.as_bytes());
digest[..8]
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
fn ensure_review_event_log() -> Arc<AnyEventLog> {
active_event_log()
.unwrap_or_else(|| install_memory_for_current_thread(REVIEW_EVENT_LOG_QUEUE_DEPTH))
}
async fn append_review_trust_record(
log: &Arc<AnyEventLog>,
input: ReviewTrustInput<'_>,
) -> Result<TrustRecord, VmError> {
let (agent, autonomy_tier, trace_id) = review_identity();
let outcome = if has_blocking_findings(input.findings) {
TrustOutcome::Failure
} else {
TrustOutcome::Success
};
let mut record = TrustRecord::new(agent, REVIEW_ACTION, None, outcome, trace_id, autonomy_tier);
record
.metadata
.insert("rubric".to_string(), serde_json::json!(input.rubric_text));
record.metadata.insert(
"rubric_preset".to_string(),
input
.rubric_preset
.map(serde_json::Value::from)
.unwrap_or(serde_json::Value::Null),
);
record.metadata.insert(
"requested_rounds".to_string(),
serde_json::json!(input.max_rounds),
);
record.metadata.insert(
"completed_rounds".to_string(),
serde_json::json!(input.completed_rounds),
);
record.metadata.insert(
"finding_count".to_string(),
serde_json::json!(input.findings.len()),
);
record.metadata.insert(
"blocking_finding_count".to_string(),
serde_json::json!(input
.findings
.iter()
.filter(|finding| finding.severity == "blocking")
.count()),
);
record.metadata.insert(
"secret_scan_finding_count".to_string(),
serde_json::json!(input.secret_scan_findings.len()),
);
record.metadata.insert(
"finding_categories".to_string(),
serde_json::json!(input
.findings
.iter()
.map(|finding| finding.category.clone())
.collect::<BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>()),
);
record
.metadata
.insert("summary".to_string(), serde_json::json!(input.summary));
record.metadata.insert(
"diff_bytes".to_string(),
serde_json::json!(input.diff.len()),
);
record.metadata.insert(
"diff_sha256".to_string(),
serde_json::json!(sha256_hex(input.diff.as_bytes())),
);
append_trust_record(log, &record)
.await
.map_err(|error| VmError::Runtime(format!("self_review: {error}")))?;
Ok(record)
}
fn review_identity() -> (String, AutonomyTier, String) {
if let Some(context) = current_dispatch_context() {
return (
context.agent_id,
context.autonomy_tier,
context.trigger_event.trace_id.0,
);
}
let username = std::env::var("USER").unwrap_or_else(|_| "unknown".to_string());
let workspace = crate::stdlib::process::source_root_path()
.file_name()
.map(|value| value.to_string_lossy().into_owned())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "workspace".to_string());
(
format!("local::{username}::{workspace}"),
AutonomyTier::Suggest,
format!("trace-{}", uuid::Uuid::now_v7()),
)
}
fn sha256_hex(input: &[u8]) -> String {
let digest = sha2::Sha256::digest(input);
digest.iter().map(|byte| format!("{byte:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::compiler::Compiler;
use crate::llm::helpers::reset_provider_key_cache;
use crate::stdlib::register_vm_stdlib;
use crate::value::VmValue;
use harn_lexer::Lexer;
use harn_parser::Parser;
fn run_script(source: &str) -> String {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let mut lexer = Lexer::new(source);
let tokens = lexer.tokenize().unwrap();
let mut parser = Parser::new(tokens);
let program = parser.parse().unwrap();
let chunk = Compiler::new().compile(&program).unwrap();
let mut vm = Vm::new();
register_vm_stdlib(&mut vm);
let _ = vm.execute(&chunk).await.unwrap();
vm.output().trim_end().to_string()
})
.await
})
}
fn with_mock_provider<T>(f: impl FnOnce() -> T) -> T {
let _guard = crate::llm::env_lock().lock().expect("env lock");
let prev_provider = std::env::var("HARN_LLM_PROVIDER").ok();
let prev_model = std::env::var("HARN_LLM_MODEL").ok();
unsafe {
std::env::set_var("HARN_LLM_PROVIDER", "mock");
std::env::remove_var("HARN_LLM_MODEL");
}
reset_provider_key_cache();
crate::llm::mock::reset_llm_mock_state();
crate::event_log::reset_active_event_log();
crate::stdlib::reset_stdlib_state();
let result = f();
crate::llm::mock::reset_llm_mock_state();
crate::event_log::reset_active_event_log();
crate::stdlib::reset_stdlib_state();
unsafe {
match prev_provider {
Some(value) => std::env::set_var("HARN_LLM_PROVIDER", value),
None => std::env::remove_var("HARN_LLM_PROVIDER"),
}
match prev_model {
Some(value) => std::env::set_var("HARN_LLM_MODEL", value),
None => std::env::remove_var("HARN_LLM_MODEL"),
}
}
reset_provider_key_cache();
result
}
#[test]
fn resolve_rubric_supports_presets_and_custom_text() {
let default = resolve_rubric(None);
assert_eq!(default.1.as_deref(), Some("default"));
assert!(default.0.contains("test coverage"));
let code = resolve_rubric(Some(&VmValue::String(Rc::from("code"))));
assert_eq!(code.1.as_deref(), Some("code"));
assert!(code.0.contains("regressions"));
let custom = resolve_rubric(Some(&VmValue::String(Rc::from("focus on docs drift"))));
assert_eq!(custom.1, None);
assert_eq!(custom.0, "focus on docs drift");
}
#[test]
fn self_review_records_llm_and_trust_results() {
with_mock_provider(|| {
let out = run_script(
r#"
import "std/review"
import "std/triggers"
pipeline test(task) {
llm_mock({
text: "{\"summary\":\"Parser change needs a regression test.\",\"findings\":[{\"severity\":\"blocking\",\"category\":\"test_coverage\",\"title\":\"Missing regression test\",\"detail\":\"The parser behavior changed without a focused regression test.\",\"suggestion\":\"Add a conformance case.\",\"file\":\"conformance/tests/parser_case.harn\",\"line_start\":1,\"line_end\":1}]}"
})
let result: ReviewResult = self_review("diff --git a/src/parser.rs b/src/parser.rs\n+parse_new_branch()", "code", 1)
println(result.rubric_preset == "code")
println(result.has_blocking_findings)
println(result.findings[0].source == "llm")
let records: list<TrustRecord> = trust_query({action: "pr.self_review"})
println(len(records) == 1)
println(records[0].metadata.blocking_finding_count == 1)
}
"#,
);
assert_eq!(out, "true\ntrue\ntrue\ntrue\ntrue");
});
}
#[test]
fn self_review_merges_secret_scan_findings() {
with_mock_provider(|| {
let out = run_script(
r#"
import "std/review"
pipeline test(task) {
llm_mock({text: "{\"summary\":\"No extra issues.\",\"findings\":[]}"})
let result: ReviewResult = self_review("diff --git a/.env b/.env\n+OPENAI_API_KEY=sk-abcdefghijklmnopqrstuvwx123456", "default", 1)
println(result.has_blocking_findings)
println(len(result.secret_scan_findings) == 1)
println(result.findings[0].source == "secret_scan")
}
"#,
);
assert_eq!(out, "true\ntrue\ntrue");
});
}
#[test]
fn self_review_runs_multiple_rounds_and_includes_prior_findings_in_prompt() {
with_mock_provider(|| {
let out = run_script(
r#"
import "std/review"
pipeline test(task) {
llm_mock({
text: "{\"summary\":\"Maybe add a test.\",\"findings\":[{\"severity\":\"warning\",\"category\":\"test_coverage\",\"title\":\"Consider a test\",\"detail\":\"A narrow regression test would help.\"}]}"
})
llm_mock({
text: "{\"summary\":\"Clean after reconsidering the evidence.\",\"findings\":[]}"
})
let result: ReviewResult = self_review("diff --git a/src/lib.rs b/src/lib.rs\n+let value = 1", "default", 2)
let calls = llm_mock_calls()
let second_prompt = calls[1].messages[0].content
println(len(result.rounds) == 2)
println(result.rounds[1].summary == "Clean after reconsidering the evidence.")
println(contains(second_prompt, "Prior round findings"))
}
"#,
);
assert_eq!(out, "true\ntrue\ntrue");
});
}
}