diff --git a/Cargo.lock b/Cargo.lock index 54b18c5..f436c62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1104,7 +1104,7 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "postil-cli" -version = "0.8.18" +version = "0.8.19" dependencies = [ "aho-corasick", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 610c53c..1bae21c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "postil-cli" -version = "0.8.18" +version = "0.8.19" edition = "2024" description = "Postil: a low-noise AI review gate. Silent on clean PRs, hard gate on real risk." license = "Apache-2.0" diff --git a/src/llm.rs b/src/llm.rs index 3d5f454..2bfeb6a 100644 --- a/src/llm.rs +++ b/src/llm.rs @@ -510,6 +510,7 @@ struct RawFinding { severity: String, #[serde(default)] kind: Option, + #[serde(default)] repository_context: RawRepositoryContext, #[serde(default = "default_confidence")] confidence: f64, @@ -523,6 +524,7 @@ struct RawFinding { #[derive(Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] struct RawRepositoryContext { + #[serde(default)] claim: RawRepositoryClaimKind, #[serde(default)] resources: Vec, @@ -8568,17 +8570,31 @@ mod tests { } #[test] - fn rejects_findings_without_explicit_repository_context() { - let missing = r#"{ + fn defaults_omitted_repository_context_to_diff_local() { + let omitted = r#"{ "summary": "", "findings": [{ "path": "config.yaml", "line": 1, "severity": "warn", - "body": "The counterpart remains on the old version." + "body": "The changed value is invalid." }] }"#; - assert!(parse_review(missing).is_err()); + let parsed = parse_review(omitted).unwrap(); + assert!(matches!( + parsed.findings[0].repository_context.claim, + RawRepositoryClaimKind::None + )); + + let empty = omitted.replace( + "\"body\": \"The changed value is invalid.\"", + "\"repositoryContext\": {},\n \"body\": \"The changed value is invalid.\"", + ); + let parsed = parse_review(&empty).unwrap(); + assert!(matches!( + parsed.findings[0].repository_context.claim, + RawRepositoryClaimKind::None + )); let explicit = r#"{ "summary": "", diff --git a/src/prompt.rs b/src/prompt.rs index 3dd2385..9650a45 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -144,14 +144,15 @@ pub fn review_contract(cfg: &Config) -> String { `.postil/change-metadata`. Findings citing other lines are discarded as \ ungrounded.\n\ \n\ - Every finding MUST include `repositoryContext`. Use `none` whenever the exact cited \ - changed line is sufficient to establish the defect, even if unchanged code elsewhere \ - could also be relevant. Use `absence` only when the conclusion depends on a construct \ - missing from the complete reviewed head. Use `mismatch` only when the conclusion depends \ - on a repository target having an expected value not established by the cited changed \ - line; name that target in resources, paths, or identifiers and the expected value in \ - values or versions. Populated arrays are conjunctive and refute only when matched in one \ - file. Repository claims require the complete reviewed head. Public \ + `repositoryContext` is optional and defaults to a diff-local finding. Omit it whenever \ + the exact cited changed line is sufficient to establish the defect, even if unchanged \ + code elsewhere could also be relevant. Include it only when the conclusion depends on \ + repository-wide evidence, using `claim: absence` for a construct missing from the \ + complete reviewed head or `claim: mismatch` for a repository target whose expected \ + value is not established by the cited changed line. When included, name the target in \ + `resources`, `paths`, or `identifiers` and the expected value in `values` or `versions`; \ + include all five arrays even when empty. Populated arrays are conjunctive and refute \ + only when matched in one file. Repository claims require the complete reviewed head. Public \ text names the concrete construct \ and correction, never review-input boundaries such as `in the diff`, retrieval mechanics, \ delegated evidence collection, or guessed files.\n", @@ -223,8 +224,6 @@ pub fn system_prompt(cfg: &Config, current_utc_date: Date) -> String { \"findings\": [{\"path\": \"file path from the diff\", \"line\": ,\n \ \"endLine\": , \"severity\": \"info|warn|error\",\n \ \"kind\": \"risk|humanEscalation|guardrail|uncertainty|contentPolicy\", \"confidence\": <0..1>,\n \ - \"repositoryContext\": {\"claim\": \"none|absence|mismatch\", \"resources\": [], \"values\": [],\n \ - \"versions\": [], \"paths\": [], \"identifiers\": []},\n \ \"title\": \"short imperative title\", \"body\": \"specific, evidence-based markdown\",\n \ \"evidence\": \"exact non-empty new-side text from the cited line\"}]}\n\ \n\ @@ -546,17 +545,19 @@ mod tests { } #[test] - fn generator_reserves_repository_claims_for_repository_dependent_conclusions() { + fn generator_omits_repository_claims_for_diff_local_conclusions() { let prompt = system_prompt(&Config::default(), trusted_date()); + assert!( + prompt.contains("`repositoryContext` is optional and defaults to a diff-local finding") + ); assert!(prompt.contains( - "Use `none` whenever the exact cited changed line is sufficient to establish the defect" - )); - assert!(prompt.contains( - "Use `absence` only when the conclusion depends on a construct missing from the complete reviewed head" - )); - assert!(prompt.contains( - "Use `mismatch` only when the conclusion depends on a repository target having an expected value" + "Omit it whenever the exact cited changed line is sufficient to establish the defect" )); + assert!( + prompt.contains( + "Include it only when the conclusion depends on repository-wide evidence" + ) + ); } #[test] @@ -649,8 +650,6 @@ mod tests { \"findings\": [{\"path\": \"file path from the diff\", \"line\": ,\n \ \"endLine\": , \"severity\": \"info|warn|error\",\n \ \"kind\": \"risk|humanEscalation|guardrail|uncertainty|contentPolicy\", \"confidence\": <0..1>,\n \ - \"repositoryContext\": {\"claim\": \"none|absence|mismatch\", \"resources\": [], \"values\": [],\n \ - \"versions\": [], \"paths\": [], \"identifiers\": []},\n \ \"title\": \"short imperative title\", \"body\": \"specific, evidence-based markdown\",\n \ \"evidence\": \"exact non-empty new-side text from the cited line\"}]}\n\ \n\ diff --git a/src/review.rs b/src/review.rs index 25b4fae..1d136c9 100644 --- a/src/review.rs +++ b/src/review.rs @@ -3297,12 +3297,12 @@ mod tests { let below_floor = format!("Benchmark pull request{}", "x".repeat(442)); let local_budgets = batch_budgets_for_title(&local_edge); - assert_eq!(local_budgets.synthesis, 4_177); - assert_eq!(local_budgets.source, 4_178); + assert_eq!(local_budgets.synthesis, 4_227); + assert_eq!(local_budgets.source, 4_228); assert!(review_batch_budgets_are_usable(local_budgets)); let ci_budgets = batch_budgets_for_title(&ci_edge); - assert_eq!(ci_budgets.synthesis, 4_161); - assert_eq!(ci_budgets.source, 4_162); + assert_eq!(ci_budgets.synthesis, 4_211); + assert_eq!(ci_budgets.source, 4_212); assert!(review_batch_budgets_are_usable(ci_budgets)); assert_eq!( ci_budgets.stabilized_for_rendering(), @@ -3314,7 +3314,7 @@ mod tests { let below_floor_budgets = batch_budgets_for_title(&below_floor); assert_eq!( below_floor_budgets.synthesis, - diff::MIN_REVIEW_BATCH_BYTES - 319 + diff::MIN_REVIEW_BATCH_BYTES - 269 ); assert!(!review_batch_budgets_are_usable(below_floor_budgets)); }