-
Notifications
You must be signed in to change notification settings - Fork 3.6k
Edit safety: parse-gate file edits before the write lands #6238
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
98c123c
9f047aa
d14b275
d184a8b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,10 +16,12 @@ use super::diff_format::make_unified_diff; | |
| use super::file::{ | ||
| EXPECTED_HASH_DESCRIPTION, PATCH_PARAMS, PATH_ALIASES, apply_param_aliases, content_hash, | ||
| }; | ||
| use super::rust_format::normalize_edit; | ||
| use super::spec::{ | ||
| ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, | ||
| lsp_diagnostics_for_paths, optional_bool, optional_str, optional_u64, | ||
| }; | ||
| use super::syntax_check::guard_edit; | ||
|
|
||
| /// Maximum lines of context for fuzzy matching (increased for better tolerance) | ||
| const MAX_FUZZ: usize = 50; | ||
|
|
@@ -411,8 +413,9 @@ impl ToolSpec for ApplyPatchTool { | |
| source_field, | ||
| } = normalized | ||
| { | ||
| let (pending, stats) = | ||
| let (mut pending, stats) = | ||
| build_pending_writes_from_replace(entries, source_field, context)?; | ||
| normalize_pending_rust(&mut pending).await; | ||
| apply_pending_writes(&pending)?; | ||
| // Resolve absolute paths for LSP diagnostics query. | ||
| let abs_paths: Vec<PathBuf> = pending.iter().map(|p| p.path.clone()).collect(); | ||
|
|
@@ -457,8 +460,10 @@ impl ToolSpec for ApplyPatchTool { | |
| ApplyPatchPreflightKind::FilePatches(file_patches) => file_patches, | ||
| }; | ||
|
|
||
| let (pending, mut stats) = build_pending_writes_from_patches(file_patches, context, fuzz)?; | ||
| let (mut pending, mut stats) = | ||
| build_pending_writes_from_patches(file_patches, context, fuzz)?; | ||
| stats.header_path_mismatch = preflight.summary.header_path_mismatch.clone(); | ||
| normalize_pending_rust(&mut pending).await; | ||
| apply_pending_writes(&pending)?; | ||
| // Resolve absolute paths for LSP diagnostics query. | ||
| let abs_paths: Vec<PathBuf> = pending | ||
|
|
@@ -1291,7 +1296,32 @@ fn build_pending_writes_from_patches( | |
| Ok((pending, stats)) | ||
| } | ||
|
|
||
| /// Normalize the Rust files a patch rewrites (#6205), before the write and | ||
| /// before the result's diff is built, so the rendered diff and the bytes on | ||
| /// disk are the same text and the model's next anchor matches reality. | ||
| async fn normalize_pending_rust(pending: &mut [PendingWrite]) { | ||
| for entry in pending.iter_mut() { | ||
| let (Some(content), Some(original)) = (entry.content.as_ref(), entry.original.as_ref()) | ||
| else { | ||
| continue; | ||
| }; | ||
| if let Some(normalized) = normalize_edit(&entry.path, original, content).await { | ||
| entry.content = Some(normalized); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| fn apply_pending_writes(pending: &[PendingWrite]) -> Result<(), ToolError> { | ||
| // Syntax gate (#6204) ahead of the first write, not per file: a patch is | ||
| // transactional, so one unparseable result must leave every file in the | ||
| // patch untouched rather than half-applied and rolled back. | ||
| for entry in pending { | ||
| if let Some(content) = entry.content.as_ref() { | ||
| let display = entry.path.display().to_string(); | ||
| guard_edit(&entry.path, &display, entry.original.as_deref(), content)?; | ||
| } | ||
| } | ||
|
|
||
| let mut applied = Vec::new(); | ||
|
|
||
| for entry in pending { | ||
|
|
@@ -1642,6 +1672,59 @@ mod tests { | |
| assert_eq!(hunks[0].new_count, 3); | ||
| } | ||
|
|
||
| /// #6205 — a patch that lands unformatted Rust in an already-clean file is | ||
| /// normalized before the write, and the rendered diff shows the normalized | ||
| /// text, so the model's next patch context matches the bytes on disk. | ||
| #[tokio::test] | ||
| async fn patch_normalizes_rust_in_an_already_clean_file() { | ||
| let tmp = tempdir().expect("tempdir"); | ||
| let ctx = ToolContext::new(tmp.path().to_path_buf()); | ||
| let file = tmp.path().join("clean.rs"); | ||
| fs::write(&file, "fn main() {\n let x = 1;\n}\n").expect("write"); | ||
|
|
||
| let patch = "--- a/clean.rs\n+++ b/clean.rs\n@@ -1,3 +1,4 @@\n fn main() {\n let x = 1;\n+ let y=2;\n }\n"; | ||
| let result = ApplyPatchTool | ||
| .execute(json!({"path": "clean.rs", "patch": patch}), &ctx) | ||
| .await | ||
| .expect("execute"); | ||
|
|
||
| assert_eq!( | ||
| fs::read_to_string(&file).expect("read"), | ||
| "fn main() {\n let x = 1;\n let y = 2;\n}\n" | ||
| ); | ||
| let diff = result.metadata.as_ref().expect("metadata")["mutation"]["diff"] | ||
| .as_str() | ||
| .expect("diff") | ||
| .to_string(); | ||
| assert!(diff.contains("+ let y = 2;"), "{diff}"); | ||
| assert!(!diff.contains("let y=2;"), "{diff}"); | ||
| } | ||
|
|
||
| /// #6204 — a patch whose result does not parse is refused before any file | ||
| /// is written, so a multi-file patch cannot land half-applied. | ||
| #[tokio::test] | ||
| async fn patch_refuses_a_hunk_that_breaks_rust_syntax() { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [WARNING] The 'patch cannot half-apply' transactional claim is not actually tested The doc comment on There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Extend the refusal test to two files in one patch, with only the second file's post-patch content unparseable, and assert both files are unchanged. That is what actually verifies the 'gate ahead of the first write' ordering inside |
||
| let tmp = tempdir().expect("tempdir"); | ||
| let ctx = ToolContext::new(tmp.path().to_path_buf()); | ||
| let file = tmp.path().join("main.rs"); | ||
| let original = "fn main() {\n println!(\"hi\");\n}\n"; | ||
| fs::write(&file, original).expect("write"); | ||
|
|
||
| let patch = "--- a/main.rs\n+++ b/main.rs\n@@ -1,3 +1,2 @@\n fn main() {\n println!(\"hi\");\n-}\n"; | ||
| let error = ApplyPatchTool | ||
| .execute(json!({"path": "main.rs", "patch": patch}), &ctx) | ||
| .await | ||
| .expect_err("a patch that breaks Rust syntax must be refused"); | ||
|
|
||
| let message = error.to_string(); | ||
| assert!(message.contains("Rust syntax error at line"), "{message}"); | ||
| assert_eq!( | ||
| fs::read_to_string(&file).expect("read"), | ||
| original, | ||
| "a refused patch must leave the file untouched" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn input_schema_exposes_replace_and_deprecated_changes_alias() { | ||
| let schema = ApplyPatchTool.input_schema(); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,10 +7,12 @@ | |
| //! with path validation to prevent escaping the workspace boundary. | ||
|
|
||
| use super::diff_format::make_unified_diff; | ||
| use super::rust_format::{NORMALIZED_NOTE, normalize_edit}; | ||
| use super::spec::{ | ||
| ApprovalRequirement, RichToolResult, ToolCapability, ToolContext, ToolError, ToolResult, | ||
| ToolSpec, lsp_diagnostics_for_paths, optional_str, optional_u64, required_str, | ||
| }; | ||
| use super::syntax_check::guard_edit; | ||
| use async_trait::async_trait; | ||
| use serde_json::{Value, json}; | ||
| use std::borrow::Cow; | ||
|
|
@@ -1443,7 +1445,18 @@ impl WriteFileTool { | |
| // Preserve the existing file's line-ending style on overwrite (see | ||
| // `preserve_prior_line_endings`); otherwise a CRLF (Windows) file is | ||
| // silently rewritten with LF line endings. | ||
| let written = preserve_prior_line_endings(file_content, &prior_contents); | ||
| let mut written = preserve_prior_line_endings(file_content, &prior_contents); | ||
| guard_edit( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [WARNING] Both write_file sites gained the gate and normalization with no tests; the apply_patch replace path is untested too
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [INFO] The PR states the check runs at every write site, but only the edit path (file.rs main + contract), one single-file patch, and the guard unit tests are covered. The There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add coverage for the |
||
| &file_path, | ||
| path_str, | ||
| existed_before.then(|| prior_contents.as_ref()), | ||
| &written, | ||
| )?; | ||
| if existed_before | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [INFO]
|
||
| && let Some(normalized) = normalize_edit(&file_path, &prior_contents, &written).await | ||
| { | ||
| written = normalized; | ||
| } | ||
| crate::utils::write_atomic_workspace(&file_path, written.as_bytes()).map_err(|error| { | ||
| ToolError::execution_failed(format!("Failed to write {}: {error}", file_path.display())) | ||
| })?; | ||
|
|
@@ -1561,7 +1574,19 @@ impl ToolSpec for WriteFileTool { | |
| // Preserve the existing file's line-ending style on overwrite (see | ||
| // `preserve_prior_line_endings`); a full `write_file` over a CRLF | ||
| // (Windows) file otherwise silently rewrites every line ending to LF. | ||
| let written = preserve_prior_line_endings(file_content, &prior_contents); | ||
| let mut written = preserve_prior_line_endings(file_content, &prior_contents); | ||
|
|
||
| guard_edit( | ||
| &file_path, | ||
| path_str, | ||
| existed_before.then(|| prior_contents.as_ref()), | ||
| &written, | ||
| )?; | ||
| if existed_before | ||
| && let Some(normalized) = normalize_edit(&file_path, &prior_contents, &written).await | ||
| { | ||
| written = normalized; | ||
| } | ||
|
|
||
| crate::utils::write_atomic_workspace(&file_path, written.as_bytes()).map_err(|e| { | ||
| ToolError::execution_failed(format!("Failed to write {}: {}", file_path.display(), e)) | ||
|
|
@@ -2003,7 +2028,11 @@ impl EditFileTool { | |
| let normalized = normalize_contract_line_endings(without_bom); | ||
| let updated = apply_contract_edits(&normalized, &edits, path_str)?; | ||
| check_file_operation_cancelled(context)?; | ||
| let final_content = format!("{bom}{}", restore_contract_line_endings(&updated, ending)); | ||
| let mut final_content = format!("{bom}{}", restore_contract_line_endings(&updated, ending)); | ||
| guard_edit(&file_path, path_str, Some(&raw), &final_content)?; | ||
| if let Some(normalized) = normalize_edit(&file_path, &raw, &final_content).await { | ||
| final_content = normalized; | ||
| } | ||
|
|
||
| crate::utils::write_atomic_workspace(&file_path, final_content.as_bytes()).map_err( | ||
| |error| { | ||
|
|
@@ -2244,6 +2273,18 @@ impl ToolSpec for EditFileTool { | |
| )); | ||
| } | ||
|
|
||
| guard_edit(&file_path, path_str, Some(&contents), &updated)?; | ||
|
|
||
| // #6205 — normalize after the syntax gate so the next turn's anchors | ||
| // match the bytes on disk rather than the text the model emitted. | ||
| let normalized_formatting = match normalize_edit(&file_path, &contents, &updated).await { | ||
| Some(normalized) => { | ||
| updated = normalized; | ||
| true | ||
| } | ||
| None => false, | ||
| }; | ||
|
|
||
| crate::utils::write_atomic_workspace(&file_path, updated.as_bytes()).map_err(|e| { | ||
| ToolError::execution_failed(format!("Failed to write {}: {}", file_path.display(), e)) | ||
| })?; | ||
|
|
@@ -2279,7 +2320,12 @@ impl ToolSpec for EditFileTool { | |
| Some(other) => other, | ||
| None => "", | ||
| }; | ||
| let summary = format!("Replaced 1 occurrence in {display}{fuzz_note}"); | ||
| let format_note = if normalized_formatting { | ||
| NORMALIZED_NOTE | ||
| } else { | ||
| "" | ||
| }; | ||
| let summary = format!("Replaced 1 occurrence in {display}{fuzz_note}{format_note}"); | ||
| let body = if diff.is_empty() { | ||
| format!("{summary}\n(no textual changes)") | ||
| } else { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[WARNING] apply_patch's transactional claim is asserted but only tested with a single file
The guard loop in
apply_pending_writesis deliberately placed ahead of the write loop so that "one unparseable result must leave every file in the patch untouched rather than half-applied", and the CHANGELOG repeats thatapply_patch"cannot half-apply". The only test (patch_refuses_a_hunk_that_breaks_rust_syntax) uses a single file, so it cannot distinguish "guard before the first write" from "per-file guard just before each write" — the two behave identically for one file, and only the second violates the stated contract. A two-file patch whose second file breaks Rust syntax, asserting the first file's bytes are unchanged on disk, is what actually pins the placement; it would also fail today if anyone later moves the loop into the write loop.